diff --git a/README.md b/README.md index 37f040ec..08ca3cfc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ -

- Swiftfin -

Swiftfin

+
+ Swiftfin + +

Swiftfin

@@ -10,29 +11,34 @@ -

+
+

- 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** -Download on the Apple 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/). + + Download on the Apple App Store + + +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 @@ + + + AppIcon-dark-blue + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-dark-green + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-dark-jellyfin + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-dark-orange + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-dark-red + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-dark-yellow + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-invertedDark-blue + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-invertedDark-green + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-invertedDark-jellyfin + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-invertedDark-orange + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-invertedDark-red + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-invertedDark-yellow + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-invertedLight-blue + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-invertedLight-green + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-invertedLight-jellyfin + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-invertedLight-orange + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-invertedLight-red + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-invertedLight-yellow + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-light-blue + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-light-green + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-light-jellyfin + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-light-orange + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-light-red + + + + + + + + + + + + + \ 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 @@ + + + AppIcon-light-yellow + + + + + + + + + + + + + \ 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 @@ + + + primary + + + + + + + + + + + + + \ 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 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + primary-wide + + + + + + + + + + + + + \ 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(), + [ + Where( + "id == %@", + server.id + ), + ] + ) + else { fatalError("No stored server associated with given state server?") } + + let user = try SwiftfinStore.dataStack.perform { transaction in + let newUser = transaction.create(Into()) + + newUser.accessToken = accessToken + newUser.appleTVID = "" + newUser.id = id + newUser.username = username + + let editServer = transaction.edit(storedServer)! + editServer.users.insert(newUser) + + return newUser.state + } + + return user } } diff --git a/Shared/ViewModels/VideoPlayerManager.swift b/Shared/ViewModels/VideoPlayerManager.swift new file mode 100644 index 00000000..71693472 --- /dev/null +++ b/Shared/ViewModels/VideoPlayerManager.swift @@ -0,0 +1,336 @@ +// +// 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 Foundation +import JellyfinAPI +import UIKit +import VLCUI + +// TODO: better online/offline handling +// TODO: proper error catching +// TODO: better solution for previous/next/queuing +// TODO: should view models handle progress reports instead, with a protocol +// for other types of media handling + +class VideoPlayerManager: ViewModel { + + class CurrentProgressHandler: ObservableObject { + + @Published + var progress: CGFloat = 0 + @Published + var scrubbedProgress: CGFloat = 0 + + @Published + var seconds: Int = 0 + @Published + var scrubbedSeconds: Int = 0 + } + + @Published + var audioTrackIndex: Int = -1 + @Published + var state: VLCVideoPlayer.State = .opening + @Published + var subtitleTrackIndex: Int = -1 + + // MARK: ViewModel + + @Published + var previousViewModel: VideoPlayerViewModel? + @Published + var currentViewModel: VideoPlayerViewModel! { + willSet { + guard let newValue else { return } + hasSentStart = false + getAdjacentEpisodes(for: newValue.item) + } + } + + @Published + var nextViewModel: VideoPlayerViewModel? + + var currentProgressHandler: CurrentProgressHandler = .init() + let proxy: VLCVideoPlayer.Proxy = .init() + + private var currentProgressWorkItem: DispatchWorkItem? + private var hasSentStart = false + + func selectNextViewModel() { + guard let nextViewModel else { return } + currentViewModel = nextViewModel + previousViewModel = nil + self.nextViewModel = nil + } + + func selectPreviousViewModel() { + guard let previousViewModel else { return } + currentViewModel = previousViewModel + self.previousViewModel = nil + nextViewModel = nil + } + + func onTicksUpdated(ticks: Int, playbackInformation: VLCVideoPlayer.PlaybackInformation) { + + if audioTrackIndex != playbackInformation.currentAudioTrack.index { + audioTrackIndex = playbackInformation.currentAudioTrack.index + } + + if subtitleTrackIndex != playbackInformation.currentSubtitleTrack.index { + subtitleTrackIndex = playbackInformation.currentSubtitleTrack.index + } + } + + func onStateUpdated(newState: VLCVideoPlayer.State) { + guard state != newState else { return } + state = newState + + if !hasSentStart, newState == .playing { + hasSentStart = true + sendStartReport() + } + + if hasSentStart, newState == .paused { + hasSentStart = false + sendPauseReport() + } + + if newState == .stopped || newState == .ended { + sendStopReport() + } + } + + func getAdjacentEpisodes(for item: BaseItemDto) { + Task { @MainActor in + guard let seriesID = item.seriesID, item.type == .episode else { return } + + let parameters = Paths.GetEpisodesParameters( + userID: userSession.user.id, + fields: ItemFields.minimumCases, + adjacentTo: item.id!, + limit: 3 + ) + let request = Paths.getEpisodes(seriesID: seriesID, parameters: parameters) + let response = try await userSession.client.send(request) + + // 4 possible states: + // 1 - only current episode + // 2 - two episodes with next episode + // 3 - two episodes with previous episode + // 4 - three episodes with current in middle + + // 1 + guard let items = response.value.items, items.count > 1 else { return } + + var previousItem: BaseItemDto? + var nextItem: BaseItemDto? + + if items.count == 2 { + if items[0].id == item.id { + // 2 + nextItem = items[1] + + } else { + // 3 + previousItem = items[0] + } + } else { + nextItem = items[2] + previousItem = items[0] + } + + var nextViewModel: VideoPlayerViewModel? + var previousViewModel: VideoPlayerViewModel? + + if let nextItem, let nextItemMediaSource = nextItem.mediaSources?.first { + nextViewModel = try await nextItem.videoPlayerViewModel(with: nextItemMediaSource) + } + + if let previousItem, let previousItemMediaSource = previousItem.mediaSources?.first { + previousViewModel = try await previousItem.videoPlayerViewModel(with: previousItemMediaSource) + } + + await MainActor.run { + self.nextViewModel = nextViewModel + self.previousViewModel = previousViewModel + } + } + } + + func sendStartReport() { + + #if DEBUG + guard Defaults[.sendProgressReports] else { return } + #endif + + currentProgressWorkItem?.cancel() + + print("sent start report") + + Task { + let startInfo = PlaybackStartInfo( + audioStreamIndex: audioTrackIndex, + itemID: currentViewModel.item.id, + mediaSourceID: currentViewModel.mediaSource.id, + playbackStartTimeTicks: Int(Date().timeIntervalSince1970) * 10_000_000, + positionTicks: currentProgressHandler.seconds * 10_000_000, + sessionID: currentViewModel.playSessionID, + subtitleStreamIndex: subtitleTrackIndex + ) + + let request = Paths.reportPlaybackStart(startInfo) + let _ = try await userSession.client.send(request) + + let progressTask = DispatchWorkItem { + self.sendProgressReport() + } + + currentProgressWorkItem = progressTask + + DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: progressTask) + } + } + + func sendStopReport() { + + #if DEBUG + guard Defaults[.sendProgressReports] else { return } + #endif + + print("sent stop report") + + currentProgressWorkItem?.cancel() + + Task { + let stopInfo = PlaybackStopInfo( + itemID: currentViewModel.item.id, + mediaSourceID: currentViewModel.mediaSource.id, + positionTicks: currentProgressHandler.seconds * 10_000_000, + sessionID: currentViewModel.playSessionID + ) + + let request = Paths.reportPlaybackStopped(stopInfo) + let _ = try await userSession.client.send(request) + } + } + + func sendPauseReport() { + + #if DEBUG + guard Defaults[.sendProgressReports] else { return } + #endif + + print("sent pause report") + + currentProgressWorkItem?.cancel() + + Task { + let startInfo = PlaybackStartInfo( + audioStreamIndex: audioTrackIndex, + isPaused: true, + itemID: currentViewModel.item.id, + mediaSourceID: currentViewModel.mediaSource.id, + positionTicks: currentProgressHandler.seconds * 10_000_000, + sessionID: currentViewModel.playSessionID, + subtitleStreamIndex: subtitleTrackIndex + ) + + let request = Paths.reportPlaybackStart(startInfo) + let _ = try await userSession.client.send(request) + } + } + + func sendProgressReport() { + + #if DEBUG + guard Defaults[.sendProgressReports] else { return } + #endif + + let progressTask = DispatchWorkItem { + self.sendProgressReport() + } + + currentProgressWorkItem = progressTask + + DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: progressTask) + + Task { + let progressInfo = PlaybackProgressInfo( + audioStreamIndex: audioTrackIndex, + isPaused: false, + itemID: currentViewModel.item.id, + mediaSourceID: currentViewModel.item.id, + playSessionID: currentViewModel.playSessionID, + positionTicks: currentProgressHandler.seconds * 10_000_000, + sessionID: currentViewModel.playSessionID, + subtitleStreamIndex: subtitleTrackIndex + ) + + let request = Paths.reportPlaybackProgress(progressInfo) + let _ = try await userSession.client.send(request) + + print("sent progress task") + } + } +} + +// TODO: move to own file +class OnlineVideoPlayerManager: VideoPlayerManager { + + init(item: BaseItemDto, mediaSource: MediaSourceInfo) { + super.init() + + Task { + let viewModel = try await item.videoPlayerViewModel(with: mediaSource) + + await MainActor.run { + self.currentViewModel = viewModel + } + } + } +} + +// TODO: move to own file +class DownloadVideoPlayerManager: VideoPlayerManager { + + init(downloadTask: DownloadTask) { + super.init() + + guard let playbackURL = downloadTask.getMediaURL() else { + logger.error("Download task does not have media url for item: \(downloadTask.item.displayTitle)") + + return + } + + self.currentViewModel = .init( + playbackURL: playbackURL, + item: downloadTask.item, + mediaSource: .init(), + playSessionID: "", + videoStreams: downloadTask.item.videoStreams, + audioStreams: downloadTask.item.audioStreams, + subtitleStreams: downloadTask.item.subtitleStreams, + selectedAudioStreamIndex: 1, + selectedSubtitleStreamIndex: 1, + chapters: downloadTask.item.fullChapterInfo, + streamType: .direct + ) + } + + override func getAdjacentEpisodes(for item: BaseItemDto) {} + + override func sendStartReport() {} + + override func sendPauseReport() {} + + override func sendStopReport() {} + + override func sendProgressReport() {} +} diff --git a/Shared/ViewModels/VideoPlayerModel.swift b/Shared/ViewModels/VideoPlayerModel.swift deleted file mode 100644 index 553bcc31..00000000 --- a/Shared/ViewModels/VideoPlayerModel.swift +++ /dev/null @@ -1,32 +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 - -struct Subtitle { - var name: String - var id: Int32 - var url: URL? - var delivery: SubtitleDeliveryMethod - var codec: String - var languageCode: String -} - -struct AudioTrack { - var name: String - var languageCode: String - var id: Int32 -} - -class PlaybackItem: ObservableObject { - @Published - var videoType: PlayMethod = .directPlay - @Published - var videoUrl = URL(string: "https://example.com")! -} diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift new file mode 100644 index 00000000..2cf93405 --- /dev/null +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -0,0 +1,127 @@ +// +// 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 Files +import Foundation +import JellyfinAPI +import UIKit +import VLCUI + +class VideoPlayerViewModel: ViewModel { + + let playbackURL: URL + let item: BaseItemDto + let mediaSource: MediaSourceInfo + let playSessionID: String + let videoStreams: [MediaStream] + let audioStreams: [MediaStream] + let subtitleStreams: [MediaStream] + let selectedAudioStreamIndex: Int + let selectedSubtitleStreamIndex: Int + let chapters: [ChapterInfo.FullInfo] + let streamType: StreamType + + var hlsPlaybackURL: URL { + + let segmentContainer = Defaults[.VideoPlayer.Native.fMP4Container] ? "mp4" : "ts" + let userSession = Container.userSession.callAsFunction() + + let parameters = Paths.GetMasterHlsVideoPlaylistParameters( + isStatic: true, + tag: mediaSource.eTag, + playSessionID: playSessionID, + segmentContainer: segmentContainer, + minSegments: 2, + mediaSourceID: mediaSource.id!, + deviceID: UIDevice.vendorUUIDString, + audioCodec: mediaSource.audioStreams? + .compactMap(\.codec) + .joined(separator: ","), + isBreakOnNonKeyFrames: true, + requireAvc: false, + transcodingMaxAudioChannels: 6, + videoCodec: videoStreams + .compactMap(\.codec) + .joined(separator: ","), + videoStreamIndex: videoStreams.first?.index, + enableAdaptiveBitrateStreaming: true + ) + let request = Paths.getMasterHlsVideoPlaylist( + itemID: item.id!, + parameters: parameters + ) + + let hlsStreamComponents = URLComponents(url: userSession.client.fullURL(with: request), resolvingAgainstBaseURL: false)! + .addingQueryItem(key: "api_key", value: userSession.user.accessToken) + + return hlsStreamComponents.url! + } + + var vlcVideoPlayerConfiguration: VLCVideoPlayer.Configuration { + let configuration = VLCVideoPlayer.Configuration(url: playbackURL) + configuration.autoPlay = true + configuration.startTime = .seconds(max(0, item.startTimeSeconds - Defaults[.VideoPlayer.resumeOffset])) + configuration.audioIndex = .absolute(selectedAudioStreamIndex) + configuration.subtitleIndex = .absolute(selectedSubtitleStreamIndex) + configuration.subtitleSize = .absolute(Defaults[.VideoPlayer.Subtitle.subtitleSize]) + configuration.subtitleColor = .absolute(Defaults[.VideoPlayer.Subtitle.subtitleColor].uiColor) + + if let font = UIFont(name: Defaults[.VideoPlayer.Subtitle.subtitleFontName], size: 0) { + configuration.subtitleFont = .absolute(font) + } + + configuration.playbackChildren = subtitleStreams + .filter { $0.deliveryMethod == .external } + .compactMap(\.asPlaybackChild) + + return configuration + } + + init( + playbackURL: URL, + item: BaseItemDto, + mediaSource: MediaSourceInfo, + playSessionID: String, + videoStreams: [MediaStream], + audioStreams: [MediaStream], + subtitleStreams: [MediaStream], + selectedAudioStreamIndex: Int, + selectedSubtitleStreamIndex: Int, + chapters: [ChapterInfo.FullInfo], + streamType: StreamType + ) { + self.item = item + self.mediaSource = mediaSource + self.playSessionID = playSessionID + self.playbackURL = playbackURL + self.videoStreams = videoStreams + self.audioStreams = audioStreams + .adjustAudioForExternalSubtitles(externalMediaStreamCount: subtitleStreams.filter { $0.isExternal ?? false }.count) + self.subtitleStreams = subtitleStreams + .adjustExternalSubtitleIndexes(audioStreamCount: audioStreams.count) + self.selectedAudioStreamIndex = selectedAudioStreamIndex + self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex + self.chapters = chapters + self.streamType = streamType + super.init() + } + + func chapter(from seconds: Int) -> ChapterInfo.FullInfo? { + chapters.first(where: { $0.secondsRange.contains(seconds) }) + } +} + +extension VideoPlayerViewModel: Equatable { + + static func == (lhs: VideoPlayerViewModel, rhs: VideoPlayerViewModel) -> Bool { + lhs.item == rhs.item && + lhs.playbackURL == rhs.playbackURL + } +} diff --git a/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift deleted file mode 100644 index 1b0e4144..00000000 --- a/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift +++ /dev/null @@ -1,684 +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 Algorithms -import Combine -import Defaults -import Foundation -import JellyfinAPI -import UIKit - -#if os(tvOS) -import TVVLCKit -#else -import MobileVLCKit -#endif - -final class VideoPlayerViewModel: ViewModel { - // MARK: Published - - // Manually kept state because VLCKit doesn't properly set "played" - // on the VLCMediaPlayer object - @Published - var playerState: VLCMediaPlayerState = .buffering - @Published - var leftLabelText: String = "--:--" - @Published - var rightLabelText: String = "--:--" - @Published - var scrubbingTimeLabelText: String = "--:--" - @Published - var playbackSpeed: PlaybackSpeed = .one - @Published - var subtitlesEnabled: Bool { - didSet { - if syncSubtitleStateWithAdjacent { - previousItemVideoPlayerViewModel?.matchSubtitlesEnabled(with: self) - nextItemVideoPlayerViewModel?.matchSubtitlesEnabled(with: self) - } - } - } - - @Published - var selectedAudioStreamIndex: Int - @Published - var selectedSubtitleStreamIndex: Int { - didSet { - if syncSubtitleStateWithAdjacent { - previousItemVideoPlayerViewModel?.matchSubtitleStream(with: self) - nextItemVideoPlayerViewModel?.matchSubtitleStream(with: self) - } - } - } - - @Published - var previousItemVideoPlayerViewModel: VideoPlayerViewModel? - @Published - var nextItemVideoPlayerViewModel: VideoPlayerViewModel? - @Published - var jumpBackwardLength: VideoPlayerJumpLength { - willSet { - Defaults[.videoPlayerJumpBackward] = newValue - } - } - - @Published - var jumpForwardLength: VideoPlayerJumpLength { - willSet { - Defaults[.videoPlayerJumpForward] = newValue - } - } - - @Published - var isHiddenCenterViews = false - - @Published - var sliderIsScrubbing: Bool = false { - didSet { - isHiddenCenterViews = sliderIsScrubbing - beganScrubbingCurrentSeconds = currentSeconds - } - } - - @Published - var sliderPercentage: Double = 0 { - willSet { - sliderScrubbingSubject.send(self) - sliderPercentageChanged(newValue: newValue) - } - } - - @Published - var autoplayEnabled: Bool { - willSet { - previousItemVideoPlayerViewModel?.autoplayEnabled = newValue - nextItemVideoPlayerViewModel?.autoplayEnabled = newValue - Defaults[.autoplayEnabled] = newValue - } - } - - @Published - var mediaItems: [BaseItemDto.ItemDetail] - - @Published - var isHiddenOverlay = false - - // MARK: ShouldShowItems - - let shouldShowPlayPreviousItem: Bool - let shouldShowPlayNextItem: Bool - let shouldShowAutoPlay: Bool - let shouldShowJumpButtonsInOverlayMenu: Bool - - // MARK: General - - private(set) var item: BaseItemDto - let title: String - let subtitle: String? - let directStreamURL: URL - let transcodedStreamURL: URL? - let hlsStreamURL: URL - let videoStream: MediaStream - let audioStreams: [MediaStream] - let subtitleStreams: [MediaStream] - let chapters: [ChapterInfo] - let overlayType: OverlayType - let jumpGesturesEnabled: Bool - let systemControlGesturesEnabled: Bool - let seekSlideGestureEnabled: Bool - let playerGesturesLockGestureEnabled: Bool - let shouldShowChaptersInfoInBottomOverlay: Bool - let resumeOffset: Bool - let streamType: ServerStreamType - let container: String - let filename: String? - let versionName: String? - - // MARK: Experimental - - let syncSubtitleStateWithAdjacent: Bool - - // MARK: tvOS - - let confirmClose: Bool - - // Full response kept for convenience - let response: PlaybackInfoResponse - - var playerOverlayDelegate: PlayerOverlayDelegate? - - // Ticks of the time the media began playing - private var startTimeTicks: Int64 = 0 - - // MARK: Current Time - - private var beganScrubbingCurrentSeconds: Double = 0 - - var currentSeconds: Double { - let runTimeTicks = item.runTimeTicks ?? 0 - let videoDuration = Double(runTimeTicks / 10_000_000) - return round(sliderPercentage * videoDuration) - } - - var currentSecondTicks: Int64 { - Int64(currentSeconds) * 10_000_000 - } - - func setSeconds(_ seconds: Int64) { - guard let runTimeTicks = item.runTimeTicks else { return } - let videoDuration = runTimeTicks - let percentage = Double(seconds * 10_000_000) / Double(videoDuration) - - sliderPercentage = percentage - } - - // MARK: Helpers - - var currentAudioStream: MediaStream? { - audioStreams.first(where: { $0.index == selectedAudioStreamIndex }) - } - - var currentSubtitleStream: MediaStream? { - subtitleStreams.first(where: { $0.index == selectedSubtitleStreamIndex }) - } - - var currentChapter: ChapterInfo? { - let chapterPairs = chapters.adjacentPairs().map { ($0, $1) } - let chapterRanges = chapterPairs.map { ($0.startPositionTicks ?? 0, ($1.startPositionTicks ?? 1) - 1) } - - for chapterRangeIndex in 0 ..< chapterRanges.count { - if chapterRanges[chapterRangeIndex].0 <= currentSecondTicks, - currentSecondTicks < chapterRanges[chapterRangeIndex].1 - { - return chapterPairs[chapterRangeIndex].0 - } - } - - return nil - } - - // Necessary PassthroughSubject to capture manual scrubbing from sliders - let sliderScrubbingSubject = PassthroughSubject() - - // During scrubbing, many progress reports were spammed - // Send only the current report after a delay - private var progressReportTimer: Timer? - private var lastProgressReport: ReportPlaybackProgressRequest? - - // MARK: init - - init( - item: BaseItemDto, - title: String, - subtitle: String?, - directStreamURL: URL, - transcodedStreamURL: URL?, - hlsStreamURL: URL, - streamType: ServerStreamType, - response: PlaybackInfoResponse, - videoStream: MediaStream, - audioStreams: [MediaStream], - subtitleStreams: [MediaStream], - chapters: [ChapterInfo], - selectedAudioStreamIndex: Int, - selectedSubtitleStreamIndex: Int, - subtitlesEnabled: Bool, - autoplayEnabled: Bool, - overlayType: OverlayType, - shouldShowPlayPreviousItem: Bool, - shouldShowPlayNextItem: Bool, - shouldShowAutoPlay: Bool, - container: String, - filename: String?, - versionName: String? - ) { - self.item = item - self.title = title - self.subtitle = subtitle - self.directStreamURL = directStreamURL - self.transcodedStreamURL = transcodedStreamURL - self.hlsStreamURL = hlsStreamURL - self.streamType = streamType - self.response = response - self.videoStream = videoStream - self.audioStreams = audioStreams - self.subtitleStreams = subtitleStreams - self.chapters = chapters - self.selectedAudioStreamIndex = selectedAudioStreamIndex - self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex - self.subtitlesEnabled = subtitlesEnabled - self.autoplayEnabled = autoplayEnabled - self.overlayType = overlayType - self.shouldShowPlayPreviousItem = shouldShowPlayPreviousItem - self.shouldShowPlayNextItem = shouldShowPlayNextItem - self.shouldShowAutoPlay = shouldShowAutoPlay - self.container = container - self.filename = filename - self.versionName = versionName - - self.jumpBackwardLength = Defaults[.videoPlayerJumpBackward] - self.jumpForwardLength = Defaults[.videoPlayerJumpForward] - self.jumpGesturesEnabled = Defaults[.jumpGesturesEnabled] - self.systemControlGesturesEnabled = Defaults[.systemControlGesturesEnabled] - self.playerGesturesLockGestureEnabled = Defaults[.playerGesturesLockGestureEnabled] - self.seekSlideGestureEnabled = Defaults[.seekSlideGestureEnabled] - self.shouldShowJumpButtonsInOverlayMenu = Defaults[.shouldShowJumpButtonsInOverlayMenu] - self.shouldShowChaptersInfoInBottomOverlay = Defaults[.shouldShowChaptersInfoInBottomOverlay] - - self.resumeOffset = Defaults[.resumeOffset] - - self.syncSubtitleStateWithAdjacent = Defaults[.Experimental.syncSubtitleStateWithAdjacent] - - self.confirmClose = Defaults[.confirmClose] - - self.mediaItems = item.createMediaItems() - - super.init() - - self.sliderPercentage = (item.userData?.playedPercentage ?? 0) / 100 - } - - private func sliderPercentageChanged(newValue: Double) { - let runTimeTicks = item.runTimeTicks ?? 0 - let videoDuration = Double(runTimeTicks / 10_000_000) - let secondsScrubbedRemaining = videoDuration - currentSeconds - - leftLabelText = calculateTimeText(from: currentSeconds) - rightLabelText = calculateTimeText(from: secondsScrubbedRemaining) - scrubbingTimeLabelText = calculateTimeText(from: currentSeconds - beganScrubbingCurrentSeconds, isScrubbing: true) - } - - private func calculateTimeText(from duration: Double, isScrubbing: Bool = false) -> String { - let isNegative = duration < 0 - let duration = abs(duration) - let hours = floor(duration / 3600) - let minutes = duration.truncatingRemainder(dividingBy: 3600) / 60 - let seconds = duration.truncatingRemainder(dividingBy: 3600).truncatingRemainder(dividingBy: 60) - - let timeText: String - - if hours != 0 { - timeText = - "\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" - } else { - timeText = - "\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" - } - - if isScrubbing { - return "\(isNegative ? "-" : "+") \(timeText)" - } else { - return "\(isNegative ? "-" : "") \(timeText)" - } - } -} - -// MARK: Injected Values - -extension VideoPlayerViewModel { - // Injects custom values that override certain settings - func injectCustomValues(startFromBeginning: Bool = false) { - if startFromBeginning { - item.userData?.playbackPositionTicks = 0 - item.userData?.playedPercentage = 0 - sliderPercentage = 0 - sliderPercentageChanged(newValue: 0) - } - } -} - -// MARK: Adjacent Items - -extension VideoPlayerViewModel { - func getAdjacentEpisodes() { - guard let seriesID = item.seriesId, item.type == .episode else { return } - - TvShowsAPI.getEpisodes( - seriesId: seriesID, - userId: SessionManager.main.currentLogin.user.id, - fields: [.chapters], - adjacentTo: item.id, - limit: 3 - ) - .sink(receiveCompletion: { completion in - self.handleAPIRequestError(completion: completion) - }, receiveValue: { response in - - // 4 possible states: - // 1 - only current episode - // 2 - two episodes with next episode - // 3 - two episodes with previous episode - // 4 - three episodes with current in middle - - // State 1 - guard let items = response.items, items.count > 1 else { return } - - if items.count == 2 { - if items[0].id == self.item.id { - // State 2 - let nextItem = items[1] - - nextItem.createVideoPlayerViewModel() - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { viewModels in - for viewModel in viewModels { - viewModel.matchSubtitleStream(with: self) - viewModel.matchAudioStream(with: self) - } - - self.nextItemVideoPlayerViewModel = viewModels.first - } - .store(in: &self.cancellables) - } else { - // State 3 - let previousItem = items[0] - - previousItem.createVideoPlayerViewModel() - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { viewModels in - for viewModel in viewModels { - viewModel.matchSubtitleStream(with: self) - viewModel.matchAudioStream(with: self) - } - - self.previousItemVideoPlayerViewModel = viewModels.first - } - .store(in: &self.cancellables) - } - } else { - // State 4 - - let previousItem = items[0] - let nextItem = items[2] - - previousItem.createVideoPlayerViewModel() - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { viewModels in - for viewModel in viewModels { - viewModel.matchSubtitleStream(with: self) - viewModel.matchAudioStream(with: self) - } - - self.previousItemVideoPlayerViewModel = viewModels.first - } - .store(in: &self.cancellables) - - nextItem.createVideoPlayerViewModel() - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { viewModels in - for viewModel in viewModels { - viewModel.matchSubtitleStream(with: self) - viewModel.matchAudioStream(with: self) - } - - self.nextItemVideoPlayerViewModel = viewModels.first - } - .store(in: &self.cancellables) - } - }) - .store(in: &cancellables) - } - - // Potential for experimental feature of syncing subtitle states among adjacent episodes - // when using previous & next item buttons and auto-play - - private func matchSubtitleStream(with masterViewModel: VideoPlayerViewModel) { - if !masterViewModel.subtitlesEnabled { - matchSubtitlesEnabled(with: masterViewModel) - } - - guard let masterSubtitleStream = masterViewModel.subtitleStreams - .first(where: { $0.index == masterViewModel.selectedSubtitleStreamIndex }), - let matchingSubtitleStream = subtitleStreams.first(where: { mediaStreamAboutEqual($0, masterSubtitleStream) }), - let matchingSubtitleStreamIndex = matchingSubtitleStream.index else { return } - - selectedSubtitleStreamIndex = matchingSubtitleStreamIndex - } - - private func matchAudioStream(with masterViewModel: VideoPlayerViewModel) { - guard let currentAudioStream = masterViewModel.audioStreams.first(where: { $0.index == masterViewModel.selectedAudioStreamIndex }), - let matchingAudioStream = audioStreams.first(where: { mediaStreamAboutEqual($0, currentAudioStream) }) else { return } - - selectedAudioStreamIndex = matchingAudioStream.index ?? -1 - } - - private func matchSubtitlesEnabled(with masterViewModel: VideoPlayerViewModel) { - subtitlesEnabled = masterViewModel.subtitlesEnabled - } - - private func mediaStreamAboutEqual(_ lhs: MediaStream, _ rhs: MediaStream) -> Bool { - lhs.displayTitle == rhs.displayTitle && lhs.language == rhs.language - } -} - -// MARK: Progress Report Timer - -extension VideoPlayerViewModel { - private func sendNewProgressReportWithTimer() { - progressReportTimer?.invalidate() - progressReportTimer = Timer.scheduledTimer( - timeInterval: 0.7, - target: self, - selector: #selector(_sendProgressReport), - userInfo: nil, - repeats: false - ) - } -} - -// MARK: Updates - -extension VideoPlayerViewModel { - // MARK: sendPlayReport - - func sendPlayReport() { - startTimeTicks = Int64(Date().timeIntervalSince1970) * 10_000_000 - - let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil - - let reportPlaybackStartRequest = ReportPlaybackStartRequest( - canSeek: true, - itemId: item.id, - sessionId: response.playSessionId, - mediaSourceId: item.id, - audioStreamIndex: selectedAudioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, - isPaused: false, - isMuted: false, - positionTicks: item.userData?.playbackPositionTicks, - playbackStartTimeTicks: startTimeTicks, - volumeLevel: 100, - brightness: 100, - aspectRatio: nil, - playMethod: .directPlay, - liveStreamId: nil, - playSessionId: response.playSessionId, - repeatMode: .repeatNone, - nowPlayingQueue: nil, - playlistItemId: "playlistItem0" - ) - - PlaystateAPI.reportPlaybackStart(reportPlaybackStartRequest: reportPlaybackStartRequest) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { _ in - self.logger.debug("Start report sent for item: \(self.item.id ?? "No ID")") - } - .store(in: &cancellables) - } - - // MARK: sendPauseReport - - func sendPauseReport(paused: Bool) { - let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil - - let reportPlaybackStartRequest = ReportPlaybackStartRequest( - canSeek: true, - itemId: item.id, - sessionId: response.playSessionId, - mediaSourceId: item.id, - audioStreamIndex: selectedAudioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, - isPaused: paused, - isMuted: false, - positionTicks: currentSecondTicks, - playbackStartTimeTicks: startTimeTicks, - volumeLevel: 100, - brightness: 100, - aspectRatio: nil, - playMethod: .directPlay, - liveStreamId: nil, - playSessionId: response.playSessionId, - repeatMode: .repeatNone, - nowPlayingQueue: nil, - playlistItemId: "playlistItem0" - ) - - PlaystateAPI.reportPlaybackStart(reportPlaybackStartRequest: reportPlaybackStartRequest) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { _ in - self.logger.debug("Pause report sent for item: \(self.item.id ?? "No ID")") - } - .store(in: &cancellables) - } - - // MARK: sendProgressReport - - func sendProgressReport() { - let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil - - let progressInfo = ReportPlaybackProgressRequest( - canSeek: true, - itemId: item.id, - sessionId: response.playSessionId, - mediaSourceId: item.id, - audioStreamIndex: selectedAudioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, - isPaused: false, - isMuted: false, - positionTicks: currentSecondTicks, - playbackStartTimeTicks: startTimeTicks, - volumeLevel: nil, - brightness: nil, - aspectRatio: nil, - playMethod: .directPlay, - liveStreamId: nil, - playSessionId: response.playSessionId, - repeatMode: .repeatNone, - nowPlayingQueue: nil, - playlistItemId: "playlistItem0" - ) - - lastProgressReport = progressInfo - - sendNewProgressReportWithTimer() - } - - @objc - private func _sendProgressReport() { - guard let lastProgressReport = lastProgressReport else { return } - - PlaystateAPI.reportPlaybackProgress(reportPlaybackProgressRequest: lastProgressReport) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { _ in - self.logger.debug("Playback progress sent for item: \(self.item.id ?? "No ID")") - } - .store(in: &cancellables) - - self.lastProgressReport = nil - } - - // MARK: sendStopReport - - func sendStopReport() { - let reportPlaybackStoppedRequest = ReportPlaybackStoppedRequest( - itemId: item.id, - sessionId: response.playSessionId, - mediaSourceId: item.id, - positionTicks: currentSecondTicks, - liveStreamId: nil, - playSessionId: response.playSessionId, - failed: nil, - nextMediaType: nil, - playlistItemId: "playlistItem0", - nowPlayingQueue: nil - ) - - PlaystateAPI.reportPlaybackStopped(reportPlaybackStoppedRequest: reportPlaybackStoppedRequest) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { _ in - self.logger.debug("Stop report sent for item: \(self.item.id ?? "No ID")") - Notifications[.didSendStopReport].post(object: self.item.id) - } - .store(in: &cancellables) - } -} - -// MARK: Embedded/Normal Subtitle Streams - -extension VideoPlayerViewModel { - func createEmbeddedSubtitleStream(with subtitleStream: MediaStream) -> URL { - guard let baseURL = URLComponents(url: directStreamURL, resolvingAgainstBaseURL: false) else { fatalError() } - guard let queryItems = baseURL.queryItems else { fatalError() } - - var newURL = baseURL - var newQueryItems = queryItems - - newQueryItems.removeAll(where: { $0.name == "SubtitleStreamIndex" }) - newQueryItems.removeAll(where: { $0.name == "SubtitleMethod" }) - - newURL.addQueryItem(name: "SubtitleMethod", value: "Encode") - newURL.addQueryItem(name: "SubtitleStreamIndex", value: "\(subtitleStream.index ?? -1)") - - return newURL.url! - } -} - -// MARK: Subtitle Streams - -extension VideoPlayerViewModel { - func videoSubtitleStreamIndex(of subtitleStreamIndex: Int) -> Int32 { - let externalSubtitleStreams = subtitleStreams.filter { $0.isExternal == true } - - guard let externalSubtitleStreamIndex = externalSubtitleStreams.firstIndex(where: { $0.index == subtitleStreamIndex }) else { - return Int32(subtitleStreamIndex) - } - - let embeddedSubtitleStreamCount = subtitleStreams.count - externalSubtitleStreams.count - let embeddedStreamCount = 1 + audioStreams.count + embeddedSubtitleStreamCount - - return Int32(embeddedStreamCount + externalSubtitleStreamIndex) - } -} - -// MARK: Equatable - -extension VideoPlayerViewModel: Equatable { - static func == (lhs: VideoPlayerViewModel, rhs: VideoPlayerViewModel) -> Bool { - lhs.item.id == rhs.item.id && - lhs.item.userData?.playbackPositionTicks == rhs.item.userData?.playbackPositionTicks - } -} - -// MARK: Hashable - -extension VideoPlayerViewModel: Hashable { - func hash(into hasher: inout Hasher) { - hasher.combine(item) - hasher.combine(directStreamURL) - hasher.combine(filename) - hasher.combine(versionName) - } -} diff --git a/Shared/ViewModels/ViewModel.swift b/Shared/ViewModels/ViewModel.swift index 16e254af..70fde330 100644 --- a/Shared/ViewModels/ViewModel.swift +++ b/Shared/ViewModels/ViewModel.swift @@ -3,98 +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 ActivityIndicator import Combine import Factory import Foundation -import JellyfinAPI class ViewModel: ObservableObject { @Injected(LogManager.service) var logger + + @Injected(Container.userSession) + var userSession + + @Published + var error: ErrorMessage? = nil + @Published var isLoading = false - @Published - var errorMessage: ErrorMessage? - let loading = ActivityIndicator() var cancellables = Set() - init() { - loading.loading.assign(to: \.isLoading, on: self).store(in: &cancellables) - } - - func handleAPIRequestError(displayMessage: String? = nil, completion: Subscribers.Completion) { - switch completion { - case .finished: - self.errorMessage = nil - case let .failure(error): - switch error { - case is ErrorResponse: - let networkError: NetworkError - let errorResponse = error as! ErrorResponse - - switch errorResponse { - case .error(-1, _, _, _): - networkError = .URLError(response: errorResponse, displayMessage: displayMessage) - // Use the errorResponse description for debugging, rather than the user-facing friendly description which may not be implemented - logger - .error( - "Request failed: URL request failed with error \(networkError.errorMessage.code): \(errorResponse.localizedDescription)" - ) - case .error(-2, _, _, _): - networkError = .HTTPURLError(response: errorResponse, displayMessage: displayMessage) - logger - .error("Request failed: HTTP URL request failed with description: \(errorResponse.localizedDescription)") - - case let .error(_, _, _, baseError as DecodingError): - networkError = .JellyfinError(response: errorResponse, displayMessage: displayMessage) - if case let .dataCorrupted(decodeContext) = baseError { - let codingPath = decodeContext.codingPath.map(\.stringValue).joined(separator: ",") - let underlyingError = decodeContext.debugDescription - logger - .error( - "Request failed: JSON Decoding failed: Underlying Error: \(underlyingError) - Coding Path: [\(codingPath)]" - ) - } else { - logger - .error("Request failed: JSON Decoding failed!") - } - - default: - networkError = .JellyfinError(response: errorResponse, displayMessage: displayMessage) - // Able to use user-facing friendly description here since just HTTP status codes - logger - .error( - "Request failed: \(networkError.errorMessage.code) - \(networkError.errorMessage.title): \(networkError.errorMessage.message)\n\(error.localizedDescription)" - ) - } - - self.errorMessage = networkError.errorMessage - - case is SwiftfinStore.Error: - let swiftfinError = error as! SwiftfinStore.Error - let errorMessage = ErrorMessage( - code: ErrorMessage.noShowErrorCode, - title: swiftfinError.title, - message: swiftfinError.errorDescription ?? "" - ) - self.errorMessage = errorMessage - logger.error("Request failed: \(swiftfinError.errorDescription ?? "")") - - default: - let genericErrorMessage = ErrorMessage( - code: ErrorMessage.noShowErrorCode, - title: "Generic Error", - message: error.localizedDescription - ) - self.errorMessage = genericErrorMessage - logger.error("Request failed: Generic error - \(error.localizedDescription)") - } - } - } + init() {} } diff --git a/Shared/Views/AppIcon.swift b/Shared/Views/AppIcon.swift deleted file mode 100644 index b72eb577..00000000 --- a/Shared/Views/AppIcon.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct AppIcon: View { - var body: some View { - Bundle.main.iconFileName - .flatMap { UIImage(named: $0) } - .map { Image(uiImage: $0).resizable() } - } -} diff --git a/Shared/Views/AttributeFillView.swift b/Shared/Views/AttributeFillView.swift deleted file mode 100644 index a363fd4c..00000000 --- a/Shared/Views/AttributeFillView.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 SwiftUI - -// TODO: Replace with `attributeStyle` -struct AttributeFillView: View { - - let text: String - - var body: some View { - Text(text) - .font(.caption) - .fontWeight(.semibold) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .hidden() - .background { - Color(UIColor.lightGray) - .cornerRadius(2) - .inverseMask( - Text(text) - .font(.caption) - .fontWeight(.semibold) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - ) - } - } -} diff --git a/Shared/Views/AttributeOutlineView.swift b/Shared/Views/AttributeOutlineView.swift deleted file mode 100644 index 21b6f158..00000000 --- a/Shared/Views/AttributeOutlineView.swift +++ /dev/null @@ -1,27 +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 - -// TODO: Replace with `attributeStyle` -struct AttributeOutlineView: View { - - let text: String - - var body: some View { - Text(text) - .font(.caption) - .fontWeight(.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/Views/BlurView.swift b/Shared/Views/BlurView.swift index b627f24a..6f0c9494 100644 --- a/Shared/Views/BlurView.swift +++ b/Shared/Views/BlurView.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/Views/Divider.swift b/Shared/Views/Divider.swift index d659fc49..db593356 100644 --- a/Shared/Views/Divider.swift +++ b/Shared/Views/Divider.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/Views/ImageView.swift b/Shared/Views/ImageView.swift index c4cbb160..c5c67c8e 100644 --- a/Shared/Views/ImageView.swift +++ b/Shared/Views/ImageView.swift @@ -3,16 +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 BlurHashKit +import JellyfinAPI import Nuke import NukeUI import SwiftUI import UIKit struct ImageSource: Hashable { + let url: URL? let blurHash: String? @@ -22,45 +24,25 @@ struct ImageSource: Hashable { } } -struct DefaultFailureView: View { - - var body: some View { - Color.secondary - } -} - -struct ImageView: View { +struct ImageView: View { @State private var sources: [ImageSource] - private var image: (NukeUI.Image) -> ImageType - private var placeholder: (() -> PlaceholderView)? - private var failure: () -> FailureView - private var resizingMode: ImageResizingMode - private init( - _ sources: [ImageSource], - resizingMode: ImageResizingMode, - @ViewBuilder image: @escaping (NukeUI.Image) -> ImageType, - placeHolder: (() -> PlaceholderView)?, - @ViewBuilder failureView: @escaping () -> FailureView - ) { - _sources = State(initialValue: sources) - self.resizingMode = resizingMode - self.image = image - self.placeholder = placeHolder - self.failure = failureView - } + private var image: (NukeUI.Image) -> any View + private var placeholder: (() -> any View)? + private var failure: () -> any View + private var resizingMode: ImageResizingMode @ViewBuilder private func _placeholder(_ currentSource: ImageSource) -> some View { if let placeholder = placeholder { placeholder() + .eraseToAnyView() } else if let blurHash = currentSource.blurHash { - BlurHashView(blurHash: blurHash, size: .Circle(radius: 16)) + BlurHashView(blurHash: blurHash, size: .Square(length: 16)) } else { - Color.secondarySystemFill - .opacity(0.5) + DefaultPlaceholderView() } } @@ -71,58 +53,62 @@ struct ImageView: Vie _placeholder(currentSource) } else if let _image = state.image { image(_image.resizingMode(resizingMode)) + .eraseToAnyView() } else if state.error != nil { - failure().onAppear { - sources.removeFirst() - } + failure() + .eraseToAnyView() + .onAppear { + sources.removeFirstSafe() + } } } .pipeline(ImagePipeline(configuration: .withDataCache)) .id(currentSource) } else { failure() + .eraseToAnyView() } } } -extension ImageView where ImageType == NukeUI.Image, PlaceholderView == EmptyView, FailureView == DefaultFailureView { +extension ImageView { init(_ source: ImageSource) { self.init( - [source], - resizingMode: .aspectFill, + sources: [source], image: { $0 }, - placeHolder: nil, - failureView: { DefaultFailureView() } + placeholder: nil, + failure: { DefaultFailureView() }, + resizingMode: .aspectFill ) } init(_ sources: [ImageSource]) { self.init( - sources, - resizingMode: .aspectFill, + sources: sources, image: { $0 }, - placeHolder: nil, - failureView: { DefaultFailureView() } + placeholder: nil, + failure: { DefaultFailureView() }, + resizingMode: .aspectFill ) } init(_ source: URL?) { self.init( - [ImageSource(url: source, blurHash: nil)], - resizingMode: .aspectFill, + sources: [ImageSource(url: source, blurHash: nil)], image: { $0 }, - placeHolder: nil, - failureView: { DefaultFailureView() } + placeholder: nil, + failure: { DefaultFailureView() }, + resizingMode: .aspectFill ) } init(_ sources: [URL?]) { self.init( - sources.map { ImageSource(url: $0, blurHash: nil) }, - resizingMode: .aspectFill, + sources: sources.map { ImageSource(url: $0, blurHash: nil) }, image: { $0 }, - placeHolder: nil, - failureView: { DefaultFailureView() } + placeholder: nil, + failure: { DefaultFailureView() }, + resizingMode: .aspectFill ) } } @@ -130,47 +116,40 @@ extension ImageView where ImageType == NukeUI.Image, PlaceholderView == EmptyVie // MARK: Extensions extension ImageView { - @ViewBuilder - func image(@ViewBuilder _ content: @escaping (NukeUI.Image) -> I) -> ImageView { - ImageView( - sources, - resizingMode: resizingMode, - image: content, - placeHolder: placeholder, - failureView: failure - ) + + func image(@ViewBuilder _ content: @escaping (NukeUI.Image) -> any View) -> Self { + copy(modifying: \.image, with: content) } - @ViewBuilder - func placeholder(@ViewBuilder _ content: @escaping () -> P) -> ImageView { - ImageView( - sources, - resizingMode: resizingMode, - image: image, - placeHolder: content, - failureView: failure - ) + func placeholder(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.placeholder, with: content) } - @ViewBuilder - func failure(@ViewBuilder _ content: @escaping () -> F) -> ImageView { - ImageView( - sources, - resizingMode: resizingMode, - image: image, - placeHolder: placeholder, - failureView: content - ) + func failure(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.failure, with: content) } - @ViewBuilder - func resizingMode(_ resizingMode: ImageResizingMode) -> ImageView { - ImageView( - sources, - resizingMode: resizingMode, - image: image, - placeHolder: placeholder, - failureView: failure - ) + func resizingMode(_ resizingMode: ImageResizingMode) -> Self { + copy(modifying: \.resizingMode, with: resizingMode) + } +} + +// MARK: Defaults + +extension ImageView { + + struct DefaultFailureView: View { + + var body: some View { + Color.secondarySystemFill + } + } + + struct DefaultPlaceholderView: View { + + var body: some View { + Color.secondarySystemFill + .opacity(0.5) + } } } diff --git a/Shared/Views/InitialFailureView.swift b/Shared/Views/InitialFailureView.swift index b0782641..f68a49c8 100644 --- a/Shared/Views/InitialFailureView.swift +++ b/Shared/Views/InitialFailureView.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/Views/PlainNavigationLinkButton.swift b/Shared/Views/PlainNavigationLinkButton.swift index a967c107..5f01be67 100644 --- a/Shared/Views/PlainNavigationLinkButton.swift +++ b/Shared/Views/PlainNavigationLinkButton.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/Views/PosterIndicators/FavoriteIndicator.swift b/Shared/Views/PosterIndicators/FavoriteIndicator.swift new file mode 100644 index 00000000..4b344659 --- /dev/null +++ b/Shared/Views/PosterIndicators/FavoriteIndicator.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 SwiftUI + +struct FavoriteIndicator: View { + + let size: CGFloat + + var body: some View { + ZStack(alignment: .bottomLeading) { + Color.clear + + Image(systemName: "heart.circle.fill") + .resizable() + .frame(width: size, height: size) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .pink) + .padding(3) + } + } +} diff --git a/Shared/Views/PosterIndicators/ProgressIndicator.swift b/Shared/Views/PosterIndicators/ProgressIndicator.swift new file mode 100644 index 00000000..7047c17b --- /dev/null +++ b/Shared/Views/PosterIndicators/ProgressIndicator.swift @@ -0,0 +1,31 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct ProgressIndicator: View { + + @Default(.accentColor) + private var accentColor + + let progress: CGFloat + let height: CGFloat + + var body: some View { + VStack { + Spacer() + + accentColor + .scaleEffect(x: progress, y: 1, anchor: .leading) + .frame(height: height) + } + .frame(maxWidth: .infinity) + } +} diff --git a/Shared/Views/PosterIndicators/UnwatchedIndicator.swift b/Shared/Views/PosterIndicators/UnwatchedIndicator.swift new file mode 100644 index 00000000..77d86373 --- /dev/null +++ b/Shared/Views/PosterIndicators/UnwatchedIndicator.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 Defaults +import SwiftUI + +struct UnwatchedIndicator: View { + + let size: CGFloat + + var body: some View { + ZStack(alignment: .topTrailing) { + Color.clear + + Q3RightTriangle() + .frame(width: size, height: size) + } + } +} + +struct Q3RightTriangle: Shape { + + func path(in rect: CGRect) -> Path { + var path = Path() + + path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + + return path + } +} diff --git a/Shared/Views/PosterIndicators/WatchedIndicator.swift b/Shared/Views/PosterIndicators/WatchedIndicator.swift new file mode 100644 index 00000000..8124bf4c --- /dev/null +++ b/Shared/Views/PosterIndicators/WatchedIndicator.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 SwiftUI + +struct WatchedIndicator: View { + + let size: CGFloat + + var body: some View { + ZStack(alignment: .bottomTrailing) { + Color.clear + + Image(systemName: "checkmark.circle.fill") + .resizable() + .frame(width: size, height: size) + .accentSymbolRendering(accentColor: .white) + .padding(3) + } + } +} diff --git a/Shared/Views/ProgressBar.swift b/Shared/Views/ProgressBar.swift index 3709865e..576b82e5 100644 --- a/Shared/Views/ProgressBar.swift +++ b/Shared/Views/ProgressBar.swift @@ -3,11 +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 +// TODO: Replace scaling with size so that the Capsule corner radius +// is not affected + struct ProgressBar: View { let progress: CGFloat @@ -19,7 +22,6 @@ struct ProgressBar: View { .opacity(0.2) Capsule() - .foregroundColor(.jellyfinPurple) .scaleEffect(x: progress, y: 1, anchor: .leading) } .frame(maxWidth: .infinity) diff --git a/Shared/Views/RotateContentView.swift b/Shared/Views/RotateContentView.swift new file mode 100644 index 00000000..3f0933a9 --- /dev/null +++ b/Shared/Views/RotateContentView.swift @@ -0,0 +1,105 @@ +// +// 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 RotateContentView: UIViewRepresentable { + + @ObservedObject + var proxy: Proxy + + func makeUIView(context: Context) -> UIRotateContentView { + UIRotateContentView(initialView: nil, proxy: proxy) + } + + func updateUIView(_ uiView: UIRotateContentView, context: Context) {} + + class Proxy: ObservableObject { + + weak var rotateContentView: UIRotateContentView? + + func update(_ content: () -> any View) { + + let newHostingController = UIHostingController(rootView: AnyView(content()), ignoreSafeArea: true) + newHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newHostingController.view.backgroundColor = .clear + + rotateContentView?.update(with: newHostingController.view) + } + } +} + +class UIRotateContentView: UIView { + + private(set) var currentView: UIView? + var proxy: RotateContentView.Proxy + + init(initialView: UIView?, proxy: RotateContentView.Proxy) { + self.proxy = proxy + + super.init(frame: .zero) + + proxy.rotateContentView = self + + guard let initialView else { return } + + initialView.translatesAutoresizingMaskIntoConstraints = false + initialView.alpha = 0 + + addSubview(initialView) + NSLayoutConstraint.activate([ + initialView.topAnchor.constraint(equalTo: topAnchor), + initialView.bottomAnchor.constraint(equalTo: bottomAnchor), + initialView.leftAnchor.constraint(equalTo: leftAnchor), + initialView.rightAnchor.constraint(equalTo: rightAnchor), + ]) + + self.currentView = initialView + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(with newView: UIView?) { + + guard let newView else { + UIView.animate(withDuration: 0.3) { + self.currentView?.alpha = 0 + } completion: { _ in + self.currentView?.removeFromSuperview() + self.currentView = newView + } + return + } + + newView.translatesAutoresizingMaskIntoConstraints = false + newView.alpha = 0 + + addSubview(newView) + NSLayoutConstraint.activate([ + newView.topAnchor.constraint(equalTo: topAnchor), + newView.bottomAnchor.constraint(equalTo: bottomAnchor), + newView.leftAnchor.constraint(equalTo: leftAnchor), + newView.rightAnchor.constraint(equalTo: rightAnchor), + ]) + + UIView.animate(withDuration: 0.3) { + newView.alpha = 1 + self.currentView?.alpha = 0 + } completion: { _ in + self.currentView?.removeFromSuperview() + self.currentView = newView + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + currentView?.hitTest(point, with: event) + } +} diff --git a/Shared/Views/SelectorView.swift b/Shared/Views/SelectorView.swift index a0cb0714..1887e798 100644 --- a/Shared/Views/SelectorView.swift +++ b/Shared/Views/SelectorView.swift @@ -3,47 +3,48 @@ // License, v2.0. If 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 // TODO: Implement different behavior types, where selected/unselected -// items appear in different sections -struct SelectorView: View { +// items can appear in different sections + +struct SelectorView: View { + + @Default(.accentColor) + private var accentColor + + @Binding + private var selection: [Item] private let allItems: [Item] - @Binding - private var selectedItems: [Item] + private var label: (Item) -> any View private let type: SelectorType - init(type: SelectorType, allItems: [Item], selectedItems: Binding<[Item]>) { - self.type = type - self.allItems = allItems - self._selectedItems = selectedItems - } - var body: some View { - List { - ForEach(allItems, id: \.displayName) { item in - Button { - switch type { - case .single: - handleSingleSelect(with: item) - case .multi: - handleMultiSelect(with: item) - } - } label: { - HStack { - Text(item.displayName) - .foregroundColor(.primary) + List(allItems) { item in + Button { + switch type { + case .single: + handleSingleSelect(with: item) + case .multi: + handleMultiSelect(with: item) + } + } label: { + HStack { + label(item).eraseToAnyView() - Spacer() + Spacer() - if selectedItems.contains { $0.displayName == item.displayName } { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.jellyfinPurple) - } + if selection.contains { $0.id == item.id } { + Image(systemName: "checkmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .accentSymbolRendering() } } } @@ -51,14 +52,39 @@ struct SelectorView: View { } private func handleSingleSelect(with item: Item) { - selectedItems = [item] + selection = [item] } private func handleMultiSelect(with item: Item) { - if selectedItems.contains(where: { $0.displayName == item.displayName }) { - selectedItems.removeAll(where: { $0.displayName == item.displayName }) + if selection.contains(where: { $0.id == item.id }) { + selection.removeAll(where: { $0.id == item.id }) } else { - selectedItems.append(item) + selection.append(item) } } } + +extension SelectorView { + + init(selection: Binding<[Item]>, allItems: [Item], type: SelectorType) { + self.init( + selection: selection, + allItems: allItems, + label: { Text($0.displayTitle).foregroundColor(.primary) }, + type: type + ) + } + + init(selection: Binding, allItems: [Item]) { + self.init( + selection: .init(get: { [selection.wrappedValue] }, set: { selection.wrappedValue = $0[0] }), + allItems: allItems, + label: { Text($0.displayTitle).foregroundColor(.primary) }, + type: .single + ) + } + + func label(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + copy(modifying: \.label, with: content) + } +} diff --git a/Shared/Views/SeparatorHStack.swift b/Shared/Views/SeparatorHStack.swift new file mode 100644 index 00000000..3658f961 --- /dev/null +++ b/Shared/Views/SeparatorHStack.swift @@ -0,0 +1,72 @@ +// +// 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 + +// https://movingparts.io/variadic-views-in-swiftui + +struct SeparatorHStack: View { + + private var content: () -> any View + private var separator: () -> any View + + var body: some View { + _VariadicView.Tree(SeparatorHStackLayout(separator: separator)) { + AnyView(content()) + } + } +} + +extension SeparatorHStack { + + init(@ViewBuilder _ content: @escaping () -> any View) { + self.init( + content: content, + separator: { Divider() } + ) + } + + func separator(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.separator, with: content) + } +} + +struct SeparatorHStackLayout: _VariadicView_UnaryViewRoot { + + var separator: () -> any View + + @ViewBuilder + func body(children: _VariadicView.Children) -> some View { + + let last = children.last?.id + + localHStack { + ForEach(children) { child in + child + + if child.id != last { + separator() + .eraseToAnyView() + } + } + } + } + + @ViewBuilder + private func localHStack(@ViewBuilder content: @escaping () -> some View) -> some View { + #if os(tvOS) + HStack(spacing: 0) { + content() + } + #else + HStack { + content() + } + #endif + } +} diff --git a/Shared/Views/TextPairView.swift b/Shared/Views/TextPairView.swift new file mode 100644 index 00000000..6c081c5c --- /dev/null +++ b/Shared/Views/TextPairView.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 SwiftUI + +struct TextPairView: View { + + let leading: String + let trailing: String + + var body: some View { + HStack { + Text(leading) + + Spacer() + + Text(trailing) + .foregroundColor(.secondary) + } + } +} + +extension TextPairView { + + init(_ textPair: TextPair) { + self.init( + leading: textPair.displayTitle, + trailing: textPair.subtitle + ) + } +} diff --git a/Shared/Views/TruncatedTextView.swift b/Shared/Views/TruncatedTextView.swift index 1f227949..6504ea39 100644 --- a/Shared/Views/TruncatedTextView.swift +++ b/Shared/Views/TruncatedTextView.swift @@ -3,36 +3,32 @@ // License, v2.0. If 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 struct TruncatedTextView: View { + @Default(.accentColor) + private var accentColor + @State private var truncated: Bool = false @State private var fullSize: CGFloat = 0 - private var font: Font = .body - private var lineLimit: Int = 3 - private var foregroundColor: Color = .primary + private var font: Font + private var lineLimit: Int + private let text: String + private var seeMoreAction: () -> Void + private let seeMoreText = "... \(L10n.seeMore)" - let text: String - let seeMoreAction: () -> Void - let seeMoreText = "... \(L10n.seeMore)" - - public init(text: String, seeMoreAction: @escaping () -> Void) { - self.text = text - self.seeMoreAction = seeMoreAction - } - - public var body: some View { + var body: some View { ZStack(alignment: .bottomTrailing) { Text(text) .font(font) - .foregroundColor(foregroundColor) .lineLimit(lineLimit) .if(truncated) { text in text.mask { @@ -50,9 +46,9 @@ struct TruncatedTextView: View { startPoint: .leading, endPoint: .trailing ) - .frame(width: seeMoreText.widthOfString(usingFont: font.toUIFont()) + 15) + .frame(width: seeMoreText.widthOfString(usingFont: font.uiFont) + 15) } - .frame(height: seeMoreText.heightOfString(usingFont: font.toUIFont())) + .frame(height: seeMoreText.heightOfString(usingFont: font.uiFont)) } } } @@ -61,14 +57,14 @@ struct TruncatedTextView: View { #if os(tvOS) Text(seeMoreText) .font(font) - .foregroundColor(.purple) + .foregroundColor(accentColor) #else Button { seeMoreAction() } label: { Text(seeMoreText) .font(font) - .foregroundColor(.purple) + .foregroundColor(accentColor) } #endif } @@ -112,21 +108,25 @@ struct TruncatedTextView: View { } extension TruncatedTextView { + + init(text: String) { + self.init( + font: .body, + lineLimit: 1000, + text: text, + seeMoreAction: {} + ) + } + func font(_ font: Font) -> Self { - var result = self - result.font = font - return result + copy(modifying: \.font, with: font) } - func lineLimit(_ lineLimit: Int) -> Self { - var result = self - result.lineLimit = lineLimit - return result + func lineLimit(_ limit: Int) -> Self { + copy(modifying: \.lineLimit, with: limit) } - func foregroundColor(_ color: Color) -> Self { - var result = self - result.foregroundColor = color - return result + func seeMoreAction(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.seeMoreAction, with: action) } } diff --git a/Swiftfin tvOS/Views/AboutAppView.swift b/Shared/Views/Wrapped View.swift similarity index 60% rename from Swiftfin tvOS/Views/AboutAppView.swift rename to Shared/Views/Wrapped View.swift index 0fa86c4c..4419bf5a 100644 --- a/Swiftfin tvOS/Views/AboutAppView.swift +++ b/Shared/Views/Wrapped View.swift @@ -3,14 +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 SwiftUI -struct AboutAppView: View { +struct WrappedView: View { + + let content: () -> any View var body: some View { - Text("dud") + content() + .eraseToAnyView() } } diff --git a/Swiftfin tvOS/App/PreferenceUIHosting/PreferenceUIHostingController.swift b/Swiftfin tvOS/App/PreferenceUIHosting/PreferenceUIHostingController.swift new file mode 100644 index 00000000..0dfacccd --- /dev/null +++ b/Swiftfin tvOS/App/PreferenceUIHosting/PreferenceUIHostingController.swift @@ -0,0 +1,133 @@ +// +// 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 +import UIKit + +// MARK: PreferenceUIHostingController + +class PreferenceUIHostingController: UIHostingController { + + init(@ViewBuilder wrappedView: @escaping () -> V) { + let box = Box() + super.init(rootView: AnyView( + wrappedView() + .onPreferenceChange(ViewPreferenceKey.self) { + box.value?._viewPreference = $0 + } + .onPreferenceChange(DidPressMenuPreferenceKey.self) { + box.value?.didPressMenuAction = $0 + } + .onPreferenceChange(DidPressSelectPreferenceKey.self) { + box.value?.didPressSelectAction = $0 + } + )) + box.value = self + + addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenuSelector)) + addButtonPressRecognizer(pressType: .select, action: #selector(didPressSelectSelector)) + } + + @objc + dynamic required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + super.modalPresentationStyle = .fullScreen + } + + private class Box { + weak var value: PreferenceUIHostingController? + init() {} + } + + public var _viewPreference: UIUserInterfaceStyle = .unspecified { + didSet { + overrideUserInterfaceStyle = _viewPreference + } + } + + var didPressMenuAction: ActionHolder = .init(action: {}) + var didPressSelectAction: ActionHolder = .init(action: {}) + + private func addButtonPressRecognizer(pressType: UIPress.PressType, action: Selector) { + let pressRecognizer = UITapGestureRecognizer() + pressRecognizer.addTarget(self, action: action) + pressRecognizer.allowedPressTypes = [NSNumber(value: pressType.rawValue)] + view.addGestureRecognizer(pressRecognizer) + } + + @objc + private func didPressMenuSelector() { + DispatchQueue.main.async { + self.didPressMenuAction.action() + } + } + + @objc + private func didPressSelectSelector() { + DispatchQueue.main.async { + self.didPressSelectAction.action() + } + } +} + +struct ActionHolder: Equatable { + + static func == (lhs: ActionHolder, rhs: ActionHolder) -> Bool { + lhs.uuid == rhs.uuid + } + + var action: () -> Void + let uuid = UUID().uuidString +} + +// MARK: Preference Keys + +struct ViewPreferenceKey: PreferenceKey { + typealias Value = UIUserInterfaceStyle + + static var defaultValue: UIUserInterfaceStyle = .unspecified + + static func reduce(value: inout UIUserInterfaceStyle, nextValue: () -> UIUserInterfaceStyle) { + value = nextValue() + } +} + +struct DidPressMenuPreferenceKey: PreferenceKey { + + static var defaultValue: ActionHolder = .init(action: {}) + + static func reduce(value: inout ActionHolder, nextValue: () -> ActionHolder) { + value = nextValue() + } +} + +struct DidPressSelectPreferenceKey: PreferenceKey { + + static var defaultValue: ActionHolder = .init(action: {}) + + static func reduce(value: inout ActionHolder, nextValue: () -> ActionHolder) { + value = nextValue() + } +} + +// MARK: Preference Key View Extension + +extension View { + + func overrideViewPreference(_ viewPreference: UIUserInterfaceStyle) -> some View { + preference(key: ViewPreferenceKey.self, value: viewPreference) + } + + func onMenuPressed(_ action: @escaping () -> Void) -> some View { + preference(key: DidPressMenuPreferenceKey.self, value: ActionHolder(action: action)) + } + + func onSelectPressed(_ action: @escaping () -> Void) -> some View { + preference(key: DidPressSelectPreferenceKey.self, value: ActionHolder(action: action)) + } +} diff --git a/Swiftfin tvOS/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift b/Swiftfin tvOS/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift new file mode 100644 index 00000000..a56fd452 --- /dev/null +++ b/Swiftfin tvOS/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift @@ -0,0 +1,79 @@ +// +// 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 +import SwizzleSwift +import UIKit + +// MARK: - wrapper view + +/// Wrapper view that will apply swizzling to make iOS query the child view for preference settings. +/// Used in combination with PreferenceUIHostingController. +/// +/// Source: https://gist.github.com/Amzd/01e1f69ecbc4c82c8586dcd292b1d30d +struct PreferenceUIHostingControllerView: UIViewControllerRepresentable { + init(@ViewBuilder wrappedView: @escaping () -> Wrapped) { + _ = UIViewController.preferenceSwizzling + self.wrappedView = wrappedView + } + + var wrappedView: () -> Wrapped + + func makeUIViewController(context: Context) -> PreferenceUIHostingController { + PreferenceUIHostingController { wrappedView() } + } + + func updateUIViewController(_ uiViewController: PreferenceUIHostingController, context: Context) {} +} + +// MARK: - swizzling uiviewcontroller extensions + +extension UIViewController { + static var preferenceSwizzling: Void = { + Swizzle(UIViewController.self) { +// #selector(getter: childForScreenEdgesDeferringSystemGestures) <-> #selector(swizzled_childForScreenEdgesDeferringSystemGestures) +// #selector(getter: childForHomeIndicatorAutoHidden) <-> #selector(swizzled_childForHomeIndicatorAutoHidden) + } + }() +} + +extension UIViewController { + @objc + func swizzled_childForScreenEdgesDeferringSystemGestures() -> UIViewController? { + if self is PreferenceUIHostingController { + // dont continue searching + return nil + } else { + return search() + } + } + + @objc + func swizzled_childForHomeIndicatorAutoHidden() -> UIViewController? { + if self is PreferenceUIHostingController { + // dont continue searching + return nil + } else { + return search() + } + } + + private func search() -> PreferenceUIHostingController? { + if let result = children.compactMap({ $0 as? PreferenceUIHostingController }).first { + return result + } + + for child in children { + if let result = child.search() { + return result + } + } + + return nil + } +} diff --git a/Swiftfin tvOS/App/SwiftfinApp.swift b/Swiftfin tvOS/App/SwiftfinApp.swift new file mode 100644 index 00000000..d0235402 --- /dev/null +++ b/Swiftfin tvOS/App/SwiftfinApp.swift @@ -0,0 +1,41 @@ +// +// 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 Logging +import Pulse +import PulseLogHandler +import SwiftUI + +@main +struct SwiftfinApp: App { + + init() { + + // Logging + LoggingSystem.bootstrap { label in + + var loggers: [LogHandler] = [PersistentLogHandler(label: label).withLogLevel(.trace)] + + #if DEBUG + loggers.append(SwiftfinConsoleLogger()) + #endif + + return MultiplexLogHandler(loggers) + } + + CoreStoreDefaults.dataStack = SwiftfinStore.dataStack + CoreStoreDefaults.logger = SwiftfinCorestoreLogger() + } + + var body: some Scene { + WindowGroup { + MainCoordinator().view() + } + } +} diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/1280x768-back.png b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/1280x768-back.png deleted file mode 100644 index f4ec4eda..00000000 Binary files a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/1280x768-back.png and /dev/null differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json deleted file mode 100644 index 3d73e5f8..00000000 --- a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - }, - "layers" : [ - { - "filename" : "Front.imagestacklayer" - }, - { - "filename" : "Back.imagestacklayer" - } - ] -} diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/512.png b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/512.png deleted file mode 100644 index b5626a5d..00000000 Binary files a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/512.png and /dev/null differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/400x240-back.png b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/400x240-back.png deleted file mode 100644 index 313aad33..00000000 Binary files a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/400x240-back.png and /dev/null differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json deleted file mode 100644 index e1178b2e..00000000 --- a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "images" : [ - { - "filename" : "400x240-back.png", - "idiom" : "tv", - "scale" : "1x" - }, - { - "filename" : "Webp.net-resizeimage.png", - "idiom" : "tv", - "scale" : "2x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Webp.net-resizeimage.png b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Webp.net-resizeimage.png deleted file mode 100644 index 56bcb845..00000000 Binary files a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Webp.net-resizeimage.png and /dev/null differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json deleted file mode 100644 index 3d73e5f8..00000000 --- a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - }, - "layers" : [ - { - "filename" : "Front.imagestacklayer" - }, - { - "filename" : "Back.imagestacklayer" - } - ] -} diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/216.png b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/216.png deleted file mode 100644 index f64eb855..00000000 Binary files a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/216.png and /dev/null differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json deleted file mode 100644 index 597613ac..00000000 --- a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "images" : [ - { - "filename" : "216.png", - "idiom" : "tv", - "scale" : "1x" - }, - { - "filename" : "Webp.net-resizeimage-2.png", - "idiom" : "tv", - "scale" : "2x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Webp.net-resizeimage-2.png b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Webp.net-resizeimage-2.png deleted file mode 100644 index 5059fe96..00000000 Binary files a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Webp.net-resizeimage-2.png and /dev/null differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Contents.json b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Contents.json deleted file mode 100644 index f47ba43d..00000000 --- a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Contents.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "assets" : [ - { - "filename" : "App Icon - App Store.imagestack", - "idiom" : "tv", - "role" : "primary-app-icon", - "size" : "1280x768" - }, - { - "filename" : "App Icon.imagestack", - "idiom" : "tv", - "role" : "primary-app-icon", - "size" : "400x240" - }, - { - "filename" : "Top Shelf Image Wide.imageset", - "idiom" : "tv", - "role" : "top-shelf-image-wide", - "size" : "2320x720" - }, - { - "filename" : "Top Shelf Image.imageset", - "idiom" : "tv", - "role" : "top-shelf-image", - "size" : "1920x720" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json deleted file mode 100644 index d4b5af42..00000000 --- a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "images" : [ - { - "filename" : "top shelf.png", - "idiom" : "tv", - "scale" : "1x" - }, - { - "filename" : "Untitled-1.png", - "idiom" : "tv", - "scale" : "2x" - }, - { - "filename" : "top shelf-1.png", - "idiom" : "tv-marketing", - "scale" : "1x" - }, - { - "filename" : "Untitled-2.png", - "idiom" : "tv-marketing", - "scale" : "2x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-1.png b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-1.png deleted file mode 100644 index 897796d6..00000000 Binary files a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-1.png and /dev/null differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-2.png b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-2.png deleted file mode 100644 index 897796d6..00000000 Binary files a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Untitled-2.png and /dev/null differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf-1.png b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf-1.png deleted file mode 100644 index e2f1dd19..00000000 Binary files a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf-1.png and /dev/null differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf.png b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf.png deleted file mode 100644 index e2f1dd19..00000000 Binary files a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/top shelf.png and /dev/null differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json deleted file mode 100644 index 21e50b6b..00000000 --- a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "images" : [ - { - "filename" : "top shelf.png", - "idiom" : "tv", - "scale" : "1x" - }, - { - "filename" : "Untitled-2.png", - "idiom" : "tv", - "scale" : "2x" - }, - { - "filename" : "top shelf-1.png", - "idiom" : "tv-marketing", - "scale" : "1x" - }, - { - "filename" : "Untitled-1.png", - "idiom" : "tv-marketing", - "scale" : "2x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-1.png b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-1.png deleted file mode 100644 index 1ee0e6c4..00000000 Binary files a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-1.png and /dev/null differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-2.png b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-2.png deleted file mode 100644 index 1ee0e6c4..00000000 Binary files a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Untitled-2.png and /dev/null differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf-1.png b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf-1.png deleted file mode 100644 index 6f204dfa..00000000 Binary files a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf-1.png and /dev/null differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf.png b/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf.png deleted file mode 100644 index 6f204dfa..00000000 Binary files a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/top shelf.png and /dev/null differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/Swiftfin tvOS/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json similarity index 58% rename from Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json rename to Swiftfin tvOS/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json index 7b0faedf..618e9ae3 100644 --- a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json +++ b/Swiftfin tvOS/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json @@ -1,8 +1,8 @@ { "images" : [ { - "filename" : "1280x768-back.png", - "idiom" : "tv" + "filename" : "jellyfin-blob.svg", + "idiom" : "universal" } ], "info" : { diff --git a/Swiftfin tvOS/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg b/Swiftfin tvOS/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg new file mode 100644 index 00000000..db72d151 --- /dev/null +++ b/Swiftfin tvOS/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg @@ -0,0 +1,15 @@ + + + Combined Shape + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin tvOS/Components/ChevronButton.swift b/Swiftfin tvOS/Components/ChevronButton.swift new file mode 100644 index 00000000..59551ddb --- /dev/null +++ b/Swiftfin tvOS/Components/ChevronButton.swift @@ -0,0 +1,63 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2023 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct ChevronButton: View { + + private let title: String + private let subtitle: String? + private var leadingView: () -> any View + private var onSelect: () -> Void + + var body: some View { + Button { + onSelect() + } label: { + HStack { + + leadingView() + .eraseToAnyView() + + Text(title) + .foregroundColor(.primary) + + Spacer() + + if let subtitle { + Text(subtitle) + .foregroundColor(.secondary) + } + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + } + } +} + +extension ChevronButton { + + init(title: String, subtitle: String? = nil) { + self.init( + title: title, + subtitle: subtitle, + leadingView: { EmptyView() }, + onSelect: {} + ) + } + + func leadingView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.leadingView, with: content) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin tvOS/Components/CinematicBackgroundView.swift b/Swiftfin tvOS/Components/CinematicBackgroundView.swift new file mode 100644 index 00000000..a49f7ee2 --- /dev/null +++ b/Swiftfin tvOS/Components/CinematicBackgroundView.swift @@ -0,0 +1,62 @@ +// +// 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 JellyfinAPI +import SwiftUI + +// TODO: better name + +struct CinematicBackgroundView: View { + + @ObservedObject + var viewModel: ViewModel + + @StateObject + private var proxy: RotateContentView.Proxy = .init() + + var initialItem: Item? + + var body: some View { + RotateContentView(proxy: proxy) + .onChange(of: viewModel.currentItem) { newItem in + proxy.update { + ImageView(newItem?.landscapePosterImageSources(maxWidth: UIScreen.main.bounds.width, single: false) ?? []) + .placeholder { + Color.clear + } + .failure { + Color.clear + } + } + } + } + + class ViewModel: ObservableObject { + + @Published + var currentItem: Item? + + private var cancellables = Set() + private var currentItemSubject = CurrentValueSubject(nil) + + init() { + currentItemSubject + .debounce(for: 0.5, scheduler: DispatchQueue.main) + .sink { newItem in + self.currentItem = newItem + } + .store(in: &cancellables) + } + + func select(item: Item) { + guard currentItem != item else { return } + currentItemSubject.send(item) + } + } +} diff --git a/Swiftfin tvOS/Components/CinematicItemSelector.swift b/Swiftfin tvOS/Components/CinematicItemSelector.swift index 0f03b817..b4271644 100644 --- a/Swiftfin tvOS/Components/CinematicItemSelector.swift +++ b/Swiftfin tvOS/Components/CinematicItemSelector.swift @@ -3,31 +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 Combine import JellyfinAPI -import Nuke import SwiftUI -struct CinematicItemSelector< - Item: Poster, - TopContent: View, - ItemContent: View, - ItemImageOverlay: View, - ItemContextMenu: View, - TrailingContent: View ->: View { +// TODO: better name - @ObservedObject - private var viewModel: CinematicBackgroundView.ViewModel = .init() +struct CinematicItemSelector: View { - private var topContent: (Item) -> TopContent - private var itemContent: (Item) -> ItemContent - private var itemImageOverlay: (Item) -> ItemImageOverlay - private var itemContextMenu: (Item) -> ItemContextMenu - private var trailingContent: () -> TrailingContent + @State + private var focusedItem: Item? + + @StateObject + private var viewModel: CinematicBackgroundView.ViewModel = .init() + + private var topContent: (Item) -> any View + private var itemContent: (Item) -> any View + private var itemImageOverlay: (Item) -> any View + private var itemContextMenu: (Item) -> any View + private var trailingContent: () -> any View private var onSelect: (Item) -> Void let items: [Item] @@ -63,7 +60,9 @@ struct CinematicItemSelector< VStack(alignment: .leading, spacing: 10) { if let currentItem = viewModel.currentItem { topContent(currentItem) - .id(currentItem.displayName) + .eraseToAnyView() + .id(currentItem.hashValue) + .transition(.opacity) } PosterHStack(type: .landscape, items: items) @@ -72,115 +71,23 @@ struct CinematicItemSelector< .contextMenu(itemContextMenu) .trailing(trailingContent) .onSelect(onSelect) - .onFocus { item in - viewModel.select(item: item) - } + .focusedItem($focusedItem) } } .frame(height: UIScreen.main.bounds.height - 75) .frame(maxWidth: .infinity) - } - - struct CinematicBackgroundView: UIViewRepresentable { - - @ObservedObject - var viewModel: ViewModel - var initialItem: Item? - - @ViewBuilder - private func imageView(for item: Item?) -> some View { - ImageView(item?.landscapePosterImageSources(maxWidth: UIScreen.main.bounds.width, single: false) ?? []) + .onChange(of: focusedItem) { newValue in + guard let newValue else { return } + viewModel.select(item: newValue) } - - func makeUIView(context: Context) -> UIRotateImageView { - let hostingController = UIHostingController(rootView: imageView(for: initialItem), ignoreSafeArea: true) - return UIRotateImageView(initialView: hostingController.view) - } - - func updateUIView(_ uiView: UIRotateImageView, context: Context) { - let hostingController = UIHostingController(rootView: imageView(for: viewModel.currentItem), ignoreSafeArea: true) - uiView.update(with: hostingController.view) - } - - class ViewModel: ObservableObject { - - @Published - var currentItem: Item? - private var cancellables = Set() - - private var currentItemSubject = CurrentValueSubject(nil) - - init() { - currentItemSubject - .debounce(for: 0.5, scheduler: DispatchQueue.main) - .sink { newItem in - self.currentItem = newItem - } - .store(in: &cancellables) - } - - func select(item: Item) { - guard currentItem != item else { return } - currentItemSubject.send(item) - } - } - } - - class UIRotateImageView: UIView { - - private var currentView: UIView? - - init(initialView: UIView) { - super.init(frame: .zero) - - initialView.translatesAutoresizingMaskIntoConstraints = false - initialView.alpha = 0 - - addSubview(initialView) - NSLayoutConstraint.activate([ - initialView.topAnchor.constraint(equalTo: topAnchor), - initialView.bottomAnchor.constraint(equalTo: bottomAnchor), - initialView.leftAnchor.constraint(equalTo: leftAnchor), - initialView.rightAnchor.constraint(equalTo: rightAnchor), - ]) - - self.currentView = initialView - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func update(with newView: UIView) { - newView.translatesAutoresizingMaskIntoConstraints = false - newView.alpha = 0 - - addSubview(newView) - NSLayoutConstraint.activate([ - newView.topAnchor.constraint(equalTo: topAnchor), - newView.bottomAnchor.constraint(equalTo: bottomAnchor), - newView.leftAnchor.constraint(equalTo: leftAnchor), - newView.rightAnchor.constraint(equalTo: rightAnchor), - ]) - - UIView.animate(withDuration: 0.3) { - newView.alpha = 1 - self.currentView?.alpha = 0 - } completion: { _ in - self.currentView?.removeFromSuperview() - self.currentView = newView - } + .onAppear { + focusedItem = items.first } } } -extension CinematicItemSelector where TopContent == EmptyView, - ItemContent == EmptyView, - ItemImageOverlay == EmptyView, - ItemContextMenu == EmptyView, - TrailingContent == EmptyView -{ +extension CinematicItemSelector { + init(items: [Item]) { self.init( topContent: { _ in EmptyView() }, @@ -196,79 +103,27 @@ extension CinematicItemSelector where TopContent == EmptyView, extension CinematicItemSelector { - @ViewBuilder - func topContent(@ViewBuilder _ content: @escaping (Item) -> T) - -> CinematicItemSelector { - CinematicItemSelector( - topContent: content, - itemContent: itemContent, - itemImageOverlay: itemImageOverlay, - itemContextMenu: itemContextMenu, - trailingContent: trailingContent, - onSelect: onSelect, - items: items - ) + func topContent(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + copy(modifying: \.topContent, with: content) } - @ViewBuilder - func content(@ViewBuilder _ content: @escaping (Item) -> C) - -> CinematicItemSelector { - CinematicItemSelector( - topContent: topContent, - itemContent: content, - itemImageOverlay: itemImageOverlay, - itemContextMenu: itemContextMenu, - trailingContent: trailingContent, - onSelect: onSelect, - items: items - ) + func content(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + copy(modifying: \.itemContent, with: content) } - @ViewBuilder - func itemImageOverlay(@ViewBuilder _ imageOverlay: @escaping (Item) -> O) - -> CinematicItemSelector { - CinematicItemSelector( - topContent: topContent, - itemContent: itemContent, - itemImageOverlay: imageOverlay, - itemContextMenu: itemContextMenu, - trailingContent: trailingContent, - onSelect: onSelect, - items: items - ) + func itemImageOverlay(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + copy(modifying: \.itemImageOverlay, with: content) } - @ViewBuilder - func contextMenu(@ViewBuilder _ contextMenu: @escaping (Item) -> M) - -> CinematicItemSelector { - CinematicItemSelector( - topContent: topContent, - itemContent: itemContent, - itemImageOverlay: itemImageOverlay, - itemContextMenu: contextMenu, - trailingContent: trailingContent, - onSelect: onSelect, - items: items - ) + func contextMenu(@ViewBuilder _ content: @escaping (Item) -> M) -> Self { + copy(modifying: \.itemContextMenu, with: content) } - @ViewBuilder - func trailingContent(@ViewBuilder _ content: @escaping () -> T) - -> CinematicItemSelector { - CinematicItemSelector( - topContent: topContent, - itemContent: itemContent, - itemImageOverlay: itemImageOverlay, - itemContextMenu: itemContextMenu, - trailingContent: content, - onSelect: onSelect, - items: items - ) + func trailingContent(@ViewBuilder _ content: @escaping () -> T) -> Self { + copy(modifying: \.trailingContent, with: content) } func onSelect(_ action: @escaping (Item) -> Void) -> Self { - var copy = self - copy.onSelect = action - return copy + copy(modifying: \.onSelect, with: action) } } diff --git a/Swiftfin tvOS/Components/DotHStack.swift b/Swiftfin tvOS/Components/DotHStack.swift index addab0a3..6a581aa4 100644 --- a/Swiftfin tvOS/Components/DotHStack.swift +++ b/Swiftfin tvOS/Components/DotHStack.swift @@ -3,232 +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 SwiftUI struct DotHStack: View { - private let items: [AnyView] - private let restItems: [AnyView] - private let alignment: HorizontalAlignment + @ViewBuilder + var content: () -> any View var body: some View { - HStack(spacing: 0) { - items.first - - ForEach(0 ..< restItems.count, id: \.self) { i in - + SeparatorHStack(content) + .separator { Circle() .frame(width: 5, height: 5) - .padding(.horizontal) - - restItems[i] + .padding(.horizontal, 10) } - } - } -} - -extension DotHStack { - - init( - _ data: Data, - id: KeyPath = \.self, - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: @escaping (Data.Element) -> Content - ) { - self.alignment = alignment - self.items = data.map { content($0[keyPath: id]).eraseToAnyView() } - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () -> A - ) { - self.alignment = alignment - self.items = [content().eraseToAnyView()] - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () -> TupleView<(A, B)> - ) { - self.alignment = alignment - let _content = content() - - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () -> TupleView<(A, B, C)> - ) { - self.alignment = alignment - let _content = content() - - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - _content.value.2.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () -> TupleView<(A, B, C, D)> - ) { - self.alignment = alignment - let _content = content() - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - _content.value.2.eraseToAnyView(), - _content.value.3.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () -> TupleView<(A, B, C, D, E)> - ) { - self.alignment = alignment - let _content = content() - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - _content.value.2.eraseToAnyView(), - _content.value.3.eraseToAnyView(), - _content.value.4.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () -> TupleView<(A, B, C, D, E, F)> - ) { - self.alignment = alignment - let _content = content() - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - _content.value.2.eraseToAnyView(), - _content.value.3.eraseToAnyView(), - _content.value.4.eraseToAnyView(), - _content.value.5.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () -> TupleView<(A, B, C, D, E, F, G)> - ) { - self.alignment = alignment - let _content = content() - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - _content.value.2.eraseToAnyView(), - _content.value.3.eraseToAnyView(), - _content.value.4.eraseToAnyView(), - _content.value.5.eraseToAnyView(), - _content.value.6.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () - -> TupleView<(A, B, C, D, E, F, G, H)> - ) { - self.alignment = alignment - let _content = content() - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - _content.value.2.eraseToAnyView(), - _content.value.3.eraseToAnyView(), - _content.value.4.eraseToAnyView(), - _content.value.5.eraseToAnyView(), - _content.value.6.eraseToAnyView(), - _content.value.7.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () - -> TupleView<(A, B, C, D, E, F, G, H, I)> - ) { - self.alignment = alignment - let _content = content() - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - _content.value.2.eraseToAnyView(), - _content.value.3.eraseToAnyView(), - _content.value.4.eraseToAnyView(), - _content.value.5.eraseToAnyView(), - _content.value.6.eraseToAnyView(), - _content.value.7.eraseToAnyView(), - _content.value.8.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) - } - - init< - A: View, - B: View, - C: View, - D: View, - E: View, - F: View, - G: View, - H: View, - I: View, - J: View - >( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () - -> TupleView<( - A, - B, - C, - D, - E, - F, - G, - H, - I, - J - )> - ) { - self.alignment = alignment - let _content = content() - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - _content.value.2.eraseToAnyView(), - _content.value.3.eraseToAnyView(), - _content.value.4.eraseToAnyView(), - _content.value.5.eraseToAnyView(), - _content.value.6.eraseToAnyView(), - _content.value.7.eraseToAnyView(), - _content.value.8.eraseToAnyView(), - _content.value.9.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) } } diff --git a/Swiftfin tvOS/Components/EnumPickerView.swift b/Swiftfin tvOS/Components/EnumPickerView.swift new file mode 100644 index 00000000..34cb7648 --- /dev/null +++ b/Swiftfin tvOS/Components/EnumPickerView.swift @@ -0,0 +1,60 @@ +// +// 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 EnumPickerView: View { + + @Binding + private var selection: EnumType + + private var descriptionView: () -> any View + private var title: String? + + var body: some View { + SplitFormWindowView() + .descriptionView(descriptionView) + .contentView { + Section { + ForEach(EnumType.allCases.asArray, id: \.hashValue) { item in + Button { + selection = item + } label: { + HStack { + Text(item.displayTitle) + + Spacer() + + if selection == item { + Image(systemName: "checkmark.circle.fill") + } + } + } + } + } + } + } +} + +extension EnumPickerView { + + init( + title: String? = nil, + selection: Binding + ) { + self.init( + selection: selection, + descriptionView: { EmptyView() }, + title: title + ) + } + + func descriptionView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.descriptionView, with: content) + } +} diff --git a/Swiftfin tvOS/Components/InlineEnumToggle.swift b/Swiftfin tvOS/Components/InlineEnumToggle.swift new file mode 100644 index 00000000..63ef8837 --- /dev/null +++ b/Swiftfin tvOS/Components/InlineEnumToggle.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 SwiftUI + +struct InlineEnumToggle: View { + + @Binding + private var selection: ItemType + + private let title: String + + var body: some View { + Button { + guard let currentSelectionIndex = ItemType.allCases.firstIndex(of: selection) else { return } + + if ItemType.allCases.index(currentSelectionIndex, offsetBy: 1) == ItemType.allCases.endIndex { + selection = ItemType.allCases[ItemType.allCases.startIndex] + } else { + selection = ItemType.allCases[ItemType.allCases.index(currentSelectionIndex, offsetBy: 1)] + } + } label: { + HStack { + Text(title) + + Spacer() + + Text(selection.displayTitle) + .foregroundColor(.secondary) + } + } + } +} + +extension InlineEnumToggle { + + init(title: String, selection: Binding) { + self.init( + selection: selection, + title: title + ) + } +} diff --git a/Swiftfin tvOS/Components/ItemDetailsView.swift b/Swiftfin tvOS/Components/ItemDetailsView.swift deleted file mode 100644 index 8804e4e4..00000000 --- a/Swiftfin tvOS/Components/ItemDetailsView.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 SwiftUI - -// TODO: Replace and remove - -struct ItemDetailsView: View { - - @ObservedObject - var viewModel: ItemViewModel - @FocusState - private var focused: Bool - - var body: some View { - - ZStack(alignment: .leading) { - - Color(UIColor.darkGray).opacity(focused ? 0.2 : 0) - .cornerRadius(30, corners: [.topLeft, .topRight]) - - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 20) { - L10n.information.text - .font(.title3) - .padding(.bottom, 5) - - // ForEach(viewModel.informationItems, id: \.self.title) { informationItem in - // ItemDetail(title: informationItem.title, content: informationItem.content) - // } - } - - Spacer() - - if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - VStack(alignment: .leading, spacing: 20) { - L10n.media.text - .font(.title3) - .padding(.bottom, 5) - - ForEach(selectedVideoPlayerViewModel.mediaItems, id: \.self.title) { mediaItem in - ItemDetail(title: mediaItem.title, content: mediaItem.content) - } - } - } - - Spacer() - } - .ignoresSafeArea() - .focusable() - .focused($focused) - .padding(50) - } - } -} - -fileprivate struct ItemDetail: View { - - let title: String - let content: String - - var body: some View { - VStack(alignment: .leading) { - Text(title) - .font(.body) - Text(content) - .font(.footnote) - .foregroundColor(.secondary) - } - } -} - -struct RoundedCorner: Shape { - - var radius: CGFloat = .infinity - var corners: UIRectCorner = .allCorners - - func path(in rect: CGRect) -> Path { - let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) - return Path(path.cgPath) - } -} - -extension View { - func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { - clipShape(RoundedCorner(radius: radius, corners: corners)) - } -} diff --git a/Swiftfin tvOS/Components/LandscapeItemElement.swift b/Swiftfin tvOS/Components/LandscapeItemElement.swift index f0b4d17e..a9ada745 100644 --- a/Swiftfin tvOS/Components/LandscapeItemElement.swift +++ b/Swiftfin tvOS/Components/LandscapeItemElement.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 @@ -60,7 +60,7 @@ struct LandscapeItemElement: View { .ignoresSafeArea() .overlay( ZStack { - if item.userData?.played ?? false { + if item.userData?.isPlayed ?? false { Image(systemName: "circle.fill") .foregroundColor(.white) Image(systemName: "checkmark.circle.fill") @@ -81,7 +81,7 @@ struct LandscapeItemElement: View { .frame(width: 445, height: 90) .mask(CutOffShadow()) VStack(alignment: .leading) { - Text("CONTINUE • \(item.progress ?? "")") + Text("CONTINUE • \(item.progressLabel ?? "")") .font(.caption) .fontWeight(.medium) .offset(y: 5) diff --git a/Swiftfin tvOS/Components/LandscapePosterProgressBar.swift b/Swiftfin tvOS/Components/LandscapePosterProgressBar.swift index 7dffa7c8..4475ba66 100644 --- a/Swiftfin tvOS/Components/LandscapePosterProgressBar.swift +++ b/Swiftfin tvOS/Components/LandscapePosterProgressBar.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/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift b/Swiftfin tvOS/Components/LiveTVChannelItemElement.swift similarity index 99% rename from Swiftfin tvOS/Views/LiveTVChannelItemElement.swift rename to Swiftfin tvOS/Components/LiveTVChannelItemElement.swift index 5aa16125..e0f086cd 100644 --- a/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift +++ b/Swiftfin tvOS/Components/LiveTVChannelItemElement.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 JellyfinAPI import SwiftUI struct LiveTVChannelItemElement: View { + @FocusState private var focused: Bool @State diff --git a/Swiftfin tvOS/Components/NonePosterButton.swift b/Swiftfin tvOS/Components/NonePosterButton.swift new file mode 100644 index 00000000..e163bb02 --- /dev/null +++ b/Swiftfin tvOS/Components/NonePosterButton.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 SwiftUI + +struct NonePosterButton: View { + + let type: PosterType + + var body: some View { + Button { + ZStack { + ZStack { + Color(UIColor.darkGray) + .opacity(0.5) + + VStack(spacing: 20) { + Image(systemName: "minus.circle") + .font(.title) + .foregroundColor(.secondary) + + L10n.none.text + .font(.title3) + .foregroundColor(.secondary) + } + } + .posterStyle(type: type, width: type.width) + } + } + .buttonStyle(.card) + } +} diff --git a/Swiftfin tvOS/Components/PagingLibraryView.swift b/Swiftfin tvOS/Components/PagingLibraryView.swift index 4d884159..af15cd4c 100644 --- a/Swiftfin tvOS/Components/PagingLibraryView.swift +++ b/Swiftfin tvOS/Components/PagingLibraryView.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 CollectionView @@ -11,46 +11,108 @@ import Defaults import JellyfinAPI import SwiftUI +// TODO: Figure out proper tab bar handling with the collection offset + struct PagingLibraryView: View { - @ObservedObject - var viewModel: PagingLibraryViewModel - private var onSelect: (BaseItemDto) -> Void - + @Default(.Customization.Library.cinematicBackground) + private var cinematicBackground @Default(.Customization.Library.gridPosterType) private var libraryPosterType + @Default(.Customization.showPosterLabels) + private var showPosterLabels - var body: some View { - CollectionView(items: viewModel.items) { _, item, _ in - PosterButton(item: item, type: libraryPosterType) - .onSelect { - onSelect(item) - } - } - .layout { _, layoutEnvironment in - .grid( + @FocusState + private var focusedItem: BaseItemDto? + + @ObservedObject + private var viewModel: PagingLibraryViewModel + + @State + private var presentBackground = false + @State + private var scrollViewOffset: CGPoint = .zero + + @StateObject + private var cinematicBackgroundViewModel: CinematicBackgroundView.ViewModel = .init() + + private var onSelect: (BaseItemDto) -> Void + + private func layout(layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection { + switch libraryPosterType { + case .portrait: + return .grid( layoutEnvironment: layoutEnvironment, layoutMode: .fixedNumberOfColumns(7), lineSpacing: 50 ) + case .landscape: + return .grid( + layoutEnvironment: layoutEnvironment, + layoutMode: .adaptive(withMinItemSize: 400), + lineSpacing: 50, + itemSize: .estimated(400), + sectionInsets: .zero + ) } - .willReachEdge(insets: .init(top: 0, leading: 0, bottom: 600, trailing: 0)) { edge in - if !viewModel.isLoading && edge == .bottom { - viewModel.requestNextPage() + } + + var body: some View { + ZStack { + if cinematicBackground { + CinematicBackgroundView(viewModel: cinematicBackgroundViewModel) + .visible(presentBackground) + .blurred() + } + + CollectionView(items: viewModel.items.elements) { _, item, _ in + PosterButton(item: item, type: libraryPosterType) + .onSelect { + onSelect(item) + } + .focused($focusedItem, equals: item) + } + .layout { _, layoutEnvironment in + layout(layoutEnvironment: layoutEnvironment) + } + .willReachEdge(insets: .init(top: 0, leading: 0, bottom: 600, trailing: 0)) { edge in + if !viewModel.isLoading && edge == .bottom { + viewModel.requestNextPage() + } + } + .scrollViewOffset($scrollViewOffset) + } + .id(libraryPosterType.hashValue) + .id(showPosterLabels) + .onChange(of: focusedItem) { newValue in + guard let newValue else { + withAnimation { + presentBackground = false + } + return + } + + cinematicBackgroundViewModel.select(item: newValue) + + if !presentBackground { + withAnimation { + presentBackground = true + } } } } } extension PagingLibraryView { + init(viewModel: PagingLibraryViewModel) { - self.viewModel = viewModel - self.onSelect = { _ in } + self.init( + viewModel: viewModel, + onSelect: { _ in } + ) } func onSelect(_ action: @escaping (BaseItemDto) -> Void) -> Self { - var copy = self - copy.onSelect = action - return copy + copy(modifying: \.onSelect, with: action) } } diff --git a/Swiftfin tvOS/Components/PosterButton.swift b/Swiftfin tvOS/Components/PosterButton.swift index ff4628d0..ab8ec7eb 100644 --- a/Swiftfin tvOS/Components/PosterButton.swift +++ b/Swiftfin tvOS/Components/PosterButton.swift @@ -3,12 +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 JellyfinAPI import SwiftUI -struct PosterButton: View { +struct PosterButton: View { @FocusState private var isFocused: Bool @@ -17,13 +19,16 @@ struct PosterButton Content - private var imageOverlay: () -> ImageOverlay - private var contextMenu: () -> ContextMenu + private var content: () -> any View + private var imageOverlay: () -> any View + private var contextMenu: () -> any View private var onSelect: () -> Void - private var onFocus: (() -> Void)? private var singleImage: Bool + // Setting the .focused() modifier causes significant performance issues. + // Only set if desiring focus changes + private var onFocusChanged: ((Bool) -> Void)? + private var itemWidth: CGFloat { type.width * itemScale } @@ -38,160 +43,159 @@ struct PosterButton, - ImageOverlay == EmptyView, - ContextMenu == EmptyView -{ +extension PosterButton { + init(item: Item, type: PosterType, singleImage: Bool = false) { self.init( item: item, type: type, itemScale: 1, horizontalAlignment: .leading, - content: { PosterButtonDefaultContentView(item: item) }, - imageOverlay: { EmptyView() }, + content: { DefaultContentView(item: item) }, + imageOverlay: { DefaultOverlay(item: item) }, contextMenu: { EmptyView() }, onSelect: {}, - onFocus: nil, - singleImage: singleImage + singleImage: singleImage, + onFocusChanged: nil ) } } extension PosterButton { + func horizontalAlignment(_ alignment: HorizontalAlignment) -> Self { - var copy = self - copy.horizontalAlignment = alignment - return copy + copy(modifying: \.horizontalAlignment, with: alignment) } func scaleItem(_ scale: CGFloat) -> Self { - var copy = self - copy.itemScale = scale - return copy + copy(modifying: \.itemScale, with: scale) } - @ViewBuilder - func content(@ViewBuilder _ content: @escaping () -> C) -> PosterButton { - PosterButton( - item: item, - type: type, - itemScale: itemScale, - horizontalAlignment: horizontalAlignment, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - onSelect: onSelect, - onFocus: onFocus, - singleImage: singleImage - ) + func content(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.content, with: content) } - @ViewBuilder - func imageOverlay(@ViewBuilder _ imageOverlay: @escaping () -> O) -> PosterButton { - PosterButton( - item: item, - type: type, - itemScale: itemScale, - horizontalAlignment: horizontalAlignment, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - onSelect: onSelect, - onFocus: onFocus, - singleImage: singleImage - ) + func imageOverlay(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.imageOverlay, with: content) } - @ViewBuilder - func contextMenu(@ViewBuilder _ contextMenu: @escaping () -> M) -> PosterButton { - PosterButton( - item: item, - type: type, - itemScale: itemScale, - horizontalAlignment: horizontalAlignment, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - onSelect: onSelect, - onFocus: onFocus, - singleImage: singleImage - ) + func contextMenu(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.contextMenu, with: content) } func onSelect(_ action: @escaping () -> Void) -> Self { - var copy = self - copy.onSelect = action - return copy + copy(modifying: \.onSelect, with: action) } - func onFocus(_ action: @escaping () -> Void) -> Self { - var copy = self - copy.onFocus = action - return copy + func onFocusChanged(_ action: @escaping (Bool) -> Void) -> Self { + copy(modifying: \.onFocusChanged, with: action) } } // MARK: default content view -struct PosterButtonDefaultContentView: View { +extension PosterButton { - let item: Item + struct DefaultContentView: View { - var body: some View { - VStack(alignment: .leading) { - if item.showTitle { - Text(item.displayName) - .font(.footnote) - .fontWeight(.regular) - .foregroundColor(.primary) - .lineLimit(2) + let item: Item + + var body: some View { + VStack(alignment: .leading) { + if item.showTitle { + Text(item.displayTitle) + .font(.footnote) + .fontWeight(.regular) + .foregroundColor(.primary) + .lineLimit(2) + } + + if let description = item.subtitle { + Text(description) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(2) + } } + } + } - if let description = item.subtitle { - Text(description) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(2) + // TODO: Find better way for these indicators, see EpisodeCard + struct DefaultOverlay: View { + + @Default(.Customization.Indicators.showFavorited) + private var showFavorited + @Default(.Customization.Indicators.showProgress) + private var showProgress + @Default(.Customization.Indicators.showUnplayed) + private var showUnplayed + @Default(.Customization.Indicators.showPlayed) + private var showPlayed + + let item: Item + + var body: some View { + ZStack { + if let item = item as? BaseItemDto { + if item.userData?.isPlayed ?? false { + WatchedIndicator(size: 45) + .visible(showPlayed) + } else { + if (item.userData?.playbackPositionTicks ?? 0) > 0 { + ProgressIndicator(progress: (item.userData?.playedPercentage ?? 0) / 100, height: 10) + .visible(showProgress) + } else { + UnwatchedIndicator(size: 45) + .foregroundColor(.jellyfinPurple) + .visible(showUnplayed) + } + } + + if item.userData?.isFavorite ?? false { + FavoriteIndicator(size: 45) + .visible(showFavorited) + } + } } } } diff --git a/Swiftfin tvOS/Components/PosterHStack.swift b/Swiftfin tvOS/Components/PosterHStack.swift index b4a327cf..1148b66d 100644 --- a/Swiftfin tvOS/Components/PosterHStack.swift +++ b/Swiftfin tvOS/Components/PosterHStack.swift @@ -3,23 +3,25 @@ // License, v2.0. If 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 -struct PosterHStack: View { +struct PosterHStack: View { private var title: String? private var type: PosterType private var items: [Item] private var itemScale: CGFloat - private var content: (Item) -> Content - private var imageOverlay: (Item) -> ImageOverlay - private var contextMenu: (Item) -> ContextMenu - private var trailingContent: () -> TrailingContent + private var content: (Item) -> any View + private var imageOverlay: (Item) -> any View + private var contextMenu: (Item) -> any View + private var trailingContent: () -> any View private var onSelect: (Item) -> Void - private var onFocus: ((Item) -> Void)? + + // See PosterButton for implementation reason + private var focusedItem: Binding? var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -41,19 +43,19 @@ struct PosterHStack, - ImageOverlay == EmptyView, - ContextMenu == EmptyView, - TrailingContent == EmptyView -{ +extension PosterHStack { + init( title: String? = nil, type: PosterType, @@ -92,100 +91,43 @@ extension PosterHStack where Content == PosterButtonDefaultContentView, type: type, items: items, itemScale: 1, - content: { PosterButtonDefaultContentView(item: $0) }, - imageOverlay: { _ in EmptyView() }, + content: { PosterButton.DefaultContentView(item: $0) }, + imageOverlay: { PosterButton.DefaultOverlay(item: $0) }, contextMenu: { _ in EmptyView() }, trailingContent: { EmptyView() }, onSelect: { _ in }, - onFocus: nil + focusedItem: nil ) } } extension PosterHStack { + func scaleItems(_ scale: CGFloat) -> Self { - var copy = self - copy.itemScale = scale - return copy + copy(modifying: \.itemScale, with: scale) } - @ViewBuilder - func content(@ViewBuilder _ content: @escaping (Item) -> C) - -> PosterHStack { - PosterHStack( - title: title, - type: type, - items: items, - itemScale: itemScale, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - trailingContent: trailingContent, - onSelect: onSelect, - onFocus: onFocus - ) + func content(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + copy(modifying: \.content, with: content) } - @ViewBuilder - func imageOverlay(@ViewBuilder _ imageOverlay: @escaping (Item) -> O) - -> PosterHStack { - PosterHStack( - title: title, - type: type, - items: items, - itemScale: itemScale, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - trailingContent: trailingContent, - onSelect: onSelect, - onFocus: onFocus - ) + func imageOverlay(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + copy(modifying: \.imageOverlay, with: content) } - @ViewBuilder - func contextMenu(@ViewBuilder _ contextMenu: @escaping (Item) -> M) - -> PosterHStack { - PosterHStack( - title: title, - type: type, - items: items, - itemScale: itemScale, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - trailingContent: trailingContent, - onSelect: onSelect, - onFocus: onFocus - ) + func contextMenu(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { + copy(modifying: \.contextMenu, with: content) } - @ViewBuilder - func trailing(@ViewBuilder _ trailingContent: @escaping () -> T) - -> PosterHStack { - PosterHStack( - title: title, - type: type, - items: items, - itemScale: itemScale, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - trailingContent: trailingContent, - onSelect: onSelect, - onFocus: onFocus - ) + func trailing(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.trailingContent, with: content) } func onSelect(_ action: @escaping (Item) -> Void) -> Self { - var copy = self - copy.onSelect = action - return copy + copy(modifying: \.onSelect, with: action) } - func onFocus(_ action: @escaping (Item) -> Void) -> Self { - var copy = self - copy.onFocus = action - return copy + func focusedItem(_ binding: Binding) -> Self { + copy(modifying: \.focusedItem, with: binding) } } diff --git a/Swiftfin tvOS/Components/SFSymbolButton.swift b/Swiftfin tvOS/Components/SFSymbolButton.swift index 4f6d5af5..ffd7ecf6 100644 --- a/Swiftfin tvOS/Components/SFSymbolButton.swift +++ b/Swiftfin tvOS/Components/SFSymbolButton.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 @@ -11,22 +11,17 @@ import UIKit struct SFSymbolButton: UIViewRepresentable { - let systemName: String - let action: () -> Void + private var onSelect: () -> Void private let pointSize: CGFloat - - init(systemName: String, pointSize: CGFloat = 24, action: @escaping () -> Void) { - self.systemName = systemName - self.action = action - self.pointSize = pointSize - } + private let systemName: String + private let systemNameFocused: String? func makeUIView(context: Context) -> some UIButton { var configuration = UIButton.Configuration.plain() configuration.cornerStyle = .capsule let buttonAction = UIAction(title: "") { _ in - self.action() + self.onSelect() } let button = UIButton(configuration: configuration, primaryAction: buttonAction) @@ -36,18 +31,34 @@ struct SFSymbolButton: UIViewRepresentable { button.setImage(symbolImage, for: .normal) + if let systemNameFocused { + let focusedSymbolImage = UIImage(systemName: systemNameFocused, withConfiguration: symbolImageConfig) + + button.setImage(focusedSymbolImage, for: .focused) + } + return button } func updateUIView(_ uiView: UIViewType, context: Context) {} } -extension SFSymbolButton: Hashable { - static func == (lhs: SFSymbolButton, rhs: SFSymbolButton) -> Bool { - lhs.systemName == rhs.systemName +extension SFSymbolButton { + + init( + systemName: String, + systemNameFocused: String? = nil, + pointSize: CGFloat = 32 + ) { + self.init( + onSelect: {}, + pointSize: pointSize, + systemName: systemName, + systemNameFocused: systemNameFocused + ) } - func hash(into hasher: inout Hasher) { - hasher.combine(systemName) + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) } } diff --git a/Swiftfin tvOS/Components/SeeAllPoster.swift b/Swiftfin tvOS/Components/SeeAllPosterButton.swift similarity index 76% rename from Swiftfin tvOS/Components/SeeAllPoster.swift rename to Swiftfin tvOS/Components/SeeAllPosterButton.swift index a00d434c..ef03a4ad 100644 --- a/Swiftfin tvOS/Components/SeeAllPoster.swift +++ b/Swiftfin tvOS/Components/SeeAllPosterButton.swift @@ -3,12 +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 SwiftUI -struct SeeAllPoster: View { +struct SeeAllPosterButton: View { private let type: PosterType private var onSelect: () -> Void @@ -31,19 +31,20 @@ struct SeeAllPoster: View { } .posterStyle(type: type, width: type.width) } - .buttonStyle(.plain) + .buttonStyle(.card) } } -extension SeeAllPoster { +extension SeeAllPosterButton { + init(type: PosterType) { - self.type = type - self.onSelect = {} + self.init( + type: type, + onSelect: {} + ) } func onSelect(_ action: @escaping () -> Void) -> Self { - var copy = self - copy.onSelect = action - return copy + copy(modifying: \.onSelect, with: action) } } diff --git a/Swiftfin tvOS/Components/ServerButton.swift b/Swiftfin tvOS/Components/ServerButton.swift index aad4056d..b409a08f 100644 --- a/Swiftfin tvOS/Components/ServerButton.swift +++ b/Swiftfin tvOS/Components/ServerButton.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 @@ -27,7 +27,7 @@ struct ServerButton: View { .font(.title2) .foregroundColor(.primary) - Text(server.currentURI) + Text(server.currentURL.absoluteString) .font(.footnote) .disabled(true) .foregroundColor(.secondary) @@ -40,14 +40,13 @@ struct ServerButton: View { } extension ServerButton { + init(server: SwiftfinStore.State.Server) { self.server = server self.onSelect = {} } func onSelect(_ action: @escaping () -> Void) -> Self { - var copy = self - copy.onSelect = action - return copy + copy(modifying: \.onSelect, with: action) } } diff --git a/Swiftfin tvOS/Components/SplitFormWindowView.swift b/Swiftfin tvOS/Components/SplitFormWindowView.swift new file mode 100644 index 00000000..61a1c428 --- /dev/null +++ b/Swiftfin tvOS/Components/SplitFormWindowView.swift @@ -0,0 +1,58 @@ +// +// 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: See if `descriptionTopPadding` is really necessary to fix the navigation bar padding, or just add all the time + +struct SplitFormWindowView: View { + + private var descriptionTopPadding: Bool = false + + private var contentView: () -> any View + private var descriptionView: () -> any View + + var body: some View { + HStack { + + descriptionView() + .eraseToAnyView() + .frame(maxWidth: .infinity) + + Form { + contentView() + .eraseToAnyView() + } + .if(descriptionTopPadding) { view in + view.padding(.top) + } + } + } +} + +extension SplitFormWindowView { + + init() { + self.init( + contentView: { EmptyView() }, + descriptionView: { Color.clear } + ) + } + + func contentView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.contentView, with: content) + } + + func descriptionView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.descriptionView, with: content) + } + + func withDescriptionTopPadding() -> Self { + copy(modifying: \.descriptionTopPadding, with: true) + } +} diff --git a/Swiftfin tvOS/Components/StepperView.swift b/Swiftfin tvOS/Components/StepperView.swift new file mode 100644 index 00000000..f99ca53a --- /dev/null +++ b/Swiftfin tvOS/Components/StepperView.swift @@ -0,0 +1,107 @@ +// +// 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 StepperView: View { + + @Binding + private var value: Value + + private var title: String + private var description: String? + private var range: ClosedRange + private let step: Value.Stride + private var formatter: (Value) -> String + private var onCloseSelected: () -> Void + + var body: some View { + VStack { + VStack { + Spacer() + + Text(title) + .font(.title) + .fontWeight(.semibold) + + if let description { + Text(description) + .padding(.vertical) + } + } + .frame(maxHeight: .infinity) + + formatter(value).text + .font(.title) + .frame(height: 250) + + VStack { + + HStack { + Button { + guard value >= range.lowerBound else { return } + value = value.advanced(by: -step) + } label: { + Image(systemName: "minus") + .font(.title2.weight(.bold)) + .frame(width: 200, height: 75) + } + .buttonStyle(.card) + + Button { + guard value <= range.upperBound else { return } + value = value.advanced(by: step) + } label: { + Image(systemName: "plus") + .font(.title2.weight(.bold)) + .frame(width: 200, height: 75) + } + .buttonStyle(.card) + } + + Button { + onCloseSelected() + } label: { + Text("Close") + } + + Spacer() + } + .frame(maxHeight: .infinity) + } + } +} + +extension StepperView { + + init( + title: String, + description: String? = nil, + value: Binding, + range: ClosedRange, + step: Value.Stride + ) { + self.init( + value: value, + title: title, + description: description, + range: range, + step: step, + formatter: { $0.description }, + onCloseSelected: {} + ) + } + + func valueFormatter(_ formatter: @escaping (Value) -> String) -> Self { + copy(modifying: \.formatter, with: formatter) + } + + func onCloseSelected(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onCloseSelected, with: action) + } +} diff --git a/Swiftfin tvOS/Components/UserProfileButton.swift b/Swiftfin tvOS/Components/UserProfileButton.swift index daef445d..1f6730b4 100644 --- a/Swiftfin tvOS/Components/UserProfileButton.swift +++ b/Swiftfin tvOS/Components/UserProfileButton.swift @@ -3,14 +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 Factory import JellyfinAPI import SwiftUI struct UserProfileButton: View { + @Injected(Container.userSession) + private var userSession + @FocusState private var isFocused: Bool @@ -22,8 +26,8 @@ struct UserProfileButton: View { self.action = {} } - init(user: SwiftfinStore.State.User) { - self.init(user: .init(name: user.username, id: user.id)) + init(user: UserState) { + self.init(user: .init(id: user.id, name: user.username)) } var body: some View { @@ -31,7 +35,7 @@ struct UserProfileButton: View { Button { action() } label: { - ImageView(user.profileImageSource(maxWidth: 250, maxHeight: 250)) + ImageView(user.profileImageSource(client: userSession.client, maxWidth: 250, maxHeight: 250)) .failure { Image(systemName: "person.fill") .resizable() @@ -49,9 +53,8 @@ struct UserProfileButton: View { } extension UserProfileButton { + func onSelect(_ action: @escaping () -> Void) -> Self { - var copy = self - copy.action = action - return copy + copy(modifying: \.action, with: action) } } diff --git a/Swiftfin tvOS/ImageButtonStyle.swift b/Swiftfin tvOS/ImageButtonStyle.swift index 5a286a6f..ed36bce2 100644 --- a/Swiftfin tvOS/ImageButtonStyle.swift +++ b/Swiftfin tvOS/ImageButtonStyle.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 // struct ImageButtonStyle: ButtonStyle { diff --git a/Swiftfin tvOS/Info.plist b/Swiftfin tvOS/Info.plist index 22b92be4..dcd9246a 100644 --- a/Swiftfin tvOS/Info.plist +++ b/Swiftfin tvOS/Info.plist @@ -2,9 +2,9 @@ - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName + UIUserInterfaceStyle + Dark + CFBundledisplayTitle Jellyfin CFBundleExecutable $(EXECUTABLE_NAME) @@ -36,7 +36,5 @@ arm64 - UIUserInterfaceStyle - Dark diff --git a/Swiftfin tvOS/Objects/FocusGuide.swift b/Swiftfin tvOS/Objects/FocusGuide.swift index f0c2898f..0fc6ea0d 100644 --- a/Swiftfin tvOS/Objects/FocusGuide.swift +++ b/Swiftfin tvOS/Objects/FocusGuide.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/Swiftfin tvOS/Views/BasicAppSettingsView.swift b/Swiftfin tvOS/Views/BasicAppSettingsView.swift index c693a361..817018c7 100644 --- a/Swiftfin tvOS/Views/BasicAppSettingsView.swift +++ b/Swiftfin tvOS/Views/BasicAppSettingsView.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,56 +13,69 @@ import SwiftUI struct BasicAppSettingsView: View { @EnvironmentObject - private var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router - @ObservedObject - var viewModel: BasicAppSettingsViewModel - @State - var resetTapped: Bool = false + private var router: BasicAppSettingsCoordinator.Router - @Default(.appAppearance) - var appAppearance + @ObservedObject + var viewModel: SettingsViewModel + + @State + private var resetUserSettingsSelected: Bool = false + @State + private var removeAllServersSelected: Bool = false var body: some View { - Form { + SplitFormWindowView() + .descriptionView { + Image("jellyfin-blob-blue") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { - Section { - Button {} label: { - HStack { - L10n.version.text - Spacer() - Text("\(UIApplication.appVersion ?? .emptyDash) (\(UIApplication.bundleVersion ?? .emptyDash))") - .foregroundColor(.secondary) + Section { + + Button { + TextPairView( + leading: L10n.version, + trailing: "\(UIApplication.appVersion ?? .emptyDash) (\(UIApplication.bundleVersion ?? .emptyDash))" + ) + } + + ChevronButton(title: "Logs") + .onSelect { + router.route(to: \.log) + } + } + + Section { + + Button { + resetUserSettingsSelected = true + } label: { + L10n.resetUserSettings.text + } + + Button { + removeAllServersSelected = true + } label: { + Text("Remove All Servers") } } - } header: { - L10n.about.text } - - // TODO: Implement once design is theme appearance friendly - // Section { - // Picker(L10n.appearance, selection: $appAppearance) { - // ForEach(self.viewModel.appearances, id: \.self) { appearance in - // Text(appearance.localizedName).tag(appearance.rawValue) - // } - // } - // } header: { - // L10n.accessibility.text - // } - - Button { - resetTapped = true - } label: { - L10n.reset.text + .withDescriptionTopPadding() + .navigationTitle(L10n.settings) + .alert(L10n.resetUserSettings, isPresented: $resetUserSettingsSelected) { + Button(L10n.reset, role: .destructive) { + viewModel.resetUserSettings() + } + } message: { + Text("Reset all settings back to defaults.") } - } - .alert(L10n.reset, isPresented: $resetTapped, actions: { - Button(role: .destructive) { - viewModel.resetAppSettings() - basicAppSettingsRouter.dismissCoordinator() - } label: { - L10n.reset.text + .alert("Remove All Servers", isPresented: $removeAllServersSelected) { + Button(L10n.reset, role: .destructive) { + viewModel.removeAllServers() + } } - }) - .navigationTitle(L10n.settings) } } diff --git a/Swiftfin tvOS/Views/BasicLibraryView.swift b/Swiftfin tvOS/Views/BasicLibraryView.swift index ad2f8c63..a88ddc6b 100644 --- a/Swiftfin tvOS/Views/BasicLibraryView.swift +++ b/Swiftfin tvOS/Views/BasicLibraryView.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 CollectionView @@ -15,6 +15,7 @@ struct BasicLibraryView: View { @EnvironmentObject private var router: BasicLibraryCoordinator.Router + @ObservedObject var viewModel: PagingLibraryViewModel @@ -23,6 +24,7 @@ struct BasicLibraryView: View { ProgressView() } + // TODO: add retry @ViewBuilder private var noResultsView: some View { L10n.noResults.text @@ -38,14 +40,12 @@ struct BasicLibraryView: View { } var body: some View { - Group { - if viewModel.isLoading && viewModel.items.isEmpty { - loadingView - } else if viewModel.items.isEmpty { - noResultsView - } else { - libraryItemsView - } + if viewModel.isLoading && viewModel.items.isEmpty { + loadingView + } else if viewModel.items.isEmpty { + noResultsView + } else { + libraryItemsView } } } diff --git a/Swiftfin tvOS/Views/CastAndCrewLibraryView.swift b/Swiftfin tvOS/Views/CastAndCrewLibraryView.swift index 9c459cfe..e84462c5 100644 --- a/Swiftfin tvOS/Views/CastAndCrewLibraryView.swift +++ b/Swiftfin tvOS/Views/CastAndCrewLibraryView.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 CollectionView @@ -14,6 +14,7 @@ struct CastAndCrewLibraryView: View { @EnvironmentObject private var router: CastAndCrewLibraryCoordinator.Router + let people: [BaseItemPerson] @ViewBuilder diff --git a/Swiftfin tvOS/Views/ConnectToServerView.swift b/Swiftfin tvOS/Views/ConnectToServerView.swift index 7d1e8925..4dafb91c 100644 --- a/Swiftfin tvOS/Views/ConnectToServerView.swift +++ b/Swiftfin tvOS/Views/ConnectToServerView.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 @@ -12,49 +12,101 @@ import SwiftUI struct ConnectToServerView: View { + @EnvironmentObject + private var router: ConnectToServerCoodinator.Router + @ObservedObject var viewModel: ConnectToServerViewModel - @State - private var uri = "" - @Default(.defaultHTTPScheme) - private var defaultHTTPScheme + @State + private var connectionError: Error? + @State + private var connectionTask: Task? + @State + private var duplicateServer: (server: ServerState, url: URL)? + @State + private var isConnecting: Bool = false + @State + private var isPresentingConnectionError: Bool = false + @State + private var isPresentingDuplicateServerAlert: Bool = false + @State + private var isPresentingError: Bool = false + @State + private var url = "http://" + + private func connectToServer(at url: String) { + let task = Task { + isConnecting = true + connectionError = nil + + do { + let serverConnection = try await viewModel.connectToServer(url: url) + + if viewModel.isDuplicate(server: serverConnection.server) { + duplicateServer = serverConnection + isPresentingDuplicateServerAlert = true + } else { + try viewModel.save(server: serverConnection.server) + router.route(to: \.userSignIn, serverConnection.server) + } + } catch { + connectionError = error + isPresentingConnectionError = true + } + + isConnecting = false + } + + connectionTask = task + } @ViewBuilder private var connectForm: some View { VStack(alignment: .leading) { - Section { - TextField(L10n.serverURL, text: $uri) - .disableAutocorrection(true) - .autocapitalization(.none) - .keyboardType(.URL) - .onAppear { - if uri == "" { - uri = "\(defaultHTTPScheme.rawValue)://" - } - } + L10n.connectToJellyfinServer.text + + TextField(L10n.serverURL, text: $url) + .disableAutocorrection(true) + .autocapitalization(.none) + .keyboardType(.URL) + + if isConnecting { Button { - viewModel.connectToServer(uri: uri) + connectionTask?.cancel() + isConnecting = false } label: { - HStack { - if viewModel.isLoading { - ProgressView() - } - - L10n.connect.text - .bold() - .font(.callout) - } - .frame(height: 75) - .frame(maxWidth: .infinity) - .background(viewModel.isLoading || uri.isEmpty ? .secondary : Color.jellyfinPurple) + L10n.cancel.text + .foregroundColor(.red) + .bold() + .font(.callout) + .frame(height: 75) + .frame(maxWidth: .infinity) } - .disabled(viewModel.isLoading || uri.isEmpty) - .buttonStyle(.plain) - } header: { - L10n.connectToJellyfinServer.text + .buttonStyle(.card) + } else { + Button { + connectToServer(at: url) + } label: { + L10n.connect.text + .bold() + .font(.callout) + .frame(height: 75) + .frame(maxWidth: .infinity) + .background { + if isConnecting || url.isEmpty { + Color.secondary + } else { + Color.jellyfinPurple + } + } + } + .disabled(isConnecting || url.isEmpty) + .buttonStyle(.card) } + + Spacer() } } @@ -76,7 +128,7 @@ struct ConnectToServerView: View { } @ViewBuilder - private var localServers: some View { + private var publicServers: some View { VStack(alignment: .center) { HStack { @@ -84,32 +136,31 @@ struct ConnectToServerView: View { .font(.title3) .fontWeight(.semibold) - SFSymbolButton(systemName: "arrow.clockwise") { - viewModel.discoverServers() - } - .frame(width: 30, height: 30) - .disabled(viewModel.searching || viewModel.isLoading) + SFSymbolButton(systemName: "arrow.clockwise") + .onSelect { + viewModel.discoverServers() + } + .frame(width: 30, height: 30) + .disabled(viewModel.isSearching || viewModel.isLoading) } - if viewModel.searching { + if viewModel.isSearching { searchingDiscoverServers .frame(maxHeight: .infinity) + } else if viewModel.discoveredServers.isEmpty { + noLocalServersFound + .frame(maxHeight: .infinity) } else { - if viewModel.discoveredServers.isEmpty { - noLocalServersFound - .frame(maxHeight: .infinity) - } else { - ScrollView { - LazyVStack { - ForEach(viewModel.discoveredServers, id: \.self) { server in - ServerButton(server: server) - .onSelect { - viewModel.connectToServer(uri: server.currentURI) - } - } + ScrollView { + VStack { + ForEach(viewModel.discoveredServers, id: \.id) { server in + ServerButton(server: server) + .onSelect { + connectToServer(at: server.currentURL.absoluteString) + } } - .padding() } + .padding() } } } @@ -120,26 +171,26 @@ struct ConnectToServerView: View { connectForm .frame(maxWidth: .infinity) - localServers + publicServers .frame(maxWidth: .infinity) } .navigationTitle(L10n.connect.text) .onAppear { viewModel.discoverServers() } - .alert(item: $viewModel.errorMessage) { _ in - Alert( - title: Text(viewModel.alertTitle), - message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), - dismissButton: .cancel() - ) - } - .alert(item: $viewModel.addServerURIPayload) { _ in - Alert( - title: L10n.existingServer.text, - message: L10n.serverAlreadyExistsPrompt(viewModel.addServerURIPayload?.server.name ?? .emptyDash).text, - dismissButton: .cancel() - ) - } +// .alert(item: $viewModel.errorMessage) { _ in +// Alert( +// title: Text(viewModel.alertTitle), +// message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), +// dismissButton: .cancel() +// ) +// } +// .alert(item: $viewModel.addServerURIPayload) { _ in +// Alert( +// title: L10n.existingServer.text, +// message: L10n.serverAlreadyExistsPrompt(viewModel.addServerURIPayload?.server.name ?? .emptyDash).text, +// dismissButton: .cancel() +// ) +// } } } diff --git a/Swiftfin tvOS/Views/FontPickerView.swift b/Swiftfin tvOS/Views/FontPickerView.swift new file mode 100644 index 00000000..e23e6c4d --- /dev/null +++ b/Swiftfin tvOS/Views/FontPickerView.swift @@ -0,0 +1,54 @@ +// +// 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 SwiftUI + +struct FontPickerView: View { + + @Binding + private var selection: String + + @State + private var updateSelection: String + + init(selection: Binding) { + self._selection = selection + self.updateSelection = selection.wrappedValue + } + + var body: some View { + SplitFormWindowView() + .descriptionView { + Image(systemName: "character.textbox") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + ForEach(UIFont.familyNames, id: \.self) { fontFamily in + Button { + selection = fontFamily + updateSelection = fontFamily + } label: { + HStack { + Text(fontFamily) + .font(.custom(fontFamily, size: 28)) + + Spacer() + + if updateSelection == fontFamily { + Image(systemName: "checkmark.circle.fill") + } + } + } + } + } + .withDescriptionTopPadding() + } +} diff --git a/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift b/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift index 45e420f4..c9b5eb94 100644 --- a/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift +++ b/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.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 @@ -15,6 +15,7 @@ extension HomeView { @EnvironmentObject private var router: HomeCoordinator.Router + @ObservedObject var viewModel: ItemTypeLibraryViewModel @@ -43,7 +44,7 @@ extension HomeView { EmptyView() } .failure { - Text(item.displayName) + Text(item.displayTitle) .font(.largeTitle) .fontWeight(.semibold) } @@ -53,7 +54,7 @@ extension HomeView { router.route(to: \.item, item) } .trailingContent { - SeeAllPoster(type: .landscape) + SeeAllPosterButton(type: .landscape) .onSelect { router.route(to: \.basicLibrary, .init(title: L10n.recentlyAdded, viewModel: viewModel)) } diff --git a/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift b/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift index fc824c86..ebf2823c 100644 --- a/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift +++ b/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.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 @@ -15,6 +15,7 @@ extension HomeView { @EnvironmentObject private var router: HomeCoordinator.Router + @ObservedObject var viewModel: HomeViewModel @@ -43,7 +44,7 @@ extension HomeView { EmptyView() } .failure { - Text(item.displayName) + Text(item.displayTitle) .font(.largeTitle) .fontWeight(.semibold) } @@ -60,7 +61,7 @@ extension HomeView { } .itemImageOverlay { item in LandscapePosterProgressBar( - title: item.progress ?? L10n.continue, + title: item.progressLabel ?? L10n.continue, progress: (item.userData?.playedPercentage ?? 0) / 100 ) } diff --git a/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift b/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift index 69e4e198..534880bb 100644 --- a/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift +++ b/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.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 @@ -15,17 +15,18 @@ extension HomeView { @EnvironmentObject private var router: HomeCoordinator.Router + @StateObject var viewModel: LibraryViewModel var body: some View { PosterHStack( - title: L10n.latestWithString(viewModel.parent?.displayName ?? .emptyDash), + title: L10n.latestWithString(viewModel.parent?.displayTitle ?? .emptyDash), type: .portrait, - items: viewModel.items + items: viewModel.items.prefix(20).asArray ) .trailing { - SeeAllPoster(type: .portrait) + SeeAllPosterButton(type: .portrait) .onSelect { router.route(to: \.library, viewModel.libraryCoordinatorParameters) } diff --git a/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift b/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift index b42e791b..5f0990fa 100644 --- a/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift +++ b/Swiftfin tvOS/Views/HomeView/Components/NextUpView.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 @@ -42,7 +42,7 @@ extension HomeView { router.route(to: \.item, item) } .trailing { - SeeAllPoster(type: nextUpPosterType) + SeeAllPosterButton(type: nextUpPosterType) .onSelect { router.route(to: \.basicLibrary, .init(title: L10n.nextUp, viewModel: viewModel)) } diff --git a/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift b/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift index 67bdaf08..0dced400 100644 --- a/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift +++ b/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.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 @@ -31,7 +31,7 @@ extension HomeView { router.route(to: \.item, item) } .trailing { - SeeAllPoster(type: recentlyAddedPosterType) + SeeAllPosterButton(type: recentlyAddedPosterType) .onSelect { router.route(to: \.basicLibrary, .init(title: L10n.recentlyAdded, viewModel: viewModel)) } diff --git a/Swiftfin tvOS/Views/HomeView/HomeContentView.swift b/Swiftfin tvOS/Views/HomeView/HomeContentView.swift index 071cd56d..87b8dc17 100644 --- a/Swiftfin tvOS/Views/HomeView/HomeContentView.swift +++ b/Swiftfin tvOS/Views/HomeView/HomeContentView.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/Swiftfin tvOS/Views/HomeView/HomeErrorView.swift b/Swiftfin tvOS/Views/HomeView/HomeErrorView.swift index 765e4193..776a8afd 100644 --- a/Swiftfin tvOS/Views/HomeView/HomeErrorView.swift +++ b/Swiftfin tvOS/Views/HomeView/HomeErrorView.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 @@ -30,7 +30,7 @@ extension HomeView { .frame(width: 100, height: 100) } - Text("\(errorMessage.code)") +// Text("\(errorMessage.code)") Text(errorMessage.message) .frame(minWidth: 50, maxWidth: 240) diff --git a/Swiftfin tvOS/Views/HomeView/HomeView.swift b/Swiftfin tvOS/Views/HomeView/HomeView.swift index 63a49ad1..1631f467 100644 --- a/Swiftfin tvOS/Views/HomeView/HomeView.swift +++ b/Swiftfin tvOS/Views/HomeView/HomeView.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 @@ -22,7 +22,10 @@ struct HomeView: View { var body: some View { Group { if let errorMessage = viewModel.errorMessage { - ErrorView(viewModel: viewModel, errorMessage: errorMessage) + ErrorView( + viewModel: viewModel, + errorMessage: .init(message: errorMessage) + ) } else if viewModel.isLoading { ProgressView() } else { diff --git a/Swiftfin tvOS/Views/ItemView/CinematicCollectionItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicCollectionItemView.swift index d9501d88..02e0232d 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicCollectionItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicCollectionItemView.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/Swiftfin tvOS/Views/ItemView/CinematicEpisodeItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicEpisodeItemView.swift index b1f8f599..1c3b3edb 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicEpisodeItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicEpisodeItemView.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/Swiftfin tvOS/Views/ItemView/CinematicItemAboutView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemAboutView.swift index 0957c290..11c82086 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemAboutView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemAboutView.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/Swiftfin tvOS/Views/ItemView/CinematicItemViewTopRow.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemViewTopRow.swift index 553e6e2c..82c47bcf 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemViewTopRow.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemViewTopRow.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/Swiftfin tvOS/Views/ItemView/CinematicSeasonItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicSeasonItemView.swift index 485a654c..f44efa45 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicSeasonItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicSeasonItemView.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/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift b/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift index 6492888b..2c677467 100644 --- a/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift +++ b/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.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/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemView.swift b/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemView.swift index 593abc89..cd3e3ed4 100644 --- a/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemView.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/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift index 69a5e59a..fbe17806 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.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 @@ -35,7 +35,7 @@ extension ItemView { .posterStyle(type: .portrait, width: 270) InformationCard( - title: viewModel.item.displayName, + title: viewModel.item.displayTitle, content: viewModel.item.overview ?? L10n.noOverviewAvailable ) diff --git a/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutViewCard.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutViewCard.swift index 328028f8..7d39a26f 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutViewCard.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutViewCard.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 @@ -29,8 +29,9 @@ extension ItemView.AboutView { .lineLimit(2) Spacer() + .frame(maxWidth: .infinity) - TruncatedTextView(text: content, seeMoreAction: {}) + TruncatedTextView(text: content) .font(.subheadline) .lineLimit(4) } @@ -50,3 +51,12 @@ extension ItemView.AboutView { } } } + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ItemView.AboutView.InformationCard( + title: "Subtitles", + content: "Fre - Default - PGSSUB" + ) + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift index 17a607c6..e669a83c 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.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 @@ -21,9 +21,9 @@ extension ItemView { viewModel.toggleWatchState() } label: { Group { - if viewModel.isWatched { + if viewModel.isPlayed { Image(systemName: "checkmark.circle.fill") - .foregroundColor(.jellyfinPurple) + .accentSymbolRendering(accentColor: .white) } else { Image(systemName: "checkmark.circle") } @@ -39,10 +39,11 @@ extension ItemView { } label: { Group { if viewModel.isFavorited { - Image(systemName: "heart.fill") - .foregroundColor(.red) + Image(systemName: "heart.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .pink) } else { - Image(systemName: "heart") + Image(systemName: "heart.circle") } } .font(.title3) diff --git a/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift index 52613c2d..3996e284 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.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 @@ -16,35 +16,38 @@ extension ItemView { var viewModel: ItemViewModel var body: some View { - HStack(spacing: 0) { + HStack(spacing: 25) { + if let officialRating = viewModel.item.officialRating { - AttributeOutlineView(text: officialRating) - .padding(.trailing) + Text(officialRating) + .asAttributeStyle(.outline) } - if let selectedPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - if selectedPlayerViewModel.item.isHD ?? false { - AttributeFillView(text: "HD") - .padding(.trailing) + if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { + + if mediaStreams.hasHDVideo { + Text("HD") + .asAttributeStyle(.fill) } - if (selectedPlayerViewModel.videoStream.width ?? 0) > 3800 { - AttributeFillView(text: "4K") - .padding(.trailing) + if mediaStreams.has4KVideo { + Text("4K") + .asAttributeStyle(.fill) } - if selectedPlayerViewModel.audioStreams.contains(where: { $0.channelLayout == "5.1" }) { - AttributeFillView(text: "5.1") - .padding(.trailing) + if mediaStreams.has51AudioChannelLayout { + Text("5.1") + .asAttributeStyle(.fill) } - if selectedPlayerViewModel.audioStreams.contains(where: { $0.channelLayout == "7.1" }) { - AttributeFillView(text: "7.1") - .padding(.trailing) + if mediaStreams.has71AudioChannelLayout { + Text("7.1") + .asAttributeStyle(.fill) } - if !selectedPlayerViewModel.subtitleStreams.isEmpty { - AttributeOutlineView(text: "CC") + if mediaStreams.hasSubtitles { + Text("CC") + .asAttributeStyle(.outline) } } } diff --git a/Swiftfin tvOS/Views/ItemView/Components/CastAndCrewHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/CastAndCrewHStack.swift index 865323e1..d565424a 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/CastAndCrewHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/CastAndCrewHStack.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 @@ -15,6 +15,7 @@ extension ItemView { @EnvironmentObject private var router: ItemCoordinator.Router + let people: [BaseItemPerson] var body: some View { @@ -24,10 +25,14 @@ extension ItemView { items: people.filter(\.isDisplayed).prefix(20).asArray ) .trailing { - SeeAllPoster(type: .portrait) - .onSelect { - router.route(to: \.castAndCrew, people) - } + if people.isEmpty { + NonePosterButton(type: .portrait) + } else { + SeeAllPosterButton(type: .portrait) + .onSelect { + router.route(to: \.castAndCrew, people) + } + } } .onSelect { person in router.route(to: \.library, .init(parent: person, type: .person, filters: .init())) diff --git a/Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift b/Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift index fb9d3275..d69afe18 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/PlayButton.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 @@ -15,19 +15,22 @@ extension ItemView { @Injected(LogManager.service) private var logger + @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router + @ObservedObject var viewModel: ItemViewModel + @FocusState - var isFocused: Bool + private var isFocused: Bool var body: some View { Button { - if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) + if let playButtonItem = viewModel.playButtonItem, let selectedMediaSource = viewModel.selectedMediaSource { + router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: playButtonItem, mediaSource: selectedMediaSource)) } else { - logger.error("Attempted to play item but no playback information available") + logger.error("No media source available") } } label: { HStack(spacing: 15) { @@ -51,24 +54,24 @@ extension ItemView { } .focused($isFocused) .buttonStyle(.card) - .contextMenu { - if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { - Button { - if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true) - itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) - } else { - logger.error("Attempted to play item but no playback information available") - } - } label: { - Label(L10n.playFromBeginning, systemImage: "gobackward") - } - - Button(role: .cancel) {} label: { - L10n.cancel.text - } - } - } +// .contextMenu { +// if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { +// Button { +// if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { +// selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true) +// router.route(to: \.videoPlayer, selectedVideoPlayerViewModel) +// } else { +// logger.error("Attempted to play item but no playback information available") +// } +// } label: { +// Label(L10n.playFromBeginning, systemImage: "gobackward") +// } +// +// Button(role: .cancel) {} label: { +// L10n.cancel.text +// } +// } +// } } } } diff --git a/Swiftfin tvOS/Views/ItemView/Components/SimilarItemsHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/SimilarItemsHStack.swift index 009e14b0..9f9c8fcb 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/SimilarItemsHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/SimilarItemsHStack.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 @@ -28,11 +28,15 @@ extension ItemView { items: items ) .trailing { - SeeAllPoster(type: similarPosterType) - .onSelect { - let viewModel = StaticLibraryViewModel(items: items) - router.route(to: \.basicLibrary, .init(title: L10n.recommended, viewModel: viewModel)) - } + if items.isEmpty { + NonePosterButton(type: similarPosterType) + } else { + SeeAllPosterButton(type: similarPosterType) + .onSelect { + let viewModel = StaticLibraryViewModel(items: items) + router.route(to: \.basicLibrary, .init(title: L10n.recommended, viewModel: viewModel)) + } + } } .onSelect { item in router.route(to: \.item, item) diff --git a/Swiftfin tvOS/Views/ItemView/Components/SpecialFeaturesHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/SpecialFeaturesHStack.swift new file mode 100644 index 00000000..c2766344 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/SpecialFeaturesHStack.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 JellyfinAPI +import SwiftUI + +extension ItemView { + + struct SpecialFeaturesHStack: View { + + @EnvironmentObject + private var router: ItemCoordinator.Router + + let items: [BaseItemDto] + + var body: some View { + PosterHStack( + title: "Special Features", + type: .landscape, + items: items + ) + .onSelect { item in + guard let mediaSource = item.mediaSources?.first else { return } + router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: item, mediaSource: mediaSource)) + } + .imageOverlay { _ in + EmptyView() + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/EpisodeItemView/EpisodeItemContentView.swift b/Swiftfin tvOS/Views/ItemView/EpisodeItemView/EpisodeItemContentView.swift index 3c8b23f6..a0099805 100644 --- a/Swiftfin tvOS/Views/ItemView/EpisodeItemView/EpisodeItemContentView.swift +++ b/Swiftfin tvOS/Views/ItemView/EpisodeItemView/EpisodeItemContentView.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 @@ -21,7 +21,7 @@ extension EpisodeItemView { var body: some View { VStack(spacing: 0) { - Self.EpisodeCinematicHeaderView(viewModel: viewModel) + EpisodeCinematicHeaderView(viewModel: viewModel) .frame(height: UIScreen.main.bounds.height - 150) .padding(.bottom, 50) @@ -70,7 +70,7 @@ extension EpisodeItemView.ContentView { } @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router @FocusState private var focusedLayer: CinematicHeaderFocusLayer? @ObservedObject @@ -94,7 +94,7 @@ extension EpisodeItemView.ContentView { .foregroundColor(.secondary) } - Text(viewModel.item.displayName) + Text(viewModel.item.displayTitle) .font(.title2) .fontWeight(.semibold) .lineLimit(1) @@ -104,7 +104,7 @@ extension EpisodeItemView.ContentView { if let overview = viewModel.item.overview { Text(overview) .font(.subheadline) - .lineLimit(4) + .lineLimit(3) } else { L10n.noOverviewAvailable.text } diff --git a/Swiftfin tvOS/Views/ItemView/EpisodeItemView/EpisodeItemView.swift b/Swiftfin tvOS/Views/ItemView/EpisodeItemView/EpisodeItemView.swift index d625d082..1f1812d2 100644 --- a/Swiftfin tvOS/Views/ItemView/EpisodeItemView/EpisodeItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/EpisodeItemView/EpisodeItemView.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/Swiftfin tvOS/Views/ItemView/ItemView.swift b/Swiftfin tvOS/Views/ItemView/ItemView.swift index 46d3c04c..e80287f4 100644 --- a/Swiftfin tvOS/Views/ItemView/ItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/ItemView.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/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift b/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift index 9aa6f9e9..95d72733 100644 --- a/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift +++ b/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemContentView.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 @@ -27,6 +27,10 @@ extension MovieItemView { ItemView.CastAndCrewHStack(people: viewModel.item.people ?? []) + if !viewModel.specialFeatures.isEmpty { + ItemView.SpecialFeaturesHStack(items: viewModel.specialFeatures) + } + ItemView.SimilarItemsHStack(items: viewModel.similarItems) ItemView.AboutView(viewModel: viewModel) diff --git a/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemView.swift b/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemView.swift index 915eac03..50e609b6 100644 --- a/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemView.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/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift b/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift index 22800875..5080173a 100644 --- a/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift +++ b/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.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 @@ -45,7 +45,7 @@ extension ItemView { } @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router @ObservedObject var viewModel: ItemViewModel @FocusState @@ -72,7 +72,7 @@ extension ItemView { EmptyView() } .failure { - Text(viewModel.item.displayName) + Text(viewModel.item.displayTitle) .font(.largeTitle) .fontWeight(.semibold) .lineLimit(2) @@ -86,6 +86,7 @@ extension ItemView { .lineLimit(3) HStack { + DotHStack { if let firstGenre = viewModel.item.genres?.first { firstGenre.text diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift index 82caae98..57fe3b3f 100644 --- a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift +++ b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift @@ -3,43 +3,34 @@ // License, v2.0. If 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 +// TODO: Should episodes also respect some indicator settings? + struct EpisodeCard: View { + @Injected(LogManager.service) + private var logger + @EnvironmentObject private var router: ItemCoordinator.Router - @State - private var cancellables = Set() let episode: BaseItemDto var body: some View { - VStack(alignment: .center, spacing: 20) { - Button { - // TODO: Figure out ad-hoc video player view model creation - episode.createVideoPlayerViewModel() - .sink(receiveCompletion: { _ in }) { viewModels in - guard !viewModels.isEmpty else { return } - self.router.route(to: \.videoPlayer, viewModels[0]) - } - .store(in: &cancellables) - } label: { - ImageView( - episode.imageSource(.primary, maxWidth: 600) - ) - .failure { - InitialFailureView(episode.title.initials) - } - .frame(width: 550, height: 308) - } - .buttonStyle(.card) - + PosterButton( + item: episode, + type: .landscape, + singleImage: true + ) + .scaleItem(1.57) + .content { Button { router.route(to: \.item, episode) } label: { @@ -55,11 +46,11 @@ struct EpisodeCard: View { .foregroundColor(.secondary) } - Text(episode.displayName) + Text(episode.displayTitle) .font(.footnote) .padding(.bottom, 1) - if episode.unaired { + if episode.isUnaired { Text(episode.airDateLabel ?? L10n.noOverviewAvailable) .font(.caption) .lineLimit(1) @@ -74,12 +65,34 @@ struct EpisodeCard: View { L10n.seeMore.text .font(.caption) .fontWeight(.medium) - .foregroundColor(Color(UIColor.systemCyan)) + .foregroundColor(.jellyfinPurple) } .frame(width: 510, height: 220) .padding() } .buttonStyle(.card) } + .imageOverlay { + ZStack { + if episode.userData?.isPlayed ?? false { + WatchedIndicator(size: 45) + } else { + if (episode.userData?.playbackPositionTicks ?? 0) > 0 { + LandscapePosterProgressBar( + title: episode.progressLabel ?? L10n.continue, + progress: (episode.userData?.playedPercentage ?? 0) / 100 + ) + .padding() + } + } + } + } + .onSelect { + guard let mediaSource = episode.mediaSources?.first else { + logger.error("No media source attached to episode", metadata: ["episode title": .string(episode.displayTitle)]) + return + } + router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: episode, mediaSource: mediaSource)) + } } } diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodesView.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodeSelector.swift similarity index 77% rename from Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodesView.swift rename to Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodeSelector.swift index a5774655..8ca8b6f9 100644 --- a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodesView.swift +++ b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodeSelector.swift @@ -3,17 +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 Introspect import JellyfinAPI import SwiftUI -struct SeriesEpisodesView: View { +struct SeriesEpisodeSelector: View { @ObservedObject var viewModel: SeriesItemViewModel + @EnvironmentObject private var parentFocusGuide: FocusGuide @@ -28,7 +29,7 @@ struct SeriesEpisodesView: View { } } -extension SeriesEpisodesView { +extension SeriesEpisodeSelector { // MARK: SeasonsHStack @@ -36,22 +37,24 @@ extension SeriesEpisodesView { @ObservedObject var viewModel: SeriesItemViewModel + @EnvironmentObject private var focusGuide: FocusGuide + @FocusState private var focusedSeason: BaseItemDto? var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack { - ForEach(viewModel.sortedSeasons, id: \.self) { season in - Button {} label: { - Text(season.displayName) + ForEach(viewModel.menuSections.keys.sorted(by: { viewModel.menuSectionSort($0, $1) }), id: \.self) { season in + Button { + Text(season.displayTitle) .fontWeight(.semibold) .fixedSize() .padding(.vertical, 10) .padding(.horizontal, 20) - .if(viewModel.selectedSeason == season) { text in + .if(viewModel.menuSelection == season) { text in text .background(Color.white) .foregroundColor(.black) @@ -65,7 +68,7 @@ extension SeriesEpisodesView { .focusGuide( focusGuide, tag: "seasons", - onContentFocus: { focusedSeason = viewModel.selectedSeason }, + onContentFocus: { focusedSeason = viewModel.menuSelection }, top: "top", bottom: "episodes" ) @@ -76,13 +79,13 @@ extension SeriesEpisodesView { } .onChange(of: focusedSeason) { season in guard let season = season else { return } - viewModel.select(season: season) + viewModel.select(section: season) } } } } -extension SeriesEpisodesView { +extension SeriesEpisodeSelector { // MARK: EpisodesHStack @@ -98,24 +101,31 @@ extension SeriesEpisodesView { @State private var lastFocusedEpisodeID: String? @State - private var currentEpisodes: [BaseItemDto] = [] - @State private var wrappedScrollView: UIScrollView? + private var items: [BaseItemDto] { + guard let selection = viewModel.menuSelection, + let items = viewModel.menuSections[selection] else { return [.noResults] } + return items.compactMap(\._item) + } + var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 40) { - if !currentEpisodes.isEmpty { - ForEach(currentEpisodes, id: \.self) { episode in + if !items.isEmpty { + ForEach(items, id: \.self) { episode in EpisodeCard(episode: episode) .focused($focusedEpisodeID, equals: episode.id) } - } else { + } else if viewModel.isLoading { ForEach(1 ..< 10) { i in EpisodeCard(episode: .placeHolder) .redacted(reason: .placeholder) .focused($focusedEpisodeID, equals: "\(i)") } + } else { + EpisodeCard(episode: .noResults) + .focused($focusedEpisodeID, equals: "no-results") } } .padding(.horizontal, 50) @@ -147,18 +157,16 @@ extension SeriesEpisodesView { .introspectScrollView { scrollView in wrappedScrollView = scrollView } - .onChange(of: viewModel.selectedSeason) { _ in - currentEpisodes = viewModel.currentEpisodes ?? [] - lastFocusedEpisodeID = currentEpisodes.first?.id + .onChange(of: viewModel.menuSelection) { _ in + lastFocusedEpisodeID = items.first?.id wrappedScrollView?.scrollToTop(animated: false) } .onChange(of: focusedEpisodeID) { episodeIndex in guard let episodeIndex = episodeIndex else { return } lastFocusedEpisodeID = episodeIndex } - .onChange(of: viewModel.seasonsEpisodes) { _ in - currentEpisodes = viewModel.currentEpisodes ?? [] - lastFocusedEpisodeID = currentEpisodes.first?.id + .onChange(of: viewModel.menuSections) { _ in + lastFocusedEpisodeID = items.first?.id } } } diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift index 38dcd146..f813ffa8 100644 --- a/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift +++ b/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.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 @@ -14,8 +14,9 @@ extension SeriesItemView { struct ContentView: View { - @ObservedObject + @StateObject private var focusGuide = FocusGuide() + @ObservedObject var viewModel: SeriesItemViewModel @@ -27,7 +28,7 @@ extension SeriesItemView { .frame(height: UIScreen.main.bounds.height - 150) .padding(.bottom, 50) - SeriesEpisodesView(viewModel: viewModel) + SeriesEpisodeSelector(viewModel: viewModel) .environmentObject(focusGuide) ItemView.CastAndCrewHStack(people: viewModel.item.people ?? []) diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemView.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemView.swift index 9f896343..59337178 100644 --- a/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemView.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/Swiftfin tvOS/Views/LibraryView.swift b/Swiftfin tvOS/Views/LibraryView.swift index 94044b86..e07f4195 100644 --- a/Swiftfin tvOS/Views/LibraryView.swift +++ b/Swiftfin tvOS/Views/LibraryView.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 CollectionView @@ -15,6 +15,7 @@ struct LibraryView: View { @EnvironmentObject private var router: LibraryCoordinator.Router + @ObservedObject var viewModel: LibraryViewModel @@ -23,6 +24,7 @@ struct LibraryView: View { ProgressView() } + // TODO: add retry @ViewBuilder private var noResultsView: some View { L10n.noResults.text @@ -52,14 +54,12 @@ struct LibraryView: View { } var body: some View { - Group { - if viewModel.isLoading && viewModel.items.isEmpty { - loadingView - } else if viewModel.items.isEmpty { - noResultsView - } else { - libraryItemsView - } + if viewModel.isLoading && viewModel.items.isEmpty { + loadingView + } else if viewModel.items.isEmpty { + noResultsView + } else { + libraryItemsView } } } diff --git a/Swiftfin tvOS/Views/LiveTVChannelsView.swift b/Swiftfin tvOS/Views/LiveTVChannelsView.swift index 036c1f12..5334c91e 100644 --- a/Swiftfin tvOS/Views/LiveTVChannelsView.swift +++ b/Swiftfin tvOS/Views/LiveTVChannelsView.swift @@ -3,123 +3,90 @@ // License, v2.0. If 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 CollectionView import Foundation import JellyfinAPI import SwiftUI -import SwiftUICollection typealias LiveTVChannelViewProgram = (timeDisplay: String, title: String) struct LiveTVChannelsView: View { + @EnvironmentObject private var router: LiveTVChannelsCoordinator.Router + @StateObject var viewModel = LiveTVChannelsViewModel() - var body: some View { - if viewModel.isLoading == true { - ProgressView() - } else if !viewModel.rows.isEmpty { - CollectionView(rows: viewModel.rows) { _, _ in - createGridLayout() - } cell: { indexPath, cell in - makeCellView(indexPath: indexPath, cell: cell) - } supplementaryView: { _, indexPath in - EmptyView() - .accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea() - .onAppear { - viewModel.startScheduleCheckTimer() - } - .onDisappear { - viewModel.stopScheduleCheckTimer() - } - } else { - VStack { - Text("No results.") - Button { - viewModel.getChannels() - } label: { - Text("Reload") - } - } - } + @ViewBuilder + private var loadingView: some View { + ProgressView() + } + + // TODO: add retry + @ViewBuilder + private var noResultsView: some View { + L10n.noResults.text } @ViewBuilder - func makeCellView(indexPath: IndexPath, cell: LiveTVChannelRowCell) -> some View { - let item = cell.item - let channel = item.channel - let currentProgramDisplayText = item.currentProgram? + private var channelsView: some View { + CollectionView(items: viewModel.channelPrograms) { _, channelProgram, _ in + channelCell(for: channelProgram) + } + .layout { _, layoutEnvironment in + .grid( + layoutEnvironment: layoutEnvironment, + layoutMode: .fixedNumberOfColumns(4), + itemSpacing: 8, + lineSpacing: 16, + itemSize: .estimated(400), + sectionInsets: .zero + ) + } + .ignoresSafeArea() + } + + @ViewBuilder + private func channelCell(for channelProgram: LiveTVChannelProgram) -> some View { + let channel = channelProgram.channel + let currentProgramDisplayText = channelProgram.currentProgram? .programDisplayText(timeFormatter: viewModel.timeFormatter) ?? LiveTVChannelViewProgram(timeDisplay: "", title: "") - let nextItems = item.programs.filter { program in + let nextItems = channelProgram.programs.filter { program in guard let start = program.startDate else { return false } - guard let currentStart = item.currentProgram?.startDate else { + guard let currentStart = channelProgram.currentProgram?.startDate else { return false } return start > currentStart } + LiveTVChannelItemElement( channel: channel, - currentProgram: item.currentProgram, + currentProgram: channelProgram.currentProgram, currentProgramText: currentProgramDisplayText, - nextProgramsText: nextProgramsDisplayText(nextItems: nextItems, timeFormatter: viewModel.timeFormatter), - onSelect: { loadingAction in - loadingAction(true) - self.viewModel.fetchVideoPlayerViewModel(item: channel) { playerViewModel in - self.router.route(to: \.videoPlayer, playerViewModel) - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - loadingAction(false) - } - } + nextProgramsText: nextProgramsDisplayText( + nextItems: nextItems, + timeFormatter: viewModel.timeFormatter + ), + onSelect: { _ in + router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: channel.mediaSources!.first!)) } ) } - private func createGridLayout() -> NSCollectionLayoutSection { - // I don't know why tvOS has a margin on the sides of a collection view - // But it does, even with contentInset = .zero and ignoreSafeArea. - let sideMargin = CGFloat(30) - let itemWidth = (UIScreen.main.bounds.width / 4) - (sideMargin * 2) - let itemSize = NSCollectionLayoutSize( - widthDimension: .absolute(itemWidth), - heightDimension: .absolute(itemWidth) - ) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - item.edgeSpacing = .init( - leading: .fixed(8), - top: .fixed(8), - trailing: .fixed(8), - bottom: .fixed(8) - ) - - let groupSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(itemWidth) - ) - let group = NSCollectionLayoutGroup.horizontal( - layoutSize: groupSize, - subitems: [item] - ) - group.edgeSpacing = .init( - leading: .fixed(0), - top: .fixed(16), - trailing: .fixed(0), - bottom: .fixed(16) - ) - group.contentInsets = .zero - - let section = NSCollectionLayoutSection(group: group) - section.contentInsets = .zero - - return section + var body: some View { + if viewModel.isLoading && viewModel.channels.isEmpty { + loadingView + } else if viewModel.channels.isEmpty { + noResultsView + } else { + channelsView + } } private func nextProgramsDisplayText(nextItems: [BaseItemDto], timeFormatter: DateFormatter) -> [LiveTVChannelViewProgram] { diff --git a/Swiftfin tvOS/Views/LiveTVHomeView.swift b/Swiftfin tvOS/Views/LiveTVHomeView.swift index 475c64a3..6ba93595 100644 --- a/Swiftfin tvOS/Views/LiveTVHomeView.swift +++ b/Swiftfin tvOS/Views/LiveTVHomeView.swift @@ -3,21 +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 Foundation import SwiftUI struct LiveTVHomeView: View { + @EnvironmentObject var mainCoordinator: MainCoordinator.Router var body: some View { - Button {} label: { - Text("Return Home") - }.onAppear { - self.mainCoordinator.root(\.mainTab) - } + Button("Return Home") + .onAppear { + mainCoordinator.root(\.mainTab) + } } } diff --git a/Swiftfin tvOS/Views/LiveTVProgramsView.swift b/Swiftfin tvOS/Views/LiveTVProgramsView.swift index f40d3e89..6c16d724 100644 --- a/Swiftfin tvOS/Views/LiveTVProgramsView.swift +++ b/Swiftfin tvOS/Views/LiveTVProgramsView.swift @@ -3,17 +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 CollectionView import Foundation import SwiftUI struct LiveTVProgramsView: View { + @EnvironmentObject - private var programsRouter: LiveTVProgramsCoordinator.Router + private var router: LiveTVProgramsCoordinator.Router + @StateObject - var viewModel = LiveTVProgramsViewModel() + var viewModel: LiveTVProgramsViewModel var body: some View { ScrollView { @@ -30,13 +33,11 @@ struct LiveTVProgramsView: View { Spacer().frame(width: 45) ForEach(items, id: \.id) { item in Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } + guard let channelID = item.channelID, + let channel = viewModel.findChannel(id: channelID), + let mediaSource = channel.mediaSources?.first else { return } + + router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource)) } label: { LandscapeItemElement(item: item) } @@ -58,13 +59,11 @@ struct LiveTVProgramsView: View { Spacer().frame(width: 45) ForEach(items, id: \.id) { item in Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } + guard let channelID = item.channelID, + let channel = viewModel.findChannel(id: channelID), + let mediaSource = channel.mediaSources?.first else { return } + + router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource)) } label: { LandscapeItemElement(item: item) } @@ -86,13 +85,11 @@ struct LiveTVProgramsView: View { Spacer().frame(width: 45) ForEach(items, id: \.id) { item in Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } + guard let channelID = item.channelID, + let channel = viewModel.findChannel(id: channelID), + let mediaSource = channel.mediaSources?.first else { return } + + router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource)) } label: { LandscapeItemElement(item: item) } @@ -114,13 +111,11 @@ struct LiveTVProgramsView: View { Spacer().frame(width: 45) ForEach(items, id: \.id) { item in Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } + guard let channelID = item.channelID, + let channel = viewModel.findChannel(id: channelID), + let mediaSource = channel.mediaSources?.first else { return } + + router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource)) } label: { LandscapeItemElement(item: item) } @@ -142,13 +137,11 @@ struct LiveTVProgramsView: View { Spacer().frame(width: 45) ForEach(items, id: \.id) { item in Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } + guard let channelID = item.channelID, + let channel = viewModel.findChannel(id: channelID), + let mediaSource = channel.mediaSources?.first else { return } + + router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource)) } label: { LandscapeItemElement(item: item) } @@ -170,13 +163,11 @@ struct LiveTVProgramsView: View { Spacer().frame(width: 45) ForEach(items, id: \.id) { item in Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } + guard let channelID = item.channelID, + let channel = viewModel.findChannel(id: channelID), + let mediaSource = channel.mediaSources?.first else { return } + + router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource)) } label: { LandscapeItemElement(item: item) } @@ -188,5 +179,7 @@ struct LiveTVProgramsView: View { } } } + .edgesIgnoringSafeArea(.bottom) + .edgesIgnoringSafeArea(.horizontal) } } diff --git a/Swiftfin tvOS/Views/MediaView.swift b/Swiftfin tvOS/Views/MediaView.swift index b832a55b..8aac222b 100644 --- a/Swiftfin tvOS/Views/MediaView.swift +++ b/Swiftfin tvOS/Views/MediaView.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 CollectionView +import Defaults import JellyfinAPI import Stinsen import SwiftUI @@ -17,37 +18,23 @@ struct MediaView: View { private var tabRouter: MainCoordinator.Router @EnvironmentObject private var router: MediaCoordinator.Router + @ObservedObject var viewModel: MediaViewModel var body: some View { - CollectionView(items: viewModel.libraryItems) { _, item, _ in - PosterButton(item: item, type: .landscape) - .scaleItem(1.12) + CollectionView(items: viewModel.libraryItems) { _, viewModel, _ in + LibraryCard(viewModel: viewModel) .onSelect { - switch item.library.collectionType { + switch viewModel.item.collectionType { case "favorites": - router.route(to: \.library, .init(parent: item.library, type: .library, filters: .favorites)) + router.route(to: \.library, .init(parent: viewModel.item, type: .library, filters: .favorites)) case "folders": - router.route(to: \.library, .init(parent: item.library, type: .folders, filters: .init())) + router.route(to: \.library, .init(parent: viewModel.item, type: .folders, filters: .init())) case "liveTV": tabRouter.root(\.liveTV) default: - router.route(to: \.library, .init(parent: item.library, type: .library, filters: .init())) - } - } - .imageOverlay { - ZStack { - Color.black - .opacity(0.5) - - Text(item.library.displayName) - .foregroundColor(.white) - .font(.title2) - .fontWeight(.semibold) - .lineLimit(2) - .multilineTextAlignment(.center) - .frame(alignment: .center) + router.route(to: \.library, .init(parent: viewModel.item, type: .library, filters: .init())) } } } @@ -63,3 +50,66 @@ struct MediaView: View { .ignoresSafeArea() } } + +extension MediaView { + + struct LibraryCard: View { + + @ObservedObject + var viewModel: MediaItemViewModel + + private var onSelect: () -> Void + + private var itemWidth: CGFloat { + PosterType.landscape.width * (UIDevice.isPhone ? 0.85 : 1) + } + + var body: some View { + Button { + onSelect() + } label: { + Group { + if let imageSources = viewModel.imageSources { + ImageView(imageSources) + } else { + ImageView(nil) + } + } + .overlay { + if Defaults[.Customization.Library.randomImage] || + viewModel.item.collectionType == "favorites" + { + ZStack { + Color.black + .opacity(0.5) + + Text(viewModel.item.displayTitle) + .foregroundColor(.white) + .font(.title2) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(alignment: .center) + } + } + } + .posterStyle(type: .landscape, width: itemWidth) + } + .buttonStyle(.card) + } + } +} + +extension MediaView.LibraryCard { + + init(viewModel: MediaItemViewModel) { + self.init( + viewModel: viewModel, + onSelect: {} + ) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin tvOS/Views/SearchView.swift b/Swiftfin tvOS/Views/SearchView.swift index 7cd97a50..1e130425 100644 --- a/Swiftfin tvOS/Views/SearchView.swift +++ b/Swiftfin tvOS/Views/SearchView.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 @@ -13,26 +13,13 @@ struct SearchView: View { @EnvironmentObject private var router: SearchCoordinator.Router + @ObservedObject var viewModel: SearchViewModel @State private var searchText = "" - @ViewBuilder - private var suggestionsView: some View { - VStack(spacing: 20) { - ForEach(viewModel.suggestions, id: \.id) { item in - Button { - searchText = item.displayName - } label: { - Text(item.displayName) - .font(.body) - } - } - } - } - @ViewBuilder private var resultsView: some View { ScrollView(showsIndicators: false) { @@ -42,8 +29,7 @@ struct SearchView: View { } if !viewModel.collections.isEmpty { - // TODO: Localize after organization - itemsSection(title: "Collections", keyPath: \.collections) + itemsSection(title: L10n.collections, keyPath: \.collections) } if !viewModel.series.isEmpty { @@ -55,8 +41,7 @@ struct SearchView: View { } if !viewModel.people.isEmpty { - // TODO: Localize after organization - itemsSection(title: "People", keyPath: \.people) + itemsSection(title: L10n.people, keyPath: \.people) } } } diff --git a/Swiftfin tvOS/Views/ServerDetailView.swift b/Swiftfin tvOS/Views/ServerDetailView.swift index 05ab8fbc..7dbfbcc4 100644 --- a/Swiftfin tvOS/Views/ServerDetailView.swift +++ b/Swiftfin tvOS/Views/ServerDetailView.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 @@ -14,37 +14,38 @@ struct ServerDetailView: View { var viewModel: ServerDetailViewModel var body: some View { - Form { - Section(header: L10n.serverDetails.text) { - HStack { - L10n.name.text - Spacer() - Text(SessionManager.main.currentLogin.server.name) - .foregroundColor(.secondary) - } - .focusable() + SplitFormWindowView() + .descriptionView { + Image(systemName: "server.rack") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + Section(header: L10n.serverDetails.text) { - HStack { - L10n.url.text - Spacer() - Text(SessionManager.main.currentLogin.server.currentURI) - .foregroundColor(.secondary) - } + TextPairView( + leading: L10n.name, + trailing: viewModel.server.name + ) - HStack { - L10n.version.text - Spacer() - Text(SessionManager.main.currentLogin.server.version) - .foregroundColor(.secondary) - } + TextPairView( + leading: L10n.url, + trailing: viewModel.server.currentURL.absoluteString + ) - HStack { - L10n.operatingSystem.text - Spacer() - Text(SessionManager.main.currentLogin.server.os) - .foregroundColor(.secondary) + TextPairView( + leading: L10n.version, + trailing: viewModel.server.version + ) + + TextPairView( + leading: L10n.operatingSystem, + trailing: viewModel.server.os + ) } } - } + .withDescriptionTopPadding() + .navigationTitle(L10n.server) } } diff --git a/Swiftfin tvOS/Views/ServerListView.swift b/Swiftfin tvOS/Views/ServerListView.swift index 5072bdf7..02198513 100644 --- a/Swiftfin tvOS/Views/ServerListView.swift +++ b/Swiftfin tvOS/Views/ServerListView.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 CollectionView @@ -13,6 +13,7 @@ struct ServerListView: View { @EnvironmentObject private var router: ServerListCoordinator.Router + @ObservedObject var viewModel: ServerListViewModel @@ -75,22 +76,22 @@ struct ServerListView: View { .navigationTitle(L10n.servers) .if(!viewModel.servers.isEmpty) { view in view.toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - router.route(to: \.connectToServer) - } label: { - Image(systemName: "plus.circle.fill") - } - .contextMenu { - Button { - router.route(to: \.basicAppSettings) - } label: { - L10n.settings.text + ToolbarItem(placement: .navigationBarTrailing) { + SFSymbolButton(systemName: "plus.circle.fill") + .onSelect { + router.route(to: \.connectToServer) } - } } } } +// .toolbar { +// ToolbarItem(placement: .navigationBarLeading) { +// SFSymbolButton(systemName: "gearshape.fill") +// .onSelect { +// router.route(to: \.basicAppSettings) +// } +// } +// } .alert(item: $longPressedServer) { server in Alert( title: Text(server.name), diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift index 2182944d..0f6c040a 100644 --- a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.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 @@ -11,18 +11,92 @@ import SwiftUI struct CustomizeViewsSettings: View { + @Default(.Customization.shouldShowMissingSeasons) + private var shouldShowMissingSeasons + @Default(.Customization.shouldShowMissingEpisodes) + private var shouldShowMissingEpisodes + @Default(.Customization.showPosterLabels) - var showPosterLabels + private var showPosterLabels + @Default(.Customization.nextUpPosterType) + private var nextUpPosterType + @Default(.Customization.recentlyAddedPosterType) + private var recentlyAddedPosterType + @Default(.Customization.latestInLibraryPosterType) + private var latestInLibraryPosterType + @Default(.Customization.similarPosterType) + private var similarPosterType + @Default(.Customization.searchPosterType) + private var searchPosterType + @Default(.Customization.Library.gridPosterType) + private var libraryGridPosterType + + @Default(.Customization.Library.cinematicBackground) + private var cinematicBackground + @Default(.Customization.Library.randomImage) + private var libraryRandomImage + @Default(.Customization.Library.showFavorites) + private var showFavorites + + @EnvironmentObject + private var router: SettingsCoordinator.Router var body: some View { - Form { - Section { - - Toggle(L10n.showPosterLabels, isOn: $showPosterLabels) - - } header: { - L10n.customize.text + SplitFormWindowView() + .descriptionView { + Image(systemName: "gearshape") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) } - } + .contentView { + + Section { + + Toggle(L10n.showMissingSeasons, isOn: $shouldShowMissingSeasons) + + Toggle(L10n.showMissingEpisodes, isOn: $shouldShowMissingEpisodes) + } header: { + L10n.missingItems.text + } + + Section { + + ChevronButton(title: "Indicators") + .onSelect { + router.route(to: \.indicatorSettings) + } + + Toggle(L10n.showPosterLabels, isOn: $showPosterLabels) + + InlineEnumToggle(title: L10n.next, selection: $nextUpPosterType) + + InlineEnumToggle(title: L10n.recentlyAdded, selection: $recentlyAddedPosterType) + + InlineEnumToggle(title: L10n.latestWithString(L10n.library), selection: $latestInLibraryPosterType) + + InlineEnumToggle(title: L10n.recommended, selection: $similarPosterType) + + InlineEnumToggle(title: L10n.search, selection: $searchPosterType) + + InlineEnumToggle(title: L10n.library, selection: $libraryGridPosterType) + + } header: { + Text("Posters") + } + + Section { + + Toggle("Cinematic Background", isOn: $cinematicBackground) + + Toggle("Random Image", isOn: $libraryRandomImage) + + Toggle("Show Favorites", isOn: $showFavorites) + } header: { + L10n.library.text + } + } + .withDescriptionTopPadding() + .navigationTitle(L10n.customize) } } diff --git a/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift index f0bf2e5b..c3aee860 100644 --- a/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.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 @@ -12,48 +12,44 @@ import SwiftUI struct ExperimentalSettingsView: View { @Default(.Experimental.forceDirectPlay) - var forceDirectPlay + private var forceDirectPlay @Default(.Experimental.syncSubtitleStateWithAdjacent) - var syncSubtitleStateWithAdjacent - @Default(.Experimental.nativePlayer) - var nativePlayer - @Default(.Experimental.usefmp4Hls) - var usefmp4Hls + private var syncSubtitleStateWithAdjacent @Default(.Experimental.liveTVAlphaEnabled) - var liveTVAlphaEnabled + private var liveTVAlphaEnabled @Default(.Experimental.liveTVForceDirectPlay) - var liveTVForceDirectPlay - @Default(.Experimental.liveTVNativePlayer) - var liveTVNativePlayer + private var liveTVForceDirectPlay var body: some View { - Form { - Section { - - Toggle("Force Direct Play", isOn: $forceDirectPlay) - - Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent) - - Toggle("Native Player", isOn: $nativePlayer) - - Toggle("Use fmp4 with HLS", isOn: $usefmp4Hls) - - } header: { - L10n.experimental.text + SplitFormWindowView() + .descriptionView { + Image(systemName: "gearshape") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) } + .contentView { + Section { - Section { + Toggle("Force Direct Play", isOn: $forceDirectPlay) - Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled) + Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent) - Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay) + } header: { + L10n.experimental.text + } - Toggle("Live TV Native Player", isOn: $liveTVNativePlayer) + Section { - } header: { - Text("Live TV") + Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled) + + Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay) + + } header: { + Text("Live TV") + } } - } + .navigationTitle(L10n.experimental) } } diff --git a/Swiftfin tvOS/Views/SettingsView/IndicatorSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/IndicatorSettingsView.swift new file mode 100644 index 00000000..5fc0b5b9 --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/IndicatorSettingsView.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 Defaults +import SwiftUI + +// TODO: show a sample poster to model indicators + +struct IndicatorSettingsView: View { + + @Default(.Customization.Indicators.showFavorited) + private var showFavorited + @Default(.Customization.Indicators.showProgress) + private var showProgress + @Default(.Customization.Indicators.showUnplayed) + private var showUnwatched + @Default(.Customization.Indicators.showPlayed) + private var showWatched + + var body: some View { + SplitFormWindowView() + .descriptionView { + Image(systemName: "checkmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + + Section { + + Toggle("Show Favorited", isOn: $showFavorited) + + Toggle("Show Progress", isOn: $showProgress) + + Toggle("Show Unwatched", isOn: $showUnwatched) + + Toggle("Show Watched", isOn: $showWatched) + } + } + .withDescriptionTopPadding() + .navigationTitle("Indicators") + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/MissingItemsSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/MissingItemsSettingsView.swift deleted file mode 100644 index cfc1a06f..00000000 --- a/Swiftfin tvOS/Views/SettingsView/MissingItemsSettingsView.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct MissingItemsSettingsView: View { - - @Default(.shouldShowMissingSeasons) - var shouldShowMissingSeasons - - @Default(.shouldShowMissingEpisodes) - var shouldShowMissingEpisodes - - var body: some View { - Form { - Section { - Toggle(L10n.showMissingSeasons, isOn: $shouldShowMissingSeasons) - Toggle(L10n.showMissingEpisodes, isOn: $shouldShowMissingEpisodes) - } header: { - L10n.missingItems.text - } - } - } -} diff --git a/Swiftfin tvOS/Views/SettingsView/OverlaySettingsView.swift b/Swiftfin tvOS/Views/SettingsView/OverlaySettingsView.swift deleted file mode 100644 index 032d309f..00000000 --- a/Swiftfin tvOS/Views/SettingsView/OverlaySettingsView.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 SwiftUI - -struct OverlaySettingsView: View { - - @Default(.shouldShowPlayPreviousItem) - var shouldShowPlayPreviousItem - @Default(.shouldShowPlayNextItem) - var shouldShowPlayNextItem - @Default(.shouldShowAutoPlay) - var shouldShowAutoPlay - - var body: some View { - Form { - Section(header: L10n.overlay.text) { - - Toggle(isOn: $shouldShowPlayPreviousItem) { - HStack { - Image(systemName: "chevron.left.circle") - L10n.playPreviousItem.text - } - } - - Toggle(isOn: $shouldShowPlayNextItem) { - HStack { - Image(systemName: "chevron.right.circle") - L10n.playNextItem.text - } - } - - Toggle(isOn: $shouldShowAutoPlay) { - HStack { - Image(systemName: "play.circle.fill") - L10n.autoPlay.text - } - } - } - } - } -} diff --git a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift index e506858a..076ba21a 100644 --- a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift @@ -3,154 +3,94 @@ // License, v2.0. If 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 CoreData import Defaults +import Factory import JellyfinAPI import SwiftUI struct SettingsView: View { + @Default(.VideoPlayer.videoPlayerType) + private var videoPlayerType + @EnvironmentObject - private var settingsRouter: SettingsCoordinator.Router + private var router: SettingsCoordinator.Router + @ObservedObject var viewModel: SettingsViewModel - @Default(.videoPlayerJumpForward) - var jumpForwardLength - @Default(.videoPlayerJumpBackward) - var jumpBackwardLength - @Default(.downActionShowsMenu) - var downActionShowsMenu - @Default(.confirmClose) - var confirmClose - @Default(.resumeOffset) - var resumeOffset - @Default(.subtitleSize) - var subtitleSize - var body: some View { - GeometryReader { reader in - HStack { + SplitFormWindowView() + .descriptionView { + Image("jellyfin-blob-blue") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + Section { - Image(uiImage: UIImage(named: "App Icon")!) - .cornerRadius(30) - .scaleEffect(2) - .frame(width: reader.size.width / 2) - - Form { - Section(header: EmptyView()) { - - Button {} label: { - HStack { - L10n.user.text - Spacer() - Text(viewModel.user.username) - .foregroundColor(.jellyfinPurple) - } - } - - Button { - settingsRouter.route(to: \.serverDetail) - } label: { - HStack { - L10n.server.text - .foregroundColor(.primary) - Spacer() - Text(viewModel.server.name) - .foregroundColor(.jellyfinPurple) - - Image(systemName: "chevron.right") - .foregroundColor(.jellyfinPurple) - } - } - - Button { - SessionManager.main.logout() - } label: { - L10n.switchUser.text - .foregroundColor(Color.jellyfinPurple) - .font(.callout) - } + Button {} label: { + TextPairView( + leading: L10n.user, + trailing: viewModel.userSession.user.username + ) } - Section(header: L10n.videoPlayer.text) { - Picker(L10n.jumpForwardLength, selection: $jumpForwardLength) { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in - Text(length.label).tag(length.rawValue) - } - } - - Picker(L10n.jumpBackwardLength, selection: $jumpBackwardLength) { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in - Text(length.label).tag(length.rawValue) - } - } - - Toggle(L10n.resume5SecondOffset, isOn: $resumeOffset) - - Toggle(L10n.pressDownForMenu, isOn: $downActionShowsMenu) - - Toggle(L10n.confirmClose, isOn: $confirmClose) - - Button { - settingsRouter.route(to: \.overlaySettings) - } label: { - HStack { - L10n.overlay.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } - - Button { - settingsRouter.route(to: \.experimentalSettings) - } label: { - HStack { - L10n.experimental.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } + ChevronButton( + title: L10n.server, + subtitle: viewModel.userSession.server.name + ) + .onSelect { + router.route(to: \.serverDetail, viewModel.userSession.server) } - Section(header: L10n.accessibility.text) { - Button { - settingsRouter.route(to: \.customizeViewsSettings) - } label: { - HStack { - L10n.customize.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } - - Picker(L10n.subtitleSize, selection: $subtitleSize) { - ForEach(SubtitleSize.allCases, id: \.self) { size in - Text(size.label).tag(size.rawValue) - } - } - } - - Section { - Button {} label: { - HStack { - L10n.version.text - Spacer() - Text("\(UIApplication.appVersion ?? .emptyDash) (\(UIApplication.bundleVersion ?? .emptyDash))") - .foregroundColor(.secondary) - } - } - } header: { - L10n.about.text + Button { + viewModel.signOut() + } label: { + L10n.switchUser.text + .foregroundColor(.jellyfinPurple) } } + + Section { + + InlineEnumToggle(title: "Video Player Type", selection: $videoPlayerType) + + ChevronButton(title: L10n.videoPlayer) + .onSelect { + router.route(to: \.videoPlayerSettings) + } + } header: { + L10n.videoPlayer.text + } + + Section { + + ChevronButton(title: L10n.customize) + .onSelect { + router.route(to: \.customizeViewsSettings) + } + + ChevronButton(title: L10n.experimental) + .onSelect { + router.route(to: \.experimentalSettings) + } + + } header: { + L10n.accessibility.text + } + + Section { + + ChevronButton(title: "Logs") + .onSelect { + router.route(to: \.log) + } + } } - } } } diff --git a/Swiftfin tvOS/Views/SettingsView/VideoPlayerSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/VideoPlayerSettingsView.swift new file mode 100644 index 00000000..affd878b --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/VideoPlayerSettingsView.swift @@ -0,0 +1,78 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2023 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +struct VideoPlayerSettingsView: View { + + @Default(.VideoPlayer.Subtitle.subtitleFontName) + private var subtitleFontName + + @Default(.VideoPlayer.jumpBackwardLength) + private var jumpBackwardLength + @Default(.VideoPlayer.jumpForwardLength) + private var jumpForwardLength + @Default(.VideoPlayer.resumeOffset) + private var resumeOffset + + @EnvironmentObject + private var router: VideoPlayerSettingsCoordinator.Router + + @State + private var isPresentingResumeOffsetStepper: Bool = false + + var body: some View { + SplitFormWindowView() + .descriptionView { + Image(systemName: "tv") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + + Section { + ChevronButton( + title: "Resume Offset", + subtitle: resumeOffset.secondFormat + ) + .onSelect { + isPresentingResumeOffsetStepper = true + } + } footer: { + Text("Resume content seconds before the recorded resume time") + } + + Section { + ChevronButton(title: L10n.subtitleFont, subtitle: subtitleFontName) + .onSelect { + router.route(to: \.fontPicker, $subtitleFontName) + } + } footer: { + Text("Settings only affect some subtitle types") + } + } + .navigationTitle("Video Player") + .blurFullScreenCover(isPresented: $isPresentingResumeOffsetStepper) { + StepperView( + title: "Resume Offset", + description: "Resume content seconds before the recorded resume time", + value: $resumeOffset, + range: 0 ... 30, + step: 1 + ) + .valueFormatter { + $0.secondFormat + } + .onCloseSelected { + isPresentingResumeOffsetStepper = false + } + } + } +} diff --git a/Swiftfin tvOS/Views/UserListView.swift b/Swiftfin tvOS/Views/UserListView.swift index fb14db70..9a275e4e 100644 --- a/Swiftfin tvOS/Views/UserListView.swift +++ b/Swiftfin tvOS/Views/UserListView.swift @@ -3,17 +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 CollectionView +import Factory import JellyfinAPI import SwiftUI struct UserListView: View { @EnvironmentObject - private var userListRouter: UserListCoordinator.Router + private var router: UserListCoordinator.Router + @ObservedObject var viewModel: UserListViewModel @@ -52,7 +54,7 @@ struct UserListView: View { .font(.body) Button { - userListRouter.route(to: \.userSignIn, viewModel.server) + router.route(to: \.userSignIn, viewModel.server) } label: { L10n.signIn.text .bold() @@ -66,7 +68,8 @@ struct UserListView: View { var body: some View { ZStack { - ImageView(ImageAPI.getSplashscreenWithRequestBuilder().url) + + ImageView(viewModel.userSession.client.fullURL(with: Paths.getSplashscreen())) .ignoresSafeArea() Color.black @@ -85,14 +88,13 @@ struct UserListView: View { view.toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { - userListRouter.route(to: \.userSignIn, viewModel.server) + router.route(to: \.userSignIn, viewModel.server) } label: { Image(systemName: "person.crop.circle.fill.badge.plus") } } } } - .alert(item: $longPressedUser) { user in Alert( title: Text(user.username), diff --git a/Swiftfin tvOS/Views/UserSignInView.swift b/Swiftfin tvOS/Views/UserSignInView.swift index e696416c..e7061bff 100644 --- a/Swiftfin tvOS/Views/UserSignInView.swift +++ b/Swiftfin tvOS/Views/UserSignInView.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 CollectionView @@ -18,17 +18,22 @@ struct UserSignInView: View { case password } + @FocusState + private var focusedField: FocusedField? + @ObservedObject var viewModel: UserSignInViewModel + @State - private var username: String = "" + private var isPresentingQuickConnect: Bool = false @State private var password: String = "" @State - private var presentQuickConnect: Bool = false - - @FocusState - private var focusedField: FocusedField? + private var signInError: Error? + @State + private var signInTask: Task? + @State + private var username: String = "" @ViewBuilder private var signInForm: some View { @@ -45,7 +50,19 @@ struct UserSignInView: View { .focused($focusedField, equals: .password) Button { - viewModel.signIn(username: username, password: password) + let task = Task { + viewModel.isLoading = true + + do { + try await viewModel.signIn(username: username, password: password) + } catch { + signInError = error + } + + viewModel.isLoading = false + } + + signInTask = task } label: { HStack { if viewModel.isLoading { @@ -61,17 +78,17 @@ struct UserSignInView: View { .background(viewModel.isLoading || username.isEmpty ? .secondary : Color.jellyfinPurple) } .disabled(viewModel.isLoading || username.isEmpty) - .buttonStyle(.plain) + .buttonStyle(.card) Button { - presentQuickConnect = true + isPresentingQuickConnect = true } label: { L10n.quickConnect.text .frame(height: 75) .frame(maxWidth: .infinity) - .background(Color.secondary) + .background(Color.jellyfinPurple) } - .buttonStyle(.plain) + .buttonStyle(.card) } header: { L10n.signInToServer(viewModel.server.name).text } @@ -115,42 +132,41 @@ struct UserSignInView: View { @ViewBuilder private var quickConnect: some View { - ZStack { + VStack(alignment: .center) { + L10n.quickConnect.text + .font(.title3) + .fontWeight(.semibold) - BlurView() - .ignoresSafeArea() + VStack(alignment: .leading, spacing: 20) { + L10n.quickConnectStep1.text - VStack(alignment: .center) { - L10n.quickConnect.text - .font(.title3) - .fontWeight(.semibold) + L10n.quickConnectStep2.text - VStack(alignment: .leading, spacing: 20) { - L10n.quickConnectStep1.text - - L10n.quickConnectStep2.text - - L10n.quickConnectStep3.text - } - .padding(.vertical) - - Text(viewModel.quickConnectCode ?? "------") - .tracking(10) - .font(.title) - .monospacedDigit() - .frame(maxWidth: .infinity) - - Button { - presentQuickConnect = false - } label: { - L10n.close.text - .frame(width: 400, height: 75) - } - .buttonStyle(.plain) + L10n.quickConnectStep3.text } + .padding(.vertical) + + Text(viewModel.quickConnectCode ?? "------") + .tracking(10) + .font(.title) + .monospacedDigit() + .frame(maxWidth: .infinity) + + Button { + isPresentingQuickConnect = false + } label: { + L10n.close.text + .frame(width: 400, height: 75) + } + .buttonStyle(.plain) } .onAppear { - viewModel.startQuickConnect {} + Task { + for await result in viewModel.startQuickConnect() { + guard let secret = result.secret else { continue } + try? await viewModel.signIn(quickConnectSecret: secret) + } + } } .onDisappear { viewModel.stopQuickConnectAuthCheck() @@ -159,7 +175,8 @@ struct UserSignInView: View { var body: some View { ZStack { - ImageView(ImageAPI.getSplashscreenWithRequestBuilder().url) + + ImageView(viewModel.userSession.client.fullURL(with: Paths.getSplashscreen())) .ignoresSafeArea() Color.black @@ -176,15 +193,25 @@ struct UserSignInView: View { .edgesIgnoringSafeArea(.bottom) } .navigationTitle(L10n.signIn) - .alert(item: $viewModel.errorMessage) { _ in - Alert( - title: Text(viewModel.alertTitle), - message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), - dismissButton: .cancel() - ) - } - .fullScreenCover(isPresented: $presentQuickConnect, onDismiss: nil) { +// .alert(item: $viewModel.errorMessage) { _ in +// Alert( +// title: Text(viewModel.alertTitle), +// message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), +// dismissButton: .cancel() +// ) +// } + .blurFullScreenCover(isPresented: $isPresentingQuickConnect) { quickConnect } + .onAppear { + Task { + try? await viewModel.checkQuickConnect() + try? await viewModel.getPublicUsers() + } + } + .onDisappear { + viewModel.isLoading = false + viewModel.stopQuickConnectAuthCheck() + } } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/Components/LoadingView.swift b/Swiftfin tvOS/Views/VideoPlayer/Components/LoadingView.swift new file mode 100644 index 00000000..26761193 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/Components/LoadingView.swift @@ -0,0 +1,45 @@ +// +// 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 + +extension VideoPlayer { + + struct LoadingView: View { + + @EnvironmentObject + private var router: VideoPlayerCoordinator.Router + + var body: some View { + ZStack { + Color.black + + VStack(spacing: 10) { + + Text("Retrieving media information") + .foregroundColor(.white) + + ProgressView() + + Button { + router.dismissCoordinator() + } label: { + Text("Cancel") + .foregroundColor(.red) + .padding() + .overlay { + Capsule() + .stroke(Color.red, lineWidth: 1) + } + } + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveTVNativeVideoPlayerView.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveTVNativeVideoPlayerView.swift deleted file mode 100644 index 89ba1563..00000000 --- a/Swiftfin tvOS/Views/VideoPlayer/LiveTVNativeVideoPlayerView.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 SwiftUI -import UIKit - -struct LiveTVNativeVideoPlayerView: UIViewControllerRepresentable { - - let viewModel: VideoPlayerViewModel - - typealias UIViewControllerType = NativePlayerViewController - - func makeUIViewController(context: Context) -> NativePlayerViewController { - NativePlayerViewController(viewModel: viewModel) - } - - func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {} -} diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveTVPlayerViewController.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveTVPlayerViewController.swift deleted file mode 100644 index 99e34836..00000000 --- a/Swiftfin tvOS/Views/VideoPlayer/LiveTVPlayerViewController.swift +++ /dev/null @@ -1,926 +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 AVFoundation -import AVKit -import Combine -import Defaults -import Factory -import JellyfinAPI -import MediaPlayer -import SwiftUI -import TVVLCKit -import UIKit - -// TODO: Look at making the VLC player layer a view - -class LiveTVPlayerViewController: UIViewController { - - @Injected(LogManager.service) - private var logger - - // MARK: variables - - private var viewModel: VideoPlayerViewModel - private var vlcMediaPlayer: VLCMediaPlayer - private var lastPlayerTicks: Int64 = 0 - private var lastProgressReportTicks: Int64 = 0 - private var viewModelListeners = Set() - private var overlayDismissTimer: Timer? - private var confirmCloseOverlayDismissTimer: Timer? - - private var currentPlayerTicks: Int64 { - Int64(vlcMediaPlayer.time.intValue) * 100_000 - } - - private var displayingOverlay: Bool { - currentOverlayHostingController?.view.alpha ?? 0 > 0 - } - - private var displayingContentOverlay: Bool { - currentOverlayContentHostingController?.view.alpha ?? 0 > 0 - } - - private var displayingConfirmClose: Bool { - currentConfirmCloseHostingController?.view.alpha ?? 0 > 0 - } - - private lazy var videoContentView = makeVideoContentView() - private lazy var jumpBackwardOverlayView = makeJumpBackwardOverlayView() - private lazy var jumpForwardOverlayView = makeJumpForwardOverlayView() - private var currentOverlayHostingController: UIHostingController? - private var currentOverlayContentHostingController: UIHostingController? - private var currentConfirmCloseHostingController: UIHostingController? - - // MARK: init - - init(viewModel: VideoPlayerViewModel) { - - self.viewModel = viewModel - self.vlcMediaPlayer = VLCMediaPlayer() - - super.init(nibName: nil, bundle: nil) - - viewModel.playerOverlayDelegate = self - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupSubviews() { - view.addSubview(videoContentView) - view.addSubview(jumpForwardOverlayView) - view.addSubview(jumpBackwardOverlayView) - - jumpBackwardOverlayView.alpha = 0 - jumpForwardOverlayView.alpha = 0 - } - - private func setupConstraints() { - NSLayoutConstraint.activate([ - videoContentView.topAnchor.constraint(equalTo: view.topAnchor), - videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), - videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor), - ]) - NSLayoutConstraint.activate([ - jumpBackwardOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 300), - jumpBackwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - NSLayoutConstraint.activate([ - jumpForwardOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -300), - jumpForwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - } - - // MARK: viewWillDisappear - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - didSelectClose() - - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) - defaultNotificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) - defaultNotificationCenter.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) - } - - // MARK: viewDidLoad - - override func viewDidLoad() { - super.viewDidLoad() - - setupSubviews() - setupConstraints() - - view.backgroundColor = .black - - setupMediaPlayer(newViewModel: viewModel) - - setupPanGestureRecognizer() - - addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenu)) - - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.addObserver( - self, - selector: #selector(appWillTerminate), - name: UIApplication.willTerminateNotification, - object: nil - ) - defaultNotificationCenter.addObserver( - self, - selector: #selector(appWillResignActive), - name: UIApplication.willResignActiveNotification, - object: nil - ) - defaultNotificationCenter.addObserver( - self, - selector: #selector(appWillResignActive), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - } - - @objc - private func appWillTerminate() { - viewModel.sendStopReport() - } - - @objc - private func appWillResignActive() { - showOverlay() - - stopOverlayDismissTimer() - - vlcMediaPlayer.pause() - - viewModel.sendPauseReport(paused: true) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - startPlayback() - } - - // MARK: subviews - - private func makeVideoContentView() -> UIView { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .black - - return view - } - - private func makeJumpBackwardOverlayView() -> UIImageView { - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) - let forwardSymbolImage = UIImage(systemName: viewModel.jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) - let imageView = UIImageView(image: forwardSymbolImage) - imageView.translatesAutoresizingMaskIntoConstraints = false - - return imageView - } - - private func makeJumpForwardOverlayView() -> UIImageView { - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) - let forwardSymbolImage = UIImage(systemName: viewModel.jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) - let imageView = UIImageView(image: forwardSymbolImage) - imageView.translatesAutoresizingMaskIntoConstraints = false - - return imageView - } - - private func setupPanGestureRecognizer() { - let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(userPanned(panGestureRecognizer:))) - view.addGestureRecognizer(panGestureRecognizer) - } - - // MARK: pressesBegan - - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - guard let buttonPress = presses.first?.type else { return } - - switch buttonPress { - case .menu: () // Captured by other recognizer - case .playPause: - hideConfirmCloseOverlay() - - didSelectMain() - case .select: - hideConfirmCloseOverlay() - - didGenerallyTap() - case .upArrow: - hideConfirmCloseOverlay() - case .downArrow: - hideConfirmCloseOverlay() - - if Defaults[.downActionShowsMenu] { - if !displayingContentOverlay && !displayingOverlay { - didSelectMenu() - } - } - case .leftArrow: - hideConfirmCloseOverlay() - - if !displayingContentOverlay && !displayingOverlay { - didSelectBackward() - } - case .rightArrow: - hideConfirmCloseOverlay() - - if !displayingContentOverlay && !displayingOverlay { - didSelectForward() - } - case .pageUp: () - case .pageDown: () - @unknown default: () - } - } - - private func addButtonPressRecognizer(pressType: UIPress.PressType, action: Selector) { - let pressRecognizer = UITapGestureRecognizer() - pressRecognizer.addTarget(self, action: action) - pressRecognizer.allowedPressTypes = [NSNumber(value: pressType.rawValue)] - view.addGestureRecognizer(pressRecognizer) - } - - // MARK: didPressMenu - - @objc - private func didPressMenu() { - if displayingOverlay { - hideOverlay() - } else if displayingContentOverlay { - hideOverlayContent() - } else if viewModel.confirmClose && !displayingConfirmClose { - - showConfirmCloseOverlay() - restartConfirmCloseDismissTimer() - - } else { - vlcMediaPlayer.pause() - - dismiss(animated: true, completion: nil) - } - } - - @objc - private func userPanned(panGestureRecognizer: UIPanGestureRecognizer) { - if displayingOverlay { - restartOverlayDismissTimer() - } - } - - // MARK: setupOverlayHostingController - - private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { - - // TODO: Look at injecting viewModel into the environment so it updates the current overlay - - // Main overlay - if let currentOverlayHostingController = currentOverlayHostingController { - // UX fade-out - UIView.animate(withDuration: 0.5) { - currentOverlayHostingController.view.alpha = 0 - } completion: { _ in - currentOverlayHostingController.view.isHidden = true - - currentOverlayHostingController.view.removeFromSuperview() - currentOverlayHostingController.removeFromParent() - } - } - - let newOverlayView = tvOSLiveTVOverlay(viewModel: viewModel) - let newOverlayHostingController = UIHostingController(rootView: newOverlayView) - - newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newOverlayHostingController.view.backgroundColor = UIColor.clear - - // UX fade-in - newOverlayHostingController.view.alpha = 0 - - addChild(newOverlayHostingController) - view.addSubview(newOverlayHostingController.view) - newOverlayHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - // UX fade-in - UIView.animate(withDuration: 0.5) { - newOverlayHostingController.view.alpha = 1 - } - - self.currentOverlayHostingController = newOverlayHostingController - - // Media Stream selection - if let currentOverlayContentHostingController = currentOverlayContentHostingController { - currentOverlayContentHostingController.view.isHidden = true - - currentOverlayContentHostingController.view.removeFromSuperview() - currentOverlayContentHostingController.removeFromParent() - } - - let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel) - - let newOverlayContentHostingController = UIHostingController(rootView: newSmallMenuOverlayView) - - newOverlayContentHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newOverlayContentHostingController.view.backgroundColor = UIColor.clear - - newOverlayContentHostingController.view.alpha = 0 - - addChild(newOverlayContentHostingController) - view.addSubview(newOverlayContentHostingController.view) - newOverlayContentHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newOverlayContentHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newOverlayContentHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newOverlayContentHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newOverlayContentHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - self.currentOverlayContentHostingController = newOverlayContentHostingController - - // Confirm close - if let currentConfirmCloseHostingController = currentConfirmCloseHostingController { - currentConfirmCloseHostingController.view.isHidden = true - - currentConfirmCloseHostingController.view.removeFromSuperview() - currentConfirmCloseHostingController.removeFromParent() - } - - let newConfirmCloseOverlay = ConfirmCloseOverlay() - - let newConfirmCloseHostingController = UIHostingController(rootView: newConfirmCloseOverlay) - - newConfirmCloseHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newConfirmCloseHostingController.view.backgroundColor = UIColor.clear - - newConfirmCloseHostingController.view.alpha = 0 - - addChild(newConfirmCloseHostingController) - view.addSubview(newConfirmCloseHostingController.view) - newConfirmCloseHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newConfirmCloseHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newConfirmCloseHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newConfirmCloseHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newConfirmCloseHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - self.currentConfirmCloseHostingController = newConfirmCloseHostingController - - // There is a behavior when setting this that the navigation bar - // on the current navigation controller pops up, re-hide it - self.navigationController?.isNavigationBarHidden = true - } -} - -// MARK: setupMediaPlayer - -extension LiveTVPlayerViewController { - - /// Main function that handles setting up the media player with the current VideoPlayerViewModel - /// and also takes the role of setting the 'viewModel' property with the given viewModel - /// - /// Use case for this is setting new media within the same VLCPlayerViewController - func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { - - // remove old player - - if vlcMediaPlayer.media != nil { - viewModelListeners.forEach { $0.cancel() } - - vlcMediaPlayer.stop() - viewModel.sendStopReport() - viewModel.playerOverlayDelegate = nil - } - - vlcMediaPlayer = VLCMediaPlayer() - - // setup with new player and view model - - vlcMediaPlayer = VLCMediaPlayer() - - vlcMediaPlayer.delegate = self - vlcMediaPlayer.drawable = videoContentView - - vlcMediaPlayer.setSubtitleSize(Defaults[.subtitleSize]) - - stopOverlayDismissTimer() - - // Stop current media if there is one - if vlcMediaPlayer.media != nil { - viewModelListeners.forEach { $0.cancel() } - - vlcMediaPlayer.stop() - viewModel.sendStopReport() - viewModel.playerOverlayDelegate = nil - } - - lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - - // TODO: Custom buffer/cache amounts - - let media: VLCMedia - - if let transcodedURL = newViewModel.transcodedStreamURL, - !Defaults[.Experimental.forceDirectPlay] - { - media = VLCMedia(url: transcodedURL) - } else { - media = VLCMedia(url: newViewModel.directStreamURL) - } - - media.addOption("--prefetch-buffer-size=1048576") - media.addOption("--network-caching=5000") - - vlcMediaPlayer.media = media - - setupOverlayHostingController(viewModel: newViewModel) - setupViewModelListeners(viewModel: newViewModel) - - newViewModel.getAdjacentEpisodes() - newViewModel.playerOverlayDelegate = self - - let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 - - if startPercentage > 0 { - if viewModel.resumeOffset { - let runTimeTicks = viewModel.item.runTimeTicks ?? 0 - let videoDurationSeconds = Double(runTimeTicks / 10_000_000) - var startSeconds = round((startPercentage / 100) * videoDurationSeconds) - startSeconds = startSeconds.subtract(5, floor: 0) - let newStartPercentage = startSeconds / videoDurationSeconds - newViewModel.sliderPercentage = newStartPercentage - } else { - newViewModel.sliderPercentage = startPercentage / 100 - } - } - - viewModel = newViewModel - - if viewModel.streamType == .direct { - logger.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? .emptyDash)") - } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { - logger.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? .emptyDash)") - } else { - logger.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? .emptyDash)") - } - } - - // MARK: startPlayback - - func startPlayback() { - vlcMediaPlayer.play() - - // Setup external subtitles - for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { - if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { - vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) - } - } - - setMediaPlayerTimeAtCurrentSlider() - - viewModel.sendPlayReport() - - restartOverlayDismissTimer(interval: 5) - } - - // MARK: setupViewModelListeners - - private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { - viewModel.$playbackSpeed.sink { newSpeed in - self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) - }.store(in: &viewModelListeners) - - viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in - if sliderIsScrubbing { - self.didBeginScrubbing() - } else { - self.didEndScrubbing() - } - }.store(in: &viewModelListeners) - - viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in - self.didSelectAudioStream(index: newAudioStreamIndex) - }.store(in: &viewModelListeners) - - viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in - self.didSelectSubtitleStream(index: newSubtitleStreamIndex) - }.store(in: &viewModelListeners) - - viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in - self.didToggleSubtitles(newValue: newSubtitlesEnabled) - }.store(in: &viewModelListeners) - } - - func setMediaPlayerTimeAtCurrentSlider() { - // Necessary math as VLCMediaPlayer doesn't work well - // by just setting the position - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let runTimeTicks = viewModel.item.runTimeTicks ?? 0 - let videoDuration = Double(runTimeTicks / 10_000_000) - let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) - let newPositionOffset = secondsScrubbedTo - videoPosition - - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } - } -} - -// MARK: Show/Hide Overlay - -extension LiveTVPlayerViewController { - - private func showOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 1 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 1 - } - } - - private func hideOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 0 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 0 - } - } - - private func toggleOverlay() { - if displayingOverlay { - hideOverlay() - } else { - showOverlay() - } - } - - private func showOverlayContent() { - guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } - - guard currentOverlayContentHostingController.view.alpha != 1 else { return } - - currentOverlayContentHostingController.view.setNeedsFocusUpdate() - currentOverlayContentHostingController.setNeedsFocusUpdate() - setNeedsFocusUpdate() - - UIView.animate(withDuration: 0.2) { - currentOverlayContentHostingController.view.alpha = 1 - } - } - - private func hideOverlayContent() { - guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } - - guard currentOverlayContentHostingController.view.alpha != 0 else { return } - - setNeedsFocusUpdate() - - UIView.animate(withDuration: 0.2) { - currentOverlayContentHostingController.view.alpha = 0 - } - } -} - -// MARK: Show/Hide Jump - -extension LiveTVPlayerViewController { - - private func flashJumpBackwardOverlay() { - jumpBackwardOverlayView.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - self.jumpBackwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpBackwardOverlay() - } - } - - private func hideJumpBackwardOverlay() { - UIView.animate(withDuration: 0.3) { - self.jumpBackwardOverlayView.alpha = 0 - } - } - - private func flashJumpFowardOverlay() { - jumpForwardOverlayView.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - self.jumpForwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpForwardOverlay() - } - } - - private func hideJumpForwardOverlay() { - UIView.animate(withDuration: 0.3) { - self.jumpForwardOverlayView.alpha = 0 - } - } -} - -// MARK: Show/Hide Confirm close - -extension LiveTVPlayerViewController { - - private func showConfirmCloseOverlay() { - guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } - - UIView.animate(withDuration: 0.2) { - currentConfirmCloseHostingController.view.alpha = 1 - } - } - - private func hideConfirmCloseOverlay() { - guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } - - UIView.animate(withDuration: 0.5) { - currentConfirmCloseHostingController.view.alpha = 0 - } - } -} - -// MARK: OverlayTimer - -extension LiveTVPlayerViewController { - - private func restartOverlayDismissTimer(interval: Double = 5) { - self.overlayDismissTimer?.invalidate() - self.overlayDismissTimer = Timer.scheduledTimer( - timeInterval: interval, - target: self, - selector: #selector(dismissTimerFired), - userInfo: nil, - repeats: false - ) - } - - @objc - private func dismissTimerFired() { - hideOverlay() - } - - private func stopOverlayDismissTimer() { - overlayDismissTimer?.invalidate() - } -} - -// MARK: Confirm Close Overlay Timer - -extension LiveTVPlayerViewController { - - private func restartConfirmCloseDismissTimer() { - self.confirmCloseOverlayDismissTimer?.invalidate() - self.confirmCloseOverlayDismissTimer = Timer.scheduledTimer( - timeInterval: 5, - target: self, - selector: #selector(confirmCloseTimerFired), - userInfo: nil, - repeats: false - ) - } - - @objc - private func confirmCloseTimerFired() { - hideConfirmCloseOverlay() - } - - private func stopConfirmCloseDismissTimer() { - confirmCloseOverlayDismissTimer?.invalidate() - } -} - -// MARK: VLCMediaPlayerDelegate - -extension LiveTVPlayerViewController: VLCMediaPlayerDelegate { - - // MARK: mediaPlayerStateChanged - - func mediaPlayerStateChanged(_ aNotification: Notification) { - - // Don't show buffering if paused, usually here while scrubbing - if vlcMediaPlayer.state == .buffering && viewModel.playerState == .paused { - return - } - - viewModel.playerState = vlcMediaPlayer.state - - if vlcMediaPlayer.state == VLCMediaPlayerState.ended { - if viewModel.autoplayEnabled && viewModel.nextItemVideoPlayerViewModel != nil { - didSelectPlayNextItem() - } else { - didSelectClose() - } - } - } - - // MARK: mediaPlayerTimeChanged - - func mediaPlayerTimeChanged(_ aNotification: Notification) { - - if !viewModel.sliderIsScrubbing { - viewModel.sliderPercentage = Double(vlcMediaPlayer.position) - } - - // Have to manually set playing because VLCMediaPlayer doesn't - // properly set it itself - if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { - viewModel.playerState = VLCMediaPlayerState.playing - } - - // If needing to fix subtitle streams during playback - if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.videoSubtitleStreamIndex(of: viewModel.selectedSubtitleStreamIndex) && - viewModel.subtitlesEnabled - { - didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) - } - - if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { - didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) - } - - lastPlayerTicks = currentPlayerTicks - - // Send progress report every 5 seconds - if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - } -} - -// MARK: PlayerOverlayDelegate - -extension LiveTVPlayerViewController: PlayerOverlayDelegate { - - func didSelectAudioStream(index: Int) { - // on live tv, it seems this gets set to -1 which disables the audio track. - // vlcMediaPlayer.currentAudioTrackIndex = Int32(index) - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - /// Do not call when setting to index -1 - func didSelectSubtitleStream(index: Int) { - - viewModel.subtitlesEnabled = true - vlcMediaPlayer.currentVideoSubTitleIndex = viewModel.videoSubtitleStreamIndex(of: index) - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - func didSelectClose() { - vlcMediaPlayer.stop() - - viewModel.sendStopReport() - - dismiss(animated: true, completion: nil) - } - - func didToggleSubtitles(newValue: Bool) { - if newValue { - vlcMediaPlayer.currentVideoSubTitleIndex = viewModel.videoSubtitleStreamIndex(of: viewModel.selectedSubtitleStreamIndex) - } else { - vlcMediaPlayer.currentVideoSubTitleIndex = -1 - } - } - - func didSelectMenu() { - stopOverlayDismissTimer() - - hideOverlay() - showOverlayContent() - } - - func didSelectBackward() { - - flashJumpBackwardOverlay() - - vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) - - if displayingOverlay { - restartOverlayDismissTimer() - } - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - func didSelectForward() { - - flashJumpFowardOverlay() - - vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) - - if displayingOverlay { - restartOverlayDismissTimer() - } - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - func didSelectMain() { - - switch viewModel.playerState { - case .buffering: - vlcMediaPlayer.play() - restartOverlayDismissTimer() - case .playing: - viewModel.sendPauseReport(paused: true) - vlcMediaPlayer.pause() - - showOverlay() - restartOverlayDismissTimer(interval: 5) - case .paused: - viewModel.sendPauseReport(paused: false) - vlcMediaPlayer.play() - restartOverlayDismissTimer() - default: () - } - } - - func didGenerallyTap() { - toggleOverlay() - - restartOverlayDismissTimer(interval: 5) - } - - func didBeginScrubbing() { - stopOverlayDismissTimer() - } - - func didEndScrubbing() { - setMediaPlayerTimeAtCurrentSlider() - - restartOverlayDismissTimer() - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - func didSelectPlayPreviousItem() { - if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) - startPlayback() - } - } - - func didSelectPlayNextItem() { - if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) - startPlayback() - } - } - - func didSelectChapter(_ chapter: ChapterInfo) { - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let chapterSeconds = Double((chapter.startPositionTicks ?? 0) / 10_000_000) - let newPositionOffset = chapterSeconds - videoPosition - - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } - - viewModel.sendProgressReport() - } -} diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveTVVideoPlayerView.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveTVVideoPlayerView.swift deleted file mode 100644 index e4f3ef56..00000000 --- a/Swiftfin tvOS/Views/VideoPlayer/LiveTVVideoPlayerView.swift +++ /dev/null @@ -1,24 +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 -import UIKit - -struct LiveTVVideoPlayerView: UIViewControllerRepresentable { - - let viewModel: VideoPlayerViewModel - - typealias UIViewControllerType = LiveTVPlayerViewController - - func makeUIViewController(context: Context) -> LiveTVPlayerViewController { - - LiveTVPlayerViewController(viewModel: viewModel) - } - - func updateUIViewController(_ uiViewController: LiveTVPlayerViewController, context: Context) {} -} diff --git a/Swiftfin tvOS/Views/VideoPlayer/NativePlayerViewController.swift b/Swiftfin tvOS/Views/VideoPlayer/NativePlayerViewController.swift deleted file mode 100644 index ed6f8251..00000000 --- a/Swiftfin tvOS/Views/VideoPlayer/NativePlayerViewController.swift +++ /dev/null @@ -1,134 +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 AVKit -import Combine -import JellyfinAPI -import UIKit - -class NativePlayerViewController: AVPlayerViewController { - - let viewModel: VideoPlayerViewModel - - var timeObserverToken: Any? - - var lastProgressTicks: Int64 = 0 - - private var cancellables = Set() - - init(viewModel: VideoPlayerViewModel) { - - self.viewModel = viewModel - - super.init(nibName: nil, bundle: nil) - - let player: AVPlayer - - if let transcodedStreamURL = viewModel.transcodedStreamURL { - player = AVPlayer(url: transcodedStreamURL) - } else { - player = AVPlayer(url: viewModel.hlsStreamURL) - } - - player.appliesMediaSelectionCriteriaAutomatically = false - player.currentItem?.externalMetadata = createMetadata() - - let timeScale = CMTimeScale(NSEC_PER_SEC) - let time = CMTime(seconds: 5, preferredTimescale: timeScale) - - timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in - if time.seconds != 0 { - self?.sendProgressReport(seconds: time.seconds) - } - } - - self.player = player - - self.allowsPictureInPicturePlayback = true - self.player?.allowsExternalPlayback = true - } - - private func createMetadata() -> [AVMetadataItem] { - let allMetadata: [AVMetadataIdentifier: Any] = [ - .commonIdentifierTitle: viewModel.title, - .iTunesMetadataTrackSubTitle: viewModel.subtitle ?? "", - // Need to fix against an image that doesn't exist - // .commonIdentifierArtwork: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))? - // .pngData() as Any, - // .commonIdentifierDescription: viewModel.item.overview ?? "", - // .iTunesMetadataContentRating: viewModel.item.officialRating ?? "", - // .quickTimeMetadataGenre: viewModel.item.genres?.first ?? "", - ] - - return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) } - } - - private func createMetadataItem( - for identifier: AVMetadataIdentifier, - value: Any - ) -> AVMetadataItem { - let item = AVMutableMetadataItem() - item.identifier = identifier - item.value = value as? NSCopying & NSObjectProtocol - // Specify "und" to indicate an undefined language. - item.extendedLanguageTag = "und" - return item.copy() as! AVMetadataItem - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - stop() - removePeriodicTimeObserver() - } - - func removePeriodicTimeObserver() { - if let timeObserverToken = timeObserverToken { - player?.removeTimeObserver(timeObserverToken) - self.timeObserverToken = nil - } - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - player?.seek( - to: CMTimeMake(value: viewModel.currentSecondTicks, timescale: 10_000_000), - toleranceBefore: CMTimeMake(value: 1, timescale: 1), - toleranceAfter: CMTimeMake(value: 1, timescale: 1), - completionHandler: { _ in - self.play() - } - ) - } - - private func play() { - player?.play() - - viewModel.sendPlayReport() - } - - private func sendProgressReport(seconds: Double) { - viewModel.setSeconds(Int64(seconds)) - viewModel.sendProgressReport() - } - - private func stop() { - self.player?.pause() - viewModel.sendStopReport() - } -} diff --git a/Swiftfin tvOS/Views/VideoPlayer/NativeVideoPlayer.swift b/Swiftfin tvOS/Views/VideoPlayer/NativeVideoPlayer.swift new file mode 100644 index 00000000..035ad473 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/NativeVideoPlayer.swift @@ -0,0 +1,172 @@ +// +// 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 AVKit +import Combine +import Defaults +import JellyfinAPI +import SwiftUI + +struct NativeVideoPlayer: View { + + @EnvironmentObject + private var router: VideoPlayerCoordinator.Router + + @ObservedObject + private var videoPlayerManager: VideoPlayerManager + + init(manager: VideoPlayerManager) { + self.videoPlayerManager = manager + } + + @ViewBuilder + private var playerView: some View { + NativeVideoPlayerView(videoPlayerManager: videoPlayerManager) + } + + var body: some View { + Group { + if let _ = videoPlayerManager.currentViewModel { + playerView + } else { +// VideoPlayer.LoadingView() + Text("Loading") + } + } + .navigationBarHidden(true) + .ignoresSafeArea() + } +} + +struct NativeVideoPlayerView: UIViewControllerRepresentable { + + let videoPlayerManager: VideoPlayerManager + + func makeUIViewController(context: Context) -> UINativeVideoPlayerViewController { + UINativeVideoPlayerViewController(manager: videoPlayerManager) + } + + func updateUIViewController(_ uiViewController: UINativeVideoPlayerViewController, context: Context) {} +} + +class UINativeVideoPlayerViewController: AVPlayerViewController { + + let videoPlayerManager: VideoPlayerManager + + private var rateObserver: NSKeyValueObservation! + private var timeObserverToken: Any! + + init(manager: VideoPlayerManager) { + + self.videoPlayerManager = manager + + super.init(nibName: nil, bundle: nil) + + let newPlayer: AVPlayer = .init(url: manager.currentViewModel.hlsPlaybackURL) + + newPlayer.allowsExternalPlayback = true + newPlayer.appliesMediaSelectionCriteriaAutomatically = false + newPlayer.currentItem?.externalMetadata = createMetadata() + + rateObserver = newPlayer.observe(\.rate, options: .new) { _, change in + guard let newValue = change.newValue else { return } + + if newValue == 0 { + self.videoPlayerManager.onStateUpdated(newState: .paused) + } else { + self.videoPlayerManager.onStateUpdated(newState: .playing) + } + } + + let time = CMTime(seconds: 0.1, preferredTimescale: 1000) + + timeObserverToken = newPlayer.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in + + guard let self else { return } + + if time.seconds >= 0 { + let newSeconds = Int(time.seconds) + let progress = CGFloat(newSeconds) / CGFloat(self.videoPlayerManager.currentViewModel.item.runTimeSeconds) + + self.videoPlayerManager.currentProgressHandler.progress = progress + self.videoPlayerManager.currentProgressHandler.scrubbedProgress = progress + self.videoPlayerManager.currentProgressHandler.seconds = newSeconds + self.videoPlayerManager.currentProgressHandler.scrubbedSeconds = newSeconds + } + } + + player = newPlayer + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + stop() + guard let timeObserverToken else { return } + player?.removeTimeObserver(timeObserverToken) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + player?.seek( + to: CMTimeMake( + value: Int64(videoPlayerManager.currentViewModel.item.startTimeSeconds - Defaults[.VideoPlayer.resumeOffset]), + timescale: 1 + ), + toleranceBefore: .zero, + toleranceAfter: .zero, + completionHandler: { _ in + self.play() + } + ) + } + + private func createMetadata() -> [AVMetadataItem] { + let allMetadata: [AVMetadataIdentifier: Any?] = [ + .commonIdentifierTitle: videoPlayerManager.currentViewModel.item.displayTitle, + .iTunesMetadataTrackSubTitle: videoPlayerManager.currentViewModel.item.subtitle, + ] + + return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) } + } + + private func createMetadataItem( + for identifier: AVMetadataIdentifier, + value: Any? + ) -> AVMetadataItem? { + guard let value else { return nil } + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + // Specify "und" to indicate an undefined language. + item.extendedLanguageTag = "und" + return item.copy() as? AVMetadataItem + } + + private func play() { + player?.play() + + videoPlayerManager.sendStartReport() + } + + private func stop() { + player?.pause() + + videoPlayerManager.sendStopReport() + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/ChapterOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/ChapterOverlay.swift new file mode 100644 index 00000000..76f13985 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/ChapterOverlay.swift @@ -0,0 +1,109 @@ +// +// 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 +import VLCUI + +extension VideoPlayer { + + struct ChapterOverlay: View { + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + + @EnvironmentObject + private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + @EnvironmentObject + private var videoPlayerProxy: VLCVideoPlayer.Proxy + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @State + private var scrollViewProxy: ScrollViewProxy? = nil + + var body: some View { + VStack { + + Spacer() + + HStack { + + L10n.chapters.text + .font(.largeTitle) + .fontWeight(.bold) + + Spacer() + } + .padding2() + .padding2() + + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(alignment: .top) { + ForEach(viewModel.chapters, id: \.hashValue) { chapter in + PosterButton( + item: chapter, + type: .landscape + ) + .imageOverlay { + if chapter.secondsRange.contains(currentProgressHandler.seconds) { + RoundedRectangle(cornerRadius: 6) + .stroke(Color.jellyfinPurple, lineWidth: 8) + } + } + .content { + VStack(alignment: .leading, spacing: 5) { + Text(chapter.chapterInfo.displayTitle) + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(1) + .foregroundColor(.white) + + Text(chapter.chapterInfo.timestampLabel) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(Color(UIColor.systemBlue)) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background { + Color(UIColor.darkGray).opacity(0.2).cornerRadius(4) + } + } + } + .onSelect { + let seconds = chapter.chapterInfo.startTimeSeconds + videoPlayerProxy.setTime(.seconds(seconds)) + + if videoPlayerManager.state != .playing { + videoPlayerProxy.play() + } + } + } + } + .padding2() + .padding2(.horizontal) + } + .onChange(of: currentOverlayType) { newValue in + guard newValue == .chapters else { return } + if let currentChapter = viewModel.chapter(from: currentProgressHandler.seconds) { + scrollViewProxy?.scrollTo(currentChapter.hashValue, anchor: .center) + } + } + .onAppear { + scrollViewProxy = proxy + } + } + } + } + } +} diff --git a/Shared/ViewModels/VideoPlayerViewModel/ServerStreamType.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ActionButtons.swift similarity index 65% rename from Shared/ViewModels/VideoPlayerViewModel/ServerStreamType.swift rename to Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ActionButtons.swift index 182fdee2..1d88a82d 100644 --- a/Shared/ViewModels/VideoPlayerViewModel/ServerStreamType.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ActionButtons.swift @@ -3,12 +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 Foundation -enum ServerStreamType { - case direct - case transcode +extension VideoPlayer.Overlay { + + enum ActionButtons {} } diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift new file mode 100644 index 00000000..1949aff0 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.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 Defaults +import SwiftUI + +extension VideoPlayer.Overlay.ActionButtons { + + struct AutoPlay: View { + + @Default(.VideoPlayer.autoPlayEnabled) + private var autoPlayEnabled + + @EnvironmentObject + private var overlayTimer: TimerProxy + + var body: some View { + SFSymbolButton( + systemName: autoPlayEnabled ? "play.circle.fill" : "stop.circle" + ) + .onSelect { + autoPlayEnabled.toggle() + overlayTimer.start(5) + } + .frame(maxWidth: 30, maxHeight: 30) + .id(autoPlayEnabled) + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ChaptersActionButton.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ChaptersActionButton.swift new file mode 100644 index 00000000..b325550c --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ChaptersActionButton.swift @@ -0,0 +1,33 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2023 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension VideoPlayer.Overlay.ActionButtons { + + struct Chapters: View { + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + + @EnvironmentObject + private var overlayTimer: TimerProxy + + var body: some View { + SFSymbolButton( + systemName: "photo", + systemNameFocused: "photo.fill" + ) + .onSelect { + currentOverlayType = .chapters + } + .frame(maxWidth: 30, maxHeight: 30) + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayNextItemActionButton.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayNextItemActionButton.swift new file mode 100644 index 00000000..07e03f40 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayNextItemActionButton.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 VideoPlayer.Overlay.ActionButtons { + + struct PlayNextItem: View { + + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + + var body: some View { + SFSymbolButton(systemName: "chevron.right.circle") + .onSelect { + videoPlayerManager.selectNextViewModel() + overlayTimer.start(5) + } + .frame(maxWidth: 30, maxHeight: 30) + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayPreviousItemActionButton.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayPreviousItemActionButton.swift new file mode 100644 index 00000000..d5c9d18b --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayPreviousItemActionButton.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 VideoPlayer.Overlay.ActionButtons { + + struct PlayPreviousItem: View { + + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + + var body: some View { + SFSymbolButton(systemName: "chevron.left.circle") + .onSelect { + videoPlayerManager.selectPreviousViewModel() + overlayTimer.start(5) + } + .frame(maxWidth: 30, maxHeight: 30) + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift new file mode 100644 index 00000000..308e91d7 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift @@ -0,0 +1,76 @@ +// +// 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 VideoPlayer.Overlay { + + struct BarActionButtons: View { + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @ViewBuilder + private var autoPlayButton: some View { + if viewModel.item.type == .episode { + ActionButtons.AutoPlay() + } + } + + @ViewBuilder + private var chaptersButton: some View { + if !viewModel.chapters.isEmpty { + ActionButtons.Chapters() + } + } + + @ViewBuilder + private var playNextItemButton: some View { + if viewModel.item.type == .episode { + ActionButtons.PlayNextItem() + } + } + + @ViewBuilder + private var playPreviousItemButton: some View { + if viewModel.item.type == .episode { + ActionButtons.PlayPreviousItem() + } + } + + @ViewBuilder + private var menuItemButton: some View { + SFSymbolButton( + systemName: "ellipsis.circle", + systemNameFocused: "ellipsis.circle.fill" + ) + .onSelect { + currentOverlayType = .smallMenu + } + .frame(maxWidth: 30, maxHeight: 30) + } + + var body: some View { + HStack { + playPreviousItemButton + + playNextItemButton + + autoPlayButton + + chaptersButton + + menuItemButton + } + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/BottomBarView.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/BottomBarView.swift new file mode 100644 index 00000000..d02ad5fc --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/BottomBarView.swift @@ -0,0 +1,110 @@ +// +// 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 SwiftUI + +extension VideoPlayer.Overlay { + + struct BottomBarView: View { + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + @Environment(\.isScrubbing) + @Binding + private var isScrubbing: Bool + + @EnvironmentObject + private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @FocusState + private var isBarFocused: Bool + + @ViewBuilder + private var playbackStateView: some View { + if videoPlayerManager.state == .playing { + Image(systemName: "pause.circle") + } else if videoPlayerManager.state == .paused { + Image(systemName: "play.circle") + } else { + ProgressView() + } + } + + var body: some View { + VStack(alignment: .VideoPlayerTitleAlignmentGuide, spacing: 10) { + + if let subtitle = viewModel.item.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundColor(.white) + .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in + dimensions[.leading] + } + } + + HStack { + + Text(viewModel.item.displayTitle) + .font(.largeTitle) + .fontWeight(.bold) + .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in + dimensions[.leading] + } + + Spacer() + + BarActionButtons() + } + + tvOSSliderView(value: $currentProgressHandler.scrubbedProgress) + .onEditingChanged { isEditing in + isScrubbing = isEditing + + if isEditing { + overlayTimer.pause() + } else { + overlayTimer.start(5) + } + } + .focused($isBarFocused) + .frame(height: 60) +// .visible(isScrubbing || isPresentingOverlay) + + HStack(spacing: 15) { + + Text(currentProgressHandler.scrubbedSeconds.timeLabel) + .monospacedDigit() + .foregroundColor(.white) + + playbackStateView + .frame(maxWidth: 40, maxHeight: 40) + + Spacer() + + Text((viewModel.item.runTimeSeconds - currentProgressHandler.scrubbedSeconds).timeLabel.prepending("-")) + .monospacedDigit() + .foregroundColor(.white) + } + } + .onChange(of: isPresentingOverlay) { newValue in + guard newValue else { return } + } + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/SliderView.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/SliderView.swift new file mode 100644 index 00000000..4186b8f6 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/SliderView.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 SwiftUI + +struct tvOSSliderView: UIViewRepresentable { + + @Binding + private var value: CGFloat + + private var onEditingChanged: (Bool) -> Void + + // TODO: look at adjusting value dependent on item runtime + private let maxValue: Double = 1000 + + func makeUIView(context: Context) -> UITVOSSlider { + let slider = UITVOSSlider( + value: _value, + onEditingChanged: onEditingChanged + ) + + slider.value = Float(value) + slider.minimumValue = 0 + slider.maximumValue = Float(maxValue) + slider.thumbSize = 25 + slider.thumbTintColor = .white + slider.minimumTrackTintColor = .white + slider.focusScaleFactor = 1.4 + slider.panDampingValue = 50 + slider.fineTunningVelocityThreshold = 1000 + + return slider + } + + func updateUIView(_ uiView: UITVOSSlider, context: Context) {} +} + +extension tvOSSliderView { + + init(value: Binding) { + self.init( + value: value, + onEditingChanged: { _ in } + ) + } + + func onEditingChanged(_ action: @escaping (Bool) -> Void) -> Self { + copy(modifying: \.onEditingChanged, with: action) + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/tvOSSlider.swift similarity index 67% rename from Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift rename to Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/tvOSSlider.swift index 32e7e153..a764a01e 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/tvOSSLider/tvOSSlider.swift @@ -3,21 +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 // // Modification of https://github.com/zattoo/TvOSSlider -import GameController +import SwiftUI import UIKit -enum DPadState { - case select - case right - case left - case up - case down -} +// TODO: Replace private let trackViewHeight: CGFloat = 5 private let animationDuration: TimeInterval = 0.3 @@ -34,13 +28,10 @@ private let decelerationRate: Float = 0.92 private let decelerationMaxVelocity: Float = 1000 /// A control used to select a single value from a continuous range of values. -public final class TvOSSlider: UIControl { - - // MARK: - Public +final class UITVOSSlider: UIControl { /// The slider’s current value. - @IBInspectable - public var value: Float { + var value: Float { get { storedValue } @@ -55,67 +46,60 @@ public final class TvOSSlider: UIControl { } /// The minimum value of the slider. - @IBInspectable - public var minimumValue: Float = defaultMinimumValue { + var minimumValue: Float = defaultMinimumValue { didSet { value = max(value, minimumValue) } } /// The maximum value of the slider. - @IBInspectable - public var maximumValue: Float = defaultMaximumValue { + var maximumValue: Float = defaultMaximumValue { didSet { value = min(value, maximumValue) } } /// A Boolean value indicating whether changes in the slider’s value generate continuous update events. - @IBInspectable - public var isContinuous: Bool = defaultIsContinuous + var isContinuous: Bool = defaultIsContinuous /// The color used to tint the default minimum track images. - @IBInspectable - public var minimumTrackTintColor: UIColor? = defaultMininumTrackTintColor { + var minimumTrackTintColor: UIColor? = defaultMininumTrackTintColor { didSet { minimumTrackView.backgroundColor = minimumTrackTintColor } } /// The color used to tint the default maximum track images. - @IBInspectable - public var maximumTrackTintColor: UIColor? { + var maximumTrackTintColor: UIColor? { didSet { maximumTrackView.backgroundColor = maximumTrackTintColor } } /// The color used to tint the default thumb images. - @IBInspectable - public var thumbTintColor: UIColor = defaultThumbTintColor { + var thumbTintColor: UIColor = defaultThumbTintColor { didSet { thumbView.backgroundColor = thumbTintColor } } /// Scale factor applied to the slider when receiving the focus - @IBInspectable - public var focusScaleFactor: CGFloat = defaultFocusScaleFactor { + var focusScaleFactor: CGFloat = defaultFocusScaleFactor { didSet { updateStateDependantViews() } } /// Value added or subtracted from the current value on steps left or right updates - public var stepValue: Float = defaultStepValue + var stepValue: Float = defaultStepValue /// Damping value for panning gestures - public var panDampingValue: Float = 5 + var panDampingValue: Float = 5 // Size for thumb view - public var thumbSize: CGFloat = 30 + var thumbSize: CGFloat = 30 - public var fineTunningVelocityThreshold: Float = 600 + var fineTunningVelocityThreshold: Float = 600 /** Sets the slider’s current value, allowing you to animate the change visually. @@ -124,7 +108,7 @@ public final class TvOSSlider: UIControl { - value: The new value to assign to the value property - animated: Specify true to animate the change in value; otherwise, specify false to update the slider’s appearance immediately. Animations are performed asynchronously and do not block the calling thread. */ - public func setValue(_ value: Float, animated: Bool) { + func setValue(_ value: Float, animated: Bool) { self.value = value stopDeceleratingTimer() @@ -143,7 +127,7 @@ public final class TvOSSlider: UIControl { - image: The minimum track image to associate with the specified states. - state: The control state with which to associate the image. */ - public func setMinimumTrackImage(_ image: UIImage?, for state: UIControl.State) { + func setMinimumTrackImage(_ image: UIImage?, for state: UIControl.State) { minimumTrackViewImages[state.rawValue] = image updateStateDependantViews() } @@ -155,7 +139,7 @@ public final class TvOSSlider: UIControl { - image: The maximum track image to associate with the specified states. - state: The control state with which to associate the image. */ - public func setMaximumTrackImage(_ image: UIImage?, for state: UIControl.State) { + func setMaximumTrackImage(_ image: UIImage?, for state: UIControl.State) { maximumTrackViewImages[state.rawValue] = image updateStateDependantViews() } @@ -167,81 +151,25 @@ public final class TvOSSlider: UIControl { - image: The thumb image to associate with the specified states. - state: The control state with which to associate the image. */ - public func setThumbImage(_ image: UIImage?, for state: UIControl.State) { + func setThumbImage(_ image: UIImage?, for state: UIControl.State) { thumbViewImages[state.rawValue] = image updateStateDependantViews() } - /// The minimum track image currently being used to render the slider. - public var currentMinimumTrackImage: UIImage? { - minimumTrackView.image - } - - /// Contains the maximum track image currently being used to render the slider. - public var currentMaximumTrackImage: UIImage? { - maximumTrackView.image - } - - /// The thumb image currently being used to render the slider. - public var currentThumbImage: UIImage? { - thumbView.image - } - - /** - Returns the minimum track image associated with the specified control state. - - - Parameters: - - state: The control state whose minimum track image you want to use. Specify a single control state value for this parameter. - - - Returns: The minimum track image associated with the specified state, or nil if no image has been set. This method might also return nil if you specify multiple control states in the state parameter. For a description of track images, see Customizing the Slider’s Appearance. - */ - public func minimumTrackImage(for state: UIControl.State) -> UIImage? { - minimumTrackViewImages[state.rawValue] - } - - /** - Returns the maximum track image associated with the specified control state. - - - Parameters: - - state: The control state whose maximum track image you want to use. Specify a single control state value for this parameter. - - - Returns: The maximum track image associated with the specified state, or nil if an appropriate image could not be retrieved. This method might return nil if you specify multiple control states in the state parameter. For a description of track images, see Customizing the Slider’s Appearance. - */ - public func maximumTrackImage(for state: UIControl.State) -> UIImage? { - maximumTrackViewImages[state.rawValue] - } - - /** - Returns the thumb image associated with the specified control state. - - - Parameters: - - state: The control state whose thumb image you want to use. Specify a single control state value for this parameter. - - - Returns: The thumb image associated with the specified state, or nil if an appropriate image could not be retrieved. This method might return nil if you specify multiple control states in the state parameter. For a description of track and thumb images, see Customizing the Slider’s Appearance. - */ - public func thumbImage(for state: UIControl.State) -> UIImage? { - thumbViewImages[state.rawValue] - } - // MARK: - Initializers - /// :nodoc: - // public override init(frame: CGRect) { - // super.init(frame: frame) - // setUpView() - // } + private var onEditingChanged: (Bool) -> Void + private var valueBinding: Binding - /// :nodoc: - // public required init?(coder aDecoder: NSCoder) { - // super.init(coder: aDecoder) - // setUpView() - // } + init( + value: Binding, + onEditingChanged: @escaping (Bool) -> Void + ) { + self.onEditingChanged = onEditingChanged + self.valueBinding = value - // MARK: VideoPlayerVieModel init - - init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel super.init(frame: .zero) + setUpView() } @@ -257,7 +185,7 @@ public final class TvOSSlider: UIControl { // MARK: - UIControlStates /// :nodoc: - override public var isEnabled: Bool { + override var isEnabled: Bool { didSet { panGestureRecognizer.isEnabled = isEnabled updateStateDependantViews() @@ -265,21 +193,21 @@ public final class TvOSSlider: UIControl { } /// :nodoc: - override public var isSelected: Bool { + override var isSelected: Bool { didSet { updateStateDependantViews() } } /// :nodoc: - override public var isHighlighted: Bool { + override var isHighlighted: Bool { didSet { updateStateDependantViews() } } /// :nodoc: - override public func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { + override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { coordinator.addCoordinatedAnimations({ self.updateStateDependantViews() }, completion: nil) @@ -287,11 +215,9 @@ public final class TvOSSlider: UIControl { // MARK: - Private - private let viewModel: VideoPlayerViewModel! - private typealias ControlState = UInt - public var storedValue: Float = defaultValue + private var storedValue: Float = defaultValue private var thumbViewImages: [ControlState: UIImage] = [:] private var thumbView: UIImageView! @@ -311,8 +237,6 @@ public final class TvOSSlider: UIControl { private var thumbViewCenterXConstraint: NSLayoutConstraint! - private var dPadState: DPadState = .select - private weak var deceleratingTimer: Timer? private var deceleratingVelocity: Float = 0 @@ -331,12 +255,6 @@ public final class TvOSSlider: UIControl { setUpGestures() - NotificationCenter.default.addObserver( - self, - selector: #selector(controllerConnected(note:)), - name: .GCControllerDidConnect, - object: nil - ) updateStateDependantViews() } @@ -426,24 +344,6 @@ public final class TvOSSlider: UIControl { } } - @objc - private func controllerConnected(note: NSNotification) { - guard let controller = note.object as? GCController else { return } - guard let micro = controller.microGamepad else { return } - - let threshold: Float = 0.7 - micro.reportsAbsoluteDpadValues = true - micro.dpad.valueChangedHandler = { [weak self] _, x, _ in - if x < -threshold { - self?.dPadState = .left - } else if x > threshold { - self?.dPadState = .right - } else { - self?.dPadState = .select - } - } - } - @objc private func handleDeceleratingTimer(timer: Timer) { let centerX = thumbViewCenterXConstraintConstant + deceleratingVelocity * 0.01 @@ -461,8 +361,8 @@ public final class TvOSSlider: UIControl { stopDeceleratingTimer() } - viewModel.sliderPercentage = Double(percent) - viewModel.sliderIsScrubbing = false + valueBinding.wrappedValue = CGFloat(percent) + onEditingChanged(false) } private func stopDeceleratingTimer() { @@ -485,22 +385,18 @@ public final class TvOSSlider: UIControl { @objc private func panGestureWasTriggered(panGestureRecognizer: UIPanGestureRecognizer) { - if self.isVerticalGesture(panGestureRecognizer) { - return - } + guard !isVerticalGesture(panGestureRecognizer) else { return } let translation = Float(panGestureRecognizer.translation(in: self).x) let velocity = Float(panGestureRecognizer.velocity(in: self).x) switch panGestureRecognizer.state { case .began: - viewModel.sliderIsScrubbing = true + onEditingChanged(true) stopDeceleratingTimer() thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) case .changed: - viewModel.sliderIsScrubbing = true - let centerX = thumbViewCenterXConstraintConstant + translation / panDampingValue let percent = centerX / Float(trackView.frame.width) value = minimumValue + ((maximumValue - minimumValue) * percent) @@ -508,7 +404,7 @@ public final class TvOSSlider: UIControl { sendActions(for: .valueChanged) } - viewModel.sliderPercentage = Double(percent) + valueBinding.wrappedValue = CGFloat(percent) case .ended, .cancelled: thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) @@ -524,7 +420,7 @@ public final class TvOSSlider: UIControl { repeats: true ) } else { - viewModel.sliderIsScrubbing = false + onEditingChanged(false) stopDeceleratingTimer() } default: @@ -535,31 +431,12 @@ public final class TvOSSlider: UIControl { @objc private func leftTapWasTriggered() { // setValue(value-stepValue, animated: true) - viewModel.playerOverlayDelegate?.didSelectBackward() +// viewModel.playerOverlayDelegate?.didSelectBackward() } @objc private func rightTapWasTriggered() { // setValue(value+stepValue, animated: true) - viewModel.playerOverlayDelegate?.didSelectForward() - } - - override public func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - for press in presses { - switch press.type { - case .select where dPadState == .left: - panGestureRecognizer.isEnabled = false - leftTapWasTriggered() - case .select where dPadState == .right: - panGestureRecognizer.isEnabled = false - rightTapWasTriggered() - case .select: - panGestureRecognizer.isEnabled = false - default: - break - } - } - panGestureRecognizer.isEnabled = true - super.pressesBegan(presses, with: event) +// viewModel.playerOverlayDelegate?.didSelectForward() } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/ConfirmCloseOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/ConfirmCloseOverlay.swift index 9145609b..946648f6 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/ConfirmCloseOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/ConfirmCloseOverlay.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 @@ -12,7 +12,7 @@ struct ConfirmCloseOverlay: View { var body: some View { VStack { HStack { - Image(systemName: "chevron.left.circle.fill") + Image(systemName: "xmark.circle.fill") .font(.system(size: 96)) .padding(3) .background(Color.black.opacity(0.4).mask(Circle())) @@ -26,14 +26,3 @@ struct ConfirmCloseOverlay: View { .padding() } } - -struct ConfirmCloseOverlay_Previews: PreviewProvider { - static var previews: some View { - ZStack { - Color.red.ignoresSafeArea() - - ConfirmCloseOverlay() - .ignoresSafeArea() - } - } -} diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/MainOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/MainOverlay.swift new file mode 100644 index 00000000..ef90c0b8 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/MainOverlay.swift @@ -0,0 +1,82 @@ +// +// 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 SwiftUI + +extension VideoPlayer { + + struct MainOverlay: View { + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + @Environment(\.isScrubbing) + @Binding + private var isScrubbing: Bool + + @EnvironmentObject + private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler + @EnvironmentObject + private var overlayTimer: TimerProxy + + var body: some View { + VStack { + + Spacer() + + VideoPlayer.Overlay.BottomBarView() + .padding2() + .padding2() + .background { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black.opacity(0.8), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + } + } + .environmentObject(overlayTimer) + } + } +} + +// struct VideoPlayerOverlay_Preview: PreviewProvider { +// +// static var previews: some View { +// ZStack { +// +// Color.red +// +// VideoPlayer.MainOverlay() +// .environmentObject(VideoPlayerManager()) +// .environmentObject(VideoPlayerViewModel( +// playbackURL: URL(string: "http://apple.com")!, +// item: .init(indexNumber: 1, name: "Interstellar", parentIndexNumber: 1, seriesName: "New Girl", type: .episode), +// mediaSource: .init(), +// playSessionID: "", +// videoStreams: [], +// audioStreams: [], +// subtitleStreams: [], +// selectedAudioStreamIndex: 1, +// selectedSubtitleStreamIndex: 1, +// chapters: [], +// streamType: .direct) +// ) +// .environmentObject(VideoPlayerManager.CurrentProgressHandler()) +// .environmentObject(TimerProxy()) +// } +// .ignoresSafeArea() +// } +// } diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Overlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Overlay.swift new file mode 100644 index 00000000..faf945d9 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Overlay.swift @@ -0,0 +1,101 @@ +// +// 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 +import VLCUI + +extension VideoPlayer { + + struct Overlay: View { + + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + + @EnvironmentObject + private var proxy: VLCVideoPlayer.Proxy + @EnvironmentObject + private var router: VideoPlayerCoordinator.Router + + @State + private var confirmCloseWorkItem: DispatchWorkItem? + @State + private var currentOverlayType: VideoPlayer.OverlayType = .main + + @StateObject + private var overlayTimer: TimerProxy = .init() + + var body: some View { + ZStack { + + MainOverlay() + .visible(currentOverlayType == .main) + + ConfirmCloseOverlay() + .visible(currentOverlayType == .confirmClose) + + SmallMenuOverlay() + .visible(currentOverlayType == .smallMenu) + + ChapterOverlay() + .visible(currentOverlayType == .chapters) + } + .visible(isPresentingOverlay) + .animation(.linear(duration: 0.1), value: currentOverlayType) + .environment(\.currentOverlayType, $currentOverlayType) + .environmentObject(overlayTimer) + .onChange(of: currentOverlayType) { newValue in + if [.smallMenu, .chapters].contains(newValue) { + overlayTimer.pause() + } else if isPresentingOverlay { + overlayTimer.start(5) + } + } + .onChange(of: overlayTimer.isActive) { isActive in + guard !isActive else { return } + + withAnimation(.linear(duration: 0.3)) { + isPresentingOverlay = false + } + } + .onSelectPressed { + currentOverlayType = .main + isPresentingOverlay = true + overlayTimer.start(5) + } + .onMenuPressed { + + overlayTimer.start(5) + confirmCloseWorkItem?.cancel() + + if isPresentingOverlay && currentOverlayType == .confirmClose { + proxy.stop() + router.dismissCoordinator() + } else if isPresentingOverlay && currentOverlayType == .smallMenu { + currentOverlayType = .main + } else { + withAnimation { + currentOverlayType = .confirmClose + isPresentingOverlay = true + } + + let task = DispatchWorkItem { + withAnimation { + isPresentingOverlay = false + overlayTimer.stop() + } + } + + confirmCloseWorkItem = task + + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift index 617c4185..42cc789e 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift @@ -3,365 +3,171 @@ // License, v2.0. If 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 import SwiftUI -// TODO: Needs replacement/reworking -struct SmallMediaStreamSelectionView: View { +extension VideoPlayer { - enum Layer: Hashable { - case subtitles - case audio - case playbackSpeed - case chapters - } + struct SmallMenuOverlay: View { - enum MediaSection: Hashable { - case titles - case items - } + enum MenuSection: String, Displayable { + case audio + case chapters + case playbackSpeed + case subtitles - @ObservedObject - var viewModel: VideoPlayerViewModel - private let chapterImages: [URL] - - @State - private var updateFocusedLayer: Layer = .subtitles - @State - private var lastFocusedLayer: Layer = .subtitles - - @FocusState - private var subtitlesFocused: Bool - @FocusState - private var audioFocused: Bool - @FocusState - private var playbackSpeedFocused: Bool - @FocusState - private var chaptersFocused: Bool - @FocusState - private var focusedSection: MediaSection? - @FocusState - private var focusedLayer: Layer? { - willSet { - updateFocusedLayer = newValue! - - if focusedSection == .titles { - lastFocusedLayer = newValue! + var displayTitle: String { + switch self { + case .audio: + return "Audio" + case .chapters: + return "Chapters" + case .playbackSpeed: + return "Playback Speed" + case .subtitles: + return "Subtitles" + } } } - } - init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel - self.chapterImages = viewModel.item.getChapterImage(maxWidth: 500) - } + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + @EnvironmentObject + private var viewModel: VideoPlayerViewModel - var body: some View { - ZStack(alignment: .bottom) { - LinearGradient( - gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), - startPoint: .top, - endPoint: .bottom + @FocusState + private var focusedSection: MenuSection? + + @State + private var lastFocusedSection: MenuSection? + + @StateObject + private var focusGuide: FocusGuide = .init() + + @ViewBuilder + private var subtitleMenu: some View { + HStack { + ForEach(viewModel.subtitleStreams, id: \.self) { mediaStream in + Button {} label: { + if videoPlayerManager.subtitleTrackIndex == mediaStream.index { + Label(mediaStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") + } else { + Text(mediaStream.displayTitle ?? L10n.noTitle) + } + } + } + } + .frame(height: 80) + .padding(.horizontal, 50) + .padding(.top) + .padding(.bottom, 45) + .focusGuide( + focusGuide, + tag: "contents", + top: "sections" ) - .ignoresSafeArea() - .frame(height: 300) + } + var body: some View { VStack { Spacer() - HStack { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + if !viewModel.subtitleStreams.isEmpty { + SectionButton( + section: .subtitles, + focused: $focusedSection, + lastFocused: $lastFocusedSection + ) + } - // MARK: Subtitle Header + if !viewModel.audioStreams.isEmpty { + SectionButton( + section: .audio, + focused: $focusedSection, + lastFocused: $lastFocusedSection + ) + } - Button { - updateFocusedLayer = .subtitles - focusedLayer = .subtitles - } label: { - if updateFocusedLayer == .subtitles { - HStack(spacing: 15) { - Image(systemName: "captions.bubble") - L10n.subtitles.text - } - .padding() - .background(Color.white) - .foregroundColor(.black) - } else { - HStack(spacing: 15) { - Image(systemName: "captions.bubble") - L10n.subtitles.text - } - .padding() - } - } - .buttonStyle(.plain) - .background(Color.clear) - .focused($focusedLayer, equals: .subtitles) - .focused($subtitlesFocused) - .onChange(of: subtitlesFocused) { isFocused in - if isFocused { - focusedLayer = .subtitles + SectionButton( + section: .playbackSpeed, + focused: $focusedSection, + lastFocused: $lastFocusedSection + ) + + if !viewModel.chapters.isEmpty { + SectionButton( + section: .chapters, + focused: $focusedSection, + lastFocused: $lastFocusedSection + ) } } + .focusGuide( + focusGuide, + tag: "sections", + onContentFocus: { focusedSection = lastFocusedSection }, + bottom: "contents" + ) + .frame(height: 70) + .padding(.horizontal, 50) + .padding(.top) + .padding(.bottom, 45) + } - // MARK: Audio Header - - Button { - updateFocusedLayer = .audio - focusedLayer = .audio - } label: { - if updateFocusedLayer == .audio { - HStack(spacing: 15) { - Image(systemName: "speaker.wave.3") - L10n.audio.text - } - .padding() - .background(Color.white) - .foregroundColor(.black) - } else { - HStack(spacing: 15) { - Image(systemName: "speaker.wave.3") - L10n.audio.text - } - .padding() - } - } - .buttonStyle(.plain) - .background(Color.clear) - .focused($focusedLayer, equals: .audio) - .focused($audioFocused) - .onChange(of: audioFocused) { isFocused in - if isFocused { - focusedLayer = .audio - } - } - - // MARK: Playback Speed Header - - Button { - updateFocusedLayer = .playbackSpeed - focusedLayer = .playbackSpeed - } label: { - if updateFocusedLayer == .playbackSpeed { - HStack(spacing: 15) { - Image(systemName: "speedometer") - L10n.playbackSpeed.text - } - .padding() - .background(Color.white) - .foregroundColor(.black) - } else { - HStack(spacing: 15) { - Image(systemName: "speedometer") - L10n.playbackSpeed.text - } - .padding() - } - } - .buttonStyle(.plain) - .background(Color.clear) - .focused($focusedLayer, equals: .playbackSpeed) - .focused($playbackSpeedFocused) - .onChange(of: playbackSpeedFocused) { isFocused in - if isFocused { - focusedLayer = .playbackSpeed - } - } - - // MARK: Chapters Header - - if !viewModel.chapters.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + switch lastFocusedSection { + case .subtitles: + subtitleMenu + default: Button { - updateFocusedLayer = .chapters - focusedLayer = .chapters - } label: { - if updateFocusedLayer == .chapters { - HStack(spacing: 15) { - Image(systemName: "list.dash") - L10n.chapters.text - } - .padding() - .background(Color.white) + Text("None") + } + } + } + } + .ignoresSafeArea() + .background { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black.opacity(0.8), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + } + .onChange(of: focusedSection) { newValue in + guard let newValue else { return } + lastFocusedSection = newValue + } + } + + struct SectionButton: View { + + let section: MenuSection + let focused: FocusState.Binding + let lastFocused: Binding + + var body: some View { + Button { + Text(section.displayTitle) + .fontWeight(.semibold) + .fixedSize() + .padding() + .if(lastFocused.wrappedValue == section) { text in + text + .background(.white) .foregroundColor(.black) - } else { - HStack(spacing: 15) { - Image(systemName: "list.dash") - L10n.chapters.text - } - .padding() - } } - .buttonStyle(.plain) - .background(Color.clear) - .focused($focusedLayer, equals: .chapters) - .focused($chaptersFocused) - .onChange(of: chaptersFocused) { isFocused in - if isFocused { - focusedLayer = .chapters - } - } - } - - Spacer() - } - .padding() - .focusSection() - .focused($focusedSection, equals: .titles) - .onChange(of: focusedSection) { _ in - if focusedSection == .titles { - if lastFocusedLayer == .subtitles { - subtitlesFocused = true - } else if lastFocusedLayer == .audio { - audioFocused = true - } else if lastFocusedLayer == .playbackSpeed { - playbackSpeedFocused = true - } - } - } - - if updateFocusedLayer == .subtitles && lastFocusedLayer == .subtitles { - // MARK: Subtitles - - subtitleMenuView - } else if updateFocusedLayer == .audio && lastFocusedLayer == .audio { - // MARK: Audio - - audioMenuView - } else if updateFocusedLayer == .playbackSpeed && lastFocusedLayer == .playbackSpeed { - // MARK: Playback Speed - - playbackSpeedMenuView - } else if updateFocusedLayer == .chapters && lastFocusedLayer == .chapters { - // MARK: Chapters - - chaptersMenuView - } - } - } - } - - @ViewBuilder - private var subtitleMenuView: some View { - ScrollView(.horizontal) { - HStack { - if viewModel.subtitleStreams.isEmpty { - Button {} label: { - L10n.none.text - } - } else { - ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in - Button { - viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 - } label: { - if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { - Label(subtitleStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") - } else { - Text(subtitleStream.displayTitle ?? L10n.noTitle) - } - } - } - } - } - .padding(.vertical) - .focusSection() - .focused($focusedSection, equals: .items) - } - } - - @ViewBuilder - private var audioMenuView: some View { - ScrollView(.horizontal) { - HStack { - if viewModel.audioStreams.isEmpty { - Button {} label: { - Text("None") - } - } else { - ForEach(viewModel.audioStreams, id: \.self) { audioStream in - Button { - viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 - } label: { - if audioStream.index == viewModel.selectedAudioStreamIndex { - Label(audioStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") - } else { - Text(audioStream.displayTitle ?? L10n.noTitle) - } - } - } - } - } - .padding(.vertical) - .focusSection() - .focused($focusedSection, equals: .items) - } - } - - @ViewBuilder - private var playbackSpeedMenuView: some View { - ScrollView(.horizontal) { - HStack { - ForEach(PlaybackSpeed.allCases, id: \.self) { playbackSpeed in - Button { - viewModel.playbackSpeed = playbackSpeed - } label: { - if playbackSpeed == viewModel.playbackSpeed { - Label(playbackSpeed.displayTitle, systemImage: "checkmark") - } else { - Text(playbackSpeed.displayTitle) - } - } - } - } - .padding(.vertical) - .focusSection() - .focused($focusedSection, equals: .items) - } - } - - @ViewBuilder - private var chaptersMenuView: some View { - ScrollView(.horizontal, showsIndicators: false) { - ScrollViewReader { reader in - HStack { - ForEach(0 ..< viewModel.chapters.count, id: \.self) { chapterIndex in - VStack(alignment: .leading) { - Button { - viewModel.playerOverlayDelegate?.didSelectChapter(viewModel.chapters[chapterIndex]) - } label: { - ImageView(chapterImages[chapterIndex]) - .cornerRadius(10) - .frame(width: 350, height: 210) - } - .buttonStyle(.card) - - VStack(alignment: .leading, spacing: 5) { - - Text(viewModel.chapters[chapterIndex].name ?? L10n.noTitle) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.white) - - Text(viewModel.chapters[chapterIndex].timestampLabel) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(Color(UIColor.systemBlue)) - .padding(.vertical, 2) - .padding(.horizontal, 4) - .background { - Color(UIColor.darkGray).opacity(0.2).cornerRadius(4) - } - } - } - .id(viewModel.chapters[chapterIndex]) - } - } - .padding(.top) - .onAppear { - reader.scrollTo(viewModel.currentChapter) } + .buttonStyle(.plain) + .background(.clear) + .focused(focused, equals: section) } } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift deleted file mode 100644 index 84ef35db..00000000 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift +++ /dev/null @@ -1,177 +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 JellyfinAPI -import SwiftUI - -struct tvOSLiveTVOverlay: View { - - @ObservedObject - var viewModel: VideoPlayerViewModel - @Default(.downActionShowsMenu) - var downActionShowsMenu - - @ViewBuilder - private var mainButtonView: some View { - switch viewModel.playerState { - case .stopped, .paused: - Image(systemName: "play.circle") - case .playing: - Image(systemName: "pause.circle") - default: - ProgressView() - } - } - - var body: some View { - ZStack(alignment: .bottom) { - - LinearGradient( - gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), - startPoint: .top, - endPoint: .bottom - ) - .ignoresSafeArea() - .frame(height: viewModel.subtitle == nil ? 180 : 210) - - VStack { - - Spacer() - - HStack(alignment: .bottom) { - - VStack(alignment: .leading) { - if let subtitle = viewModel.subtitle { - Text(subtitle) - .font(.subheadline) - .foregroundColor(.white) - } - - Text(viewModel.title) - .font(.title3) - .fontWeight(.bold) - } - - Spacer() - - if viewModel.shouldShowPlayPreviousItem { - SFSymbolButton(systemName: "chevron.left.circle", action: { - viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() - }) - .frame(maxWidth: 30, maxHeight: 30) - .disabled(viewModel.previousItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } - - if viewModel.shouldShowPlayNextItem { - SFSymbolButton(systemName: "chevron.right.circle", action: { - viewModel.playerOverlayDelegate?.didSelectPlayNextItem() - }) - .frame(maxWidth: 30, maxHeight: 30) - .disabled(viewModel.nextItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } - - if viewModel.shouldShowAutoPlay { - if viewModel.autoplayEnabled { - SFSymbolButton(systemName: "play.circle.fill") { - viewModel.autoplayEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } else { - SFSymbolButton(systemName: "stop.circle") { - viewModel.autoplayEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } - } - - if !viewModel.subtitleStreams.isEmpty { - if viewModel.subtitlesEnabled { - SFSymbolButton(systemName: "captions.bubble.fill") { - viewModel.subtitlesEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } else { - SFSymbolButton(systemName: "captions.bubble") { - viewModel.subtitlesEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } - } - - if !downActionShowsMenu { - SFSymbolButton(systemName: "ellipsis.circle") { - viewModel.playerOverlayDelegate?.didSelectMenu() - } - .frame(maxWidth: 30, maxHeight: 30) - } - } - .offset(x: 0, y: 10) - - SliderView(viewModel: viewModel) - .frame(maxHeight: 40) - - HStack { - - HStack(spacing: 10) { - mainButtonView - .frame(maxWidth: 40, maxHeight: 40) - - Text(viewModel.leftLabelText) - } - - Spacer() - - Text(viewModel.rightLabelText) - } - .offset(x: 0, y: -10) - } - } - .foregroundColor(.white) - } -} - -struct tvOSLiveTVOverlay_Previews: PreviewProvider { - - static let videoPlayerViewModel = VideoPlayerViewModel( - item: BaseItemDto(), - title: "Glorious Purpose", - subtitle: "Loki - S1E1", - directStreamURL: URL(string: "www.apple.com")!, - transcodedStreamURL: nil, - hlsStreamURL: URL(string: "www.apple.com")!, - streamType: .direct, - response: PlaybackInfoResponse(), - videoStream: MediaStream(), - audioStreams: [MediaStream(displayTitle: "English", index: -1)], - subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], - chapters: [], - selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1, - subtitlesEnabled: true, - autoplayEnabled: false, - overlayType: .compact, - shouldShowPlayPreviousItem: true, - shouldShowPlayNextItem: true, - shouldShowAutoPlay: true, - container: "", - filename: nil, - versionName: nil - ) - - static var previews: some View { - ZStack { - Color.red - .ignoresSafeArea() - - tvOSLiveTVOverlay(viewModel: videoPlayerViewModel) - } - } -} diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift deleted file mode 100644 index d4a2e433..00000000 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift +++ /dev/null @@ -1,177 +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 JellyfinAPI -import SwiftUI - -struct tvOSVLCOverlay: View { - - @ObservedObject - var viewModel: VideoPlayerViewModel - @Default(.downActionShowsMenu) - var downActionShowsMenu - - @ViewBuilder - private var mainButtonView: some View { - switch viewModel.playerState { - case .stopped, .paused: - Image(systemName: "play.circle") - case .playing: - Image(systemName: "pause.circle") - default: - ProgressView() - } - } - - var body: some View { - ZStack(alignment: .bottom) { - - LinearGradient( - gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), - startPoint: .top, - endPoint: .bottom - ) - .ignoresSafeArea() - .frame(height: viewModel.subtitle == nil ? 180 : 210) - - VStack { - - Spacer() - - HStack(alignment: .bottom) { - - VStack(alignment: .leading) { - if let subtitle = viewModel.subtitle { - Text(subtitle) - .font(.subheadline) - .foregroundColor(.white) - } - - Text(viewModel.title) - .font(.title3) - .fontWeight(.bold) - } - - Spacer() - - if viewModel.shouldShowPlayPreviousItem { - SFSymbolButton(systemName: "chevron.left.circle", action: { - viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() - }) - .frame(maxWidth: 30, maxHeight: 30) - .disabled(viewModel.previousItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } - - if viewModel.shouldShowPlayNextItem { - SFSymbolButton(systemName: "chevron.right.circle", action: { - viewModel.playerOverlayDelegate?.didSelectPlayNextItem() - }) - .frame(maxWidth: 30, maxHeight: 30) - .disabled(viewModel.nextItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } - - if viewModel.shouldShowAutoPlay { - if viewModel.autoplayEnabled { - SFSymbolButton(systemName: "play.circle.fill") { - viewModel.autoplayEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } else { - SFSymbolButton(systemName: "stop.circle") { - viewModel.autoplayEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } - } - - if !viewModel.subtitleStreams.isEmpty { - if viewModel.subtitlesEnabled { - SFSymbolButton(systemName: "captions.bubble.fill") { - viewModel.subtitlesEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } else { - SFSymbolButton(systemName: "captions.bubble") { - viewModel.subtitlesEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } - } - - if !downActionShowsMenu { - SFSymbolButton(systemName: "ellipsis.circle") { - viewModel.playerOverlayDelegate?.didSelectMenu() - } - .frame(maxWidth: 30, maxHeight: 30) - } - } - .offset(x: 0, y: 10) - - SliderView(viewModel: viewModel) - .frame(maxHeight: 40) - - HStack { - - HStack(spacing: 10) { - mainButtonView - .frame(maxWidth: 40, maxHeight: 40) - - Text(viewModel.leftLabelText) - } - - Spacer() - - Text(viewModel.rightLabelText) - } - .offset(x: 0, y: -10) - } - } - .foregroundColor(.white) - } -} - -struct tvOSVLCOverlay_Previews: PreviewProvider { - - static let videoPlayerViewModel = VideoPlayerViewModel( - item: BaseItemDto(), - title: "Glorious Purpose", - subtitle: "Loki - S1E1", - directStreamURL: URL(string: "www.apple.com")!, - transcodedStreamURL: nil, - hlsStreamURL: URL(string: "www.apple.com")!, - streamType: .direct, - response: PlaybackInfoResponse(), - videoStream: MediaStream(), - audioStreams: [MediaStream(displayTitle: "English", index: -1)], - subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], - chapters: [], - selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1, - subtitlesEnabled: true, - autoplayEnabled: false, - overlayType: .compact, - shouldShowPlayPreviousItem: true, - shouldShowPlayNextItem: true, - shouldShowAutoPlay: true, - container: "", - filename: nil, - versionName: nil - ) - - static var previews: some View { - ZStack { - Color.red - .ignoresSafeArea() - - tvOSVLCOverlay(viewModel: videoPlayerViewModel) - } - } -} diff --git a/Swiftfin tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift b/Swiftfin tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift deleted file mode 100644 index 7cbc961a..00000000 --- a/Swiftfin tvOS/Views/VideoPlayer/PlayerOverlayDelegate.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 -import JellyfinAPI - -protocol PlayerOverlayDelegate { - - func didSelectClose() - func didSelectMenu() - - func didSelectBackward() - func didSelectForward() - func didSelectMain() - - func didGenerallyTap() - - func didBeginScrubbing() - func didEndScrubbing() - - func didSelectAudioStream(index: Int) - func didSelectSubtitleStream(index: Int) - - func didSelectPlayPreviousItem() - func didSelectPlayNextItem() - - func didSelectChapter(_ chapter: ChapterInfo) -} diff --git a/Swiftfin tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/Swiftfin tvOS/Views/VideoPlayer/VLCPlayerViewController.swift deleted file mode 100644 index 5158912f..00000000 --- a/Swiftfin tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ /dev/null @@ -1,925 +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 AVFoundation -import AVKit -import Combine -import Defaults -import Factory -import JellyfinAPI -import MediaPlayer -import SwiftUI -import TVVLCKit -import UIKit - -// TODO: Look at making the VLC player layer a view - -class VLCPlayerViewController: UIViewController { - - @Injected(LogManager.service) - private var logger - - // MARK: variables - - private var viewModel: VideoPlayerViewModel - private var vlcMediaPlayer: VLCMediaPlayer - private var lastPlayerTicks: Int64 = 0 - private var lastProgressReportTicks: Int64 = 0 - private var viewModelListeners = Set() - private var overlayDismissTimer: Timer? - private var confirmCloseOverlayDismissTimer: Timer? - - private var currentPlayerTicks: Int64 { - Int64(vlcMediaPlayer.time.intValue) * 100_000 - } - - private var displayingOverlay: Bool { - currentOverlayHostingController?.view.alpha ?? 0 > 0 - } - - private var displayingContentOverlay: Bool { - currentOverlayContentHostingController?.view.alpha ?? 0 > 0 - } - - private var displayingConfirmClose: Bool { - currentConfirmCloseHostingController?.view.alpha ?? 0 > 0 - } - - private lazy var videoContentView = makeVideoContentView() - private lazy var jumpBackwardOverlayView = makeJumpBackwardOverlayView() - private lazy var jumpForwardOverlayView = makeJumpForwardOverlayView() - private var currentOverlayHostingController: UIHostingController? - private var currentOverlayContentHostingController: UIHostingController? - private var currentConfirmCloseHostingController: UIHostingController? - - // MARK: init - - init(viewModel: VideoPlayerViewModel) { - - self.viewModel = viewModel - self.vlcMediaPlayer = VLCMediaPlayer() - - super.init(nibName: nil, bundle: nil) - - viewModel.playerOverlayDelegate = self - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupSubviews() { - view.addSubview(videoContentView) - view.addSubview(jumpForwardOverlayView) - view.addSubview(jumpBackwardOverlayView) - - jumpBackwardOverlayView.alpha = 0 - jumpForwardOverlayView.alpha = 0 - } - - private func setupConstraints() { - NSLayoutConstraint.activate([ - videoContentView.topAnchor.constraint(equalTo: view.topAnchor), - videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), - videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor), - ]) - NSLayoutConstraint.activate([ - jumpBackwardOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 300), - jumpBackwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - NSLayoutConstraint.activate([ - jumpForwardOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -300), - jumpForwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - } - - // MARK: viewWillDisappear - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - didSelectClose() - - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) - defaultNotificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) - defaultNotificationCenter.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) - } - - // MARK: viewDidLoad - - override func viewDidLoad() { - super.viewDidLoad() - - setupSubviews() - setupConstraints() - - view.backgroundColor = .black - - setupMediaPlayer(newViewModel: viewModel) - - setupPanGestureRecognizer() - - addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenu)) - - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.addObserver( - self, - selector: #selector(appWillTerminate), - name: UIApplication.willTerminateNotification, - object: nil - ) - defaultNotificationCenter.addObserver( - self, - selector: #selector(appWillResignActive), - name: UIApplication.willResignActiveNotification, - object: nil - ) - defaultNotificationCenter.addObserver( - self, - selector: #selector(appWillResignActive), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - } - - @objc - private func appWillTerminate() { - viewModel.sendStopReport() - } - - @objc - private func appWillResignActive() { - showOverlay() - - stopOverlayDismissTimer() - - vlcMediaPlayer.pause() - - viewModel.sendPauseReport(paused: true) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - startPlayback() - } - - // MARK: subviews - - private func makeVideoContentView() -> UIView { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .black - - return view - } - - private func makeJumpBackwardOverlayView() -> UIImageView { - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) - let forwardSymbolImage = UIImage(systemName: viewModel.jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) - let imageView = UIImageView(image: forwardSymbolImage) - imageView.translatesAutoresizingMaskIntoConstraints = false - - return imageView - } - - private func makeJumpForwardOverlayView() -> UIImageView { - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) - let forwardSymbolImage = UIImage(systemName: viewModel.jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) - let imageView = UIImageView(image: forwardSymbolImage) - imageView.translatesAutoresizingMaskIntoConstraints = false - - return imageView - } - - private func setupPanGestureRecognizer() { - let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(userPanned(panGestureRecognizer:))) - view.addGestureRecognizer(panGestureRecognizer) - } - - // MARK: pressesBegan - - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - guard let buttonPress = presses.first?.type else { return } - - switch buttonPress { - case .menu: () // Captured by other recognizer - case .playPause: - hideConfirmCloseOverlay() - - didSelectMain() - case .select: - hideConfirmCloseOverlay() - - didGenerallyTap() - case .upArrow: - hideConfirmCloseOverlay() - case .downArrow: - hideConfirmCloseOverlay() - - if Defaults[.downActionShowsMenu] { - if !displayingContentOverlay && !displayingOverlay { - didSelectMenu() - } - } - case .leftArrow: - hideConfirmCloseOverlay() - - if !displayingContentOverlay && !displayingOverlay { - didSelectBackward() - } - case .rightArrow: - hideConfirmCloseOverlay() - - if !displayingContentOverlay && !displayingOverlay { - didSelectForward() - } - case .pageUp: () - case .pageDown: () - @unknown default: () - } - } - - private func addButtonPressRecognizer(pressType: UIPress.PressType, action: Selector) { - let pressRecognizer = UITapGestureRecognizer() - pressRecognizer.addTarget(self, action: action) - pressRecognizer.allowedPressTypes = [NSNumber(value: pressType.rawValue)] - view.addGestureRecognizer(pressRecognizer) - } - - // MARK: didPressMenu - - @objc - private func didPressMenu() { - if displayingOverlay { - hideOverlay() - } else if displayingContentOverlay { - hideOverlayContent() - } else if viewModel.confirmClose && !displayingConfirmClose { - - showConfirmCloseOverlay() - restartConfirmCloseDismissTimer() - - } else { - vlcMediaPlayer.pause() - - dismiss(animated: true, completion: nil) - } - } - - @objc - private func userPanned(panGestureRecognizer: UIPanGestureRecognizer) { - if displayingOverlay { - restartOverlayDismissTimer() - } - } - - // MARK: setupOverlayHostingController - - private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { - - // TODO: Look at injecting viewModel into the environment so it updates the current overlay - - // Main overlay - if let currentOverlayHostingController = currentOverlayHostingController { - // UX fade-out - UIView.animate(withDuration: 0.5) { - currentOverlayHostingController.view.alpha = 0 - } completion: { _ in - currentOverlayHostingController.view.isHidden = true - - currentOverlayHostingController.view.removeFromSuperview() - currentOverlayHostingController.removeFromParent() - } - } - - let newOverlayView = tvOSVLCOverlay(viewModel: viewModel) - let newOverlayHostingController = UIHostingController(rootView: newOverlayView) - - newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newOverlayHostingController.view.backgroundColor = UIColor.clear - - // UX fade-in - newOverlayHostingController.view.alpha = 0 - - addChild(newOverlayHostingController) - view.addSubview(newOverlayHostingController.view) - newOverlayHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - // UX fade-in - UIView.animate(withDuration: 0.5) { - newOverlayHostingController.view.alpha = 1 - } - - self.currentOverlayHostingController = newOverlayHostingController - - // Media Stream selection - if let currentOverlayContentHostingController = currentOverlayContentHostingController { - currentOverlayContentHostingController.view.isHidden = true - - currentOverlayContentHostingController.view.removeFromSuperview() - currentOverlayContentHostingController.removeFromParent() - } - - let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel) - - let newOverlayContentHostingController = UIHostingController(rootView: newSmallMenuOverlayView) - - newOverlayContentHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newOverlayContentHostingController.view.backgroundColor = UIColor.clear - - newOverlayContentHostingController.view.alpha = 0 - - addChild(newOverlayContentHostingController) - view.addSubview(newOverlayContentHostingController.view) - newOverlayContentHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newOverlayContentHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newOverlayContentHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newOverlayContentHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newOverlayContentHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - self.currentOverlayContentHostingController = newOverlayContentHostingController - - // Confirm close - if let currentConfirmCloseHostingController = currentConfirmCloseHostingController { - currentConfirmCloseHostingController.view.isHidden = true - - currentConfirmCloseHostingController.view.removeFromSuperview() - currentConfirmCloseHostingController.removeFromParent() - } - - let newConfirmCloseOverlay = ConfirmCloseOverlay() - - let newConfirmCloseHostingController = UIHostingController(rootView: newConfirmCloseOverlay) - - newConfirmCloseHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newConfirmCloseHostingController.view.backgroundColor = UIColor.clear - - newConfirmCloseHostingController.view.alpha = 0 - - addChild(newConfirmCloseHostingController) - view.addSubview(newConfirmCloseHostingController.view) - newConfirmCloseHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newConfirmCloseHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newConfirmCloseHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newConfirmCloseHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newConfirmCloseHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - self.currentConfirmCloseHostingController = newConfirmCloseHostingController - - // There is a behavior when setting this that the navigation bar - // on the current navigation controller pops up, re-hide it - self.navigationController?.isNavigationBarHidden = true - } -} - -// MARK: setupMediaPlayer - -extension VLCPlayerViewController { - - /// Main function that handles setting up the media player with the current VideoPlayerViewModel - /// and also takes the role of setting the 'viewModel' property with the given viewModel - /// - /// Use case for this is setting new media within the same VLCPlayerViewController - func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { - - // remove old player - - if vlcMediaPlayer.media != nil { - viewModelListeners.forEach { $0.cancel() } - - vlcMediaPlayer.stop() - viewModel.sendStopReport() - viewModel.playerOverlayDelegate = nil - } - - vlcMediaPlayer = VLCMediaPlayer() - - // setup with new player and view model - - vlcMediaPlayer = VLCMediaPlayer() - - vlcMediaPlayer.delegate = self - vlcMediaPlayer.drawable = videoContentView - - vlcMediaPlayer.setSubtitleSize(Defaults[.subtitleSize]) - - stopOverlayDismissTimer() - - // Stop current media if there is one - if vlcMediaPlayer.media != nil { - viewModelListeners.forEach { $0.cancel() } - - vlcMediaPlayer.stop() - viewModel.sendStopReport() - viewModel.playerOverlayDelegate = nil - } - - lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - - // TODO: Custom buffer/cache amounts - - let media: VLCMedia - - if let transcodedURL = newViewModel.transcodedStreamURL, - !Defaults[.Experimental.forceDirectPlay] - { - media = VLCMedia(url: transcodedURL) - } else { - media = VLCMedia(url: newViewModel.directStreamURL) - } - - media.addOption("--prefetch-buffer-size=1048576") - media.addOption("--network-caching=5000") - - vlcMediaPlayer.media = media - - setupOverlayHostingController(viewModel: newViewModel) - setupViewModelListeners(viewModel: newViewModel) - - newViewModel.getAdjacentEpisodes() - newViewModel.playerOverlayDelegate = self - - let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 - - if startPercentage > 0 { - if viewModel.resumeOffset { - let runTimeTicks = viewModel.item.runTimeTicks ?? 0 - let videoDurationSeconds = Double(runTimeTicks / 10_000_000) - var startSeconds = round((startPercentage / 100) * videoDurationSeconds) - startSeconds = startSeconds.subtract(5, floor: 0) - let newStartPercentage = startSeconds / videoDurationSeconds - newViewModel.sliderPercentage = newStartPercentage - } else { - newViewModel.sliderPercentage = startPercentage / 100 - } - } - - viewModel = newViewModel - - if viewModel.streamType == .direct { - logger.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? .emptyDash)") - } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { - logger.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? .emptyDash)") - } else { - logger.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? .emptyDash)") - } - } - - // MARK: startPlayback - - func startPlayback() { - vlcMediaPlayer.play() - - // Setup external subtitles - for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { - if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { - vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) - } - } - - setMediaPlayerTimeAtCurrentSlider() - - viewModel.sendPlayReport() - - restartOverlayDismissTimer(interval: 5) - } - - // MARK: setupViewModelListeners - - private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { - viewModel.$playbackSpeed.sink { newSpeed in - self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) - }.store(in: &viewModelListeners) - - viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in - if sliderIsScrubbing { - self.didBeginScrubbing() - } else { - self.didEndScrubbing() - } - }.store(in: &viewModelListeners) - - viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in - self.didSelectAudioStream(index: newAudioStreamIndex) - }.store(in: &viewModelListeners) - - viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in - self.didSelectSubtitleStream(index: newSubtitleStreamIndex) - }.store(in: &viewModelListeners) - - viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in - self.didToggleSubtitles(newValue: newSubtitlesEnabled) - }.store(in: &viewModelListeners) - } - - func setMediaPlayerTimeAtCurrentSlider() { - // Necessary math as VLCMediaPlayer doesn't work well - // by just setting the position - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let runTimeTicks = viewModel.item.runTimeTicks ?? 0 - let videoDuration = Double(runTimeTicks / 10_000_000) - let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) - let newPositionOffset = secondsScrubbedTo - videoPosition - - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } - } -} - -// MARK: Show/Hide Overlay - -extension VLCPlayerViewController { - - private func showOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 1 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 1 - } - } - - private func hideOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 0 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 0 - } - } - - private func toggleOverlay() { - if displayingOverlay { - hideOverlay() - } else { - showOverlay() - } - } - - private func showOverlayContent() { - guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } - - guard currentOverlayContentHostingController.view.alpha != 1 else { return } - - currentOverlayContentHostingController.view.setNeedsFocusUpdate() - currentOverlayContentHostingController.setNeedsFocusUpdate() - setNeedsFocusUpdate() - - UIView.animate(withDuration: 0.2) { - currentOverlayContentHostingController.view.alpha = 1 - } - } - - private func hideOverlayContent() { - guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } - - guard currentOverlayContentHostingController.view.alpha != 0 else { return } - - setNeedsFocusUpdate() - - UIView.animate(withDuration: 0.2) { - currentOverlayContentHostingController.view.alpha = 0 - } - } -} - -// MARK: Show/Hide Jump - -extension VLCPlayerViewController { - - private func flashJumpBackwardOverlay() { - jumpBackwardOverlayView.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - self.jumpBackwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpBackwardOverlay() - } - } - - private func hideJumpBackwardOverlay() { - UIView.animate(withDuration: 0.3) { - self.jumpBackwardOverlayView.alpha = 0 - } - } - - private func flashJumpFowardOverlay() { - jumpForwardOverlayView.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - self.jumpForwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpForwardOverlay() - } - } - - private func hideJumpForwardOverlay() { - UIView.animate(withDuration: 0.3) { - self.jumpForwardOverlayView.alpha = 0 - } - } -} - -// MARK: Show/Hide Confirm close - -extension VLCPlayerViewController { - - private func showConfirmCloseOverlay() { - guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } - - UIView.animate(withDuration: 0.2) { - currentConfirmCloseHostingController.view.alpha = 1 - } - } - - private func hideConfirmCloseOverlay() { - guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } - - UIView.animate(withDuration: 0.5) { - currentConfirmCloseHostingController.view.alpha = 0 - } - } -} - -// MARK: OverlayTimer - -extension VLCPlayerViewController { - - private func restartOverlayDismissTimer(interval: Double = 5) { - self.overlayDismissTimer?.invalidate() - self.overlayDismissTimer = Timer.scheduledTimer( - timeInterval: interval, - target: self, - selector: #selector(dismissTimerFired), - userInfo: nil, - repeats: false - ) - } - - @objc - private func dismissTimerFired() { - hideOverlay() - } - - private func stopOverlayDismissTimer() { - overlayDismissTimer?.invalidate() - } -} - -// MARK: Confirm Close Overlay Timer - -extension VLCPlayerViewController { - - private func restartConfirmCloseDismissTimer() { - self.confirmCloseOverlayDismissTimer?.invalidate() - self.confirmCloseOverlayDismissTimer = Timer.scheduledTimer( - timeInterval: 5, - target: self, - selector: #selector(confirmCloseTimerFired), - userInfo: nil, - repeats: false - ) - } - - @objc - private func confirmCloseTimerFired() { - hideConfirmCloseOverlay() - } - - private func stopConfirmCloseDismissTimer() { - confirmCloseOverlayDismissTimer?.invalidate() - } -} - -// MARK: VLCMediaPlayerDelegate - -extension VLCPlayerViewController: VLCMediaPlayerDelegate { - - // MARK: mediaPlayerStateChanged - - func mediaPlayerStateChanged(_ aNotification: Notification) { - - // Don't show buffering if paused, usually here while scrubbing - if vlcMediaPlayer.state == .buffering && viewModel.playerState == .paused { - return - } - - viewModel.playerState = vlcMediaPlayer.state - - if vlcMediaPlayer.state == VLCMediaPlayerState.ended { - if viewModel.autoplayEnabled && viewModel.nextItemVideoPlayerViewModel != nil { - didSelectPlayNextItem() - } else { - didSelectClose() - } - } - } - - // MARK: mediaPlayerTimeChanged - - func mediaPlayerTimeChanged(_ aNotification: Notification) { - - if !viewModel.sliderIsScrubbing { - viewModel.sliderPercentage = Double(vlcMediaPlayer.position) - } - - // Have to manually set playing because VLCMediaPlayer doesn't - // properly set it itself - if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { - viewModel.playerState = VLCMediaPlayerState.playing - } - - // If needing to fix subtitle streams during playback - if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.videoSubtitleStreamIndex(of: viewModel.selectedSubtitleStreamIndex) && - viewModel.subtitlesEnabled - { - didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) - } - - if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { - didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) - } - - lastPlayerTicks = currentPlayerTicks - - // Send progress report every 5 seconds - if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - } -} - -// MARK: PlayerOverlayDelegate - -extension VLCPlayerViewController: PlayerOverlayDelegate { - - func didSelectAudioStream(index: Int) { - vlcMediaPlayer.currentAudioTrackIndex = Int32(index) - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - /// Do not call when setting to index -1 - func didSelectSubtitleStream(index: Int) { - - viewModel.subtitlesEnabled = true - vlcMediaPlayer.currentVideoSubTitleIndex = viewModel.videoSubtitleStreamIndex(of: index) - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - func didSelectClose() { - vlcMediaPlayer.stop() - - viewModel.sendStopReport() - - dismiss(animated: true, completion: nil) - } - - func didToggleSubtitles(newValue: Bool) { - if newValue { - vlcMediaPlayer.currentVideoSubTitleIndex = viewModel.videoSubtitleStreamIndex(of: viewModel.selectedSubtitleStreamIndex) - } else { - vlcMediaPlayer.currentVideoSubTitleIndex = -1 - } - } - - func didSelectMenu() { - stopOverlayDismissTimer() - - hideOverlay() - showOverlayContent() - } - - func didSelectBackward() { - - flashJumpBackwardOverlay() - - vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) - - if displayingOverlay { - restartOverlayDismissTimer() - } - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - func didSelectForward() { - - flashJumpFowardOverlay() - - vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) - - if displayingOverlay { - restartOverlayDismissTimer() - } - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - func didSelectMain() { - - switch viewModel.playerState { - case .buffering: - vlcMediaPlayer.play() - restartOverlayDismissTimer() - case .playing: - viewModel.sendPauseReport(paused: true) - vlcMediaPlayer.pause() - - showOverlay() - restartOverlayDismissTimer(interval: 5) - case .paused: - viewModel.sendPauseReport(paused: false) - vlcMediaPlayer.play() - restartOverlayDismissTimer() - default: () - } - } - - func didGenerallyTap() { - toggleOverlay() - - restartOverlayDismissTimer(interval: 5) - } - - func didBeginScrubbing() { - stopOverlayDismissTimer() - } - - func didEndScrubbing() { - setMediaPlayerTimeAtCurrentSlider() - - restartOverlayDismissTimer() - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - func didSelectPlayPreviousItem() { - if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) - startPlayback() - } - } - - func didSelectPlayNextItem() { - if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) - startPlayback() - } - } - - func didSelectChapter(_ chapter: ChapterInfo) { - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let chapterSeconds = Double((chapter.startPositionTicks ?? 0) / 10_000_000) - let newPositionOffset = chapterSeconds - videoPosition - - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } - - viewModel.sendProgressReport() - } -} diff --git a/Swiftfin tvOS/Views/VideoPlayer/VideoPlayer.swift b/Swiftfin tvOS/Views/VideoPlayer/VideoPlayer.swift new file mode 100644 index 00000000..63f77db4 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/VideoPlayer.swift @@ -0,0 +1,121 @@ +// +// 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 SwiftUI +import VLCUI + +struct VideoPlayer: View { + + enum OverlayType { + case chapters + case confirmClose + case main + case smallMenu + } + + @EnvironmentObject + private var router: VideoPlayerCoordinator.Router + + @ObservedObject + private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler + @ObservedObject + private var videoPlayerManager: VideoPlayerManager + + @State + private var isPresentingOverlay: Bool = false + @State + private var isScrubbing: Bool = false + + private var overlay: () -> any View + + @ViewBuilder + private var playerView: some View { + ZStack { + VLCVideoPlayer(configuration: videoPlayerManager.currentViewModel.vlcVideoPlayerConfiguration) + .proxy(videoPlayerManager.proxy) + .onTicksUpdated { ticks, _ in + + let newSeconds = ticks / 1000 + let newProgress = CGFloat(newSeconds) / CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) + currentProgressHandler.progress = newProgress + currentProgressHandler.seconds = newSeconds + + guard !isScrubbing else { return } + currentProgressHandler.scrubbedProgress = newProgress + } + .onStateUpdated { state, _ in + + videoPlayerManager.onStateUpdated(newState: state) + + if state == .ended { + if let _ = videoPlayerManager.nextViewModel, + Defaults[.VideoPlayer.autoPlayEnabled] + { + videoPlayerManager.selectNextViewModel() + } else { + router.dismissCoordinator() + } + } + } + + overlay() + .eraseToAnyView() + .environmentObject(videoPlayerManager) + .environmentObject(videoPlayerManager.currentProgressHandler) + .environmentObject(videoPlayerManager.currentViewModel!) + .environmentObject(videoPlayerManager.proxy) + .environment(\.isPresentingOverlay, $isPresentingOverlay) + .environment(\.isScrubbing, $isScrubbing) + } + .onChange(of: videoPlayerManager.currentProgressHandler.scrubbedProgress) { newValue in + DispatchQueue.main.async { + videoPlayerManager.currentProgressHandler + .scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue) + } + } + } + + @ViewBuilder + private var loadingView: some View { + Text("Retrieving media information") + } + + var body: some View { + ZStack { + + Color.black + + if let _ = videoPlayerManager.currentViewModel { + playerView + } else { + loadingView + } + } + .ignoresSafeArea() + .onChange(of: isScrubbing) { newValue in + guard !newValue else { return } + videoPlayerManager.proxy.setTime(.seconds(currentProgressHandler.scrubbedSeconds)) + } + } +} + +extension VideoPlayer { + + init(manager: VideoPlayerManager) { + self.init( + currentProgressHandler: manager.currentProgressHandler, + videoPlayerManager: manager, + overlay: { EmptyView() } + ) + } + + func overlay(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.overlay, with: content) + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/VideoPlayerView.swift b/Swiftfin tvOS/Views/VideoPlayer/VideoPlayerView.swift deleted file mode 100644 index 7d3a2cd1..00000000 --- a/Swiftfin tvOS/Views/VideoPlayer/VideoPlayerView.swift +++ /dev/null @@ -1,38 +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 -import UIKit - -struct NativePlayerView: UIViewControllerRepresentable { - - let viewModel: VideoPlayerViewModel - - typealias UIViewControllerType = NativePlayerViewController - - func makeUIViewController(context: Context) -> NativePlayerViewController { - - NativePlayerViewController(viewModel: viewModel) - } - - func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {} -} - -struct VLCPlayerView: UIViewControllerRepresentable { - - let viewModel: VideoPlayerViewModel - - typealias UIViewControllerType = VLCPlayerViewController - - func makeUIViewController(context: Context) -> VLCPlayerViewController { - - VLCPlayerViewController(viewModel: viewModel) - } - - func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) {} -} diff --git a/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift b/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift deleted file mode 100644 index 10bab22e..00000000 --- a/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct SliderView: UIViewRepresentable { - - @ObservedObject - var viewModel: VideoPlayerViewModel - - // TODO: look at adjusting value dependent on item runtime - private let maxValue: Double = 1000 - - func updateUIView(_ uiView: TvOSSlider, context: Context) { - guard !viewModel.sliderIsScrubbing else { return } - uiView.value = Float(maxValue * viewModel.sliderPercentage) - } - - func makeUIView(context: Context) -> TvOSSlider { - let slider = TvOSSlider(viewModel: viewModel) - - slider.minimumValue = 0 - slider.maximumValue = Float(maxValue) - slider.value = Float(maxValue * viewModel.sliderPercentage) - slider.thumbSize = 25 - slider.thumbTintColor = .white - slider.minimumTrackTintColor = .white - slider.focusScaleFactor = 1.4 - slider.panDampingValue = 50 - slider.fineTunningVelocityThreshold = 1000 - - return slider - } -} diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index d0223b89..1d5b0f18 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -3,21 +3,16 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; }; 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; }; - 09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; }; - 09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CC626819B4500AE350E /* VideoPlayerModel.swift */; }; - 09389CC826819B4600AE350E /* VideoPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CC626819B4500AE350E /* VideoPlayerModel.swift */; }; 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; }; - 531690F7267ACC00005D8AB9 /* LandscapeItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */; }; 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; }; 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */; }; - 5321753E2671DE9C005491E6 /* ItemFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* ItemFilters.swift */; }; 53352571265EA0A0006CCA86 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 53352570265EA0A0006CCA86 /* Introspect */; }; 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5338F74D263B61370014BF09 /* ConnectToServerView.swift */; }; 534D4FF026A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEE26A7D7CC000A7A48 /* Localizable.strings */; }; @@ -26,22 +21,17 @@ 534D4FF426A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FE726A7D7CC000A7A48 /* Localizable.strings */; }; 534D4FF626A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEB26A7D7CC000A7A48 /* Localizable.strings */; }; 534D4FF726A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEB26A7D7CC000A7A48 /* Localizable.strings */; }; - 535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870622669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift */; }; + 535870632669D21600D05A09 /* SwiftfinApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870622669D21600D05A09 /* SwiftfinApp.swift */; }; 535870672669D21700D05A09 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 535870662669D21700D05A09 /* Assets.xcassets */; }; - 5358706A2669D21700D05A09 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 535870692669D21700D05A09 /* Preview Assets.xcassets */; }; 5358707E2669D64F00D05A09 /* bitrates.json in Resources */ = {isa = PBXBuildFile; fileRef = AE8C3158265D6F90008AA076 /* bitrates.json */; }; 535870912669D7A800D05A09 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 535870902669D7A800D05A09 /* Introspect */; }; - 535870A82669D8AE00D05A09 /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338922660107500A81A2A /* StringExtensions.swift */; }; 535870AD2669D8DD00D05A09 /* ItemFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* ItemFilters.swift */; }; 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAE9E2649E569005FA86D /* ItemView.swift */; }; - 53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53649AB0269CFB1900A2D8B7 /* LogManager.swift */; }; - 53649AB2269D019100A2D8B7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53649AB0269CFB1900A2D8B7 /* LogManager.swift */; }; - 5364F455266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */; }; - 5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */; }; + 5364F455266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* BaseItemPerson.swift */; }; + 5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* BaseItemPerson.swift */; }; 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB57B2678CE1000530A6E /* ViewModel.swift */; }; - 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */; }; + 5377CBF5263B596A003A4E83 /* SwiftfinApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBF4263B596A003A4E83 /* SwiftfinApp.swift */; }; 5377CBF9263B596B003A4E83 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBF8263B596B003A4E83 /* Assets.xcassets */; }; - 5377CBFC263B596B003A4E83 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBFB263B596B003A4E83 /* Preview Assets.xcassets */; }; 53913BEF26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BC926D323FE00EB3286 /* Localizable.strings */; }; 53913BF026D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BC926D323FE00EB3286 /* Localizable.strings */; }; 53913BF226D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BCC26D323FE00EB3286 /* Localizable.strings */; }; @@ -78,16 +68,11 @@ 53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */; }; 53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5722678C32A00530A6E /* HomeViewModel.swift */; }; 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53ABFDEA2679753200886593 /* ConnectToServerView.swift */; }; - 53ABFDED26799D7700886593 /* ActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 53ABFDEC26799D7700886593 /* ActivityIndicator */; }; 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CD2A3F268A49C2002ABD4E /* ItemView.swift */; }; 53EE24E6265060780068F029 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE24E5265060780068F029 /* SearchView.swift */; }; - 5D1603FC278A3D5800D22B99 /* SubtitleSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */; }; - 5D1603FD278A40DB00D22B99 /* SubtitleSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */; }; - 5D160403278A41FD00D22B99 /* VLCPlayer+subtitles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D160402278A41FD00D22B99 /* VLCPlayer+subtitles.swift */; }; - 5D32EA12278C95E30020E292 /* VLCPlayer+subtitles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D160402278A41FD00D22B99 /* VLCPlayer+subtitles.swift */; }; 5D64683D277B1649009E09AE /* PreferenceUIHostingSwizzling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D64683C277B1649009E09AE /* PreferenceUIHostingSwizzling.swift */; }; 62133890265F83A900A81A2A /* MediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6213388F265F83A900A81A2A /* MediaView.swift */; }; - 621338932660107500A81A2A /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338922660107500A81A2A /* StringExtensions.swift */; }; + 621338932660107500A81A2A /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338922660107500A81A2A /* String.swift */; }; 6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */; }; 6220D0AE26D5EABB00B8E046 /* ViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */; }; 6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */; }; @@ -95,7 +80,6 @@ 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */; }; 6220D0BA26D6092100B8E046 /* FilterCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B926D6092100B8E046 /* FilterCoordinator.swift */; }; 6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */; }; - 6220D0C626D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */; }; 6220D0C926D63F3700B8E046 /* Stinsen in Frameworks */ = {isa = PBXBuildFile; productRef = 6220D0C826D63F3700B8E046 /* Stinsen */; }; 6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0CB26D640C400B8E046 /* AppURLHandler.swift */; }; 62400C4B287ED19600F6AD3D /* UDPBroadcast.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 637FCAF3287B5B2600C0A353 /* UDPBroadcast.xcframework */; }; @@ -103,7 +87,6 @@ 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5722678C32A00530A6E /* HomeViewModel.swift */; }; 625CB5752678C33500530A6E /* MediaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5742678C33500530A6E /* MediaViewModel.swift */; }; 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */; }; - 625CB57A2678C4A400530A6E /* ActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 625CB5792678C4A400530A6E /* ActivityIndicator */; }; 6264E88C273850380081A12A /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6264E88B273850380081A12A /* Strings.swift */; }; 6264E88D273850380081A12A /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6264E88B273850380081A12A /* Strings.swift */; }; 62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */; }; @@ -141,17 +124,15 @@ 62666E3927E502CE00EC0ECD /* SwizzleSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 62666E3827E502CE00EC0ECD /* SwizzleSwift */; }; 62666E3E27E503FA00EC0ECD /* MediaAccessibility.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4BE267D40E4000E2F71 /* MediaAccessibility.framework */; }; 62666E3F27E5040300EC0ECD /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4C6267D40F4000E2F71 /* SystemConfiguration.framework */; }; - 6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; }; - 6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; }; + 6267B3D626710B8900A7371D /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* Collection.swift */; }; 62C29E9C26D0FE4200C1D2E7 /* Stinsen in Frameworks */ = {isa = PBXBuildFile; productRef = 62C29E9B26D0FE4200C1D2E7 /* Stinsen */; }; 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29E9E26D1016600C1D2E7 /* iOSMainCoordinator.swift */; }; 62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA026D102A500C1D2E7 /* iOSMainTabCoordinator.swift */; }; 62C29EA326D1030F00C1D2E7 /* ConnectToServerCoodinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA226D1030F00C1D2E7 /* ConnectToServerCoodinator.swift */; }; 62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */; }; 62C29EA826D103D500C1D2E7 /* MediaCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA726D103D500C1D2E7 /* MediaCoordinator.swift */; }; - 62C83B08288C6A630004ED0C /* FontPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C83B07288C6A630004ED0C /* FontPicker.swift */; }; - 62E1DCC3273CE19800C9AE76 /* URLExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E1DCC2273CE19800C9AE76 /* URLExtensions.swift */; }; - 62E1DCC4273CE19800C9AE76 /* URLExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E1DCC2273CE19800C9AE76 /* URLExtensions.swift */; }; + 62C83B08288C6A630004ED0C /* FontPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C83B07288C6A630004ED0C /* FontPickerView.swift */; }; + 62E1DCC3273CE19800C9AE76 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E1DCC2273CE19800C9AE76 /* URL.swift */; }; 62E632DC267D2E130063E547 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* SearchViewModel.swift */; }; 62E632DD267D2E130063E547 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* SearchViewModel.swift */; }; 62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */; }; @@ -160,14 +141,10 @@ 62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */; }; 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */; }; 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */; }; - 62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632E8267D3FF50063E547 /* SeasonItemViewModel.swift */; }; - 62E632EA267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632E8267D3FF50063E547 /* SeasonItemViewModel.swift */; }; 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */; }; 62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */; }; 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* ItemViewModel.swift */; }; 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* ItemViewModel.swift */; }; - 62EC352F267666A5000E9F2D /* SessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352E267666A5000E9F2D /* SessionManager.swift */; }; - 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352E267666A5000E9F2D /* SessionManager.swift */; }; 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62ECA01726FA685A00E8EBB7 /* DeepLink.swift */; }; 6334175B287DDFB9000603CE /* QuickConnectSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334175A287DDFB9000603CE /* QuickConnectSettingsView.swift */; }; 6334175D287DE0D0000603CE /* QuickConnectSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */; }; @@ -175,25 +152,12 @@ C400DB6A27FE894F007B65FE /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */; }; C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */; }; C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */; }; - C409CE9C284EA6EA00CABC12 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = C409CE9B284EA6EA00CABC12 /* SwiftUICollection */; }; - C409CE9E285044C800CABC12 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = C409CE9D285044C800CABC12 /* SwiftUICollection */; }; C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */; }; - C4464953281616AE00DDB461 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBF8263B596B003A4E83 /* Assets.xcassets */; }; - C453497F279A2DA50045F1E2 /* LiveTVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C453497E279A2DA50045F1E2 /* LiveTVPlayerViewController.swift */; }; - C4534981279A3F140045F1E2 /* tvOSLiveTVOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */; }; - C4534983279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */; }; - C4534985279A40C60045F1E2 /* LiveTVVideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */; }; - C45640D0281A43EF007096DE /* LiveTVNativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45640CF281A43EF007096DE /* LiveTVNativePlayerViewController.swift */; }; C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */; }; C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */; }; - C45942C927F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */; }; - C45942CB27F6984100C54FE7 /* LiveTVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942CA27F6984100C54FE7 /* LiveTVPlayerViewController.swift */; }; - C45942CD27F6994A00C54FE7 /* LiveTVPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942CC27F6994A00C54FE7 /* LiveTVPlayerView.swift */; }; C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */; }; - C45B29BB26FAC5B600CEF5E0 /* ColorExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtensions.swift */; }; C4AE2C3027498D2300AE13CF /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */; }; C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */; }; - C4B9B91427E1921B0063535C /* LiveTVNativeVideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4B9B91327E1921B0063535C /* LiveTVNativeVideoPlayerView.swift */; }; C4BE076F2720FEFF003F4AD1 /* PlainNavigationLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */; }; C4BE07722725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */; }; C4BE07742725EB66003F4AD1 /* LiveTVProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */; }; @@ -207,27 +171,30 @@ C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */; }; C4E5081B2703F82A0045C9AB /* MediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E508172703E8190045C9AB /* MediaView.swift */; }; C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */; }; - E1002B5F2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */; }; - E1002B642793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */; }; - E1002B652793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */; }; + E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfo.swift */; }; + E1002B652793CEE800E47059 /* ChapterInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfo.swift */; }; E1002B682793CFBA00E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B672793CFBA00E47059 /* Algorithms */; }; E1002B6B2793E36600E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B6A2793E36600E47059 /* Algorithms */; }; E1047E2327E5880000CB0D4A /* InitialFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* InitialFailureView.swift */; }; + E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */; }; + E104C873296E0D0A00C1C3F9 /* IndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104C872296E0D0A00C1C3F9 /* IndicatorSettingsView.swift */; }; + E10706102942F57D00646DAF /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = E107060F2942F57D00646DAF /* Pulse */; }; + E10706122942F57D00646DAF /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E10706112942F57D00646DAF /* PulseLogHandler */; }; + E10706142942F57D00646DAF /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E10706132942F57D00646DAF /* PulseUI */; }; + E10706172943F2F900646DAF /* (null) in Sources */ = {isa = PBXBuildFile; }; E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */; }; E107BB9427880A8F00354E07 /* CollectionItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */; }; - E10D87DC2784EC5200BD264C /* SeriesEpisodesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DB2784EC5200BD264C /* SeriesEpisodesView.swift */; }; - E10D87E227852FD000BD264C /* EpisodesRowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87E127852FD000BD264C /* EpisodesRowManager.swift */; }; - E10D87E327852FD000BD264C /* EpisodesRowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87E127852FD000BD264C /* EpisodesRowManager.swift */; }; - E10EAA4D277BB716000269ED /* Sliders in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA4C277BB716000269ED /* Sliders */; }; - E10EAA4F277BBCC4000269ED /* CGSizeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */; }; - E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */; }; - E10EAA53277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */; }; - E10EAA54277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */; }; - E1101177281B1E8A006A3584 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = E1101176281B1E8A006A3584 /* Puppy */; }; + E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1092F4B29106F9F00163F57 /* GestureAction.swift */; }; + E10E842A29A587110064EA49 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10E842929A587110064EA49 /* LoadingView.swift */; }; + E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10E842B29A589860064EA49 /* NonePosterButton.swift */; }; + E10EAA4F277BBCC4000269ED /* CGSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSize.swift */; }; E111D8F528D03B7500400001 /* PagingLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E111D8F428D03B7500400001 /* PagingLibraryViewModel.swift */; }; E111D8F628D03B7500400001 /* PagingLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E111D8F428D03B7500400001 /* PagingLibraryViewModel.swift */; }; E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E111D8F728D03BF900400001 /* PagingLibraryView.swift */; }; E111D8FA28D0400900400001 /* PagingLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E111D8F928D0400900400001 /* PagingLibraryView.swift */; }; + E11245B128D919CD00D8A977 /* Overlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11245B028D919CD00D8A977 /* Overlay.swift */; }; + E11245B428D97D5D00D8A977 /* BottomBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11245B328D97D5D00D8A977 /* BottomBarView.swift */; }; + E11245B728D97ED200D8A977 /* TopBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11245B628D97ED200D8A977 /* TopBarView.swift */; }; E113132B28BDB4B500930F75 /* NavBarDrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E113132A28BDB4B500930F75 /* NavBarDrawerView.swift */; }; E113132F28BDB66A00930F75 /* NavBarDrawerModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E113132E28BDB66A00930F75 /* NavBarDrawerModifier.swift */; }; E113133228BDC72000930F75 /* FilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E113133128BDC72000930F75 /* FilterView.swift */; }; @@ -249,19 +216,29 @@ E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; E11CEB8928998549003E74C7 /* BottomEdgeGradientModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */; }; E11CEB8B28998552003E74C7 /* iOSViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8A28998552003E74C7 /* iOSViewExtensions.swift */; }; - E11CEB8D28999B4A003E74C7 /* FontExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8C28999B4A003E74C7 /* FontExtensions.swift */; }; - E11CEB8E28999B4A003E74C7 /* FontExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8C28999B4A003E74C7 /* FontExtensions.swift */; }; + E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8C28999B4A003E74C7 /* Font.swift */; }; E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8F28999D84003E74C7 /* EpisodeItemView.swift */; }; E11CEB9428999D9E003E74C7 /* EpisodeItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB9328999D9E003E74C7 /* EpisodeItemContentView.swift */; }; E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */; }; E11D224327378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */; }; + E11E374D293E7EC9009EF240 /* ItemFields.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D842902933F87500D1041A /* ItemFields.swift */; }; + E11E374E293E7F08009EF240 /* MediaSourceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D8428E2933F2D900D1041A /* MediaSourceInfo.swift */; }; + E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18A8E8428D60D0000333B9A /* VideoPlayerCoordinator.swift */; }; E12186DE2718F1C50010884C /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = E12186DD2718F1C50010884C /* Defaults */; }; - E122A9132788EAAD0060FA63 /* MediaStreamExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E122A9122788EAAD0060FA63 /* MediaStreamExtension.swift */; }; - E122A9142788EAAD0060FA63 /* MediaStreamExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E122A9122788EAAD0060FA63 /* MediaStreamExtension.swift */; }; + E122A9132788EAAD0060FA63 /* MediaStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = E122A9122788EAAD0060FA63 /* MediaStream.swift */; }; + E122A9142788EAAD0060FA63 /* MediaStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = E122A9122788EAAD0060FA63 /* MediaStream.swift */; }; E1267D3E271A1F46003C492E /* PreferenceUIHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1267D3D271A1F46003C492E /* PreferenceUIHostingController.swift */; }; - E126F741278A656C00A522BF /* ServerStreamType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E126F740278A656C00A522BF /* ServerStreamType.swift */; }; - E126F742278A656C00A522BF /* ServerStreamType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E126F740278A656C00A522BF /* ServerStreamType.swift */; }; - E12B835F28C07D8500878399 /* LibraryParent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E113133728BEADBA00930F75 /* LibraryParent.swift */; }; + E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129428428F080B500796AC6 /* OnReceiveNotificationModifier.swift */; }; + E129428628F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129428428F080B500796AC6 /* OnReceiveNotificationModifier.swift */; }; + E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129428728F0831F00796AC6 /* SplitTimestamp.swift */; }; + E129429028F0BDC300796AC6 /* TimeStampType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129428F28F0BDC300796AC6 /* TimeStampType.swift */; }; + E129429328F2845000796AC6 /* SliderType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129429228F2845000796AC6 /* SliderType.swift */; }; + E129429828F4785200796AC6 /* EnumPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129429728F4785200796AC6 /* EnumPicker.swift */; }; + E129429B28F4A5E300796AC6 /* PlaybackSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129429A28F4A5E300796AC6 /* PlaybackSettingsView.swift */; }; + E12A9EF829499E0100731C3A /* JellyfinClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12A9EF729499E0100731C3A /* JellyfinClient.swift */; }; + E12A9EF929499E0100731C3A /* JellyfinClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12A9EF729499E0100731C3A /* JellyfinClient.swift */; }; + E12B93072947CD0F00CE0BD9 /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = E15210532946DF1B00375CC2 /* Pulse */; }; + E12B930D2948369F00CE0BD9 /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E12B930C2948369F00CE0BD9 /* JellyfinAPI */; }; E12CC1AE28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1AD28D0FAEA00678D5D /* NextUpLibraryViewModel.swift */; }; E12CC1AF28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1AD28D0FAEA00678D5D /* NextUpLibraryViewModel.swift */; }; E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1B028D1008F00678D5D /* NextUpView.swift */; }; @@ -273,41 +250,43 @@ E12CC1BE28D11F4500678D5D /* RecentlyAddedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1BD28D11F4500678D5D /* RecentlyAddedView.swift */; }; E12CC1BF28D1260600678D5D /* ItemTypeLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */; }; E12CC1C128D12B0A00678D5D /* BasicLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1C028D12B0A00678D5D /* BasicLibraryView.swift */; }; - E12CC1C528D12D9B00678D5D /* SeeAllPoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1C428D12D9B00678D5D /* SeeAllPoster.swift */; }; + E12CC1C528D12D9B00678D5D /* SeeAllPosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1C428D12D9B00678D5D /* SeeAllPosterButton.swift */; }; E12CC1C728D12FD600678D5D /* CinematicRecentlyAddedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1C628D12FD600678D5D /* CinematicRecentlyAddedView.swift */; }; E12CC1C928D132B800678D5D /* RecentlyAddedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1C828D132B800678D5D /* RecentlyAddedView.swift */; }; E12CC1CB28D1333400678D5D /* CinematicResumeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1CA28D1333400678D5D /* CinematicResumeItemView.swift */; }; E12CC1CD28D135C700678D5D /* NextUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1CC28D135C700678D5D /* NextUpView.swift */; }; - E1347DB2279E3C6200BC6161 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = E1347DB1279E3C6200BC6161 /* Puppy */; }; - E1347DB6279E3CA500BC6161 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = E1347DB5279E3CA500BC6161 /* Puppy */; }; - E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1384943278036C70024FB48 /* VLCPlayerViewController.swift */; }; - E13849452780370B0024FB48 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; }; - E1399474289B1EA900401ABC /* Defaults+Workaround.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1399473289B1EA900401ABC /* Defaults+Workaround.swift */; }; - E1399475289B1EA900401ABC /* Defaults+Workaround.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1399473289B1EA900401ABC /* Defaults+Workaround.swift */; }; - E13AD72E2798BC8D00FDCEE8 /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13AD72D2798BC8D00FDCEE8 /* NativePlayerViewController.swift */; }; - E13AD7302798C60F00FDCEE8 /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13AD72F2798C60F00FDCEE8 /* NativePlayerViewController.swift */; }; + E12E30F1296383810022FAC9 /* SplitFormWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12E30F0296383810022FAC9 /* SplitFormWindowView.swift */; }; + E12E30F329638B140022FAC9 /* ChevronButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12E30F229638B140022FAC9 /* ChevronButton.swift */; }; + E12E30F5296392EC0022FAC9 /* EnumPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12E30F4296392EC0022FAC9 /* EnumPickerView.swift */; }; + E12F038C28F8B0B100976CC3 /* EdgeInsets.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12F038B28F8B0B100976CC3 /* EdgeInsets.swift */; }; + E133328829538D8D00EE76AB /* Files.swift in Sources */ = {isa = PBXBuildFile; fileRef = E133328729538D8D00EE76AB /* Files.swift */; }; + E133328929538D8D00EE76AB /* Files.swift in Sources */ = {isa = PBXBuildFile; fileRef = E133328729538D8D00EE76AB /* Files.swift */; }; + E133328D2953AE4B00EE76AB /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E133328C2953AE4B00EE76AB /* CircularProgressView.swift */; }; + E133328F2953B71000EE76AB /* DownloadTaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E133328E2953B71000EE76AB /* DownloadTaskView.swift */; }; + E13332912953B91000EE76AB /* DownloadTaskCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13332902953B91000EE76AB /* DownloadTaskCoordinator.swift */; }; + E13332942953BAA100EE76AB /* DownloadTaskContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13332932953BAA100EE76AB /* DownloadTaskContentView.swift */; }; + E1356E0329A730B200382563 /* SeparatorHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1356E0129A7309D00382563 /* SeparatorHStack.swift */; }; + E1356E0429A731EB00382563 /* SeparatorHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1356E0129A7309D00382563 /* SeparatorHStack.swift */; }; + E1388A42293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1388A40293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift */; }; + E1388A43293F0AAD009721B1 /* PreferenceUIHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1388A41293F0AAD009721B1 /* PreferenceUIHostingController.swift */; }; + E1388A46293F0ABA009721B1 /* SwizzleSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E1388A45293F0ABA009721B1 /* SwizzleSwift */; }; + E139CC1D28EC836F00688DE2 /* ChapterOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E139CC1C28EC836F00688DE2 /* ChapterOverlay.swift */; }; + E139CC1F28EC83E400688DE2 /* Int.swift in Sources */ = {isa = PBXBuildFile; fileRef = E139CC1E28EC83E400688DE2 /* Int.swift */; }; E13AF3B628A0C598009093AB /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = E13AF3B528A0C598009093AB /* Nuke */; }; E13AF3B828A0C598009093AB /* NukeExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = E13AF3B728A0C598009093AB /* NukeExtensions */; }; E13AF3BA28A0C598009093AB /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = E13AF3B928A0C598009093AB /* NukeUI */; }; E13AF3BC28A0C59E009093AB /* BlurHashKit in Frameworks */ = {isa = PBXBuildFile; productRef = E13AF3BB28A0C59E009093AB /* BlurHashKit */; }; E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */; }; - E13DD3C227164941009D4DAF /* SwiftfinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C127164941009D4DAF /* SwiftfinStore.swift */; }; - E13DD3C327164941009D4DAF /* SwiftfinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C127164941009D4DAF /* SwiftfinStore.swift */; }; E13DD3C62716499E009D4DAF /* CoreStore in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3C52716499E009D4DAF /* CoreStore */; }; - E13DD3C827164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C727164B1E009D4DAF /* UIDeviceExtensions.swift */; }; - E13DD3C927164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C727164B1E009D4DAF /* UIDeviceExtensions.swift */; }; + E13DD3C827164B1E009D4DAF /* UIDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C727164B1E009D4DAF /* UIDevice.swift */; }; E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3CC27164CA7009D4DAF /* CoreStore */; }; E13DD3D327168E65009D4DAF /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3D227168E65009D4DAF /* Defaults */; }; - E13DD3D5271693CD009D4DAF /* SwiftfinStoreDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3D4271693CD009D4DAF /* SwiftfinStoreDefaults.swift */; }; - E13DD3D6271693CD009D4DAF /* SwiftfinStoreDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3D4271693CD009D4DAF /* SwiftfinStoreDefaults.swift */; }; E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */; }; E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */; }; E13DD3E527177D15009D4DAF /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3E427177D15009D4DAF /* ServerListView.swift */; }; E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3E827177ED6009D4DAF /* ServerListCoordinator.swift */; }; E13DD3EC27178A54009D4DAF /* UserSignInViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */; }; E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */; }; - E13DD3EF27178F87009D4DAF /* SwiftfinNotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3EE27178F87009D4DAF /* SwiftfinNotificationCenter.swift */; }; - E13DD3F027178F87009D4DAF /* SwiftfinNotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3EE27178F87009D4DAF /* SwiftfinNotificationCenter.swift */; }; E13DD3F227179378009D4DAF /* UserSignInCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */; }; E13DD3F5271793BB009D4DAF /* UserSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F4271793BB009D4DAF /* UserSignInView.swift */; }; E13DD3F92717E961009D4DAF /* UserListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F82717E961009D4DAF /* UserListViewModel.swift */; }; @@ -315,55 +294,179 @@ E13DD3FC2717EAE8009D4DAF /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3FB2717EAE8009D4DAF /* UserListView.swift */; }; E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */; }; E13F05EC28BC9000003499D2 /* LibraryViewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EB28BC9000003499D2 /* LibraryViewType.swift */; }; - E13F05ED28BC9000003499D2 /* LibraryViewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EB28BC9000003499D2 /* LibraryViewType.swift */; }; E13F05F128BC9016003499D2 /* LibraryItemRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EF28BC9016003499D2 /* LibraryItemRow.swift */; }; E13F05F328BC9016003499D2 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05F028BC9016003499D2 /* LibraryView.swift */; }; - E148128328C1443D003B8787 /* NameGUIDPairExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */; }; - E148128528C15472003B8787 /* APISortOrderExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128428C15472003B8787 /* APISortOrderExtensions.swift */; }; - E148128628C15475003B8787 /* APISortOrderExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128428C15472003B8787 /* APISortOrderExtensions.swift */; }; - E148128828C154BF003B8787 /* ItemFilterExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128728C154BF003B8787 /* ItemFilterExtensions.swift */; }; - E148128928C154BF003B8787 /* ItemFilterExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128728C154BF003B8787 /* ItemFilterExtensions.swift */; }; + E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */; }; + E1401CA22938122C00E8B599 /* AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA12938122C00E8B599 /* AppIcons.swift */; }; + E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA4293813F400E8B599 /* InvertedDarkAppIcon.swift */; }; + E1401CA72938140300E8B599 /* PrimaryAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA62938140300E8B599 /* PrimaryAppIcon.swift */; }; + E1401CA92938140700E8B599 /* DarkAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA82938140700E8B599 /* DarkAppIcon.swift */; }; + E1401CAB2938140A00E8B599 /* LightAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CAA2938140A00E8B599 /* LightAppIcon.swift */; }; + E1401CB129386C9200E8B599 /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CB029386C9200E8B599 /* UIColor.swift */; }; + E1401D45293A952300E8B599 /* MediaItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401D44293A952300E8B599 /* MediaItemViewModel.swift */; }; + E148128328C1443D003B8787 /* NameGuidPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */; }; + E148128528C15472003B8787 /* APISortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128428C15472003B8787 /* APISortOrder.swift */; }; + E148128628C15475003B8787 /* APISortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128428C15472003B8787 /* APISortOrder.swift */; }; + E148128828C154BF003B8787 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128728C154BF003B8787 /* ItemFilter.swift */; }; + E148128928C154BF003B8787 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128728C154BF003B8787 /* ItemFilter.swift */; }; E148128B28C15526003B8787 /* SortBy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128A28C15526003B8787 /* SortBy.swift */; }; - E148128C28C15526003B8787 /* SortBy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128A28C15526003B8787 /* SortBy.swift */; }; + E14A08CB28E6831D004FC984 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */; }; + E14A08CD28E68729004FC984 /* MenuPosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14A08CC28E68729004FC984 /* MenuPosterHStack.swift */; }; + E15210562946DF1B00375CC2 /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E15210552946DF1B00375CC2 /* PulseLogHandler */; }; + E15210582946DF1B00375CC2 /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E15210572946DF1B00375CC2 /* PulseUI */; }; + E152107C2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E152107B2947ACA000375CC2 /* InvertedLightAppIcon.swift */; }; + E152107D2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E152107B2947ACA000375CC2 /* InvertedLightAppIcon.swift */; }; E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1546776289AF46E00087E35 /* CollectionItemView.swift */; }; E154677A289AF48200087E35 /* CollectionItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1546779289AF48200087E35 /* CollectionItemContentView.swift */; }; - E15B235429B7025400DAFDDD /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E15B235329B7025400DAFDDD /* JellyfinAPI */; }; - E15B235629B7029E00DAFDDD /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E15B235529B7029E00DAFDDD /* JellyfinAPI */; }; + E154965E296CA2EF00C4EF88 /* DownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549655296CA2EF00C4EF88 /* DownloadTask.swift */; }; + E154965F296CA2EF00C4EF88 /* DownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549655296CA2EF00C4EF88 /* DownloadTask.swift */; }; + E1549660296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549656296CA2EF00C4EF88 /* SwiftfinDefaults.swift */; }; + E1549661296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549656296CA2EF00C4EF88 /* SwiftfinDefaults.swift */; }; + E1549662296CA2EF00C4EF88 /* NewSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549657296CA2EF00C4EF88 /* NewSessionManager.swift */; }; + E1549663296CA2EF00C4EF88 /* NewSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549657296CA2EF00C4EF88 /* NewSessionManager.swift */; }; + E1549664296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549658296CA2EF00C4EF88 /* SwiftfinStore.swift */; }; + E1549665296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549658296CA2EF00C4EF88 /* SwiftfinStore.swift */; }; + E1549666296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549659296CA2EF00C4EF88 /* SwiftfinNotifications.swift */; }; + E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549659296CA2EF00C4EF88 /* SwiftfinNotifications.swift */; }; + E1549668296CA2EF00C4EF88 /* PlaybackManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154965A296CA2EF00C4EF88 /* PlaybackManager.swift */; }; + E1549669296CA2EF00C4EF88 /* PlaybackManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154965A296CA2EF00C4EF88 /* PlaybackManager.swift */; }; + E154966A296CA2EF00C4EF88 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154965B296CA2EF00C4EF88 /* DownloadManager.swift */; }; + E154966B296CA2EF00C4EF88 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154965B296CA2EF00C4EF88 /* DownloadManager.swift */; }; + E154966E296CA2EF00C4EF88 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154965D296CA2EF00C4EF88 /* LogManager.swift */; }; + E154966F296CA2EF00C4EF88 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154965D296CA2EF00C4EF88 /* LogManager.swift */; }; + E1549678296CB22B00C4EF88 /* InlineEnumToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549677296CB22B00C4EF88 /* InlineEnumToggle.swift */; }; + E154967A296CB4B000C4EF88 /* VideoPlayerSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549679296CB4B000C4EF88 /* VideoPlayerSettingsView.swift */; }; + E154967C296CBB1A00C4EF88 /* FontPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154967B296CBB1A00C4EF88 /* FontPickerView.swift */; }; + E154967E296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154967D296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift */; }; + E1559A76294D960C00C1FFBC /* MainOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1559A75294D960C00C1FFBC /* MainOverlay.swift */; }; + E157563029355B7900976E1F /* UpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E157562F29355B7900976E1F /* UpdateView.swift */; }; + E15756322935642A00976E1F /* Float.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15756312935642A00976E1F /* Float.swift */; }; + E15756342936851D00976E1F /* NativeVideoPlayerSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15756332936851D00976E1F /* NativeVideoPlayerSettingsView.swift */; }; + E15756362936856700976E1F /* VideoPlayerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15756352936856700976E1F /* VideoPlayerType.swift */; }; + E1575E3C293C6B15001665B1 /* Files in Frameworks */ = {isa = PBXBuildFile; productRef = E1575E3B293C6B15001665B1 /* Files */; }; + E1575E56293E7650001665B1 /* VLCUI in Frameworks */ = {isa = PBXBuildFile; productRef = E1575E55293E7650001665B1 /* VLCUI */; }; + E1575E58293E7685001665B1 /* Files in Frameworks */ = {isa = PBXBuildFile; productRef = E1575E57293E7685001665B1 /* Files */; }; + E1575E5C293E77B5001665B1 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; }; + E1575E5D293E77B5001665B1 /* ItemViewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925F328875037002A7A66 /* ItemViewType.swift */; }; + E1575E5F293E77B5001665B1 /* StreamType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EF4C402911B783008CC695 /* StreamType.swift */; }; + E1575E63293E77B5001665B1 /* EnumPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129429728F4785200796AC6 /* EnumPicker.swift */; }; + E1575E65293E77B5001665B1 /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; + E1575E66293E77B5001665B1 /* Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A60288F32DB00CB80AA /* Poster.swift */; }; + E1575E67293E77B5001665B1 /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; }; + E1575E68293E77B5001665B1 /* LibraryParent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E113133728BEADBA00930F75 /* LibraryParent.swift */; }; + E1575E69293E77B5001665B1 /* SortBy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148128A28C15526003B8787 /* SortBy.swift */; }; + E1575E6A293E77B5001665B1 /* RoundedCorner.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E9017A28DAAE4D001B1594 /* RoundedCorner.swift */; }; + E1575E6B293E77B5001665B1 /* Displayable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55128C119D400311DFE /* Displayable.swift */; }; + E1575E6C293E77B5001665B1 /* SliderType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129429228F2845000796AC6 /* SliderType.swift */; }; + E1575E6D293E77B5001665B1 /* PosterButtonType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17665D828E80F0F00130507 /* PosterButtonType.swift */; }; + E1575E6E293E77B5001665B1 /* SpecialFeatureType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */; }; + E1575E6F293E77B5001665B1 /* GestureAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1092F4B29106F9F00163F57 /* GestureAction.swift */; }; + E1575E70293E77B5001665B1 /* TextPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528428FD191A00600579 /* TextPair.swift */; }; + E1575E71293E77B5001665B1 /* RepeatingTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */; }; + E1575E72293E77B5001665B1 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D8429229340B8300D1041A /* Utilities.swift */; }; + E1575E73293E77B5001665B1 /* SelectorType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB54E28C1197700311DFE /* SelectorType.swift */; }; + E1575E74293E77B5001665B1 /* PanDirectionGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925F62887504B002A7A66 /* PanDirectionGestureRecognizer.swift */; }; + E1575E75293E77B5001665B1 /* LibraryViewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EB28BC9000003499D2 /* LibraryViewType.swift */; }; + E1575E76293E77B5001665B1 /* VideoPlayerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15756352936856700976E1F /* VideoPlayerType.swift */; }; + E1575E77293E77B5001665B1 /* MenuPosterHStackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA656B28E78C1700592A73 /* MenuPosterHStackModel.swift */; }; + E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C8CE7B28FF015000DF5D7B /* TrailingTimestampType.swift */; }; + E1575E79293E77B5001665B1 /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; }; + E1575E7A293E77B5001665B1 /* TimeStampType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129428F28F0BDC300796AC6 /* TimeStampType.swift */; }; + E1575E7B293E77B5001665B1 /* HTTPScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19169CD272514760085832A /* HTTPScheme.swift */; }; + E1575E7C293E77B5001665B1 /* TimerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E306CC28EF6E8000537998 /* TimerProxy.swift */; }; + E1575E7D293E77B5001665B1 /* PosterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF12D28ABF989006CAC9E /* PosterType.swift */; }; + E1575E7E293E77B5001665B1 /* ItemFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* ItemFilters.swift */; }; + E1575E7F293E77B5001665B1 /* AppAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF802719D22800A11E64 /* AppAppearance.swift */; }; + E1575E80293E77CF001665B1 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */; }; + E1575E83293E784A001665B1 /* MediaItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401D44293A952300E8B599 /* MediaItemViewModel.swift */; }; + E1575E84293E7A00001665B1 /* PrimaryAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA62938140300E8B599 /* PrimaryAppIcon.swift */; }; + E1575E85293E7A00001665B1 /* DarkAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA82938140700E8B599 /* DarkAppIcon.swift */; }; + E1575E86293E7A00001665B1 /* AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA12938122C00E8B599 /* AppIcons.swift */; }; + E1575E87293E7A00001665B1 /* InvertedDarkAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA4293813F400E8B599 /* InvertedDarkAppIcon.swift */; }; + E1575E88293E7A00001665B1 /* LightAppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CAA2938140A00E8B599 /* LightAppIcon.swift */; }; + E1575E8C293E7B1E001665B1 /* UIScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A3D288F0D3D00CB80AA /* UIScreen.swift */; }; + E1575E8D293E7B1E001665B1 /* URLComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C4277A90B200918266 /* URLComponents.swift */; }; + E1575E90293E7B1E001665B1 /* EdgeInsets.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12F038B28F8B0B100976CC3 /* EdgeInsets.swift */; }; + E1575E91293E7B1E001665B1 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E1DCC2273CE19800C9AE76 /* URL.swift */; }; + E1575E92293E7B1E001665B1 /* CGSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSize.swift */; }; + E1575E93293E7B1E001665B1 /* Float.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15756312935642A00976E1F /* Float.swift */; }; + E1575E94293E7B1E001665B1 /* VerticalAlignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528128FD126C00600579 /* VerticalAlignment.swift */; }; + E1575E95293E7B1E001665B1 /* Font.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8C28999B4A003E74C7 /* Font.swift */; }; + E1575E96293E7B1E001665B1 /* UIScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0239288749540022598C /* UIScrollView.swift */; }; + E1575E98293E7B1E001665B1 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C153279A7D5A005EC829 /* UIApplication.swift */; }; + E1575E99293E7B1E001665B1 /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CB029386C9200E8B599 /* UIColor.swift */; }; + E1575E9A293E7B1E001665B1 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1644028BB301900323B0A /* Array.swift */; }; + E1575E9B293E7B1E001665B1 /* EnvironmentValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16DEAC128EFCF590058F196 /* EnvironmentValue.swift */; }; + E1575E9C293E7B1E001665B1 /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* Collection.swift */; }; + E1575E9E293E7B1E001665B1 /* Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B33EAF28EA890D0073B0FD /* Equatable.swift */; }; + E1575E9F293E7B1E001665B1 /* Int.swift in Sources */ = {isa = PBXBuildFile; fileRef = E139CC1E28EC83E400688DE2 /* Int.swift */; }; + E1575EA0293E7B1E001665B1 /* CGPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C8CE5A28FE512400DF5D7B /* CGPoint.swift */; }; + E1575EA1293E7B1E001665B1 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338922660107500A81A2A /* String.swift */; }; + E1575EA2293E7B1E001665B1 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* Color.swift */; }; + E1575EA3293E7B1E001665B1 /* UIDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C727164B1E009D4DAF /* UIDevice.swift */; }; + E1575EA6293E7D40001665B1 /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1575EA5293E7D40001665B1 /* VideoPlayer.swift */; }; + E1581E27291EF59800D6C640 /* SplitContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1581E26291EF59800D6C640 /* SplitContentView.swift */; }; E168BD10289A4162001A6922 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD08289A4162001A6922 /* HomeView.swift */; }; E168BD11289A4162001A6922 /* HomeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD09289A4162001A6922 /* HomeContentView.swift */; }; E168BD13289A4162001A6922 /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */; }; E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0E289A4162001A6922 /* LatestInLibraryView.swift */; }; E168BD15289A4162001A6922 /* HomeErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0F289A4162001A6922 /* HomeErrorView.swift */; }; + E169C7B8296D2E8200AE25F9 /* SpecialFeaturesHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E169C7B7296D2E8200AE25F9 /* SpecialFeaturesHStack.swift */; }; E16AA60828A364A6009A983C /* PosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16AA60728A364A6009A983C /* PosterButton.swift */; }; - E1734D7C28B9577700C66367 /* CollectionView in Frameworks */ = {isa = PBXBuildFile; productRef = E1734D7B28B9577700C66367 /* CollectionView */; }; - E1734D7E28B9578100C66367 /* CollectionView in Frameworks */ = {isa = PBXBuildFile; productRef = E1734D7D28B9578100C66367 /* CollectionView */; }; + E16AF11C292C98A7001422A8 /* GestureSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16AF11B292C98A7001422A8 /* GestureSettingsView.swift */; }; + E16DEAC228EFCF590058F196 /* EnvironmentValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16DEAC128EFCF590058F196 /* EnvironmentValue.swift */; }; + E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E170D0E1294CC8000017224C /* VideoPlayer+Actions.swift */; }; + E170D0E4294CC8AB0017224C /* VideoPlayer+KeyCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = E170D0E3294CC8AB0017224C /* VideoPlayer+KeyCommands.swift */; }; + E170D103294CE8BF0017224C /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E170D102294CE8BF0017224C /* LoadingView.swift */; }; + E170D105294D21FA0017224C /* MediaSourceInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E170D104294D21FA0017224C /* MediaSourceInfoView.swift */; }; + E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E170D106294D23BA0017224C /* MediaSourceInfoCoordinator.swift */; }; + E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1721FA928FB7CAC00762992 /* CompactTimeStamp.swift */; }; + E1721FAE28FB801C00762992 /* SmallPlaybackButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1721FAD28FB801C00762992 /* SmallPlaybackButtons.swift */; }; + E1722DB129491C3900CC0239 /* ImageBlurHashes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */; }; + E1722DB229491C3900CC0239 /* ImageBlurHashes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */; }; E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */; }; - E173DA5226D04AAF00CC4EB7 /* ColorExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtensions.swift */; }; + E173DA5226D04AAF00CC4EB7 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* Color.swift */; }; E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; }; - E176DE6D278E30D2001EFD8D /* EpisodeCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E176DE6C278E30D2001EFD8D /* EpisodeCard.swift */; }; + E174120F29AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */; }; + E174121029AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */; }; + E175AFF3299AC117004DCF52 /* DebugSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E175AFF2299AC117004DCF52 /* DebugSettingsView.swift */; }; + E17665D928E80F0F00130507 /* PosterButtonType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17665D828E80F0F00130507 /* PosterButtonType.swift */; }; E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859A2780F1F40094FBCF /* tvOSSlider.swift */; }; E178859E2780F53B0094FBCF /* SliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859D2780F53B0094FBCF /* SliderView.swift */; }; - E17885A02780F55C0094FBCF /* tvOSVLCOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */; }; E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17885A3278105170094FBCF /* SFSymbolButton.swift */; }; + E17AC96A2954D00E003D2BC2 /* URLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AC9692954D00E003D2BC2 /* URLResponse.swift */; }; + E17AC96B2954D00E003D2BC2 /* URLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AC9692954D00E003D2BC2 /* URLResponse.swift */; }; + E17AC96D2954E9CA003D2BC2 /* DownloadListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AC96C2954E9CA003D2BC2 /* DownloadListView.swift */; }; + E17AC96F2954EE4B003D2BC2 /* DownloadListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */; }; + E17AC9712954F636003D2BC2 /* DownloadListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AC9702954F636003D2BC2 /* DownloadListCoordinator.swift */; }; + E17AC9732955007A003D2BC2 /* DownloadTaskButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AC9722955007A003D2BC2 /* DownloadTaskButton.swift */; }; E17FB54F28C1197700311DFE /* SelectorType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB54E28C1197700311DFE /* SelectorType.swift */; }; - E17FB55028C1197700311DFE /* SelectorType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB54E28C1197700311DFE /* SelectorType.swift */; }; E17FB55228C119D400311DFE /* Displayable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55128C119D400311DFE /* Displayable.swift */; }; - E17FB55328C119D400311DFE /* Displayable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55128C119D400311DFE /* Displayable.swift */; }; E17FB55528C1250B00311DFE /* SimilarItemsHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55428C1250B00311DFE /* SimilarItemsHStack.swift */; }; E17FB55728C1256400311DFE /* CastAndCrewHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */; }; E17FB55928C125E900311DFE /* StudiosHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55828C125E900311DFE /* StudiosHStack.swift */; }; E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55A28C1266400311DFE /* GenresHStack.swift */; }; - E184C160288C5C08000B25BA /* RequestBuilderExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E184C15F288C5C08000B25BA /* RequestBuilderExtensions.swift */; }; - E184C161288C5C08000B25BA /* RequestBuilderExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E184C15F288C5C08000B25BA /* RequestBuilderExtensions.swift */; }; + E18295E429CAC6F100F91ED0 /* BasicNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154967D296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift */; }; E185920628CDAA6400326F80 /* CastAndCrewHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E185920528CDAA6400326F80 /* CastAndCrewHStack.swift */; }; E185920828CDAAA200326F80 /* SimilarItemsHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E185920728CDAAA200326F80 /* SimilarItemsHStack.swift */; }; E185920A28CEF23A00326F80 /* FocusGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = E185920928CEF23A00326F80 /* FocusGuide.swift */; }; + E187A60229AB28F0008387E6 /* RotateContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E187A60129AB28F0008387E6 /* RotateContentView.swift */; }; + E187A60329AB28F0008387E6 /* RotateContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E187A60129AB28F0008387E6 /* RotateContentView.swift */; }; + E187A60529AD2E25008387E6 /* StepperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E187A60429AD2E25008387E6 /* StepperView.swift */; }; E18845F526DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Poster.swift */; }; E18845F626DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Poster.swift */; }; + E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18A17EF298C68B700C22F62 /* Overlay.swift */; }; + E18A17F2298C68BB00C22F62 /* MainOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18A17F1298C68BB00C22F62 /* MainOverlay.swift */; }; + E18A8E7A28D5FEDF00333B9A /* VLCUI in Frameworks */ = {isa = PBXBuildFile; productRef = E18A8E7928D5FEDF00333B9A /* VLCUI */; }; + E18A8E7D28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18A8E7C28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift */; }; + E18A8E7E28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18A8E7C28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift */; }; + E18A8E8028D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18A8E7F28D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift */; }; + E18A8E8128D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18A8E7F28D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift */; }; + E18A8E8328D60BC400333B9A /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18A8E8228D60BC400333B9A /* VideoPlayer.swift */; }; + E18A8E8528D60D0000333B9A /* VideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18A8E8428D60D0000333B9A /* VideoPlayerCoordinator.swift */; }; E18CE0AF28A222240092E7F1 /* PublicUserSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0AE28A222240092E7F1 /* PublicUserSignInView.swift */; }; - E18CE0B228A229E70092E7F1 /* UserDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B128A229E70092E7F1 /* UserDtoExtensions.swift */; }; + E18CE0B228A229E70092E7F1 /* UserDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B128A229E70092E7F1 /* UserDto.swift */; }; E18CE0B428A22EDA0092E7F1 /* RepeatingTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */; }; - E18CE0B528A22EDD0092E7F1 /* RepeatingTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */; }; E18CE0B928A2322D0092E7F1 /* QuickConnectCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B828A2322D0092E7F1 /* QuickConnectCoordinator.swift */; }; E18E01AA288746AF0022598C /* RefreshableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A4288746AF0022598C /* RefreshableScrollView.swift */; }; E18E01AB288746AF0022598C /* PillHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A5288746AF0022598C /* PillHStack.swift */; }; @@ -387,37 +490,30 @@ E18E01EA288747230022598C /* MovieItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01CF288747230022598C /* MovieItemView.swift */; }; E18E01EB288747230022598C /* MovieItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01D0288747230022598C /* MovieItemContentView.swift */; }; E18E01EE288747230022598C /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01D5288747230022598C /* AboutView.swift */; }; - E18E01EF288747230022598C /* ListDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01D6288747230022598C /* ListDetailsView.swift */; }; E18E01F0288747230022598C /* AttributeHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01D7288747230022598C /* AttributeHStack.swift */; }; E18E01F1288747230022598C /* PlayButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01D8288747230022598C /* PlayButton.swift */; }; E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01D9288747230022598C /* ActionButtonHStack.swift */; }; E18E01FA288747580022598C /* AboutAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01F3288747580022598C /* AboutAppView.swift */; }; E18E0204288749200022598C /* Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* Divider.swift */; }; - E18E0205288749200022598C /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0200288749200022598C /* AppIcon.swift */; }; - E18E0206288749200022598C /* AttributeFillView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0201288749200022598C /* AttributeFillView.swift */; }; - E18E0207288749200022598C /* AttributeOutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0202288749200022598C /* AttributeOutlineView.swift */; }; + E18E0207288749200022598C /* AttributeStyleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0202288749200022598C /* AttributeStyleModifier.swift */; }; E18E0208288749200022598C /* BlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0203288749200022598C /* BlurView.swift */; }; - E18E021A2887492B0022598C /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0200288749200022598C /* AppIcon.swift */; }; E18E021C2887492B0022598C /* BlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0203288749200022598C /* BlurView.swift */; }; - E18E021D2887492B0022598C /* AttributeOutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0202288749200022598C /* AttributeOutlineView.swift */; }; + E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0202288749200022598C /* AttributeStyleModifier.swift */; }; E18E021E2887492B0022598C /* Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* Divider.swift */; }; E18E021F2887492B0022598C /* InitialFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* InitialFailureView.swift */; }; - E18E02202887492B0022598C /* AttributeFillView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0201288749200022598C /* AttributeFillView.swift */; }; E18E02232887492B0022598C /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; E18E02252887492B0022598C /* PlainNavigationLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */; }; - E18E023A288749540022598C /* UIScrollViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0239288749540022598C /* UIScrollViewExtensions.swift */; }; - E18E023C288749540022598C /* UIScrollViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0239288749540022598C /* UIScrollViewExtensions.swift */; }; + E18E023A288749540022598C /* UIScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0239288749540022598C /* UIScrollView.swift */; }; E19169CE272514760085832A /* HTTPScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19169CD272514760085832A /* HTTPScheme.swift */; }; - E19169CF272514760085832A /* HTTPScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19169CD272514760085832A /* HTTPScheme.swift */; }; + E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */; }; + E1921B7628E63306003A5238 /* GestureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1921B7528E63306003A5238 /* GestureView.swift */; }; E192608028D28AAD002314B4 /* UserProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E192607F28D28AAD002314B4 /* UserProfileButton.swift */; }; E192608328D2D0DB002314B4 /* Factory in Frameworks */ = {isa = PBXBuildFile; productRef = E192608228D2D0DB002314B4 /* Factory */; }; E192608828D2E5F0002314B4 /* Factory in Frameworks */ = {isa = PBXBuildFile; productRef = E192608728D2E5F0002314B4 /* Factory */; }; E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A3A288E54AD00CB80AA /* BaseItemDto+Images.swift */; }; E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A3A288E54AD00CB80AA /* BaseItemDto+Images.swift */; }; - E1937A3E288F0D3D00CB80AA /* UIScreenExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A3D288F0D3D00CB80AA /* UIScreenExtensions.swift */; }; - E1937A3F288F0D3D00CB80AA /* UIScreenExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A3D288F0D3D00CB80AA /* UIScreenExtensions.swift */; }; + E1937A3E288F0D3D00CB80AA /* UIScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A3D288F0D3D00CB80AA /* UIScreen.swift */; }; E1937A61288F32DB00CB80AA /* Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A60288F32DB00CB80AA /* Poster.swift */; }; - E1937A62288F32DB00CB80AA /* Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A60288F32DB00CB80AA /* Poster.swift */; }; E193D53227193F7B00900D82 /* ConnectToServerCoodinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA226D1030F00C1D2E7 /* ConnectToServerCoodinator.swift */; }; E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B926D6092100B8E046 /* FilterCoordinator.swift */; }; E193D53427193F7F00900D82 /* HomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */; }; @@ -436,44 +532,70 @@ E193D5502719430400900D82 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D54F2719430400900D82 /* ServerDetailView.swift */; }; E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; }; E193D553271943D500900D82 /* tvOSMainTabCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D552271943D500900D82 /* tvOSMainTabCoordinator.swift */; }; + E19DDEC72948EF9900954E10 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = E19DDEC62948EF9900954E10 /* OrderedCollections */; }; E19E551F2897326C003CE330 /* BottomEdgeGradientModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */; }; E19E6E0528A0B958005C10C8 /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = E19E6E0428A0B958005C10C8 /* Nuke */; }; E19E6E0728A0B958005C10C8 /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = E19E6E0628A0B958005C10C8 /* NukeUI */; }; E19E6E0A28A0BEFF005C10C8 /* BlurHashKit in Frameworks */ = {isa = PBXBuildFile; productRef = E19E6E0928A0BEFF005C10C8 /* BlurHashKit */; }; + E19F6C5D28F5189300C5197E /* MediaStreamInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */; }; + E1A1528228FD126C00600579 /* VerticalAlignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528128FD126C00600579 /* VerticalAlignment.swift */; }; + E1A1528528FD191A00600579 /* TextPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528428FD191A00600579 /* TextPair.swift */; }; + E1A1528828FD229500600579 /* ChevronButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528728FD229500600579 /* ChevronButton.swift */; }; + E1A1528A28FD22F600600579 /* TextPairView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528928FD22F600600579 /* TextPairView.swift */; }; + E1A1528B28FD22F600600579 /* TextPairView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528928FD22F600600579 /* TextPairView.swift */; }; + E1A1528D28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528C28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift */; }; + E1A1528E28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528C28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift */; }; + E1A1529028FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528F28FD23D600600579 /* PlaybackSettingsCoordinator.swift */; }; + E1A1529128FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528F28FD23D600600579 /* PlaybackSettingsCoordinator.swift */; }; E1A16C9D2889AF1E00EA4679 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A16C9C2889AF1E00EA4679 /* AboutView.swift */; }; E1A16CA1288A7CFD00EA4679 /* AboutViewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A16CA0288A7CFD00EA4679 /* AboutViewCard.swift */; }; - E1A2C154279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C153279A7D5A005EC829 /* UIApplicationExtensions.swift */; }; - E1A2C156279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C153279A7D5A005EC829 /* UIApplicationExtensions.swift */; }; - E1A2C158279A7D76005EC829 /* BundleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C157279A7D76005EC829 /* BundleExtensions.swift */; }; - E1A2C15A279A7D76005EC829 /* BundleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C157279A7D76005EC829 /* BundleExtensions.swift */; }; - E1A2C160279A7DCA005EC829 /* AboutAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C15F279A7DCA005EC829 /* AboutAppView.swift */; }; + E1A2C154279A7D5A005EC829 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C153279A7D5A005EC829 /* UIApplication.swift */; }; E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */; }; E1A42E4C28CBD39300A14DCB /* HomeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4B28CBD39300A14DCB /* HomeContentView.swift */; }; E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */; }; E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */; }; E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331C2782541500F6439C /* PrimaryButton.swift */; }; E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; }; - E1AA33202782639D00F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; }; - E1AD104D26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */; }; - E1AD104E26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */; }; - E1AD105F26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */; }; + E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDto.swift */; }; + E1AD104E26D96CE3003E4A08 /* BaseItemDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDto.swift */; }; + E1AD105F26D9ADDD003E4A08 /* NameGuidPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */; }; E1B2AB9928808E150072B3B9 /* GoogleCast.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1B2AB9628808CDF0072B3B9 /* GoogleCast.xcframework */; }; - E1B6DCEA271A23880015B715 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = E1B6DCE9271A23880015B715 /* SwiftyJSON */; }; - E1BDE359278E9ED2004E4022 /* MissingItemsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDE358278E9ED2004E4022 /* MissingItemsSettingsView.swift */; }; + E1B33EB028EA890D0073B0FD /* Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B33EAF28EA890D0073B0FD /* Equatable.swift */; }; + E1B33ECF28EB6EA90073B0FD /* OverlayMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B33ECE28EB6EA90073B0FD /* OverlayMenu.swift */; }; + E1B33ED128EB860A0073B0FD /* LargePlaybackButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B33ED028EB860A0073B0FD /* LargePlaybackButtons.swift */; }; + E1B490442967E26300D3EDCE /* PersistentLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */; }; + E1B490452967E26300D3EDCE /* PersistentLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */; }; + E1B490472967E2E500D3EDCE /* CoreStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B490462967E2E500D3EDCE /* CoreStore.swift */; }; + E1B490482967E2E500D3EDCE /* CoreStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B490462967E2E500D3EDCE /* CoreStore.swift */; }; + E1B5784128F8AFCB00D42911 /* Wrapped View.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5784028F8AFCB00D42911 /* Wrapped View.swift */; }; + E1B5784228F8AFCB00D42911 /* Wrapped View.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5784028F8AFCB00D42911 /* Wrapped View.swift */; }; + E1B5861229E32EEF00E45D6E /* Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5861129E32EEF00E45D6E /* Set.swift */; }; + E1B5861329E32EEF00E45D6E /* Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5861129E32EEF00E45D6E /* Set.swift */; }; + E1B5F7A529577BB8004B26CF /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7A429577BB8004B26CF /* JellyfinAPI */; }; + E1B5F7A729577BCE004B26CF /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7A629577BCE004B26CF /* Pulse */; }; + E1B5F7A929577BCE004B26CF /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7A829577BCE004B26CF /* PulseLogHandler */; }; + E1B5F7AB29577BCE004B26CF /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7AA29577BCE004B26CF /* PulseUI */; }; + E1B5F7AD29577BDD004B26CF /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7AC29577BDD004B26CF /* OrderedCollections */; }; + E1B5F7AE29577CC7004B26CF /* VisibilityModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF3182952641300CC0294 /* VisibilityModifier.swift */; }; + E1BA6FC529D25DBD007D98DC /* LandscapeItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BA6FC429D25DBD007D98DC /* LandscapeItemElement.swift */; }; + E1BDF2E52951475300CC0294 /* VideoPlayerActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */; }; + E1BDF2E62951475300CC0294 /* VideoPlayerActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */; }; + E1BDF2E92951490400CC0294 /* ActionButtonSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2E82951490400CC0294 /* ActionButtonSelectorView.swift */; }; + E1BDF2EC2952290200CC0294 /* AspectFillActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2EB2952290200CC0294 /* AspectFillActionButton.swift */; }; + E1BDF2EF29522A5900CC0294 /* AudioActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2EE29522A5900CC0294 /* AudioActionButton.swift */; }; + E1BDF2F129524AB700CC0294 /* AutoPlayActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2F029524AB700CC0294 /* AutoPlayActionButton.swift */; }; + E1BDF2F329524C3B00CC0294 /* ChaptersActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2F229524C3B00CC0294 /* ChaptersActionButton.swift */; }; + E1BDF2F529524E6400CC0294 /* PlayNextItemActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2F429524E6400CC0294 /* PlayNextItemActionButton.swift */; }; + E1BDF2F729524ECD00CC0294 /* PlaybackSpeedActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2F629524ECD00CC0294 /* PlaybackSpeedActionButton.swift */; }; + E1BDF2F929524FDA00CC0294 /* PlayPreviousItemActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2F829524FDA00CC0294 /* PlayPreviousItemActionButton.swift */; }; + E1BDF2FB2952502300CC0294 /* SubtitleActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF2FA2952502300CC0294 /* SubtitleActionButton.swift */; }; + E1BDF31729525F0400CC0294 /* AdvancedActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF31629525F0400CC0294 /* AdvancedActionButton.swift */; }; + E1BDF3192952641300CC0294 /* VisibilityModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDF3182952641300CC0294 /* VisibilityModifier.swift */; }; E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; }; - E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */; }; - E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */; }; - E1C812C0277A8E5D00918266 /* VLCPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B8277A8E5D00918266 /* VLCPlayerView.swift */; }; - E1C812C3277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812BB277A8E5D00918266 /* VLCPlayerOverlayView.swift */; }; - E1C812C5277A90B200918266 /* URLComponentsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */; }; - E1C812CA277AE40900918266 /* PlayerOverlayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */; }; - E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C8277AE40900918266 /* VideoPlayerView.swift */; }; - E1C812CD277AE40A00918266 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */; }; - E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */; }; - E1C812D1277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */; }; - E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */; }; + E1C812C5277A90B200918266 /* URLComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C4277A90B200918266 /* URLComponents.swift */; }; + E1C8CE5B28FE512400DF5D7B /* CGPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C8CE5A28FE512400DF5D7B /* CGPoint.swift */; }; + E1C8CE7C28FF015000DF5D7B /* TrailingTimestampType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C8CE7B28FF015000DF5D7B /* TrailingTimestampType.swift */; }; E1C925F428875037002A7A66 /* ItemViewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925F328875037002A7A66 /* ItemViewType.swift */; }; - E1C925F528875037002A7A66 /* ItemViewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925F328875037002A7A66 /* ItemViewType.swift */; }; E1C925F72887504B002A7A66 /* PanDirectionGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925F62887504B002A7A66 /* PanDirectionGestureRecognizer.swift */; }; E1C925F928875647002A7A66 /* LatestInLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925F828875647002A7A66 /* LatestInLibraryView.swift */; }; E1C9260B2887565C002A7A66 /* MovieItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925FB2887565C002A7A66 /* MovieItemView.swift */; }; @@ -483,7 +605,7 @@ E1C926102887565C002A7A66 /* PlayButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926022887565C002A7A66 /* PlayButton.swift */; }; E1C926112887565C002A7A66 /* ActionButtonHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926032887565C002A7A66 /* ActionButtonHStack.swift */; }; E1C926122887565C002A7A66 /* SeriesItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926052887565C002A7A66 /* SeriesItemContentView.swift */; }; - E1C926132887565C002A7A66 /* SeriesEpisodesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926072887565C002A7A66 /* SeriesEpisodesView.swift */; }; + E1C926132887565C002A7A66 /* SeriesEpisodeSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926072887565C002A7A66 /* SeriesEpisodeSelector.swift */; }; E1C926152887565C002A7A66 /* EpisodeCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926092887565C002A7A66 /* EpisodeCard.swift */; }; E1C926162887565C002A7A66 /* SeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C9260A2887565C002A7A66 /* SeriesItemView.swift */; }; E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92617288756BD002A7A66 /* PosterButton.swift */; }; @@ -491,10 +613,11 @@ E1C9261C288756BD002A7A66 /* PosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92619288756BD002A7A66 /* PosterHStack.swift */; }; E1CCC3D228C858A50020ED54 /* UserProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCC3D128C858A50020ED54 /* UserProfileButton.swift */; }; E1CCF12E28ABF989006CAC9E /* PosterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF12D28ABF989006CAC9E /* PosterType.swift */; }; - E1CCF12F28ABF989006CAC9E /* PosterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF12D28ABF989006CAC9E /* PosterType.swift */; }; E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */; }; + E1CD13EF28EF364100CB46CA /* DetectOrientationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CD13EE28EF364100CB46CA /* DetectOrientationModifier.swift */; }; E1CEFBF527914C7700F60429 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CEFBF427914C7700F60429 /* CustomizeViewsSettings.swift */; }; E1CEFBF727914E6400F60429 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CEFBF627914E6400F60429 /* CustomizeViewsSettings.swift */; }; + E1CFE28028FA606800B7D34C /* ChapterTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CFE27F28FA606800B7D34C /* ChapterTrack.swift */; }; E1D3043228D175CE00587289 /* StaticLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D3043128D175CE00587289 /* StaticLibraryViewModel.swift */; }; E1D3043328D175CE00587289 /* StaticLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D3043128D175CE00587289 /* StaticLibraryViewModel.swift */; }; E1D3043528D1763100587289 /* SeeAllButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D3043428D1763100587289 /* SeeAllButton.swift */; }; @@ -505,35 +628,74 @@ E1D3044228D1976600587289 /* CastAndCrewItemRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D3044128D1976600587289 /* CastAndCrewItemRow.swift */; }; E1D3044428D1991900587289 /* LibraryViewTypeToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D3044328D1991900587289 /* LibraryViewTypeToggle.swift */; }; E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */; }; - E1D4BF7E2719D1DD00A11E64 /* BasicAppSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */; }; - E1D4BF7F2719D1DD00A11E64 /* BasicAppSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */; }; E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF802719D22800A11E64 /* AppAppearance.swift */; }; - E1D4BF822719D22800A11E64 /* AppAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF802719D22800A11E64 /* AppAppearance.swift */; }; - E1D4BF842719D25A00A11E64 /* TrackLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF832719D25A00A11E64 /* TrackLanguage.swift */; }; - E1D4BF852719D25A00A11E64 /* TrackLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF832719D25A00A11E64 /* TrackLanguage.swift */; }; - E1D4BF872719D27100A11E64 /* Bitrates.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF862719D27100A11E64 /* Bitrates.swift */; }; - E1D4BF882719D27100A11E64 /* Bitrates.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF862719D27100A11E64 /* Bitrates.swift */; }; E1D4BF8A2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */; }; E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */; }; E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */; }; - E1E00A35278628A40022235B /* DoubleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E00A34278628A40022235B /* DoubleExtensions.swift */; }; - E1E00A36278628A40022235B /* DoubleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E00A34278628A40022235B /* DoubleExtensions.swift */; }; + E1D5C39628DF90C100CDBEFB /* Slider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D5C39528DF90C100CDBEFB /* Slider.swift */; }; + E1D5C39928DF914700CDBEFB /* CapsuleSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D5C39828DF914700CDBEFB /* CapsuleSlider.swift */; }; + E1D5C39B28DF993400CDBEFB /* ThumbSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D5C39A28DF993400CDBEFB /* ThumbSlider.swift */; }; + E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D842162932AB8F00D1041A /* NativeVideoPlayer.swift */; }; + E1D8424F2932F7C400D1041A /* OverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D8424E2932F7C400D1041A /* OverviewView.swift */; }; + E1D8428F2933F2D900D1041A /* MediaSourceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D8428E2933F2D900D1041A /* MediaSourceInfo.swift */; }; + E1D842912933F87500D1041A /* ItemFields.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D842902933F87500D1041A /* ItemFields.swift */; }; + E1D8429329340B8300D1041A /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D8429229340B8300D1041A /* Utilities.swift */; }; + E1D8429529346C6400D1041A /* BasicStepper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D8429429346C6400D1041A /* BasicStepper.swift */; }; + E1D9F475296E86D400129AF3 /* NativeVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D9F474296E86D400129AF3 /* NativeVideoPlayer.swift */; }; + E1DA654C28E69B0500592A73 /* SpecialFeatureType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */; }; + E1DA656928E78B5900592A73 /* SpecialFeaturesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA656828E78B5900592A73 /* SpecialFeaturesViewModel.swift */; }; + E1DA656A28E78B5900592A73 /* SpecialFeaturesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA656828E78B5900592A73 /* SpecialFeaturesViewModel.swift */; }; + E1DA656C28E78C1700592A73 /* MenuPosterHStackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA656B28E78C1700592A73 /* MenuPosterHStackModel.swift */; }; + E1DA656F28E78C9900592A73 /* SeriesEpisodeSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA656E28E78C9900592A73 /* SeriesEpisodeSelector.swift */; }; + E1DC9814296DC06200982F06 /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E1DC9813296DC06200982F06 /* PulseLogHandler */; }; + E1DC9816296DD0FE00982F06 /* BlurViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9815296DD0FE00982F06 /* BlurViewModifier.swift */; }; + E1DC9817296DD0FE00982F06 /* BlurViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9815296DD0FE00982F06 /* BlurViewModifier.swift */; }; + E1DC9819296DD1CD00982F06 /* CinematicBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9818296DD1CD00982F06 /* CinematicBackgroundView.swift */; }; + E1DC981A296DD1CD00982F06 /* CinematicBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9818296DD1CD00982F06 /* CinematicBackgroundView.swift */; }; + E1DC981E296DD91900982F06 /* CollectionView in Frameworks */ = {isa = PBXBuildFile; productRef = E1DC981D296DD91900982F06 /* CollectionView */; }; + E1DC9821296DDBE600982F06 /* CollectionView in Frameworks */ = {isa = PBXBuildFile; productRef = E1DC9820296DDBE600982F06 /* CollectionView */; }; + E1DC983D296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC983C296DEB9B00982F06 /* UnwatchedIndicator.swift */; }; + E1DC983E296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC983C296DEB9B00982F06 /* UnwatchedIndicator.swift */; }; + E1DC9841296DEBD800982F06 /* WatchedIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9840296DEBD800982F06 /* WatchedIndicator.swift */; }; + E1DC9842296DEBD800982F06 /* WatchedIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9840296DEBD800982F06 /* WatchedIndicator.swift */; }; + E1DC9844296DECB600982F06 /* ProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9843296DECB600982F06 /* ProgressIndicator.swift */; }; + E1DC9845296DECB600982F06 /* ProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9843296DECB600982F06 /* ProgressIndicator.swift */; }; + E1DC9847296DEFF500982F06 /* FavoriteIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */; }; + E1DC9848296DEFF500982F06 /* FavoriteIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */; }; + E1E0BEB729EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E0BEB629EF450B0002E8D3 /* UIGestureRecognizer.swift */; }; + E1E0BEB829EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E0BEB629EF450B0002E8D3 /* UIGestureRecognizer.swift */; }; E1E1643A28BAC2EF00323B0A /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1643928BAC2EF00323B0A /* SearchView.swift */; }; E1E1643E28BB074000323B0A /* SelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1643D28BB074000323B0A /* SelectorView.swift */; }; E1E1643F28BB075C00323B0A /* SelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1643D28BB074000323B0A /* SelectorView.swift */; }; - E1E1644128BB301900323B0A /* ArrayExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1644028BB301900323B0A /* ArrayExtensions.swift */; }; - E1E1644228BB301900323B0A /* ArrayExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1644028BB301900323B0A /* ArrayExtensions.swift */; }; - E1E1644428BC60C600323B0A /* MediaLibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1644328BC60C600323B0A /* MediaLibraryItem.swift */; }; - E1E1644528BC60C600323B0A /* MediaLibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1644328BC60C600323B0A /* MediaLibraryItem.swift */; }; + E1E1644128BB301900323B0A /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1644028BB301900323B0A /* Array.swift */; }; + E1E1E24D28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1E24C28DF8A2E000DF5FD /* PreferenceKeys.swift */; }; + E1E1E24E28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E1E24C28DF8A2E000DF5FD /* PreferenceKeys.swift */; }; + E1E306CD28EF6E8000537998 /* TimerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E306CC28EF6E8000537998 /* TimerProxy.swift */; }; E1E48CC9271E6D410021A2F9 /* RefreshHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */; }; - E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */; }; - E1E5D5492783CDD700692DFE /* OverlaySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */; }; + E1E5D5492783CDD700692DFE /* VideoPlayerSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5472783CCF900692DFE /* VideoPlayerSettingsView.swift */; }; E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */; }; - E1E5D54F2783E67100692DFE /* OverlaySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D54E2783E67100692DFE /* OverlaySettingsView.swift */; }; E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5502783E67700692DFE /* ExperimentalSettingsView.swift */; }; E1E5D553278419D900692DFE /* ConfirmCloseOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D552278419D900692DFE /* ConfirmCloseOverlay.swift */; }; + E1E6C43B29AECBD30064123F /* BottomBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E6C43A29AECBD30064123F /* BottomBarView.swift */; }; + E1E6C43D29AECC310064123F /* BarActionButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E6C43C29AECC310064123F /* BarActionButtons.swift */; }; + E1E6C44029AECC6D0064123F /* ActionButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E6C43E29AECC5A0064123F /* ActionButtons.swift */; }; + E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E6C44129AECCD50064123F /* ActionButtons.swift */; }; + E1E6C44529AECCF20064123F /* PlayNextItemActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E6C44429AECCF20064123F /* PlayNextItemActionButton.swift */; }; + E1E6C44729AECD5D0064123F /* PlayPreviousItemActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E6C44629AECD5D0064123F /* PlayPreviousItemActionButton.swift */; }; + E1E6C44929AECEE70064123F /* AutoPlayActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E6C44829AECEE70064123F /* AutoPlayActionButton.swift */; }; + E1E6C44B29AED2B70064123F /* HorizontalAlignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E6C44A29AED2B70064123F /* HorizontalAlignment.swift */; }; + E1E6C44C29AED2BE0064123F /* HorizontalAlignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E6C44A29AED2B70064123F /* HorizontalAlignment.swift */; }; + E1E6C44E29AEE9DC0064123F /* SmallMenuOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E6C44D29AEE9DC0064123F /* SmallMenuOverlay.swift */; }; + E1E6C45029B104840064123F /* Button.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E6C44F29B104840064123F /* Button.swift */; }; + E1E6C45129B104850064123F /* Button.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E6C44F29B104840064123F /* Button.swift */; }; + E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E6C45229B1304E0064123F /* ChaptersActionButton.swift */; }; + E1E6C45629B130F50064123F /* ChapterOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E6C45529B130F50064123F /* ChapterOverlay.swift */; }; + E1E9017B28DAAE4D001B1594 /* RoundedCorner.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E9017A28DAAE4D001B1594 /* RoundedCorner.swift */; }; + E1E9017F28DAB15F001B1594 /* BarActionButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E9017E28DAB15F001B1594 /* BarActionButtons.swift */; }; E1E9EFEA28C6B96500CC1F8B /* ServerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E9EFE928C6B96400CC1F8B /* ServerButton.swift */; }; - E1E9EFEB28C7EA2C00CC1F8B /* UserDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B128A229E70092E7F1 /* UserDtoExtensions.swift */; }; + E1E9EFEB28C7EA2C00CC1F8B /* UserDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B128A229E70092E7F1 /* UserDto.swift */; }; + E1EA9F6A28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EA9F6928F8A79E00BEC442 /* VideoPlayerManager.swift */; }; + E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EA9F6928F8A79E00BEC442 /* VideoPlayerManager.swift */; }; E1EBCB42278BD174009FE6E9 /* TruncatedTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB41278BD174009FE6E9 /* TruncatedTextView.swift */; }; E1EBCB44278BD1CE009FE6E9 /* ItemOverviewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB43278BD1CE009FE6E9 /* ItemOverviewCoordinator.swift */; }; E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */; }; @@ -541,11 +703,11 @@ E1ECD8F528C1BA10008B9DC6 /* UDPBroadcast.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 637FCAF3287B5B2600C0A353 /* UDPBroadcast.xcframework */; }; E1ECD8F628C1BA10008B9DC6 /* UDPBroadcast.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 637FCAF3287B5B2600C0A353 /* UDPBroadcast.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; E1EF473A289A0F610034046B /* TruncatedTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB41278BD174009FE6E9 /* TruncatedTextView.swift */; }; + E1EF4C412911B783008CC695 /* StreamType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EF4C402911B783008CC695 /* StreamType.swift */; }; E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; - E1F0204F26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; - E1FA2F7427818A8800B4C270 /* SmallMenuOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */; }; E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FA891A289A302300176FEB /* iPadOSCollectionItemView.swift */; }; E1FA891E289A305D00176FEB /* iPadOSCollectionItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FA891D289A305D00176FEB /* iPadOSCollectionItemContentView.swift */; }; + E1FBDB6629D0F336003DD5E2 /* KeyCommandAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FBDB6529D0F336003DD5E2 /* KeyCommandAction.swift */; }; E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD09526C47118007C8DCF /* ErrorMessage.swift */; }; @@ -589,23 +751,21 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; - 628B95312670CABE0091AF3B /* Embed App Extensions */ = { + 628B95312670CABE0091AF3B /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = ( ); - name = "Embed App Extensions"; + name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = ""; }; - 09389CC626819B4500AE350E /* VideoPlayerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerModel.swift; sourceTree = ""; }; 531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; - 531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapeItemElement.swift; sourceTree = ""; }; 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlainNavigationLinkButton.swift; sourceTree = ""; }; 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfileBuilder.swift; sourceTree = ""; }; 531AC8BE26750DE20091C7EB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; @@ -615,9 +775,8 @@ 534D4FEC26A7D7CC000A7A48 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = Localizable.strings; sourceTree = ""; }; 534D4FEF26A7D7CC000A7A48 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = Localizable.strings; sourceTree = ""; }; 535870602669D21600D05A09 /* Swiftfin tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Swiftfin tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 535870622669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinPlayer_tvOSApp.swift; sourceTree = ""; }; + 535870622669D21600D05A09 /* SwiftfinApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftfinApp.swift; sourceTree = ""; }; 535870662669D21700D05A09 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 535870692669D21700D05A09 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 535870702669D21700D05A09 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 535870AC2669D8DD00D05A09 /* ItemFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemFilters.swift; sourceTree = ""; }; 535BAE9E2649E569005FA86D /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = ""; }; @@ -638,12 +797,10 @@ 5362E4C4267D40F0000E2F71 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; 5362E4C6267D40F4000E2F71 /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; 5362E4C8267D40F7000E2F71 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; - 53649AB0269CFB1900A2D8B7 /* LogManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogManager.swift; sourceTree = ""; }; - 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemPersonExtensions.swift; sourceTree = ""; }; + 5364F454266CA0DC0026ECBA /* BaseItemPerson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemPerson.swift; sourceTree = ""; }; 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Swiftfin iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinPlayerApp.swift; sourceTree = ""; }; + 5377CBF4263B596A003A4E83 /* SwiftfinApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftfinApp.swift; sourceTree = ""; }; 5377CBF8263B596B003A4E83 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 5377CBFB263B596B003A4E83 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 5377CC02263B596B003A4E83 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 53913BCA26D323FE00EB3286 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = Localizable.strings; sourceTree = ""; }; 53913BCD26D323FE00EB3286 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = Localizable.strings; sourceTree = ""; }; @@ -666,18 +823,15 @@ 53CD2A3F268A49C2002ABD4E /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = ""; }; 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MobileVLCKit.xcframework; path = Carthage/Build/MobileVLCKit.xcframework; sourceTree = ""; }; 53EE24E5265060780068F029 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; - 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleSize.swift; sourceTree = ""; }; - 5D160402278A41FD00D22B99 /* VLCPlayer+subtitles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VLCPlayer+subtitles.swift"; sourceTree = ""; }; 5D64683C277B1649009E09AE /* PreferenceUIHostingSwizzling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingSwizzling.swift; sourceTree = ""; }; 6213388F265F83A900A81A2A /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = ""; }; - 621338922660107500A81A2A /* StringExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtensions.swift; sourceTree = ""; }; + 621338922660107500A81A2A /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtensions.swift; sourceTree = ""; }; 6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCoordinator.swift; sourceTree = ""; }; 6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryCoordinator.swift; sourceTree = ""; }; 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchCoordinator.swift; sourceTree = ""; }; 6220D0B926D6092100B8E046 /* FilterCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterCoordinator.swift; sourceTree = ""; }; 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCoordinator.swift; sourceTree = ""; }; - 6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSVideoPlayerCoordinator.swift; sourceTree = ""; }; 6220D0CB26D640C400B8E046 /* AppURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLHandler.swift; sourceTree = ""; }; 625CB5722678C32A00530A6E /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; 625CB5742678C33500530A6E /* MediaViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewModel.swift; sourceTree = ""; }; @@ -712,7 +866,7 @@ 62666E2F27E5021800EC0ECD /* VideoToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = VideoToolbox.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/VideoToolbox.framework; sourceTree = DEVELOPER_DIR; }; 62666E3127E5021E00EC0ECD /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/UIKit.framework; sourceTree = DEVELOPER_DIR; }; 62666E3A27E503E400EC0ECD /* GoogleCastSDK.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GoogleCastSDK.xcframework; path = Frameworks/GoogleCastSDK.xcframework; sourceTree = ""; }; - 6267B3D526710B8900A7371D /* CollectionExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionExtensions.swift; sourceTree = ""; }; + 6267B3D526710B8900A7371D /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; 628B95212670CABD0091AF3B /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 628B95232670CABD0091AF3B /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 62C29E9E26D1016600C1D2E7 /* iOSMainCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSMainCoordinator.swift; sourceTree = ""; }; @@ -720,16 +874,14 @@ 62C29EA226D1030F00C1D2E7 /* ConnectToServerCoodinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerCoodinator.swift; sourceTree = ""; }; 62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeCoordinator.swift; sourceTree = ""; }; 62C29EA726D103D500C1D2E7 /* MediaCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaCoordinator.swift; sourceTree = ""; }; - 62C83B07288C6A630004ED0C /* FontPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontPicker.swift; sourceTree = ""; }; - 62E1DCC2273CE19800C9AE76 /* URLExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtensions.swift; sourceTree = ""; }; + 62C83B07288C6A630004ED0C /* FontPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontPickerView.swift; sourceTree = ""; }; + 62E1DCC2273CE19800C9AE76 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; 62E632DB267D2E130063E547 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = ""; }; 62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieItemViewModel.swift; sourceTree = ""; }; 62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeItemViewModel.swift; sourceTree = ""; }; - 62E632E8267D3FF50063E547 /* SeasonItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemViewModel.swift; sourceTree = ""; }; 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemViewModel.swift; sourceTree = ""; }; 62E632F2267D54030063E547 /* ItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewModel.swift; sourceTree = ""; }; - 62EC352E267666A5000E9F2D /* SessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManager.swift; sourceTree = ""; }; 62ECA01726FA685A00E8EBB7 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; 6334175A287DDFB9000603CE /* QuickConnectSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectSettingsView.swift; sourceTree = ""; }; 6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectSettingsViewModel.swift; sourceTree = ""; }; @@ -738,18 +890,9 @@ C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelsView.swift; sourceTree = ""; }; C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemWideElement.swift; sourceTree = ""; }; C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTypeLibraryViewModel.swift; sourceTree = ""; }; - C453497E279A2DA50045F1E2 /* LiveTVPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVPlayerViewController.swift; sourceTree = ""; }; - C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVOverlay.swift; sourceTree = ""; }; - C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVVideoPlayerCoordinator.swift; sourceTree = ""; }; - C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVVideoPlayerView.swift; sourceTree = ""; }; - C45640CF281A43EF007096DE /* LiveTVNativePlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVNativePlayerViewController.swift; sourceTree = ""; }; C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVCoordinator.swift; sourceTree = ""; }; - C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSLiveTVVideoPlayerCoordinator.swift; sourceTree = ""; }; - C45942CA27F6984100C54FE7 /* LiveTVPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVPlayerViewController.swift; sourceTree = ""; }; - C45942CC27F6994A00C54FE7 /* LiveTVPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVPlayerView.swift; sourceTree = ""; }; C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVHomeView.swift; sourceTree = ""; }; C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsView.swift; sourceTree = ""; }; - C4B9B91327E1921B0063535C /* LiveTVNativeVideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVNativeVideoPlayerView.swift; sourceTree = ""; }; C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsCoordinator.swift; sourceTree = ""; }; C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsView.swift; sourceTree = ""; }; C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsViewModel.swift; sourceTree = ""; }; @@ -761,17 +904,21 @@ C4E508172703E8190045C9AB /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = ""; }; C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; - E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerChapterOverlayView.swift; sourceTree = ""; }; - E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfoExtensions.swift; sourceTree = ""; }; + E1002B632793CEE700E47059 /* ChapterInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfo.swift; sourceTree = ""; }; E1047E2227E5880000CB0D4A /* InitialFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitialFailureView.swift; sourceTree = ""; }; + E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndicatorSettingsView.swift; sourceTree = ""; }; + E104C872296E0D0A00C1C3F9 /* IndicatorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndicatorSettingsView.swift; sourceTree = ""; }; E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemViewModel.swift; sourceTree = ""; }; - E10D87DB2784EC5200BD264C /* SeriesEpisodesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesEpisodesView.swift; sourceTree = ""; }; - E10D87E127852FD000BD264C /* EpisodesRowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowManager.swift; sourceTree = ""; }; - E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSizeExtensions.swift; sourceTree = ""; }; - E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+VideoPlayerViewModel.swift"; sourceTree = ""; }; + E1092F4B29106F9F00163F57 /* GestureAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureAction.swift; sourceTree = ""; }; + E10E842929A587110064EA49 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; + E10E842B29A589860064EA49 /* NonePosterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonePosterButton.swift; sourceTree = ""; }; + E10EAA4E277BBCC4000269ED /* CGSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSize.swift; sourceTree = ""; }; E111D8F428D03B7500400001 /* PagingLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagingLibraryViewModel.swift; sourceTree = ""; }; E111D8F728D03BF900400001 /* PagingLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagingLibraryView.swift; sourceTree = ""; }; E111D8F928D0400900400001 /* PagingLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagingLibraryView.swift; sourceTree = ""; }; + E11245B028D919CD00D8A977 /* Overlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Overlay.swift; sourceTree = ""; }; + E11245B328D97D5D00D8A977 /* BottomBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomBarView.swift; sourceTree = ""; }; + E11245B628D97ED200D8A977 /* TopBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopBarView.swift; sourceTree = ""; }; E113132A28BDB4B500930F75 /* NavBarDrawerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavBarDrawerView.swift; sourceTree = ""; }; E113132E28BDB66A00930F75 /* NavBarDrawerModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavBarDrawerModifier.swift; sourceTree = ""; }; E113133128BDC72000930F75 /* FilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterView.swift; sourceTree = ""; }; @@ -787,13 +934,19 @@ E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundParallaxHeaderModifier.swift; sourceTree = ""; }; E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAPIError.swift; sourceTree = ""; }; E11CEB8A28998552003E74C7 /* iOSViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSViewExtensions.swift; sourceTree = ""; }; - E11CEB8C28999B4A003E74C7 /* FontExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontExtensions.swift; sourceTree = ""; }; + E11CEB8C28999B4A003E74C7 /* Font.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Font.swift; sourceTree = ""; }; E11CEB8F28999D84003E74C7 /* EpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemView.swift; sourceTree = ""; }; E11CEB9328999D9E003E74C7 /* EpisodeItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemContentView.swift; sourceTree = ""; }; E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailCoordinator.swift; sourceTree = ""; }; - E122A9122788EAAD0060FA63 /* MediaStreamExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaStreamExtension.swift; sourceTree = ""; }; + E122A9122788EAAD0060FA63 /* MediaStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaStream.swift; sourceTree = ""; }; E1267D3D271A1F46003C492E /* PreferenceUIHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingController.swift; sourceTree = ""; }; - E126F740278A656C00A522BF /* ServerStreamType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerStreamType.swift; sourceTree = ""; }; + E129428428F080B500796AC6 /* OnReceiveNotificationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnReceiveNotificationModifier.swift; sourceTree = ""; }; + E129428728F0831F00796AC6 /* SplitTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTimestamp.swift; sourceTree = ""; }; + E129428F28F0BDC300796AC6 /* TimeStampType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeStampType.swift; sourceTree = ""; }; + E129429228F2845000796AC6 /* SliderType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderType.swift; sourceTree = ""; }; + E129429728F4785200796AC6 /* EnumPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumPicker.swift; sourceTree = ""; }; + E129429A28F4A5E300796AC6 /* PlaybackSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSettingsView.swift; sourceTree = ""; }; + E12A9EF729499E0100731C3A /* JellyfinClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinClient.swift; sourceTree = ""; }; E12CC1AD28D0FAEA00678D5D /* NextUpLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpLibraryViewModel.swift; sourceTree = ""; }; E12CC1B028D1008F00678D5D /* NextUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpView.swift; sourceTree = ""; }; E12CC1B428D1124400678D5D /* BasicLibraryCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicLibraryCoordinator.swift; sourceTree = ""; }; @@ -801,25 +954,32 @@ E12CC1BA28D11E1000678D5D /* RecentlyAddedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentlyAddedViewModel.swift; sourceTree = ""; }; E12CC1BD28D11F4500678D5D /* RecentlyAddedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentlyAddedView.swift; sourceTree = ""; }; E12CC1C028D12B0A00678D5D /* BasicLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicLibraryView.swift; sourceTree = ""; }; - E12CC1C428D12D9B00678D5D /* SeeAllPoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeeAllPoster.swift; sourceTree = ""; }; + E12CC1C428D12D9B00678D5D /* SeeAllPosterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeeAllPosterButton.swift; sourceTree = ""; }; E12CC1C628D12FD600678D5D /* CinematicRecentlyAddedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicRecentlyAddedView.swift; sourceTree = ""; }; E12CC1C828D132B800678D5D /* RecentlyAddedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentlyAddedView.swift; sourceTree = ""; }; E12CC1CA28D1333400678D5D /* CinematicResumeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicResumeItemView.swift; sourceTree = ""; }; E12CC1CC28D135C700678D5D /* NextUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpView.swift; sourceTree = ""; }; - E1384943278036C70024FB48 /* VLCPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerViewController.swift; sourceTree = ""; }; - E1399473289B1EA900401ABC /* Defaults+Workaround.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Defaults+Workaround.swift"; sourceTree = ""; }; - E13AD72D2798BC8D00FDCEE8 /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; - E13AD72F2798C60F00FDCEE8 /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; + E12E30F0296383810022FAC9 /* SplitFormWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitFormWindowView.swift; sourceTree = ""; }; + E12E30F229638B140022FAC9 /* ChevronButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronButton.swift; sourceTree = ""; }; + E12E30F4296392EC0022FAC9 /* EnumPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumPickerView.swift; sourceTree = ""; }; + E12F038B28F8B0B100976CC3 /* EdgeInsets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EdgeInsets.swift; sourceTree = ""; }; + E133328729538D8D00EE76AB /* Files.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Files.swift; sourceTree = ""; }; + E133328C2953AE4B00EE76AB /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; + E133328E2953B71000EE76AB /* DownloadTaskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadTaskView.swift; sourceTree = ""; }; + E13332902953B91000EE76AB /* DownloadTaskCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadTaskCoordinator.swift; sourceTree = ""; }; + E13332932953BAA100EE76AB /* DownloadTaskContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadTaskContentView.swift; sourceTree = ""; }; + E1356E0129A7309D00382563 /* SeparatorHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorHStack.swift; sourceTree = ""; }; + E1388A40293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingSwizzling.swift; sourceTree = ""; }; + E1388A41293F0AAD009721B1 /* PreferenceUIHostingController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingController.swift; sourceTree = ""; }; + E139CC1C28EC836F00688DE2 /* ChapterOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterOverlay.swift; sourceTree = ""; }; + E139CC1E28EC83E400688DE2 /* Int.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Int.swift; sourceTree = ""; }; E13D02842788B634000FCB04 /* Swiftfin.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Swiftfin.entitlements; sourceTree = ""; }; E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - E13DD3C127164941009D4DAF /* SwiftfinStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftfinStore.swift; sourceTree = ""; }; - E13DD3C727164B1E009D4DAF /* UIDeviceExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDeviceExtensions.swift; sourceTree = ""; }; - E13DD3D4271693CD009D4DAF /* SwiftfinStoreDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftfinStoreDefaults.swift; sourceTree = ""; }; + E13DD3C727164B1E009D4DAF /* UIDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDevice.swift; sourceTree = ""; }; E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListViewModel.swift; sourceTree = ""; }; E13DD3E427177D15009D4DAF /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = ""; }; E13DD3E827177ED6009D4DAF /* ServerListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListCoordinator.swift; sourceTree = ""; }; E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSignInViewModel.swift; sourceTree = ""; }; - E13DD3EE27178F87009D4DAF /* SwiftfinNotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftfinNotificationCenter.swift; sourceTree = ""; }; E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSignInCoordinator.swift; sourceTree = ""; }; E13DD3F4271793BB009D4DAF /* UserSignInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSignInView.swift; sourceTree = ""; }; E13DD3F82717E961009D4DAF /* UserListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListViewModel.swift; sourceTree = ""; }; @@ -828,38 +988,92 @@ E13F05EB28BC9000003499D2 /* LibraryViewType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryViewType.swift; sourceTree = ""; }; E13F05EF28BC9016003499D2 /* LibraryItemRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryItemRow.swift; sourceTree = ""; }; E13F05F028BC9016003499D2 /* LibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; - E148128428C15472003B8787 /* APISortOrderExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APISortOrderExtensions.swift; sourceTree = ""; }; - E148128728C154BF003B8787 /* ItemFilterExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemFilterExtensions.swift; sourceTree = ""; }; + E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconSelectorView.swift; sourceTree = ""; }; + E1401CA12938122C00E8B599 /* AppIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcons.swift; sourceTree = ""; }; + E1401CA4293813F400E8B599 /* InvertedDarkAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedDarkAppIcon.swift; sourceTree = ""; }; + E1401CA62938140300E8B599 /* PrimaryAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryAppIcon.swift; sourceTree = ""; }; + E1401CA82938140700E8B599 /* DarkAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DarkAppIcon.swift; sourceTree = ""; }; + E1401CAA2938140A00E8B599 /* LightAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightAppIcon.swift; sourceTree = ""; }; + E1401CB029386C9200E8B599 /* UIColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; + E1401D44293A952300E8B599 /* MediaItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItemViewModel.swift; sourceTree = ""; }; + E148128428C15472003B8787 /* APISortOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APISortOrder.swift; sourceTree = ""; }; + E148128728C154BF003B8787 /* ItemFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = ""; }; E148128A28C15526003B8787 /* SortBy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortBy.swift; sourceTree = ""; }; + E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; + E14A08CC28E68729004FC984 /* MenuPosterHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPosterHStack.swift; sourceTree = ""; }; + E152107B2947ACA000375CC2 /* InvertedLightAppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedLightAppIcon.swift; sourceTree = ""; }; E1546776289AF46E00087E35 /* CollectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemView.swift; sourceTree = ""; }; E1546779289AF48200087E35 /* CollectionItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemContentView.swift; sourceTree = ""; }; + E1549655296CA2EF00C4EF88 /* DownloadTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadTask.swift; sourceTree = ""; }; + E1549656296CA2EF00C4EF88 /* SwiftfinDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftfinDefaults.swift; sourceTree = ""; }; + E1549657296CA2EF00C4EF88 /* NewSessionManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewSessionManager.swift; sourceTree = ""; }; + E1549658296CA2EF00C4EF88 /* SwiftfinStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftfinStore.swift; sourceTree = ""; }; + E1549659296CA2EF00C4EF88 /* SwiftfinNotifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftfinNotifications.swift; sourceTree = ""; }; + E154965A296CA2EF00C4EF88 /* PlaybackManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackManager.swift; sourceTree = ""; }; + E154965B296CA2EF00C4EF88 /* DownloadManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; }; + E154965D296CA2EF00C4EF88 /* LogManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogManager.swift; sourceTree = ""; }; + E1549677296CB22B00C4EF88 /* InlineEnumToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineEnumToggle.swift; sourceTree = ""; }; + E1549679296CB4B000C4EF88 /* VideoPlayerSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerSettingsView.swift; sourceTree = ""; }; + E154967B296CBB1A00C4EF88 /* FontPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontPickerView.swift; sourceTree = ""; }; + E154967D296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicNavigationCoordinator.swift; sourceTree = ""; }; + E1559A75294D960C00C1FFBC /* MainOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainOverlay.swift; sourceTree = ""; }; + E157562F29355B7900976E1F /* UpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateView.swift; sourceTree = ""; }; + E15756312935642A00976E1F /* Float.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Float.swift; sourceTree = ""; }; + E15756332936851D00976E1F /* NativeVideoPlayerSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoPlayerSettingsView.swift; sourceTree = ""; }; + E15756352936856700976E1F /* VideoPlayerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerType.swift; sourceTree = ""; }; + E1575EA5293E7D40001665B1 /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; + E1581E26291EF59800D6C640 /* SplitContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitContentView.swift; sourceTree = ""; }; E168BD08289A4162001A6922 /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; E168BD09289A4162001A6922 /* HomeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeContentView.swift; sourceTree = ""; }; E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = ""; }; E168BD0E289A4162001A6922 /* LatestInLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LatestInLibraryView.swift; sourceTree = ""; }; E168BD0F289A4162001A6922 /* HomeErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeErrorView.swift; sourceTree = ""; }; + E169C7B7296D2E8200AE25F9 /* SpecialFeaturesHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialFeaturesHStack.swift; sourceTree = ""; }; E16AA60728A364A6009A983C /* PosterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterButton.swift; sourceTree = ""; }; + E16AF11B292C98A7001422A8 /* GestureSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureSettingsView.swift; sourceTree = ""; }; + E16DEAC128EFCF590058F196 /* EnvironmentValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentValue.swift; sourceTree = ""; }; + E170D0E1294CC8000017224C /* VideoPlayer+Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoPlayer+Actions.swift"; sourceTree = ""; }; + E170D0E3294CC8AB0017224C /* VideoPlayer+KeyCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoPlayer+KeyCommands.swift"; sourceTree = ""; }; + E170D102294CE8BF0017224C /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; + E170D104294D21FA0017224C /* MediaSourceInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourceInfoView.swift; sourceTree = ""; }; + E170D106294D23BA0017224C /* MediaSourceInfoCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourceInfoCoordinator.swift; sourceTree = ""; }; + E1721FA928FB7CAC00762992 /* CompactTimeStamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactTimeStamp.swift; sourceTree = ""; }; + E1721FAD28FB801C00762992 /* SmallPlaybackButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallPlaybackButtons.swift; sourceTree = ""; }; + E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageBlurHashes.swift; sourceTree = ""; }; E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = ""; }; - E173DA5126D04AAF00CC4EB7 /* ColorExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtensions.swift; sourceTree = ""; }; + E173DA5126D04AAF00CC4EB7 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = ""; }; - E176DE6C278E30D2001EFD8D /* EpisodeCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeCard.swift; sourceTree = ""; }; + E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCoordinatable.swift; sourceTree = ""; }; + E175AFF2299AC117004DCF52 /* DebugSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugSettingsView.swift; sourceTree = ""; }; + E17665D828E80F0F00130507 /* PosterButtonType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterButtonType.swift; sourceTree = ""; }; E178859A2780F1F40094FBCF /* tvOSSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSSlider.swift; sourceTree = ""; }; E178859D2780F53B0094FBCF /* SliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderView.swift; sourceTree = ""; }; - E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSVLCOverlay.swift; sourceTree = ""; }; E17885A3278105170094FBCF /* SFSymbolButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFSymbolButton.swift; sourceTree = ""; }; + E17AC9692954D00E003D2BC2 /* URLResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLResponse.swift; sourceTree = ""; }; + E17AC96C2954E9CA003D2BC2 /* DownloadListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadListView.swift; sourceTree = ""; }; + E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadListViewModel.swift; sourceTree = ""; }; + E17AC9702954F636003D2BC2 /* DownloadListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadListCoordinator.swift; sourceTree = ""; }; + E17AC9722955007A003D2BC2 /* DownloadTaskButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadTaskButton.swift; sourceTree = ""; }; E17FB54E28C1197700311DFE /* SelectorType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectorType.swift; sourceTree = ""; }; E17FB55128C119D400311DFE /* Displayable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Displayable.swift; sourceTree = ""; }; E17FB55428C1250B00311DFE /* SimilarItemsHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimilarItemsHStack.swift; sourceTree = ""; }; E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewHStack.swift; sourceTree = ""; }; E17FB55828C125E900311DFE /* StudiosHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudiosHStack.swift; sourceTree = ""; }; E17FB55A28C1266400311DFE /* GenresHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenresHStack.swift; sourceTree = ""; }; - E184C15F288C5C08000B25BA /* RequestBuilderExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestBuilderExtensions.swift; sourceTree = ""; }; E185920528CDAA6400326F80 /* CastAndCrewHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewHStack.swift; sourceTree = ""; }; E185920728CDAAA200326F80 /* SimilarItemsHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimilarItemsHStack.swift; sourceTree = ""; }; E185920928CEF23A00326F80 /* FocusGuide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusGuide.swift; sourceTree = ""; }; + E187A60129AB28F0008387E6 /* RotateContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RotateContentView.swift; sourceTree = ""; }; + E187A60429AD2E25008387E6 /* StepperView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepperView.swift; sourceTree = ""; }; E18845F426DD631E00B0C5B7 /* BaseItemDto+Poster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Poster.swift"; sourceTree = ""; }; + E18A17EF298C68B700C22F62 /* Overlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Overlay.swift; sourceTree = ""; }; + E18A17F1298C68BB00C22F62 /* MainOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainOverlay.swift; sourceTree = ""; }; + E18A8E7C28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+VideoPlayerViewModel.swift"; sourceTree = ""; }; + E18A8E7F28D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaSourceInfo+ItemVideoPlayerViewModel.swift"; sourceTree = ""; }; + E18A8E8228D60BC400333B9A /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; + E18A8E8428D60D0000333B9A /* VideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerCoordinator.swift; sourceTree = ""; }; E18CE0AE28A222240092E7F1 /* PublicUserSignInView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PublicUserSignInView.swift; sourceTree = ""; }; - E18CE0B128A229E70092E7F1 /* UserDtoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDtoExtensions.swift; sourceTree = ""; }; + E18CE0B128A229E70092E7F1 /* UserDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDto.swift; sourceTree = ""; }; E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatingTimer.swift; sourceTree = ""; }; E18CE0B828A2322D0092E7F1 /* QuickConnectCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectCoordinator.swift; sourceTree = ""; }; E18E01A4288746AF0022598C /* RefreshableScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RefreshableScrollView.swift; sourceTree = ""; }; @@ -884,21 +1098,20 @@ E18E01CF288747230022598C /* MovieItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MovieItemView.swift; sourceTree = ""; }; E18E01D0288747230022598C /* MovieItemContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MovieItemContentView.swift; sourceTree = ""; }; E18E01D5288747230022598C /* AboutView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; - E18E01D6288747230022598C /* ListDetailsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListDetailsView.swift; sourceTree = ""; }; E18E01D7288747230022598C /* AttributeHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeHStack.swift; sourceTree = ""; }; E18E01D8288747230022598C /* PlayButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayButton.swift; sourceTree = ""; }; E18E01D9288747230022598C /* ActionButtonHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButtonHStack.swift; sourceTree = ""; }; E18E01F3288747580022598C /* AboutAppView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutAppView.swift; sourceTree = ""; }; E18E01FF288749200022598C /* Divider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Divider.swift; sourceTree = ""; }; - E18E0200288749200022598C /* AppIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppIcon.swift; sourceTree = ""; }; - E18E0201288749200022598C /* AttributeFillView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeFillView.swift; sourceTree = ""; }; - E18E0202288749200022598C /* AttributeOutlineView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeOutlineView.swift; sourceTree = ""; }; + E18E0202288749200022598C /* AttributeStyleModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeStyleModifier.swift; sourceTree = ""; }; E18E0203288749200022598C /* BlurView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurView.swift; sourceTree = ""; }; - E18E0239288749540022598C /* UIScrollViewExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIScrollViewExtensions.swift; sourceTree = ""; }; + E18E0239288749540022598C /* UIScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIScrollView.swift; sourceTree = ""; }; E19169CD272514760085832A /* HTTPScheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPScheme.swift; sourceTree = ""; }; + E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialFeatureHStack.swift; sourceTree = ""; }; + E1921B7528E63306003A5238 /* GestureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureView.swift; sourceTree = ""; }; E192607F28D28AAD002314B4 /* UserProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileButton.swift; sourceTree = ""; }; E1937A3A288E54AD00CB80AA /* BaseItemDto+Images.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Images.swift"; sourceTree = ""; }; - E1937A3D288F0D3D00CB80AA /* UIScreenExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScreenExtensions.swift; sourceTree = ""; }; + E1937A3D288F0D3D00CB80AA /* UIScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScreen.swift; sourceTree = ""; }; E1937A60288F32DB00CB80AA /* Poster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poster.swift; sourceTree = ""; }; E193D5422719407E00900D82 /* tvOSMainCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSMainCoordinator.swift; sourceTree = ""; }; E193D546271941C500900D82 /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = ""; }; @@ -907,31 +1120,49 @@ E193D54F2719430400900D82 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = ""; }; E193D552271943D500900D82 /* tvOSMainTabCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSMainTabCoordinator.swift; sourceTree = ""; }; E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomEdgeGradientModifier.swift; sourceTree = ""; }; + E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaStreamInfoView.swift; sourceTree = ""; }; + E1A1528128FD126C00600579 /* VerticalAlignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalAlignment.swift; sourceTree = ""; }; + E1A1528428FD191A00600579 /* TextPair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextPair.swift; sourceTree = ""; }; + E1A1528728FD229500600579 /* ChevronButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronButton.swift; sourceTree = ""; }; + E1A1528928FD22F600600579 /* TextPairView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextPairView.swift; sourceTree = ""; }; + E1A1528C28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerSettingsCoordinator.swift; sourceTree = ""; }; + E1A1528F28FD23D600600579 /* PlaybackSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSettingsCoordinator.swift; sourceTree = ""; }; E1A16C9C2889AF1E00EA4679 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; E1A16CA0288A7CFD00EA4679 /* AboutViewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewCard.swift; sourceTree = ""; }; - E1A2C153279A7D5A005EC829 /* UIApplicationExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtensions.swift; sourceTree = ""; }; - E1A2C157279A7D76005EC829 /* BundleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleExtensions.swift; sourceTree = ""; }; - E1A2C15F279A7DCA005EC829 /* AboutAppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutAppView.swift; sourceTree = ""; }; + E1A2C153279A7D5A005EC829 /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemSelector.swift; sourceTree = ""; }; E1A42E4B28CBD39300A14DCB /* HomeContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeContentView.swift; sourceTree = ""; }; E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeErrorView.swift; sourceTree = ""; }; E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = ""; }; E1AA331C2782541500F6439C /* PrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButton.swift; sourceTree = ""; }; E1AA331E2782639D00F6439C /* OverlayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayType.swift; sourceTree = ""; }; - E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemDtoExtensions.swift; sourceTree = ""; }; - E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameGUIDPairExtensions.swift; sourceTree = ""; }; + E1AD104C26D96CE3003E4A08 /* BaseItemDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemDto.swift; sourceTree = ""; }; + E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameGuidPair.swift; sourceTree = ""; }; E1B2AB9628808CDF0072B3B9 /* GoogleCast.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GoogleCast.xcframework; path = Carthage/Build/GoogleCast.xcframework; sourceTree = ""; }; - E1BDE358278E9ED2004E4022 /* MissingItemsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissingItemsSettingsView.swift; sourceTree = ""; }; + E1B33EAF28EA890D0073B0FD /* Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Equatable.swift; sourceTree = ""; }; + E1B33ECE28EB6EA90073B0FD /* OverlayMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayMenu.swift; sourceTree = ""; }; + E1B33ED028EB860A0073B0FD /* LargePlaybackButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargePlaybackButtons.swift; sourceTree = ""; }; + E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentLogHandler.swift; sourceTree = ""; }; + E1B490462967E2E500D3EDCE /* CoreStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreStore.swift; sourceTree = ""; }; + E1B5784028F8AFCB00D42911 /* Wrapped View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Wrapped View.swift"; sourceTree = ""; }; + E1B5861129E32EEF00E45D6E /* Set.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Set.swift; sourceTree = ""; }; + E1BA6FC429D25DBD007D98DC /* LandscapeItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapeItemElement.swift; sourceTree = ""; }; + E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerActionButton.swift; sourceTree = ""; }; + E1BDF2E82951490400CC0294 /* ActionButtonSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonSelectorView.swift; sourceTree = ""; }; + E1BDF2EB2952290200CC0294 /* AspectFillActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AspectFillActionButton.swift; sourceTree = ""; }; + E1BDF2EE29522A5900CC0294 /* AudioActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioActionButton.swift; sourceTree = ""; }; + E1BDF2F029524AB700CC0294 /* AutoPlayActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPlayActionButton.swift; sourceTree = ""; }; + E1BDF2F229524C3B00CC0294 /* ChaptersActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChaptersActionButton.swift; sourceTree = ""; }; + E1BDF2F429524E6400CC0294 /* PlayNextItemActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayNextItemActionButton.swift; sourceTree = ""; }; + E1BDF2F629524ECD00CC0294 /* PlaybackSpeedActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSpeedActionButton.swift; sourceTree = ""; }; + E1BDF2F829524FDA00CC0294 /* PlayPreviousItemActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayPreviousItemActionButton.swift; sourceTree = ""; }; + E1BDF2FA2952502300CC0294 /* SubtitleActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleActionButton.swift; sourceTree = ""; }; + E1BDF31629525F0400CC0294 /* AdvancedActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedActionButton.swift; sourceTree = ""; }; + E1BDF3182952641300CC0294 /* VisibilityModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibilityModifier.swift; sourceTree = ""; }; E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackSpeed.swift; sourceTree = ""; }; - E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerOverlayDelegate.swift; sourceTree = ""; }; - E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerViewController.swift; sourceTree = ""; }; - E1C812B8277A8E5D00918266 /* VLCPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerView.swift; sourceTree = ""; }; - E1C812BB277A8E5D00918266 /* VLCPlayerOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerOverlayView.swift; sourceTree = ""; }; - E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLComponentsExtensions.swift; sourceTree = ""; }; - E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerOverlayDelegate.swift; sourceTree = ""; }; - E1C812C8277AE40900918266 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; - E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; - E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSVideoPlayerCoordinator.swift; sourceTree = ""; }; + E1C812C4277A90B200918266 /* URLComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLComponents.swift; sourceTree = ""; }; + E1C8CE5A28FE512400DF5D7B /* CGPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGPoint.swift; sourceTree = ""; }; + E1C8CE7B28FF015000DF5D7B /* TrailingTimestampType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingTimestampType.swift; sourceTree = ""; }; E1C925F328875037002A7A66 /* ItemViewType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemViewType.swift; sourceTree = ""; }; E1C925F62887504B002A7A66 /* PanDirectionGestureRecognizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PanDirectionGestureRecognizer.swift; sourceTree = ""; }; E1C925F828875647002A7A66 /* LatestInLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LatestInLibraryView.swift; sourceTree = ""; }; @@ -942,7 +1173,7 @@ E1C926022887565C002A7A66 /* PlayButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayButton.swift; sourceTree = ""; }; E1C926032887565C002A7A66 /* ActionButtonHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButtonHStack.swift; sourceTree = ""; }; E1C926052887565C002A7A66 /* SeriesItemContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemContentView.swift; sourceTree = ""; }; - E1C926072887565C002A7A66 /* SeriesEpisodesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesEpisodesView.swift; sourceTree = ""; }; + E1C926072887565C002A7A66 /* SeriesEpisodeSelector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesEpisodeSelector.swift; sourceTree = ""; }; E1C926092887565C002A7A66 /* EpisodeCard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCard.swift; sourceTree = ""; }; E1C9260A2887565C002A7A66 /* SeriesItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemView.swift; sourceTree = ""; }; E1C92617288756BD002A7A66 /* PosterButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PosterButton.swift; sourceTree = ""; }; @@ -951,8 +1182,10 @@ E1CCC3D128C858A50020ED54 /* UserProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileButton.swift; sourceTree = ""; }; E1CCF12D28ABF989006CAC9E /* PosterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterType.swift; sourceTree = ""; }; E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterHStack.swift; sourceTree = ""; }; + E1CD13EE28EF364100CB46CA /* DetectOrientationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectOrientationModifier.swift; sourceTree = ""; }; E1CEFBF427914C7700F60429 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; E1CEFBF627914E6400F60429 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; + E1CFE27F28FA606800B7D34C /* ChapterTrack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterTrack.swift; sourceTree = ""; }; E1D3043128D175CE00587289 /* StaticLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticLibraryViewModel.swift; sourceTree = ""; }; E1D3043428D1763100587289 /* SeeAllButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeeAllButton.swift; sourceTree = ""; }; E1D3043928D189C500587289 /* CastAndCrewLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewLibraryView.swift; sourceTree = ""; }; @@ -961,32 +1194,64 @@ E1D3044128D1976600587289 /* CastAndCrewItemRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewItemRow.swift; sourceTree = ""; }; E1D3044328D1991900587289 /* LibraryViewTypeToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewTypeToggle.swift; sourceTree = ""; }; E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = ""; }; - E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsViewModel.swift; sourceTree = ""; }; E1D4BF802719D22800A11E64 /* AppAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAppearance.swift; sourceTree = ""; }; - E1D4BF832719D25A00A11E64 /* TrackLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackLanguage.swift; sourceTree = ""; }; - E1D4BF862719D27100A11E64 /* Bitrates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bitrates.swift; sourceTree = ""; }; E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsCoordinator.swift; sourceTree = ""; }; E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = ""; }; - E1E00A34278628A40022235B /* DoubleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleExtensions.swift; sourceTree = ""; }; + E1D5C39528DF90C100CDBEFB /* Slider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Slider.swift; sourceTree = ""; }; + E1D5C39828DF914700CDBEFB /* CapsuleSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapsuleSlider.swift; sourceTree = ""; }; + E1D5C39A28DF993400CDBEFB /* ThumbSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbSlider.swift; sourceTree = ""; }; + E1D842162932AB8F00D1041A /* NativeVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoPlayer.swift; sourceTree = ""; }; + E1D8424E2932F7C400D1041A /* OverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewView.swift; sourceTree = ""; }; + E1D8428E2933F2D900D1041A /* MediaSourceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourceInfo.swift; sourceTree = ""; }; + E1D842902933F87500D1041A /* ItemFields.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemFields.swift; sourceTree = ""; }; + E1D8429229340B8300D1041A /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = ""; }; + E1D8429429346C6400D1041A /* BasicStepper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicStepper.swift; sourceTree = ""; }; + E1D9F474296E86D400129AF3 /* NativeVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoPlayer.swift; sourceTree = ""; }; + E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialFeatureType.swift; sourceTree = ""; }; + E1DA656828E78B5900592A73 /* SpecialFeaturesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialFeaturesViewModel.swift; sourceTree = ""; }; + E1DA656B28E78C1700592A73 /* MenuPosterHStackModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPosterHStackModel.swift; sourceTree = ""; }; + E1DA656E28E78C9900592A73 /* SeriesEpisodeSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesEpisodeSelector.swift; sourceTree = ""; }; + E1DC9815296DD0FE00982F06 /* BlurViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurViewModifier.swift; sourceTree = ""; }; + E1DC9818296DD1CD00982F06 /* CinematicBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicBackgroundView.swift; sourceTree = ""; }; + E1DC983C296DEB9B00982F06 /* UnwatchedIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnwatchedIndicator.swift; sourceTree = ""; }; + E1DC9840296DEBD800982F06 /* WatchedIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchedIndicator.swift; sourceTree = ""; }; + E1DC9843296DECB600982F06 /* ProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = ""; }; + E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteIndicator.swift; sourceTree = ""; }; + E1E0BEB629EF450B0002E8D3 /* UIGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIGestureRecognizer.swift; sourceTree = ""; }; E1E1643928BAC2EF00323B0A /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; E1E1643D28BB074000323B0A /* SelectorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectorView.swift; sourceTree = ""; }; - E1E1644028BB301900323B0A /* ArrayExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayExtensions.swift; sourceTree = ""; }; - E1E1644328BC60C600323B0A /* MediaLibraryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLibraryItem.swift; sourceTree = ""; }; + E1E1644028BB301900323B0A /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; + E1E1E24C28DF8A2E000DF5FD /* PreferenceKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceKeys.swift; sourceTree = ""; }; + E1E306CC28EF6E8000537998 /* TimerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerProxy.swift; sourceTree = ""; }; E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshHelper.swift; sourceTree = ""; }; - E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDetailsView.swift; sourceTree = ""; }; - E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlaySettingsView.swift; sourceTree = ""; }; + E1E5D5472783CCF900692DFE /* VideoPlayerSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerSettingsView.swift; sourceTree = ""; }; E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalSettingsView.swift; sourceTree = ""; }; - E1E5D54E2783E67100692DFE /* OverlaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlaySettingsView.swift; sourceTree = ""; }; E1E5D5502783E67700692DFE /* ExperimentalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalSettingsView.swift; sourceTree = ""; }; E1E5D552278419D900692DFE /* ConfirmCloseOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmCloseOverlay.swift; sourceTree = ""; }; + E1E6C43A29AECBD30064123F /* BottomBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomBarView.swift; sourceTree = ""; }; + E1E6C43C29AECC310064123F /* BarActionButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarActionButtons.swift; sourceTree = ""; }; + E1E6C43E29AECC5A0064123F /* ActionButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtons.swift; sourceTree = ""; }; + E1E6C44129AECCD50064123F /* ActionButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtons.swift; sourceTree = ""; }; + E1E6C44429AECCF20064123F /* PlayNextItemActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayNextItemActionButton.swift; sourceTree = ""; }; + E1E6C44629AECD5D0064123F /* PlayPreviousItemActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayPreviousItemActionButton.swift; sourceTree = ""; }; + E1E6C44829AECEE70064123F /* AutoPlayActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPlayActionButton.swift; sourceTree = ""; }; + E1E6C44A29AED2B70064123F /* HorizontalAlignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalAlignment.swift; sourceTree = ""; }; + E1E6C44D29AEE9DC0064123F /* SmallMenuOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallMenuOverlay.swift; sourceTree = ""; }; + E1E6C44F29B104840064123F /* Button.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Button.swift; sourceTree = ""; }; + E1E6C45229B1304E0064123F /* ChaptersActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChaptersActionButton.swift; sourceTree = ""; }; + E1E6C45529B130F50064123F /* ChapterOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterOverlay.swift; sourceTree = ""; }; + E1E9017A28DAAE4D001B1594 /* RoundedCorner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedCorner.swift; sourceTree = ""; }; + E1E9017E28DAB15F001B1594 /* BarActionButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarActionButtons.swift; sourceTree = ""; }; E1E9EFE928C6B96400CC1F8B /* ServerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerButton.swift; sourceTree = ""; }; + E1EA9F6928F8A79E00BEC442 /* VideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerManager.swift; sourceTree = ""; }; E1EBCB41278BD174009FE6E9 /* TruncatedTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TruncatedTextView.swift; sourceTree = ""; }; E1EBCB43278BD1CE009FE6E9 /* ItemOverviewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemOverviewCoordinator.swift; sourceTree = ""; }; E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemOverviewView.swift; sourceTree = ""; }; + E1EF4C402911B783008CC695 /* StreamType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamType.swift; sourceTree = ""; }; E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerJumpLength.swift; sourceTree = ""; }; - E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallMenuOverlay.swift; sourceTree = ""; }; E1FA891A289A302300176FEB /* iPadOSCollectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPadOSCollectionItemView.swift; sourceTree = ""; }; E1FA891D289A305D00176FEB /* iPadOSCollectionItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPadOSCollectionItemContentView.swift; sourceTree = ""; }; + E1FBDB6529D0F336003DD5E2 /* KeyCommandAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCommandAction.swift; sourceTree = ""; }; E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = ""; }; E1FCD09526C47118007C8DCF /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = ""; }; E1FE69A628C29B720021BC93 /* ProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBar.swift; sourceTree = ""; }; @@ -998,10 +1263,11 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + E1575E56293E7650001665B1 /* VLCUI in Frameworks */, + E1B5F7A529577BB8004B26CF /* JellyfinAPI in Frameworks */, 62666E1727E501CC00EC0ECD /* CFNetwork.framework in Frameworks */, - C409CE9C284EA6EA00CABC12 /* SwiftUICollection in Frameworks */, - E15B235629B7029E00DAFDDD /* JellyfinAPI in Frameworks */, 62666DFA27E5013700EC0ECD /* TVVLCKit.xcframework in Frameworks */, + E1DC981E296DD91900982F06 /* CollectionView in Frameworks */, 62666E3227E5021E00EC0ECD /* UIKit.framework in Frameworks */, 62666E2A27E5020A00EC0ECD /* OpenGLES.framework in Frameworks */, E1002B6B2793E36600E47059 /* Algorithms in Frameworks */, @@ -1012,14 +1278,19 @@ 6220D0C926D63F3700B8E046 /* Stinsen in Frameworks */, 535870912669D7A800D05A09 /* Introspect in Frameworks */, E13AF3B828A0C598009093AB /* NukeExtensions in Frameworks */, + E1575E58293E7685001665B1 /* Files in Frameworks */, + E1B5F7A729577BCE004B26CF /* Pulse in Frameworks */, E13AF3BA28A0C598009093AB /* NukeUI in Frameworks */, + E1B5F7AB29577BCE004B26CF /* PulseUI in Frameworks */, + E1B5F7A929577BCE004B26CF /* PulseLogHandler in Frameworks */, 62666E1B27E501D400EC0ECD /* CoreGraphics.framework in Frameworks */, + E1388A46293F0ABA009721B1 /* SwizzleSwift in Frameworks */, 62666E2C27E5021000EC0ECD /* QuartzCore.framework in Frameworks */, 62666E1927E501D000EC0ECD /* CoreFoundation.framework in Frameworks */, 62666E2E27E5021400EC0ECD /* Security.framework in Frameworks */, + E1B5F7AD29577BDD004B26CF /* OrderedCollections in Frameworks */, E1ECD8F528C1BA10008B9DC6 /* UDPBroadcast.xcframework in Frameworks */, 53ABFDDC267972BF00886593 /* TVServices.framework in Frameworks */, - E1734D7E28B9578100C66367 /* CollectionView in Frameworks */, 62666E1F27E501DF00EC0ECD /* CoreText.framework in Frameworks */, E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */, 62666E1527E501C800EC0ECD /* AVFoundation.framework in Frameworks */, @@ -1027,8 +1298,6 @@ 62666E1327E501C300EC0ECD /* AudioToolbox.framework in Frameworks */, E13AF3B628A0C598009093AB /* Nuke in Frameworks */, E12186DE2718F1C50010884C /* Defaults in Frameworks */, - E1347DB6279E3CA500BC6161 /* Puppy in Frameworks */, - 53ABFDED26799D7700886593 /* ActivityIndicator in Frameworks */, E192608828D2E5F0002314B4 /* Factory in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1037,43 +1306,47 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + E12B93072947CD0F00CE0BD9 /* Pulse in Frameworks */, 62666E3E27E503FA00EC0ECD /* MediaAccessibility.framework in Frameworks */, 62666DFF27E5016400EC0ECD /* CFNetwork.framework in Frameworks */, E13DD3D327168E65009D4DAF /* Defaults in Frameworks */, - E1101177281B1E8A006A3584 /* Puppy in Frameworks */, E1002B682793CFBA00E47059 /* Algorithms in Frameworks */, - E1734D7C28B9577700C66367 /* CollectionView in Frameworks */, 62666E1127E501B900EC0ECD /* UIKit.framework in Frameworks */, + E15210582946DF1B00375CC2 /* PulseUI in Frameworks */, 62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */, 62666E0327E5017100EC0ECD /* CoreMedia.framework in Frameworks */, + E10706122942F57D00646DAF /* PulseLogHandler in Frameworks */, 62666E0627E5017A00EC0ECD /* CoreVideo.framework in Frameworks */, - E10EAA4D277BB716000269ED /* Sliders in Frameworks */, + E19DDEC72948EF9900954E10 /* OrderedCollections in Frameworks */, + E10706102942F57D00646DAF /* Pulse in Frameworks */, E192608328D2D0DB002314B4 /* Factory in Frameworks */, + E10706142942F57D00646DAF /* PulseUI in Frameworks */, 62C29E9C26D0FE4200C1D2E7 /* Stinsen in Frameworks */, 62666E0227E5016D00EC0ECD /* CoreGraphics.framework in Frameworks */, + E1575E3C293C6B15001665B1 /* Files in Frameworks */, 62666E1027E501B400EC0ECD /* VideoToolbox.framework in Frameworks */, 62666E0C27E501A500EC0ECD /* OpenGLES.framework in Frameworks */, - E15B235429B7025400DAFDDD /* JellyfinAPI in Frameworks */, - C409CE9E285044C800CABC12 /* SwiftUICollection in Frameworks */, E19E6E0A28A0BEFF005C10C8 /* BlurHashKit in Frameworks */, 62666E0127E5016900EC0ECD /* CoreFoundation.framework in Frameworks */, 62400C4B287ED19600F6AD3D /* UDPBroadcast.xcframework in Frameworks */, - E1B6DCEA271A23880015B715 /* SwiftyJSON in Frameworks */, 62666E2427E501F300EC0ECD /* Foundation.framework in Frameworks */, + E18A8E7A28D5FEDF00333B9A /* VLCUI in Frameworks */, 53352571265EA0A0006CCA86 /* Introspect in Frameworks */, + E15210562946DF1B00375CC2 /* PulseLogHandler in Frameworks */, 62666E0427E5017500EC0ECD /* CoreText.framework in Frameworks */, E13DD3C62716499E009D4DAF /* CoreStore in Frameworks */, 62666E0E27E501AF00EC0ECD /* Security.framework in Frameworks */, + E1DC9814296DC06200982F06 /* PulseLogHandler in Frameworks */, E1B2AB9928808E150072B3B9 /* GoogleCast.xcframework in Frameworks */, + E1DC9821296DDBE600982F06 /* CollectionView in Frameworks */, 62666DFE27E5015700EC0ECD /* AVFoundation.framework in Frameworks */, 62666DFD27E5014F00EC0ECD /* AudioToolbox.framework in Frameworks */, + E12B930D2948369F00CE0BD9 /* JellyfinAPI in Frameworks */, E19E6E0528A0B958005C10C8 /* Nuke in Frameworks */, 62666E0D27E501AA00EC0ECD /* QuartzCore.framework in Frameworks */, E19E6E0728A0B958005C10C8 /* NukeUI in Frameworks */, 62666E3F27E5040300EC0ECD /* SystemConfiguration.framework in Frameworks */, - 625CB57A2678C4A400530A6E /* ActivityIndicator in Frameworks */, 62666E3927E502CE00EC0ECD /* SwizzleSwift in Frameworks */, - E1347DB2279E3C6200BC6161 /* Puppy in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1091,15 +1364,10 @@ 5310694F2684E7EE00CFFDBA /* VideoPlayer */ = { isa = PBXGroup; children = ( - E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */, - E178859C2780F5300094FBCF /* tvOSSLider */, - E17885A7278130690094FBCF /* Overlays */, - E1C812C8277AE40900918266 /* VideoPlayerView.swift */, - C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */, - C4B9B91327E1921B0063535C /* LiveTVNativeVideoPlayerView.swift */, - C453497E279A2DA50045F1E2 /* LiveTVPlayerViewController.swift */, - E1384943278036C70024FB48 /* VLCPlayerViewController.swift */, - E13AD72F2798C60F00FDCEE8 /* NativePlayerViewController.swift */, + E10E842829A587090064EA49 /* Components */, + E18A17F3298C68BF00C22F62 /* Overlays */, + E1575EA5293E7D40001665B1 /* VideoPlayer.swift */, + E1D9F474296E86D400129AF3 /* NativeVideoPlayer.swift */, ); path = VideoPlayer; sourceTree = ""; @@ -1107,9 +1375,8 @@ 532175392671BCED005491E6 /* ViewModels */ = { isa = PBXGroup; children = ( - E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */, 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, - E10D87E127852FD000BD264C /* EpisodesRowManager.swift */, + E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */, E113133928BEB71D00930F75 /* FilterViewModel.swift */, 625CB5722678C32A00530A6E /* HomeViewModel.swift */, C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */, @@ -1118,6 +1385,7 @@ C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */, C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */, 625CB5742678C33500530A6E /* MediaViewModel.swift */, + E1401D44293A952300E8B599 /* MediaItemViewModel.swift */, E12CC1AD28D0FAEA00678D5D /* NextUpLibraryViewModel.swift */, E111D8F428D03B7500400001 /* PagingLibraryViewModel.swift */, 6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */, @@ -1126,11 +1394,12 @@ E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */, E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */, 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */, + E1DA656828E78B5900592A73 /* SpecialFeaturesViewModel.swift */, E1D3043128D175CE00587289 /* StaticLibraryViewModel.swift */, E13DD3F82717E961009D4DAF /* UserListViewModel.swift */, E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */, - 09389CC626819B4500AE350E /* VideoPlayerModel.swift */, - E126F73F278A655300A522BF /* VideoPlayerViewModel */, + E1EA9F6928F8A79E00BEC442 /* VideoPlayerManager.swift */, + E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */, 625CB57B2678CE1000530A6E /* ViewModel.swift */, ); path = ViewModels; @@ -1191,32 +1460,23 @@ 536D3D77267BB9650004248C /* Components */, 535870702669D21700D05A09 /* Info.plist */, E185920B28CEF23F00326F80 /* Objects */, - 535870682669D21700D05A09 /* Preview Content */, E12186E02718F23B0010884C /* Views */, ); path = "Swiftfin tvOS"; sourceTree = ""; }; - 535870682669D21700D05A09 /* Preview Content */ = { - isa = PBXGroup; - children = ( - 535870692669D21700D05A09 /* Preview Assets.xcassets */, - ); - path = "Preview Content"; - sourceTree = ""; - }; 535870752669D60C00D05A09 /* Shared */ = { isa = PBXGroup; children = ( + E1401CA32938123400E8B599 /* AppIcons */, 62C29E9D26D0FE5900C1D2E7 /* Coordinators */, E1FCD08E26C466F3007C8DCF /* Errors */, 621338912660106C00A81A2A /* Extensions */, 535870AB2669D8D300D05A09 /* Objects */, AE8C3157265D6F5E008AA076 /* Resources */, 091B5A852683142E00D78B61 /* ServerDiscovery */, - 62EC352A26766657000E9F2D /* Singleton */, + E1549654296CA2EF00C4EF88 /* Services */, 6286F09F271C0AA500C40ED5 /* Strings */, - E13DD3C0271648EC009D4DAF /* SwiftfinStore */, 532175392671BCED005491E6 /* ViewModels */, E1AD105326D96F5A003E4A08 /* Views */, ); @@ -1227,26 +1487,37 @@ isa = PBXGroup; children = ( E1D4BF802719D22800A11E64 /* AppAppearance.swift */, - E1D4BF862719D27100A11E64 /* Bitrates.swift */, 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */, E17FB55128C119D400311DFE /* Displayable.swift */, + E129429728F4785200796AC6 /* EnumPicker.swift */, + E1092F4B29106F9F00163F57 /* GestureAction.swift */, E19169CD272514760085832A /* HTTPScheme.swift */, 535870AC2669D8DD00D05A09 /* ItemFilters.swift */, E1C925F328875037002A7A66 /* ItemViewType.swift */, E113133728BEADBA00930F75 /* LibraryParent.swift */, E13F05EB28BC9000003499D2 /* LibraryViewType.swift */, - E1E1644328BC60C600323B0A /* MediaLibraryItem.swift */, + E1DA656B28E78C1700592A73 /* MenuPosterHStackModel.swift */, E1AA331E2782639D00F6439C /* OverlayType.swift */, E1C925F62887504B002A7A66 /* PanDirectionGestureRecognizer.swift */, E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */, E1937A60288F32DB00CB80AA /* Poster.swift */, + E17665D828E80F0F00130507 /* PosterButtonType.swift */, E1CCF12D28ABF989006CAC9E /* PosterType.swift */, E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */, + E1E9017A28DAAE4D001B1594 /* RoundedCorner.swift */, E17FB54E28C1197700311DFE /* SelectorType.swift */, + E129429228F2845000796AC6 /* SliderType.swift */, E148128A28C15526003B8787 /* SortBy.swift */, - 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */, - E1D4BF832719D25A00A11E64 /* TrackLanguage.swift */, + E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */, + E1EF4C402911B783008CC695 /* StreamType.swift */, + E1A1528428FD191A00600579 /* TextPair.swift */, + E1E306CC28EF6E8000537998 /* TimerProxy.swift */, + E129428F28F0BDC300796AC6 /* TimeStampType.swift */, + E1C8CE7B28FF015000DF5D7B /* TrailingTimestampType.swift */, + E1D8429229340B8300D1041A /* Utilities.swift */, + E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */, E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */, + E15756352936856700976E1F /* VideoPlayerType.swift */, ); path = Objects; sourceTree = ""; @@ -1254,17 +1525,24 @@ 536D3D77267BB9650004248C /* Components */ = { isa = PBXGroup; children = ( + E12E30F229638B140022FAC9 /* ChevronButton.swift */, + E1DC9818296DD1CD00982F06 /* CinematicBackgroundView.swift */, E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */, E1C92618288756BD002A7A66 /* DotHStack.swift */, - E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */, - 531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */, + E12E30F4296392EC0022FAC9 /* EnumPickerView.swift */, + E1549677296CB22B00C4EF88 /* InlineEnumToggle.swift */, + E1BA6FC429D25DBD007D98DC /* LandscapeItemElement.swift */, E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */, + C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */, + E10E842B29A589860064EA49 /* NonePosterButton.swift */, E111D8F928D0400900400001 /* PagingLibraryView.swift */, E1C92617288756BD002A7A66 /* PosterButton.swift */, E1C92619288756BD002A7A66 /* PosterHStack.swift */, - E12CC1C428D12D9B00678D5D /* SeeAllPoster.swift */, + E12CC1C428D12D9B00678D5D /* SeeAllPosterButton.swift */, E1E9EFE928C6B96400CC1F8B /* ServerButton.swift */, E17885A3278105170094FBCF /* SFSymbolButton.swift */, + E12E30F0296383810022FAC9 /* SplitFormWindowView.swift */, + E187A60429AD2E25008387E6 /* StepperView.swift */, E1CCC3D128C858A50020ED54 /* UserProfileButton.swift */, ); path = Components; @@ -1302,20 +1580,11 @@ E1DD1127271E7D15005BE12F /* Objects */, E13D02842788B634000FCB04 /* Swiftfin.entitlements */, E11CEB85289984F5003E74C7 /* Extensions */, - 5377CBFA263B596B003A4E83 /* Preview Content */, E13DD3D027165886009D4DAF /* Views */, ); path = Swiftfin; sourceTree = ""; }; - 5377CBFA263B596B003A4E83 /* Preview Content */ = { - isa = PBXGroup; - children = ( - 5377CBFB263B596B003A4E83 /* Preview Assets.xcassets */, - ); - path = "Preview Content"; - sourceTree = ""; - }; 53913BC826D323FE00EB3286 /* fr.lproj */ = { isa = PBXGroup; children = ( @@ -1494,11 +1763,16 @@ 53F866422687A45400DCD1D7 /* Components */ = { isa = PBXGroup; children = ( + E1D8429429346C6400D1041A /* BasicStepper.swift */, + E1A1528728FD229500600579 /* ChevronButton.swift */, + E133328C2953AE4B00EE76AB /* CircularProgressView.swift */, E18E01A7288746AF0022598C /* DotHStack.swift */, E1FE69AF28C2DA4A0021BC93 /* FilterDrawerHStack */, + E1921B7528E63306003A5238 /* GestureView.swift */, E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */, E13F05EF28BC9016003499D2 /* LibraryItemRow.swift */, E1D3044328D1991900587289 /* LibraryViewTypeToggle.swift */, + E14A08CC28E68729004FC984 /* MenuPosterHStack.swift */, E111D8F728D03BF900400001 /* PagingLibraryView.swift */, E18E01A5288746AF0022598C /* PillHStack.swift */, E16AA60728A364A6009A983C /* PosterButton.swift */, @@ -1506,6 +1780,9 @@ E1AA331C2782541500F6439C /* PrimaryButton.swift */, E18E01A4288746AF0022598C /* RefreshableScrollView.swift */, E1D3043428D1763100587289 /* SeeAllButton.swift */, + E1D5C39728DF914100CDBEFB /* Slider */, + E1581E26291EF59800D6C640 /* SplitContentView.swift */, + E157562F29355B7900976E1F /* UpdateView.swift */, E192607F28D28AAD002314B4 /* UserProfileButton.swift */, ); path = Components; @@ -1523,24 +1800,37 @@ 621338912660106C00A81A2A /* Extensions */ = { isa = PBXGroup; children = ( - E1E1644028BB301900323B0A /* ArrayExtensions.swift */, - E1A2C157279A7D76005EC829 /* BundleExtensions.swift */, - E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */, - 6267B3D526710B8900A7371D /* CollectionExtensions.swift */, - E173DA5126D04AAF00CC4EB7 /* ColorExtensions.swift */, - E1399473289B1EA900401ABC /* Defaults+Workaround.swift */, - E1E00A34278628A40022235B /* DoubleExtensions.swift */, - E11CEB8C28999B4A003E74C7 /* FontExtensions.swift */, - E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */, - 621338922660107500A81A2A /* StringExtensions.swift */, - E1A2C153279A7D5A005EC829 /* UIApplicationExtensions.swift */, - E13DD3C727164B1E009D4DAF /* UIDeviceExtensions.swift */, - E1937A3D288F0D3D00CB80AA /* UIScreenExtensions.swift */, - E18E0239288749540022598C /* UIScrollViewExtensions.swift */, - E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */, - 62E1DCC2273CE19800C9AE76 /* URLExtensions.swift */, + E1E1644028BB301900323B0A /* Array.swift */, + E1E6C44F29B104840064123F /* Button.swift */, + E1C8CE5A28FE512400DF5D7B /* CGPoint.swift */, + E10EAA4E277BBCC4000269ED /* CGSize.swift */, + 6267B3D526710B8900A7371D /* Collection.swift */, + E173DA5126D04AAF00CC4EB7 /* Color.swift */, + E1B490462967E2E500D3EDCE /* CoreStore.swift */, + E12F038B28F8B0B100976CC3 /* EdgeInsets.swift */, + E16DEAC128EFCF590058F196 /* EnvironmentValue.swift */, + E1B33EAF28EA890D0073B0FD /* Equatable.swift */, + E133328729538D8D00EE76AB /* Files.swift */, + E15756312935642A00976E1F /* Float.swift */, + E11CEB8C28999B4A003E74C7 /* Font.swift */, + E1E6C44A29AED2B70064123F /* HorizontalAlignment.swift */, + E139CC1E28EC83E400688DE2 /* Int.swift */, + E1AD105226D96D5F003E4A08 /* JellyfinAPI */, + E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */, + E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */, + E1B5861129E32EEF00E45D6E /* Set.swift */, + 621338922660107500A81A2A /* String.swift */, + E1A2C153279A7D5A005EC829 /* UIApplication.swift */, + E1401CB029386C9200E8B599 /* UIColor.swift */, + E13DD3C727164B1E009D4DAF /* UIDevice.swift */, + E1E0BEB629EF450B0002E8D3 /* UIGestureRecognizer.swift */, + E1937A3D288F0D3D00CB80AA /* UIScreen.swift */, + E18E0239288749540022598C /* UIScrollView.swift */, + 62E1DCC2273CE19800C9AE76 /* URL.swift */, + E1C812C4277A90B200918266 /* URLComponents.swift */, + E17AC9692954D00E003D2BC2 /* URLResponse.swift */, + E1A1528128FD126C00600579 /* VerticalAlignment.swift */, E11895A22893409D0042947B /* ViewExtensions */, - 5D160402278A41FD00D22B99 /* VLCPlayer+subtitles.swift */, ); path = Extensions; sourceTree = ""; @@ -1557,9 +1847,12 @@ isa = PBXGroup; children = ( E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */, - E1D3043B28D18CD400587289 /* CastAndCrewLibraryCoordinator.swift */, E12CC1B428D1124400678D5D /* BasicLibraryCoordinator.swift */, + E154967D296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift */, + E1D3043B28D18CD400587289 /* CastAndCrewLibraryCoordinator.swift */, 62C29EA226D1030F00C1D2E7 /* ConnectToServerCoodinator.swift */, + E17AC9702954F636003D2BC2 /* DownloadListCoordinator.swift */, + E13332902953B91000EE76AB /* DownloadTaskCoordinator.swift */, 6220D0B926D6092100B8E046 /* FilterCoordinator.swift */, 62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */, 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */, @@ -1571,6 +1864,8 @@ C4BE07782726EE82003F4AD1 /* LiveTVTabCoordinator.swift */, E193D5412719404B00900D82 /* MainCoordinator */, 62C29EA726D103D500C1D2E7 /* MediaCoordinator.swift */, + E170D106294D23BA0017224C /* MediaSourceInfoCoordinator.swift */, + E1A1528F28FD23D600600579 /* PlaybackSettingsCoordinator.swift */, E18CE0B828A2322D0092E7F1 /* QuickConnectCoordinator.swift */, 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */, E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */, @@ -1578,21 +1873,12 @@ 6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */, E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */, E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */, - E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */, + E18A8E8428D60D0000333B9A /* VideoPlayerCoordinator.swift */, + E1A1528C28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift */, ); path = Coordinators; sourceTree = ""; }; - 62EC352A26766657000E9F2D /* Singleton */ = { - isa = PBXGroup; - children = ( - 53649AB0269CFB1900A2D8B7 /* LogManager.swift */, - 62EC352E267666A5000E9F2D /* SessionManager.swift */, - E13DD3EE27178F87009D4DAF /* SwiftfinNotificationCenter.swift */, - ); - path = Singleton; - sourceTree = ""; - }; 62ECA01926FA6D6900E8EBB7 /* AppURLHandler */ = { isa = PBXGroup; children = ( @@ -1610,15 +1896,6 @@ path = Resources; sourceTree = ""; }; - E1002B692793E12E00E47059 /* Overlays */ = { - isa = PBXGroup; - children = ( - E1C812BB277A8E5D00918266 /* VLCPlayerOverlayView.swift */, - E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */, - ); - path = Overlays; - sourceTree = ""; - }; E107BB9127880A4000354E07 /* ItemViewModel */ = { isa = PBXGroup; children = ( @@ -1626,12 +1903,45 @@ 62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */, 62E632F2267D54030063E547 /* ItemViewModel.swift */, 62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */, - 62E632E8267D3FF50063E547 /* SeasonItemViewModel.swift */, 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */, ); path = ItemViewModel; sourceTree = ""; }; + E10E842829A587090064EA49 /* Components */ = { + isa = PBXGroup; + children = ( + E10E842929A587110064EA49 /* LoadingView.swift */, + ); + path = Components; + sourceTree = ""; + }; + E11245B228D97D4A00D8A977 /* Overlays */ = { + isa = PBXGroup; + children = ( + E11245B528D97EC200D8A977 /* Components */, + E139CC1C28EC836F00688DE2 /* ChapterOverlay.swift */, + E1559A75294D960C00C1FFBC /* MainOverlay.swift */, + E11245B028D919CD00D8A977 /* Overlay.swift */, + ); + path = Overlays; + sourceTree = ""; + }; + E11245B528D97EC200D8A977 /* Components */ = { + isa = PBXGroup; + children = ( + E1BDF2ED2952296000CC0294 /* ActionButtons */, + E1E9017E28DAB15F001B1594 /* BarActionButtons.swift */, + E11245B328D97D5D00D8A977 /* BottomBarView.swift */, + E1CFE27F28FA606800B7D34C /* ChapterTrack.swift */, + E1B33ECE28EB6EA90073B0FD /* OverlayMenu.swift */, + E1721FAC28FB801000762992 /* PlaybackButtons */, + E1721FAB28FB7CCA00762992 /* Timestamp */, + E11245B628D97ED200D8A977 /* TopBarView.swift */, + ); + path = Components; + sourceTree = ""; + }; E113133028BDB6D600930F75 /* NavBarDrawerButtons */ = { isa = PBXGroup; children = ( @@ -1653,9 +1963,8 @@ E11895A22893409D0042947B /* ViewExtensions */ = { isa = PBXGroup; children = ( - E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */, - E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */, - E11895A8289383BC0042947B /* ScrollViewOffsetModifier.swift */, + E170D101294CE4C10017224C /* Modifiers */, + E1E1E24C28DF8A2E000DF5FD /* PreferenceKeys.swift */, 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */, ); path = ViewExtensions; @@ -1673,19 +1982,20 @@ E11CEB85289984F5003E74C7 /* Extensions */ = { isa = PBXGroup; children = ( - E11CEB8828998522003E74C7 /* iOSViewExtensions */, + E11CEB8828998522003E74C7 /* View */, ); path = Extensions; sourceTree = ""; }; - E11CEB8828998522003E74C7 /* iOSViewExtensions */ = { + E11CEB8828998522003E74C7 /* View */ = { isa = PBXGroup; children = ( E11CEB8A28998552003E74C7 /* iOSViewExtensions.swift */, + E1CD13EE28EF364100CB46CA /* DetectOrientationModifier.swift */, E113133028BDB6D600930F75 /* NavBarDrawerButtons */, E11895B12893842D0042947B /* NavBarOffset */, ); - path = iOSViewExtensions; + path = View; sourceTree = ""; }; E11CEB9228999D8D003E74C7 /* EpisodeItemView */ = { @@ -1700,7 +2010,8 @@ E12186DF2718F2030010884C /* App */ = { isa = PBXGroup; children = ( - 535870622669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift */, + 535870622669D21600D05A09 /* SwiftfinApp.swift */, + E1388A44293F0AB1009721B1 /* PreferenceUIHosting */, ); path = App; sourceTree = ""; @@ -1708,15 +2019,14 @@ E12186E02718F23B0010884C /* Views */ = { isa = PBXGroup; children = ( - E1A2C15F279A7DCA005EC829 /* AboutAppView.swift */, E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */, - E1D3043E28D18F5700587289 /* CastAndCrewLibraryView.swift */, E12CC1C028D12B0A00678D5D /* BasicLibraryView.swift */, + E1D3043E28D18F5700587289 /* CastAndCrewLibraryView.swift */, 53ABFDEA2679753200886593 /* ConnectToServerView.swift */, + E154967B296CBB1A00C4EF88 /* FontPickerView.swift */, E1A42E4D28CBD3B200A14DCB /* HomeView */, E193D54E271942C000900D82 /* ItemView */, 53A83C32268A309300DF3D92 /* LibraryView.swift */, - C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */, C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */, C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */, C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */, @@ -1732,15 +2042,6 @@ path = Views; sourceTree = ""; }; - E126F73F278A655300A522BF /* VideoPlayerViewModel */ = { - isa = PBXGroup; - children = ( - E126F740278A656C00A522BF /* ServerStreamType.swift */, - E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */, - ); - path = VideoPlayerViewModel; - sourceTree = ""; - }; E12CC1C328D12D6300678D5D /* Components */ = { isa = PBXGroup; children = ( @@ -1753,35 +2054,47 @@ path = Components; sourceTree = ""; }; + E13332922953BA9400EE76AB /* DownloadTaskView */ = { + isa = PBXGroup; + children = ( + E13332932953BAA100EE76AB /* DownloadTaskContentView.swift */, + E133328E2953B71000EE76AB /* DownloadTaskView.swift */, + ); + path = DownloadTaskView; + sourceTree = ""; + }; + E1388A44293F0AB1009721B1 /* PreferenceUIHosting */ = { + isa = PBXGroup; + children = ( + E1388A41293F0AAD009721B1 /* PreferenceUIHostingController.swift */, + E1388A40293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift */, + ); + path = PreferenceUIHosting; + sourceTree = ""; + }; E13DD3BB27163C3E009D4DAF /* App */ = { isa = PBXGroup; children = ( E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */, - 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */, + 5377CBF4263B596A003A4E83 /* SwiftfinApp.swift */, 5D64683B277B15E4009E09AE /* PreferenceUIHosting */, ); path = App; sourceTree = ""; }; - E13DD3C0271648EC009D4DAF /* SwiftfinStore */ = { - isa = PBXGroup; - children = ( - E13DD3C127164941009D4DAF /* SwiftfinStore.swift */, - E13DD3D4271693CD009D4DAF /* SwiftfinStoreDefaults.swift */, - ); - path = SwiftfinStore; - sourceTree = ""; - }; E13DD3D027165886009D4DAF /* Views */ = { isa = PBXGroup; children = ( E18E01F3288747580022598C /* AboutAppView.swift */, + E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */, E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */, E12CC1B828D11A1D00678D5D /* BasicLibraryView.swift */, E1D3044028D1974700587289 /* CastAndCrewLibraryView */, 5338F74D263B61370014BF09 /* ConnectToServerView.swift */, + E17AC96C2954E9CA003D2BC2 /* DownloadListView.swift */, + E13332922953BA9400EE76AB /* DownloadTaskView */, E113133128BDC72000930F75 /* FilterView.swift */, - 62C83B07288C6A630004ED0C /* FontPicker.swift */, + 62C83B07288C6A630004ED0C /* FontPickerView.swift */, E168BD07289A4162001A6922 /* HomeView */, E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */, E14F7D0A26DB3714007C3AE6 /* ItemView */, @@ -1791,6 +2104,7 @@ C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */, C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */, C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */, + E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */, 6213388F265F83A900A81A2A /* MediaView.swift */, E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */, 53EE24E5265060780068F029 /* SearchView.swift */, @@ -1804,6 +2118,19 @@ path = Views; sourceTree = ""; }; + E1401CA32938123400E8B599 /* AppIcons */ = { + isa = PBXGroup; + children = ( + E1401CA12938122C00E8B599 /* AppIcons.swift */, + E1401CA82938140700E8B599 /* DarkAppIcon.swift */, + E1401CA4293813F400E8B599 /* InvertedDarkAppIcon.swift */, + E152107B2947ACA000375CC2 /* InvertedLightAppIcon.swift */, + E1401CAA2938140A00E8B599 /* LightAppIcon.swift */, + E1401CA62938140300E8B599 /* PrimaryAppIcon.swift */, + ); + path = AppIcons; + sourceTree = ""; + }; E14F7D0A26DB3714007C3AE6 /* ItemView */ = { isa = PBXGroup; children = ( @@ -1824,6 +2151,30 @@ path = CollectionItemView; sourceTree = ""; }; + E1549654296CA2EF00C4EF88 /* Services */ = { + isa = PBXGroup; + children = ( + E154965B296CA2EF00C4EF88 /* DownloadManager.swift */, + E1549655296CA2EF00C4EF88 /* DownloadTask.swift */, + E154965D296CA2EF00C4EF88 /* LogManager.swift */, + E1549657296CA2EF00C4EF88 /* NewSessionManager.swift */, + E154965A296CA2EF00C4EF88 /* PlaybackManager.swift */, + E1549656296CA2EF00C4EF88 /* SwiftfinDefaults.swift */, + E1549659296CA2EF00C4EF88 /* SwiftfinNotifications.swift */, + E1549658296CA2EF00C4EF88 /* SwiftfinStore.swift */, + ); + path = Services; + sourceTree = ""; + }; + E1559A74294D910A00C1FFBC /* Components */ = { + isa = PBXGroup; + children = ( + E170D102294CE8BF0017224C /* LoadingView.swift */, + E129429A28F4A5E300796AC6 /* PlaybackSettingsView.swift */, + ); + path = Components; + sourceTree = ""; + }; E168BD07289A4162001A6922 /* HomeView */ = { isa = PBXGroup; children = ( @@ -1846,13 +2197,36 @@ path = Components; sourceTree = ""; }; - E176DE6E278E3522001EFD8D /* EpisodesRowView */ = { + E170D101294CE4C10017224C /* Modifiers */ = { isa = PBXGroup; children = ( - E10D87DB2784EC5200BD264C /* SeriesEpisodesView.swift */, - E176DE6C278E30D2001EFD8D /* EpisodeCard.swift */, + E18E0202288749200022598C /* AttributeStyleModifier.swift */, + E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */, + E1DC9815296DD0FE00982F06 /* BlurViewModifier.swift */, + E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */, + E129428428F080B500796AC6 /* OnReceiveNotificationModifier.swift */, + E11895A8289383BC0042947B /* ScrollViewOffsetModifier.swift */, + E1BDF3182952641300CC0294 /* VisibilityModifier.swift */, ); - path = EpisodesRowView; + path = Modifiers; + sourceTree = ""; + }; + E1721FAB28FB7CCA00762992 /* Timestamp */ = { + isa = PBXGroup; + children = ( + E1721FA928FB7CAC00762992 /* CompactTimeStamp.swift */, + E129428728F0831F00796AC6 /* SplitTimestamp.swift */, + ); + path = Timestamp; + sourceTree = ""; + }; + E1721FAC28FB801000762992 /* PlaybackButtons */ = { + isa = PBXGroup; + children = ( + E1B33ED028EB860A0073B0FD /* LargePlaybackButtons.swift */, + E1721FAD28FB801C00762992 /* SmallPlaybackButtons.swift */, + ); + path = PlaybackButtons; sourceTree = ""; }; E178859C2780F5300094FBCF /* tvOSSLider */ = { @@ -1864,17 +2238,6 @@ path = tvOSSLider; sourceTree = ""; }; - E17885A7278130690094FBCF /* Overlays */ = { - isa = PBXGroup; - children = ( - E1E5D552278419D900692DFE /* ConfirmCloseOverlay.swift */, - E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */, - C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */, - E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */, - ); - path = Overlays; - sourceTree = ""; - }; E185920B28CEF23F00326F80 /* Objects */ = { isa = PBXGroup; children = ( @@ -1883,6 +2246,30 @@ path = Objects; sourceTree = ""; }; + E18A17F3298C68BF00C22F62 /* Overlays */ = { + isa = PBXGroup; + children = ( + E18A17F4298C6A7300C22F62 /* Components */, + E1E5D552278419D900692DFE /* ConfirmCloseOverlay.swift */, + E1E6C45529B130F50064123F /* ChapterOverlay.swift */, + E18A17F1298C68BB00C22F62 /* MainOverlay.swift */, + E1E6C44D29AEE9DC0064123F /* SmallMenuOverlay.swift */, + E18A17EF298C68B700C22F62 /* Overlay.swift */, + ); + path = Overlays; + sourceTree = ""; + }; + E18A17F4298C6A7300C22F62 /* Components */ = { + isa = PBXGroup; + children = ( + E1E6C44329AECCD80064123F /* ActionButtons */, + E1E6C43A29AECBD30064123F /* BottomBarView.swift */, + E1E6C43C29AECC310064123F /* BarActionButtons.swift */, + E178859C2780F5300094FBCF /* tvOSSLider */, + ); + path = Components; + sourceTree = ""; + }; E18CE0B028A222310092E7F1 /* Components */ = { isa = PBXGroup; children = ( @@ -2003,11 +2390,14 @@ E18E01D9288747230022598C /* ActionButtonHStack.swift */, E18E01D7288747230022598C /* AttributeHStack.swift */, E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */, - E176DE6E278E3522001EFD8D /* EpisodesRowView */, + E17AC9722955007A003D2BC2 /* DownloadTaskButton.swift */, E17FB55A28C1266400311DFE /* GenresHStack.swift */, - E18E01D6288747230022598C /* ListDetailsView.swift */, + E170D104294D21FA0017224C /* MediaSourceInfoView.swift */, + E1D8424E2932F7C400D1041A /* OverviewView.swift */, E18E01D8288747230022598C /* PlayButton.swift */, + E1DA656E28E78C9900592A73 /* SeriesEpisodeSelector.swift */, E17FB55428C1250B00311DFE /* SimilarItemsHStack.swift */, + E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */, E17FB55828C125E900311DFE /* StudiosHStack.swift */, ); path = Components; @@ -2027,14 +2417,12 @@ E193D5452719418B00900D82 /* VideoPlayer */ = { isa = PBXGroup; children = ( - E13AD72D2798BC8D00FDCEE8 /* NativePlayerViewController.swift */, - C45640CF281A43EF007096DE /* LiveTVNativePlayerViewController.swift */, - E1002B692793E12E00E47059 /* Overlays */, - E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */, - E1C812B8277A8E5D00918266 /* VLCPlayerView.swift */, - C45942CC27F6994A00C54FE7 /* LiveTVPlayerView.swift */, - E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */, - C45942CA27F6984100C54FE7 /* LiveTVPlayerViewController.swift */, + E1559A74294D910A00C1FFBC /* Components */, + E11245B228D97D4A00D8A977 /* Overlays */, + E1D842162932AB8F00D1041A /* NativeVideoPlayer.swift */, + E18A8E8228D60BC400333B9A /* VideoPlayer.swift */, + E170D0E1294CC8000017224C /* VideoPlayer+Actions.swift */, + E170D0E3294CC8AB0017224C /* VideoPlayer+KeyCommands.swift */, ); path = VideoPlayer; sourceTree = ""; @@ -2073,54 +2461,83 @@ path = HomeView; sourceTree = ""; }; - E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */ = { + E1AD105226D96D5F003E4A08 /* JellyfinAPI */ = { isa = PBXGroup; children = ( - E148128428C15472003B8787 /* APISortOrderExtensions.swift */, + E148128428C15472003B8787 /* APISortOrder.swift */, + E1AD104C26D96CE3003E4A08 /* BaseItemDto.swift */, E1937A3A288E54AD00CB80AA /* BaseItemDto+Images.swift */, E18845F426DD631E00B0C5B7 /* BaseItemDto+Poster.swift */, - E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */, - E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */, + E18A8E7C28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift */, + 5364F454266CA0DC0026ECBA /* BaseItemPerson.swift */, E118959C289312020042947B /* BaseItemPerson+Poster.swift */, - 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */, - E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */, + E1002B632793CEE700E47059 /* ChapterInfo.swift */, + E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */, + E1D842902933F87500D1041A /* ItemFields.swift */, + E148128728C154BF003B8787 /* ItemFilter.swift */, E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */, - E122A9122788EAAD0060FA63 /* MediaStreamExtension.swift */, - E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */, - E148128728C154BF003B8787 /* ItemFilterExtensions.swift */, - E184C15F288C5C08000B25BA /* RequestBuilderExtensions.swift */, - E18CE0B128A229E70092E7F1 /* UserDtoExtensions.swift */, + E1D8428E2933F2D900D1041A /* MediaSourceInfo.swift */, + E18A8E7F28D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift */, + E122A9122788EAAD0060FA63 /* MediaStream.swift */, + E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */, + E18CE0B128A229E70092E7F1 /* UserDto.swift */, + E12A9EF729499E0100731C3A /* JellyfinClient.swift */, ); - path = JellyfinAPIExtensions; + path = JellyfinAPI; sourceTree = ""; }; E1AD105326D96F5A003E4A08 /* Views */ = { isa = PBXGroup; children = ( - E18E0200288749200022598C /* AppIcon.swift */, - E18E0201288749200022598C /* AttributeFillView.swift */, - E18E0202288749200022598C /* AttributeOutlineView.swift */, E18E0203288749200022598C /* BlurView.swift */, E18E01FF288749200022598C /* Divider.swift */, 531AC8BE26750DE20091C7EB /* ImageView.swift */, E1047E2227E5880000CB0D4A /* InitialFailureView.swift */, 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */, + E1DC983F296DEBA500982F06 /* PosterIndicators */, E1FE69A628C29B720021BC93 /* ProgressBar.swift */, + E187A60129AB28F0008387E6 /* RotateContentView.swift */, E1E1643D28BB074000323B0A /* SelectorView.swift */, + E1356E0129A7309D00382563 /* SeparatorHStack.swift */, + E1A1528928FD22F600600579 /* TextPairView.swift */, E1EBCB41278BD174009FE6E9 /* TruncatedTextView.swift */, + E1B5784028F8AFCB00D42911 /* Wrapped View.swift */, ); path = Views; sourceTree = ""; }; - E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */ = { + E1BDF2E7295148F400CC0294 /* VideoPlayerSettingsView */ = { isa = PBXGroup; children = ( - C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */, - 6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */, - C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */, - E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */, + E1BDF2EA2951491600CC0294 /* Components */, + E1E5D5472783CCF900692DFE /* VideoPlayerSettingsView.swift */, ); - path = VideoPlayerCoordinator; + path = VideoPlayerSettingsView; + sourceTree = ""; + }; + E1BDF2EA2951491600CC0294 /* Components */ = { + isa = PBXGroup; + children = ( + E1BDF2E82951490400CC0294 /* ActionButtonSelectorView.swift */, + ); + path = Components; + sourceTree = ""; + }; + E1BDF2ED2952296000CC0294 /* ActionButtons */ = { + isa = PBXGroup; + children = ( + E1E6C43E29AECC5A0064123F /* ActionButtons.swift */, + E1BDF31629525F0400CC0294 /* AdvancedActionButton.swift */, + E1BDF2EB2952290200CC0294 /* AspectFillActionButton.swift */, + E1BDF2EE29522A5900CC0294 /* AudioActionButton.swift */, + E1BDF2F029524AB700CC0294 /* AutoPlayActionButton.swift */, + E1BDF2F229524C3B00CC0294 /* ChaptersActionButton.swift */, + E1BDF2F629524ECD00CC0294 /* PlaybackSpeedActionButton.swift */, + E1BDF2F429524E6400CC0294 /* PlayNextItemActionButton.swift */, + E1BDF2F829524FDA00CC0294 /* PlayPreviousItemActionButton.swift */, + E1BDF2FA2952502300CC0294 /* SubtitleActionButton.swift */, + ); + path = ActionButtons; sourceTree = ""; }; E1C925FA2887565C002A7A66 /* MovieItemView */ = { @@ -2147,8 +2564,9 @@ E1C926032887565C002A7A66 /* ActionButtonHStack.swift */, E1C926012887565C002A7A66 /* AttributeHStack.swift */, E185920528CDAA6400326F80 /* CastAndCrewHStack.swift */, - E185920728CDAAA200326F80 /* SimilarItemsHStack.swift */, E1C926022887565C002A7A66 /* PlayButton.swift */, + E185920728CDAAA200326F80 /* SimilarItemsHStack.swift */, + E169C7B7296D2E8200AE25F9 /* SpecialFeaturesHStack.swift */, ); path = Components; sourceTree = ""; @@ -2166,8 +2584,8 @@ E1C926062887565C002A7A66 /* Components */ = { isa = PBXGroup; children = ( - E1C926072887565C002A7A66 /* SeriesEpisodesView.swift */, E1C926092887565C002A7A66 /* EpisodeCard.swift */, + E1C926072887565C002A7A66 /* SeriesEpisodeSelector.swift */, ); path = Components; sourceTree = ""; @@ -2181,9 +2599,31 @@ path = CastAndCrewLibraryView; sourceTree = ""; }; + E1D5C39728DF914100CDBEFB /* Slider */ = { + isa = PBXGroup; + children = ( + E1D5C39828DF914700CDBEFB /* CapsuleSlider.swift */, + E1D5C39528DF90C100CDBEFB /* Slider.swift */, + E1D5C39A28DF993400CDBEFB /* ThumbSlider.swift */, + ); + path = Slider; + sourceTree = ""; + }; + E1DC983F296DEBA500982F06 /* PosterIndicators */ = { + isa = PBXGroup; + children = ( + E1DC983C296DEB9B00982F06 /* UnwatchedIndicator.swift */, + E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */, + E1DC9840296DEBD800982F06 /* WatchedIndicator.swift */, + E1DC9843296DECB600982F06 /* ProgressIndicator.swift */, + ); + path = PosterIndicators; + sourceTree = ""; + }; E1DD1127271E7D15005BE12F /* Objects */ = { isa = PBXGroup; children = ( + E1FBDB6529D0F336003DD5E2 /* KeyCommandAction.swift */, E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */, ); path = Objects; @@ -2193,10 +2633,14 @@ isa = PBXGroup; children = ( E1CEFBF427914C7700F60429 /* CustomizeViewsSettings.swift */, + E175AFF2299AC117004DCF52 /* DebugSettingsView.swift */, E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */, - E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */, - 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */, + E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */, + E16AF11B292C98A7001422A8 /* GestureSettingsView.swift */, + E15756332936851D00976E1F /* NativeVideoPlayerSettingsView.swift */, 6334175A287DDFB9000603CE /* QuickConnectSettingsView.swift */, + 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */, + E1BDF2E7295148F400CC0294 /* VideoPlayerSettingsView */, ); path = SettingsView; sourceTree = ""; @@ -2206,13 +2650,25 @@ children = ( E1CEFBF627914E6400F60429 /* CustomizeViewsSettings.swift */, E1E5D5502783E67700692DFE /* ExperimentalSettingsView.swift */, - E1BDE358278E9ED2004E4022 /* MissingItemsSettingsView.swift */, - E1E5D54E2783E67100692DFE /* OverlaySettingsView.swift */, + E104C872296E0D0A00C1C3F9 /* IndicatorSettingsView.swift */, 5398514426B64DA100101B49 /* SettingsView.swift */, + E1549679296CB4B000C4EF88 /* VideoPlayerSettingsView.swift */, ); path = SettingsView; sourceTree = ""; }; + E1E6C44329AECCD80064123F /* ActionButtons */ = { + isa = PBXGroup; + children = ( + E1E6C44129AECCD50064123F /* ActionButtons.swift */, + E1E6C44829AECEE70064123F /* AutoPlayActionButton.swift */, + E1E6C45229B1304E0064123F /* ChaptersActionButton.swift */, + E1E6C44429AECCF20064123F /* PlayNextItemActionButton.swift */, + E1E6C44629AECD5D0064123F /* PlayPreviousItemActionButton.swift */, + ); + path = ActionButtons; + sourceTree = ""; + }; E1FA891C289A302600176FEB /* CollectionItemView */ = { isa = PBXGroup; children = ( @@ -2260,20 +2716,24 @@ name = "Swiftfin tvOS"; packageProductDependencies = ( 535870902669D7A800D05A09 /* Introspect */, - 53ABFDEC26799D7700886593 /* ActivityIndicator */, 6220D0C826D63F3700B8E046 /* Stinsen */, E13DD3CC27164CA7009D4DAF /* CoreStore */, E12186DD2718F1C50010884C /* Defaults */, E1002B6A2793E36600E47059 /* Algorithms */, - E1347DB5279E3CA500BC6161 /* Puppy */, - C409CE9B284EA6EA00CABC12 /* SwiftUICollection */, E13AF3B528A0C598009093AB /* Nuke */, E13AF3B728A0C598009093AB /* NukeExtensions */, E13AF3B928A0C598009093AB /* NukeUI */, E13AF3BB28A0C59E009093AB /* BlurHashKit */, - E1734D7D28B9578100C66367 /* CollectionView */, E192608728D2E5F0002314B4 /* Factory */, - E15B235529B7029E00DAFDDD /* JellyfinAPI */, + E1575E55293E7650001665B1 /* VLCUI */, + E1575E57293E7685001665B1 /* Files */, + E1388A45293F0ABA009721B1 /* SwizzleSwift */, + E1B5F7A429577BB8004B26CF /* JellyfinAPI */, + E1B5F7A629577BCE004B26CF /* Pulse */, + E1B5F7A829577BCE004B26CF /* PulseLogHandler */, + E1B5F7AA29577BCE004B26CF /* PulseUI */, + E1B5F7AC29577BDD004B26CF /* OrderedCollections */, + E1DC981D296DD91900982F06 /* CollectionView */, ); productName = "JellyfinPlayer tvOS"; productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */; @@ -2288,7 +2748,7 @@ 5377CBEE263B596A003A4E83 /* Frameworks */, 5377CBEF263B596A003A4E83 /* Resources */, 5302F8322658B74800647A2E /* CopyFiles */, - 628B95312670CABE0091AF3B /* Embed App Extensions */, + 628B95312670CABE0091AF3B /* Embed Foundation Extensions */, 62666DF927E5012C00EC0ECD /* Embed Frameworks */, ); buildRules = ( @@ -2298,22 +2758,24 @@ name = "Swiftfin iOS"; packageProductDependencies = ( 53352570265EA0A0006CCA86 /* Introspect */, - 625CB5792678C4A400530A6E /* ActivityIndicator */, 62C29E9B26D0FE4200C1D2E7 /* Stinsen */, E13DD3C52716499E009D4DAF /* CoreStore */, E13DD3D227168E65009D4DAF /* Defaults */, - E1B6DCE9271A23880015B715 /* SwiftyJSON */, - E10EAA4C277BB716000269ED /* Sliders */, E1002B672793CFBA00E47059 /* Algorithms */, 62666E3827E502CE00EC0ECD /* SwizzleSwift */, - E1101176281B1E8A006A3584 /* Puppy */, - C409CE9D285044C800CABC12 /* SwiftUICollection */, E19E6E0428A0B958005C10C8 /* Nuke */, E19E6E0628A0B958005C10C8 /* NukeUI */, E19E6E0928A0BEFF005C10C8 /* BlurHashKit */, - E1734D7B28B9577700C66367 /* CollectionView */, E192608228D2D0DB002314B4 /* Factory */, - E15B235329B7025400DAFDDD /* JellyfinAPI */, + E18A8E7928D5FEDF00333B9A /* VLCUI */, + E1575E3B293C6B15001665B1 /* Files */, + E15210532946DF1B00375CC2 /* Pulse */, + E15210552946DF1B00375CC2 /* PulseLogHandler */, + E15210572946DF1B00375CC2 /* PulseUI */, + E12B930C2948369F00CE0BD9 /* JellyfinAPI */, + E19DDEC62948EF9900954E10 /* OrderedCollections */, + E1DC9813296DC06200982F06 /* PulseLogHandler */, + E1DC9820296DDBE600982F06 /* CollectionView */, ); productName = JellyfinPlayer; productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */; @@ -2329,7 +2791,7 @@ New, ); LastSwiftUpdateCheck = 1250; - LastUpgradeCheck = 1320; + LastUpgradeCheck = 1400; TargetAttributes = { 5358705F2669D21600D05A09 = { CreatedOnToolsVersion = 12.5; @@ -2365,21 +2827,21 @@ mainGroup = 5377CBE8263B596A003A4E83; packageReferences = ( 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, - 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */, 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */, E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */, E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */, - E1C16B89271A2180009A5D25 /* XCRemoteSwiftPackageReference "SwiftyJSON" */, - E10EAA4B277BB716000269ED /* XCRemoteSwiftPackageReference "swiftui-sliders" */, E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */, 62666E3727E502CE00EC0ECD /* XCRemoteSwiftPackageReference "SwizzleSwift" */, - E1101175281B1E8A006A3584 /* XCRemoteSwiftPackageReference "Puppy" */, - C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */, E19E6E0328A0B958005C10C8 /* XCRemoteSwiftPackageReference "Nuke" */, E19E6E0828A0BEFF005C10C8 /* XCRemoteSwiftPackageReference "BlurHashKit" */, - E1734D7A28B9577700C66367 /* XCRemoteSwiftPackageReference "CollectionView" */, E192608128D2D0DB002314B4 /* XCRemoteSwiftPackageReference "Factory" */, - E15B235229B7025400DAFDDD /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, + E18A8E7828D5FEDF00333B9A /* XCRemoteSwiftPackageReference "VLCUI" */, + E1575E3A293C6B15001665B1 /* XCRemoteSwiftPackageReference "Files" */, + E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */, + E12B930B2948329D00CE0BD9 /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, + E19DDEC52948EF9900954E10 /* XCRemoteSwiftPackageReference "swift-collections" */, + E1DC9812296DC06200982F06 /* XCRemoteSwiftPackageReference "PulseLogHandler" */, + E1DC981F296DDBE600982F06 /* XCRemoteSwiftPackageReference "CollectionView" */, ); productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; projectDirPath = ""; @@ -2406,11 +2868,9 @@ 534D4FF726A7D7CC000A7A48 /* Localizable.strings in Resources */, 53913BF326D323FE00EB3286 /* Localizable.strings in Resources */, 53913BF626D323FE00EB3286 /* Localizable.strings in Resources */, - 5358706A2669D21700D05A09 /* Preview Assets.xcassets in Resources */, 53913C0526D323FE00EB3286 /* Localizable.strings in Resources */, 53913BFF26D323FE00EB3286 /* Localizable.strings in Resources */, 53913C0E26D323FE00EB3286 /* Localizable.strings in Resources */, - C4464953281616AE00DDB461 /* Assets.xcassets in Resources */, 53913BF026D323FE00EB3286 /* Localizable.strings in Resources */, 53913C0826D323FE00EB3286 /* Localizable.strings in Resources */, 53913C1126D323FE00EB3286 /* Localizable.strings in Resources */, @@ -2430,7 +2890,6 @@ 53913C0126D323FE00EB3286 /* Localizable.strings in Resources */, 53913C1326D323FE00EB3286 /* Localizable.strings in Resources */, 53913BF826D323FE00EB3286 /* Localizable.strings in Resources */, - 5377CBFC263B596B003A4E83 /* Preview Assets.xcassets in Resources */, 534D4FF626A7D7CC000A7A48 /* Localizable.strings in Resources */, 53913BF226D323FE00EB3286 /* Localizable.strings in Resources */, 53913BF526D323FE00EB3286 /* Localizable.strings in Resources */, @@ -2450,6 +2909,7 @@ /* Begin PBXShellScriptBuildPhase section */ 6286F09E271C093000C40ED5 /* R.swift */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -2468,6 +2928,7 @@ }; 6286F0A3271C0ABA00C40ED5 /* R.swift */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -2495,204 +2956,265 @@ E1CCC3D228C858A50020ED54 /* UserProfileButton.swift in Sources */, C4BE07742725EB66003F4AD1 /* LiveTVProgramsView.swift in Sources */, E18845F626DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */, + E1B490452967E26300D3EDCE /* PersistentLogHandler.swift in Sources */, E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */, - C4B9B91427E1921B0063535C /* LiveTVNativeVideoPlayerView.swift in Sources */, E18E021E2887492B0022598C /* Divider.swift in Sources */, + E1DC983E296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */, E107BB9427880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, - C4534985279A40C60045F1E2 /* LiveTVVideoPlayerView.swift in Sources */, - E1A2C15A279A7D76005EC829 /* BundleExtensions.swift in Sources */, + E1575E7E293E77B5001665B1 /* ItemFilters.swift in Sources */, 53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */, - E13DD3F027178F87009D4DAF /* SwiftfinNotificationCenter.swift in Sources */, + E1575E99293E7B1E001665B1 /* UIColor.swift in Sources */, + E1575E92293E7B1E001665B1 /* CGSize.swift in Sources */, + E1575E96293E7B1E001665B1 /* UIScrollView.swift in Sources */, E1A16CA1288A7CFD00EA4679 /* AboutViewCard.swift in Sources */, - E1937A3F288F0D3D00CB80AA /* UIScreenExtensions.swift in Sources */, + E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */, + E1575E6F293E77B5001665B1 /* GestureAction.swift in Sources */, + E18A8E7E28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, + E1E6C43B29AECBD30064123F /* BottomBarView.swift in Sources */, + E152107D2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */, + E1549663296CA2EF00C4EF88 /* NewSessionManager.swift in Sources */, 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */, - E17FB55328C119D400311DFE /* Displayable.swift in Sources */, E1E1643E28BB074000323B0A /* SelectorView.swift in Sources */, + E1A1529128FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */, + E187A60529AD2E25008387E6 /* StepperView.swift in Sources */, E11D224327378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */, - 5D32EA12278C95E30020E292 /* VLCPlayer+subtitles.swift in Sources */, + E1575E71293E77B5001665B1 /* RepeatingTimer.swift in Sources */, E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */, C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */, E1D3043F28D18F5700587289 /* CastAndCrewLibraryView.swift in Sources */, + E1575E63293E77B5001665B1 /* EnumPicker.swift in Sources */, E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */, + E1E0BEB829EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */, E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */, E1C926162887565C002A7A66 /* SeriesItemView.swift in Sources */, + E1575E6A293E77B5001665B1 /* RoundedCorner.swift in Sources */, + E1575EA1293E7B1E001665B1 /* String.swift in Sources */, + E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */, E1EBCB4A278BE443009FE6E9 /* ItemOverviewCoordinator.swift in Sources */, + E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */, + E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */, C4BE07772725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */, E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */, E1C9260C2887565C002A7A66 /* MovieItemContentView.swift in Sources */, E1C9260B2887565C002A7A66 /* MovieItemView.swift in Sources */, + E1E6C45629B130F50064123F /* ChapterOverlay.swift in Sources */, E12CC1C128D12B0A00678D5D /* BasicLibraryView.swift in Sources */, - E1E9EFEB28C7EA2C00CC1F8B /* UserDtoExtensions.swift in Sources */, - 62E632EA267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, + E1549665296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */, + E1E9EFEB28C7EA2C00CC1F8B /* UserDto.swift in Sources */, E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */, + E1575EA6293E7D40001665B1 /* VideoPlayer.swift in Sources */, + E1BA6FC529D25DBD007D98DC /* LandscapeItemElement.swift in Sources */, E185920628CDAA6400326F80 /* CastAndCrewHStack.swift in Sources */, - E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */, E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */, 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */, - E122A9142788EAAD0060FA63 /* MediaStreamExtension.swift in Sources */, + E122A9142788EAAD0060FA63 /* MediaStream.swift in Sources */, + E1575E74293E77B5001665B1 /* PanDirectionGestureRecognizer.swift in Sources */, + E1575E85293E7A00001665B1 /* DarkAppIcon.swift in Sources */, E178859E2780F53B0094FBCF /* SliderView.swift in Sources */, - E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */, - E12CC1C528D12D9B00678D5D /* SeeAllPoster.swift in Sources */, - E1002B652793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */, - E12B835F28C07D8500878399 /* LibraryParent.swift in Sources */, + E1575E95293E7B1E001665B1 /* Font.swift in Sources */, + E11E374D293E7EC9009EF240 /* ItemFields.swift in Sources */, + E1575E6E293E77B5001665B1 /* SpecialFeatureType.swift in Sources */, + E12CC1C528D12D9B00678D5D /* SeeAllPosterButton.swift in Sources */, + E18A8E8128D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */, + E1002B652793CEE800E47059 /* ChapterInfo.swift in Sources */, E111D8FA28D0400900400001 /* PagingLibraryView.swift in Sources */, - E18E021A2887492B0022598C /* AppIcon.swift in Sources */, - E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, - E126F742278A656C00A522BF /* ServerStreamType.swift in Sources */, E1D3043328D175CE00587289 /* StaticLibraryViewModel.swift in Sources */, + E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */, + E1549661296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */, + E1575E98293E7B1E001665B1 /* UIApplication.swift in Sources */, E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */, E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, - 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */, E1C9261C288756BD002A7A66 /* PosterHStack.swift in Sources */, - C453497F279A2DA50045F1E2 /* LiveTVPlayerViewController.swift in Sources */, + E1722DB229491C3900CC0239 /* ImageBlurHashes.swift in Sources */, + E12E30F329638B140022FAC9 /* ChevronButton.swift in Sources */, C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */, E12CC1BC28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */, + E1575E9E293E7B1E001665B1 /* Equatable.swift in Sources */, E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */, + E1575E5F293E77B5001665B1 /* StreamType.swift in Sources */, + E1388A42293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift in Sources */, E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */, + E1575E93293E7B1E001665B1 /* Float.swift in Sources */, + E1B5784228F8AFCB00D42911 /* Wrapped View.swift in Sources */, E11895AA289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, + E1575E76293E77B5001665B1 /* VideoPlayerType.swift in Sources */, + E17AC96B2954D00E003D2BC2 /* URLResponse.swift in Sources */, E1EF473A289A0F610034046B /* TruncatedTextView.swift in Sources */, E1C926112887565C002A7A66 /* ActionButtonHStack.swift in Sources */, E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */, E18E02252887492B0022598C /* PlainNavigationLinkButton.swift in Sources */, - 531690F7267ACC00005D8AB9 /* LandscapeItemElement.swift in Sources */, 62E632E1267D30CA0063E547 /* LibraryViewModel.swift in Sources */, - E1C925F528875037002A7A66 /* ItemViewType.swift in Sources */, - 535870A82669D8AE00D05A09 /* StringExtensions.swift in Sources */, 53A83C33268A309300DF3D92 /* LibraryView.swift in Sources */, 62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 5398514526B64DA100101B49 /* SettingsView.swift in Sources */, E193D54B271941D300900D82 /* ServerListView.swift in Sources */, + E1575E91293E7B1E001665B1 /* URL.swift in Sources */, 53ABFDE6267974EF00886593 /* SettingsViewModel.swift in Sources */, E111D8F628D03B7500400001 /* PagingLibraryViewModel.swift in Sources */, + E1575E87293E7A00001665B1 /* InvertedDarkAppIcon.swift in Sources */, C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */, 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */, - 6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */, 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, + E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */, + E1575E6B293E77B5001665B1 /* Displayable.swift in Sources */, + E1575E80293E77CF001665B1 /* VideoPlayerViewModel.swift in Sources */, E1C926122887565C002A7A66 /* SeriesItemContentView.swift in Sources */, E193D53727193F8700900D82 /* MediaCoordinator.swift in Sources */, - E18E023C288749540022598C /* UIScrollViewExtensions.swift in Sources */, E1A42E4C28CBD39300A14DCB /* HomeContentView.swift in Sources */, - C4534983279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift in Sources */, E12CC1CB28D1333400678D5D /* CinematicResumeItemView.swift in Sources */, + E1575E9C293E7B1E001665B1 /* Collection.swift in Sources */, E1C9260F2887565C002A7A66 /* AttributeHStack.swift in Sources */, E11CEB9428999D9E003E74C7 /* EpisodeItemContentView.swift in Sources */, - E17885A02780F55C0094FBCF /* tvOSVLCOverlay.swift in Sources */, - E148128328C1443D003B8787 /* NameGUIDPairExtensions.swift in Sources */, + E1B5F7AE29577CC7004B26CF /* VisibilityModifier.swift in Sources */, + E12A9EF929499E0100731C3A /* JellyfinClient.swift in Sources */, + E148128328C1443D003B8787 /* NameGuidPair.swift in Sources */, E185920828CDAAA200326F80 /* SimilarItemsHStack.swift in Sources */, - E1BDE359278E9ED2004E4022 /* MissingItemsSettingsView.swift in Sources */, + E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */, + E1575E5C293E77B5001665B1 /* PlaybackSpeed.swift in Sources */, C4BE07892728448B003F4AD1 /* LiveTVChannelsCoordinator.swift in Sources */, + E1DC9842296DEBD800982F06 /* WatchedIndicator.swift in Sources */, + E1575E6C293E77B5001665B1 /* SliderType.swift in Sources */, + E10E842A29A587110064EA49 /* LoadingView.swift in Sources */, E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */, C4BE078C272844AF003F4AD1 /* LiveTVChannelsView.swift in Sources */, - E1D4BF852719D25A00A11E64 /* TrackLanguage.swift in Sources */, - E148128928C154BF003B8787 /* ItemFilterExtensions.swift in Sources */, + E148128928C154BF003B8787 /* ItemFilter.swift in Sources */, + E154966F296CA2EF00C4EF88 /* LogManager.swift in Sources */, + E1575E75293E77B5001665B1 /* LibraryViewType.swift in Sources */, E193D53427193F7F00900D82 /* HomeCoordinator.swift in Sources */, E193D5502719430400900D82 /* ServerDetailView.swift in Sources */, - E1399475289B1EA900401ABC /* Defaults+Workaround.swift in Sources */, + E12E30F1296383810022FAC9 /* SplitFormWindowView.swift in Sources */, + E1575E7B293E77B5001665B1 /* HTTPScheme.swift in Sources */, + E1356E0429A731EB00382563 /* SeparatorHStack.swift in Sources */, + E1575E69293E77B5001665B1 /* SortBy.swift in Sources */, + E1B490482967E2E500D3EDCE /* CoreStore.swift in Sources */, + E1DC9845296DECB600982F06 /* ProgressIndicator.swift in Sources */, + E1DA656A28E78B5900592A73 /* SpecialFeaturesViewModel.swift in Sources */, E1C925F928875647002A7A66 /* LatestInLibraryView.swift in Sources */, E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, - E18E02202887492B0022598C /* AttributeFillView.swift in Sources */, E12CC1C928D132B800678D5D /* RecentlyAddedView.swift in Sources */, E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, + E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */, E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */, E154677A289AF48200087E35 /* CollectionItemContentView.swift in Sources */, - E148128C28C15526003B8787 /* SortBy.swift in Sources */, - E1C812D1277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift in Sources */, - E1A2C156279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */, E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */, C4BE07862728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */, - E1AD104E26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */, + E1AD104E26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */, 62E632DD267D2E130063E547 /* SearchViewModel.swift in Sources */, - 5D1603FD278A40DB00D22B99 /* SubtitleSize.swift in Sources */, - 62E1DCC4273CE19800C9AE76 /* URLExtensions.swift in Sources */, - E10EAA54277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, + E1575EA2293E7B1E001665B1 /* Color.swift in Sources */, + E1575E77293E77B5001665B1 /* MenuPosterHStackModel.swift in Sources */, + E1DC9817296DD0FE00982F06 /* BlurViewModifier.swift in Sources */, + E12E30F5296392EC0022FAC9 /* EnumPickerView.swift in Sources */, + E1575E72293E77B5001665B1 /* Utilities.swift in Sources */, + E1575E84293E7A00001665B1 /* PrimaryAppIcon.swift in Sources */, + E1E6C45129B104850064123F /* Button.swift in Sources */, + E1DC981A296DD1CD00982F06 /* CinematicBackgroundView.swift in Sources */, + E1A1528B28FD22F600600579 /* TextPairView.swift in Sources */, 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */, - E1CCF12F28ABF989006CAC9E /* PosterType.swift in Sources */, + E1575E66293E77B5001665B1 /* Poster.swift in Sources */, E18E021F2887492B0022598C /* InitialFailureView.swift in Sources */, - E1D4BF882719D27100A11E64 /* Bitrates.swift in Sources */, E1D3043D28D18CD400587289 /* CastAndCrewLibraryCoordinator.swift in Sources */, + E1575E8C293E7B1E001665B1 /* UIScreen.swift in Sources */, + E1575E88293E7A00001665B1 /* LightAppIcon.swift in Sources */, + E1549678296CB22B00C4EF88 /* InlineEnumToggle.swift in Sources */, E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */, E11CEB8928998549003E74C7 /* BottomEdgeGradientModifier.swift in Sources */, + E129428628F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, 53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */, - C4534981279A3F140045F1E2 /* tvOSLiveTVOverlay.swift in Sources */, 62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, - 53649AB2269D019100A2D8B7 /* LogManager.swift in Sources */, + E1575E9F293E7B1E001665B1 /* Int.swift in Sources */, + E1D9F475296E86D400129AF3 /* NativeVideoPlayer.swift in Sources */, + E1575E7D293E77B5001665B1 /* PosterType.swift in Sources */, E1E5D553278419D900692DFE /* ConfirmCloseOverlay.swift in Sources */, - E13DD3D6271693CD009D4DAF /* SwiftfinStoreDefaults.swift in Sources */, + E18A17F2298C68BB00C22F62 /* MainOverlay.swift in Sources */, + E1E6C44B29AED2B70064123F /* HorizontalAlignment.swift in Sources */, E193D549271941CC00900D82 /* UserSignInView.swift in Sources */, 53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */, - E184C161288C5C08000B25BA /* RequestBuilderExtensions.swift in Sources */, - E19169CF272514760085832A /* HTTPScheme.swift in Sources */, - E148128628C15475003B8787 /* APISortOrderExtensions.swift in Sources */, - E1E1644528BC60C600323B0A /* MediaLibraryItem.swift in Sources */, - E13849452780370B0024FB48 /* PlaybackSpeed.swift in Sources */, - E1C812CD277AE40A00918266 /* VideoPlayerViewModel.swift in Sources */, - C45B29BB26FAC5B600CEF5E0 /* ColorExtensions.swift in Sources */, - E1D4BF822719D22800A11E64 /* AppAppearance.swift in Sources */, - E1C926132887565C002A7A66 /* SeriesEpisodesView.swift in Sources */, + E148128628C15475003B8787 /* APISortOrder.swift in Sources */, + E1E1E24E28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */, + E1575E9B293E7B1E001665B1 /* EnvironmentValue.swift in Sources */, + E133328929538D8D00EE76AB /* Files.swift in Sources */, + E154967A296CB4B000C4EF88 /* VideoPlayerSettingsView.swift in Sources */, + E1575EA0293E7B1E001665B1 /* CGPoint.swift in Sources */, + E1C926132887565C002A7A66 /* SeriesEpisodeSelector.swift in Sources */, E12CC1CD28D135C700678D5D /* NextUpView.swift in Sources */, - E13AD7302798C60F00FDCEE8 /* NativePlayerViewController.swift in Sources */, E18E02232887492B0022598C /* ImageView.swift in Sources */, - E1E5D54F2783E67100692DFE /* OverlaySettingsView.swift in Sources */, - 09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */, + E1575E7F293E77B5001665B1 /* AppAppearance.swift in Sources */, + E1575E5D293E77B5001665B1 /* ItemViewType.swift in Sources */, + E1575E73293E77B5001665B1 /* SelectorType.swift in Sources */, E12CC1AF28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */, - E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */, E12CC1B628D1124400678D5D /* BasicLibraryCoordinator.swift in Sources */, - E1E00A36278628A40022235B /* DoubleExtensions.swift in Sources */, + E1575E7A293E77B5001665B1 /* TimeStampType.swift in Sources */, + E11E374E293E7F08009EF240 /* MediaSourceInfo.swift in Sources */, E1E1643A28BAC2EF00323B0A /* SearchView.swift in Sources */, - E1FA2F7427818A8800B4C270 /* SmallMenuOverlay.swift in Sources */, + E1388A43293F0AAD009721B1 /* PreferenceUIHostingController.swift in Sources */, E12CC1C728D12FD600678D5D /* CinematicRecentlyAddedView.swift in Sources */, - E1E1644228BB301900323B0A /* ArrayExtensions.swift in Sources */, + E1DC9848296DEFF500982F06 /* FavoriteIndicator.swift in Sources */, E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */, E1CEFBF727914E6400F60429 /* CustomizeViewsSettings.swift in Sources */, - E18CE0B528A22EDD0092E7F1 /* RepeatingTimer.swift in Sources */, - E13DD3C927164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */, + E154967C296CBB1A00C4EF88 /* FontPickerView.swift in Sources */, E193D53A27193F9000900D82 /* ServerListCoordinator.swift in Sources */, 6220D0AE26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, + E1575E86293E7A00001665B1 /* AppIcons.swift in Sources */, E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, E185920A28CEF23A00326F80 /* FocusGuide.swift in Sources */, - 5321753E2671DE9C005491E6 /* ItemFilters.swift in Sources */, - E1C812CA277AE40900918266 /* PlayerOverlayDelegate.swift in Sources */, - E1AA33202782639D00F6439C /* OverlayType.swift in Sources */, C4BE07722725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift in Sources */, - E1F0204F26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */, + E1575E68293E77B5001665B1 /* LibraryParent.swift in Sources */, E1C9260D2887565C002A7A66 /* CinematicScrollView.swift in Sources */, + E1575E90293E7B1E001665B1 /* EdgeInsets.swift in Sources */, + E1E6C43D29AECC310064123F /* BarActionButtons.swift in Sources */, + E1E6C44529AECCF20064123F /* PlayNextItemActionButton.swift in Sources */, 6264E88D273850380081A12A /* Strings.swift in Sources */, E1C926102887565C002A7A66 /* PlayButton.swift in Sources */, + E1575E67293E77B5001665B1 /* OverlayType.swift in Sources */, E1E9EFEA28C6B96500CC1F8B /* ServerButton.swift in Sources */, + E1575E65293E77B5001665B1 /* VideoPlayerJumpLength.swift in Sources */, + E169C7B8296D2E8200AE25F9 /* SpecialFeaturesHStack.swift in Sources */, E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */, + E1B5861329E32EEF00E45D6E /* Set.swift in Sources */, C4E5081B2703F82A0045C9AB /* MediaView.swift in Sources */, E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */, E113133B28BEB71D00930F75 /* FilterViewModel.swift in Sources */, - E13F05ED28BC9000003499D2 /* LibraryViewType.swift in Sources */, + E1575E70293E77B5001665B1 /* TextPair.swift in Sources */, + E1575E79293E77B5001665B1 /* DeviceProfileBuilder.swift in Sources */, E18E021C2887492B0022598C /* BlurView.swift in Sources */, - E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */, - E10D87E327852FD000BD264C /* EpisodesRowManager.swift in Sources */, - 535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */, + E1E6C44729AECD5D0064123F /* PlayPreviousItemActionButton.swift in Sources */, + E1E6C44E29AEE9DC0064123F /* SmallMenuOverlay.swift in Sources */, + E154966B296CA2EF00C4EF88 /* DownloadManager.swift in Sources */, + 535870632669D21600D05A09 /* SwiftfinApp.swift in Sources */, E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */, + E1575E9A293E7B1E001665B1 /* Array.swift in Sources */, + E1575E8D293E7B1E001665B1 /* URLComponents.swift in Sources */, + E187A60329AB28F0008387E6 /* RotateContentView.swift in Sources */, + E1575E94293E7B1E001665B1 /* VerticalAlignment.swift in Sources */, + E1575EA3293E7B1E001665B1 /* UIDevice.swift in Sources */, E193D547271941C500900D82 /* UserListView.swift in Sources */, + E1BDF2E62951475300CC0294 /* VideoPlayerActionButton.swift in Sources */, + E1E6C44929AECEE70064123F /* AutoPlayActionButton.swift in Sources */, E1C926152887565C002A7A66 /* EpisodeCard.swift in Sources */, - E1D4BF7F2719D1DD00A11E64 /* BasicAppSettingsViewModel.swift in Sources */, - E18E021D2887492B0022598C /* AttributeOutlineView.swift in Sources */, + E1A1528E28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */, + E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */, + E1549669296CA2EF00C4EF88 /* PlaybackManager.swift in Sources */, E193D53227193F7B00900D82 /* ConnectToServerCoodinator.swift in Sources */, 53ABFDE4267974EF00886593 /* MediaViewModel.swift in Sources */, - 5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, - E1937A62288F32DB00CB80AA /* Poster.swift in Sources */, + 5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */, E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */, + E1575E7C293E77B5001665B1 /* TimerProxy.swift in Sources */, E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */, - E1A2C160279A7DCA005EC829 /* AboutAppView.swift in Sources */, + E1575E6D293E77B5001665B1 /* PosterButtonType.swift in Sources */, E1FE69A828C29B720021BC93 /* ProgressBar.swift in Sources */, E1A16C9D2889AF1E00EA4679 /* AboutView.swift in Sources */, - E11CEB8E28999B4A003E74C7 /* FontExtensions.swift in Sources */, C4BE077A2726EE82003F4AD1 /* LiveTVTabCoordinator.swift in Sources */, - E13DD3C327164941009D4DAF /* SwiftfinStore.swift in Sources */, - E17FB55028C1197700311DFE /* SelectorType.swift in Sources */, - 09389CC826819B4600AE350E /* VideoPlayerModel.swift in Sources */, E193D553271943D500900D82 /* tvOSMainTabCoordinator.swift in Sources */, + E1575E83293E784A001665B1 /* MediaItemViewModel.swift in Sources */, + E174121029AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */, + E154965F296CA2EF00C4EF88 /* DownloadTask.swift in Sources */, + E154967E296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift in Sources */, E1C9261B288756BD002A7A66 /* DotHStack.swift in Sources */, + E104C873296E0D0A00C1C3F9 /* IndicatorSettingsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2700,237 +3222,333 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E11245B428D97D5D00D8A977 /* BottomBarView.swift in Sources */, E17FB55528C1250B00311DFE /* SimilarItemsHStack.swift in Sources */, - 5364F455266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, + 5364F455266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */, E18845F526DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */, - E1D4BF7E2719D1DD00A11E64 /* BasicAppSettingsViewModel.swift in Sources */, + E1B33ECF28EB6EA90073B0FD /* OverlayMenu.swift in Sources */, 6220D0B426D5ED8000B8E046 /* LibraryCoordinator.swift in Sources */, + E17AC96D2954E9CA003D2BC2 /* DownloadListView.swift in Sources */, + E1A1528828FD229500600579 /* ChevronButton.swift in Sources */, + E1B490472967E2E500D3EDCE /* CoreStore.swift in Sources */, 6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */, E18E01DB288747230022598C /* iPadOSEpisodeItemView.swift in Sources */, E13DD3F227179378009D4DAF /* UserSignInCoordinator.swift in Sources */, - 621338932660107500A81A2A /* StringExtensions.swift in Sources */, - 62C83B08288C6A630004ED0C /* FontPicker.swift in Sources */, - E122A9132788EAAD0060FA63 /* MediaStreamExtension.swift in Sources */, + 621338932660107500A81A2A /* String.swift in Sources */, + E17AC96F2954EE4B003D2BC2 /* DownloadListViewModel.swift in Sources */, + E10706172943F2F900646DAF /* (null) in Sources */, + 62C83B08288C6A630004ED0C /* FontPickerView.swift in Sources */, + E122A9132788EAAD0060FA63 /* MediaStream.swift in Sources */, + E1E9017F28DAB15F001B1594 /* BarActionButtons.swift in Sources */, E17FB55928C125E900311DFE /* StudiosHStack.swift in Sources */, - E1C812C5277A90B200918266 /* URLComponentsExtensions.swift in Sources */, + E1C812C5277A90B200918266 /* URLComponents.swift in Sources */, E1EBCB44278BD1CE009FE6E9 /* ItemOverviewCoordinator.swift in Sources */, E1C925F428875037002A7A66 /* ItemViewType.swift in Sources */, 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */, 62C29EA826D103D500C1D2E7 /* MediaCoordinator.swift in Sources */, 62E632DC267D2E130063E547 /* SearchViewModel.swift in Sources */, - E148128828C154BF003B8787 /* ItemFilterExtensions.swift in Sources */, + E1A1528A28FD22F600600579 /* TextPairView.swift in Sources */, + E1BDF2E92951490400CC0294 /* ActionButtonSelectorView.swift in Sources */, + E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */, + E148128828C154BF003B8787 /* ItemFilter.swift in Sources */, C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */, + E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */, 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */, E11895AF2893840F0042947B /* NavBarOffsetView.swift in Sources */, + E1401D45293A952300E8B599 /* MediaItemViewModel.swift in Sources */, E168BD11289A4162001A6922 /* HomeContentView.swift in Sources */, E18E01AA288746AF0022598C /* RefreshableScrollView.swift in Sources */, E18E0208288749200022598C /* BlurView.swift in Sources */, E18E01E7288747230022598C /* CollectionItemContentView.swift in Sources */, E1E1643F28BB075C00323B0A /* SelectorView.swift in Sources */, + E17665D928E80F0F00130507 /* PosterButtonType.swift in Sources */, E18E01DF288747230022598C /* iPadOSMovieItemView.swift in Sources */, E168BD13289A4162001A6922 /* ContinueWatchingView.swift in Sources */, - C45942CD27F6994A00C54FE7 /* LiveTVPlayerView.swift in Sources */, + E154966E296CA2EF00C4EF88 /* LogManager.swift in Sources */, 62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */, E18E01E8288747230022598C /* SeriesItemContentView.swift in Sources */, E16AA60828A364A6009A983C /* PosterButton.swift in Sources */, - E1E1644128BB301900323B0A /* ArrayExtensions.swift in Sources */, + E1E1644128BB301900323B0A /* Array.swift in Sources */, E18CE0AF28A222240092E7F1 /* PublicUserSignInView.swift in Sources */, + E129429828F4785200796AC6 /* EnumPicker.swift in Sources */, E18E01E5288747230022598C /* CinematicScrollView.swift in Sources */, - E1C812C0277A8E5D00918266 /* VLCPlayerView.swift in Sources */, - E176DE6D278E30D2001EFD8D /* EpisodeCard.swift in Sources */, + E154965E296CA2EF00C4EF88 /* DownloadTask.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, - E10EAA4F277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, - E10D87E227852FD000BD264C /* EpisodesRowManager.swift in Sources */, + E17AC9712954F636003D2BC2 /* DownloadListCoordinator.swift in Sources */, + E10EAA4F277BBCC4000269ED /* CGSize.swift in Sources */, E18E01EB288747230022598C /* MovieItemContentView.swift in Sources */, E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */, E18E01FA288747580022598C /* AboutAppView.swift in Sources */, + E170D103294CE8BF0017224C /* LoadingView.swift in Sources */, 6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, + E18A8E8528D60D0000333B9A /* VideoPlayerCoordinator.swift in Sources */, E19E551F2897326C003CE330 /* BottomEdgeGradientModifier.swift in Sources */, E13DD3EC27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, E12CC1BE28D11F4500678D5D /* RecentlyAddedView.swift in Sources */, + E17AC96A2954D00E003D2BC2 /* URLResponse.swift in Sources */, 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */, + E154966A296CA2EF00C4EF88 /* DownloadManager.swift in Sources */, C4BE07852728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */, + E133328829538D8D00EE76AB /* Files.swift in Sources */, E1D3043228D175CE00587289 /* StaticLibraryViewModel.swift in Sources */, + E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */, + E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */, + E16DEAC228EFCF590058F196 /* EnvironmentValue.swift in Sources */, + E1BDF2F129524AB700CC0294 /* AutoPlayActionButton.swift in Sources */, + E1FBDB6629D0F336003DD5E2 /* KeyCommandAction.swift in Sources */, + E1BDF2F929524FDA00CC0294 /* PlayPreviousItemActionButton.swift in Sources */, E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */, E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */, - 5D160403278A41FD00D22B99 /* VLCPlayer+subtitles.swift in Sources */, + E1B5861229E32EEF00E45D6E /* Set.swift in Sources */, E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, - E1A2C158279A7D76005EC829 /* BundleExtensions.swift in Sources */, C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */, E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */, + E175AFF3299AC117004DCF52 /* DebugSettingsView.swift in Sources */, E12CC1BB28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */, E17FB55228C119D400311DFE /* Displayable.swift in Sources */, - E13AD72E2798BC8D00FDCEE8 /* NativePlayerViewController.swift in Sources */, E13DD3E527177D15009D4DAF /* ServerListView.swift in Sources */, E113132B28BDB4B500930F75 /* NavBarDrawerView.swift in Sources */, - C45942CB27F6984100C54FE7 /* LiveTVPlayerViewController.swift in Sources */, E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */, E19169CE272514760085832A /* HTTPScheme.swift in Sources */, + E1559A76294D960C00C1FFBC /* MainOverlay.swift in Sources */, 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */, C4AE2C3027498D2300AE13CF /* LiveTVHomeView.swift in Sources */, + E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */, + E12A9EF829499E0100731C3A /* JellyfinClient.swift in Sources */, + E1722DB129491C3900CC0239 /* ImageBlurHashes.swift in Sources */, E1EBCB42278BD174009FE6E9 /* TruncatedTextView.swift in Sources */, 62133890265F83A900A81A2A /* MediaView.swift in Sources */, 62C29EA326D1030F00C1D2E7 /* ConnectToServerCoodinator.swift in Sources */, + E13332942953BAA100EE76AB /* DownloadTaskContentView.swift in Sources */, E18E01E1288747230022598C /* EpisodeItemContentView.swift in Sources */, + E129429B28F4A5E300796AC6 /* PlaybackSettingsView.swift in Sources */, + E1E9017B28DAAE4D001B1594 /* RoundedCorner.swift in Sources */, C400DB6A27FE894F007B65FE /* LiveTVChannelsView.swift in Sources */, E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */, E18E0204288749200022598C /* Divider.swift in Sources */, E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */, E1047E2327E5880000CB0D4A /* InitialFailureView.swift in Sources */, + E1C8CE5B28FE512400DF5D7B /* CGPoint.swift in Sources */, + E1E1E24D28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */, E1CEFBF527914C7700F60429 /* CustomizeViewsSettings.swift in Sources */, E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */, + E15756322935642A00976E1F /* Float.swift in Sources */, + E139CC1D28EC836F00688DE2 /* ChapterOverlay.swift in Sources */, E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */, - E1E5D5492783CDD700692DFE /* OverlaySettingsView.swift in Sources */, - E18E01EF288747230022598C /* ListDetailsView.swift in Sources */, - E173DA5226D04AAF00CC4EB7 /* ColorExtensions.swift in Sources */, + E1E6C45029B104840064123F /* Button.swift in Sources */, + E1E5D5492783CDD700692DFE /* VideoPlayerSettingsView.swift in Sources */, + E11245B728D97ED200D8A977 /* TopBarView.swift in Sources */, + E173DA5226D04AAF00CC4EB7 /* Color.swift in Sources */, + E1DA656928E78B5900592A73 /* SpecialFeaturesViewModel.swift in Sources */, E12CC1B928D11A1D00678D5D /* BasicLibraryView.swift in Sources */, - E1399474289B1EA900401ABC /* Defaults+Workaround.swift in Sources */, + E1B5784128F8AFCB00D42911 /* Wrapped View.swift in Sources */, + E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */, C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */, E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */, E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */, 6264E88C273850380081A12A /* Strings.swift in Sources */, C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */, + E1BDF31729525F0400CC0294 /* AdvancedActionButton.swift in Sources */, 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */, 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E113133428BE988200930F75 /* FilterDrawerHStack.swift in Sources */, + E1BDF3192952641300CC0294 /* VisibilityModifier.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, + E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, + E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */, E1171A1928A2212600FA1AF5 /* QuickConnectView.swift in Sources */, - E11CEB8D28999B4A003E74C7 /* FontExtensions.swift in Sources */, - E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */, + E1DC9819296DD1CD00982F06 /* CinematicBackgroundView.swift in Sources */, + E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */, + E139CC1F28EC83E400688DE2 /* Int.swift in Sources */, E11895A9289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, - E1C812C3277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */, - E18CE0B228A229E70092E7F1 /* UserDtoExtensions.swift in Sources */, + E1DA656C28E78C1700592A73 /* MenuPosterHStackModel.swift in Sources */, + E1DC9816296DD0FE00982F06 /* BlurViewModifier.swift in Sources */, + E14A08CB28E6831D004FC984 /* VideoPlayerViewModel.swift in Sources */, + E1DC9847296DEFF500982F06 /* FavoriteIndicator.swift in Sources */, + E1E306CD28EF6E8000537998 /* TimerProxy.swift in Sources */, + E18CE0B228A229E70092E7F1 /* UserDto.swift in Sources */, E18E01F0288747230022598C /* AttributeHStack.swift in Sources */, 6334175B287DDFB9000603CE /* QuickConnectSettingsView.swift in Sources */, E17FB54F28C1197700311DFE /* SelectorType.swift in Sources */, E13F05F128BC9016003499D2 /* LibraryItemRow.swift in Sources */, - E18E0205288749200022598C /* AppIcon.swift in Sources */, E168BD10289A4162001A6922 /* HomeView.swift in Sources */, E1D3043C28D18CD400587289 /* CastAndCrewLibraryCoordinator.swift in Sources */, + E1401CB129386C9200E8B599 /* UIColor.swift in Sources */, E18E01AB288746AF0022598C /* PillHStack.swift in Sources */, + E1401CAB2938140A00E8B599 /* LightAppIcon.swift in Sources */, E18E01E4288747230022598C /* CompactLogoScrollView.swift in Sources */, - E18E0207288749200022598C /* AttributeOutlineView.swift in Sources */, - E1002B642793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */, + E18E0207288749200022598C /* AttributeStyleModifier.swift in Sources */, + E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */, E1D3043A28D189C500587289 /* CastAndCrewLibraryView.swift in Sources */, E18E01AD288746AF0022598C /* DotHStack.swift in Sources */, + E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */, E1937A61288F32DB00CB80AA /* Poster.swift in Sources */, - 6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */, + E1EA9F6A28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, + E133328D2953AE4B00EE76AB /* CircularProgressView.swift in Sources */, + E12F038C28F8B0B100976CC3 /* EdgeInsets.swift in Sources */, + 6267B3D626710B8900A7371D /* Collection.swift in Sources */, + E152107C2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */, E168BD15289A4162001A6922 /* HomeErrorView.swift in Sources */, + E17AC9732955007A003D2BC2 /* DownloadTaskButton.swift in Sources */, + E1A1528228FD126C00600579 /* VerticalAlignment.swift in Sources */, E13DD3F5271793BB009D4DAF /* UserSignInView.swift in Sources */, E1D3044428D1991900587289 /* LibraryViewTypeToggle.swift in Sources */, E148128B28C15526003B8787 /* SortBy.swift in Sources */, - 5D1603FC278A3D5800D22B99 /* SubtitleSize.swift in Sources */, E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, + E1E0BEB729EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */, E1FA891E289A305D00176FEB /* iPadOSCollectionItemContentView.swift in Sources */, E18CE0B928A2322D0092E7F1 /* QuickConnectCoordinator.swift in Sources */, E12CC1AE28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */, + E1549666296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */, + E1A1528528FD191A00600579 /* TextPair.swift in Sources */, 6334175D287DE0D0000603CE /* QuickConnectSettingsViewModel.swift in Sources */, + E1DA656F28E78C9900592A73 /* SeriesEpisodeSelector.swift in Sources */, E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */, - 53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */, E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */, E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, - 62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, + E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */, + E1C8CE7C28FF015000DF5D7B /* TrailingTimestampType.swift in Sources */, E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */, + E13332912953B91000EE76AB /* DownloadTaskCoordinator.swift in Sources */, + E1B33ED128EB860A0073B0FD /* LargePlaybackButtons.swift in Sources */, + E1549664296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */, E113133228BDC72000930F75 /* FilterView.swift in Sources */, 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, + E170D105294D21FA0017224C /* MediaSourceInfoView.swift in Sources */, E11895AC289383EE0042947B /* NavBarOffsetModifier.swift in Sources */, + E1CD13EF28EF364100CB46CA /* DetectOrientationModifier.swift in Sources */, + E157563029355B7900976E1F /* UpdateView.swift in Sources */, + E1D8424F2932F7C400D1041A /* OverviewView.swift in Sources */, E113133628BE98AA00930F75 /* FilterDrawerButton.swift in Sources */, E13DD3FC2717EAE8009D4DAF /* UserListView.swift in Sources */, E18E01DE288747230022598C /* iPadOSSeriesItemView.swift in Sources */, + E1EF4C412911B783008CC695 /* StreamType.swift in Sources */, 6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */, + E1921B7628E63306003A5238 /* GestureView.swift in Sources */, + E18A8E8028D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */, E1E48CC9271E6D410021A2F9 /* RefreshHelper.swift in Sources */, - E1D4BF842719D25A00A11E64 /* TrackLanguage.swift in Sources */, - E1002B5F2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift in Sources */, E18E01DC288747230022598C /* iPadOSCinematicScrollView.swift in Sources */, E18E01E2288747230022598C /* EpisodeItemView.swift in Sources */, - 6220D0C626D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift in Sources */, E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */, + E1BDF2EF29522A5900CC0294 /* AudioActionButton.swift in Sources */, + E174120F29AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */, + E1DC9844296DECB600982F06 /* ProgressIndicator.swift in Sources */, 6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */, - E10D87DC2784EC5200BD264C /* SeriesEpisodesView.swift in Sources */, - E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */, E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */, E18E01E3288747230022598C /* CompactPortraitScrollView.swift in Sources */, 62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */, 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, + E18A8E8328D60BC400333B9A /* VideoPlayer.swift in Sources */, C4BE07762725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */, E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */, E1CCF12E28ABF989006CAC9E /* PosterType.swift in Sources */, - E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */, + E1D842912933F87500D1041A /* ItemFields.swift in Sources */, + E1BDF2F729524ECD00CC0294 /* PlaybackSpeedActionButton.swift in Sources */, E113132F28BDB66A00930F75 /* NavBarDrawerModifier.swift in Sources */, + E18295E429CAC6F100F91ED0 /* BasicNavigationCoordinator.swift in Sources */, C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */, + E129429328F2845000796AC6 /* SliderType.swift in Sources */, + E14A08CD28E68729004FC984 /* MenuPosterHStack.swift in Sources */, E113133A28BEB71D00930F75 /* FilterViewModel.swift in Sources */, - E13DD3C227164941009D4DAF /* SwiftfinStore.swift in Sources */, + E1E6C44C29AED2BE0064123F /* HorizontalAlignment.swift in Sources */, + E1A1528D28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */, E18E01EE288747230022598C /* AboutView.swift in Sources */, 62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */, + E1B33EB028EA890D0073B0FD /* Equatable.swift in Sources */, + E1549662296CA2EF00C4EF88 /* NewSessionManager.swift in Sources */, + E15756362936856700976E1F /* VideoPlayerType.swift in Sources */, + E1DA654C28E69B0500592A73 /* SpecialFeatureType.swift in Sources */, E11CEB8B28998552003E74C7 /* iOSViewExtensions.swift in Sources */, - E1E1644428BC60C600323B0A /* MediaLibraryItem.swift in Sources */, - E18E0206288749200022598C /* AttributeFillView.swift in Sources */, + E1401CA92938140700E8B599 /* DarkAppIcon.swift in Sources */, + E1A1529028FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */, E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */, - E1A2C154279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */, + E1A2C154279A7D5A005EC829 /* UIApplication.swift in Sources */, + E1D8428F2933F2D900D1041A /* MediaSourceInfo.swift in Sources */, + E1BDF2EC2952290200CC0294 /* AspectFillActionButton.swift in Sources */, + E1BDF2F529524E6400CC0294 /* PlayNextItemActionButton.swift in Sources */, E18E01DD288747230022598C /* iPadOSSeriesItemContentView.swift in Sources */, C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */, - E184C160288C5C08000B25BA /* RequestBuilderExtensions.swift in Sources */, + E1D5C39B28DF993400CDBEFB /* ThumbSlider.swift in Sources */, + E1DC983D296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */, E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */, E1C925F72887504B002A7A66 /* PanDirectionGestureRecognizer.swift in Sources */, E18E01E9288747230022598C /* SeriesItemView.swift in Sources */, - E18E023A288749540022598C /* UIScrollViewExtensions.swift in Sources */, + E15756342936851D00976E1F /* NativeVideoPlayerSettingsView.swift in Sources */, + E18E023A288749540022598C /* UIScrollView.swift in Sources */, E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */, + E1BDF2F329524C3B00CC0294 /* ChaptersActionButton.swift in Sources */, E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */, - 62EC352F267666A5000E9F2D /* SessionManager.swift in Sources */, + E1CFE28028FA606800B7D34C /* ChapterTrack.swift in Sources */, + E1401CA22938122C00E8B599 /* AppIcons.swift in Sources */, + E1BDF2FB2952502300CC0294 /* SubtitleActionButton.swift in Sources */, E17FB55728C1256400311DFE /* CastAndCrewHStack.swift in Sources */, 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, E113133828BEADBA00930F75 /* LibraryParent.swift in Sources */, - E1937A3E288F0D3D00CB80AA /* UIScreenExtensions.swift in Sources */, + E1401CA72938140300E8B599 /* PrimaryAppIcon.swift in Sources */, + E1937A3E288F0D3D00CB80AA /* UIScreen.swift in Sources */, C4BE076F2720FEFF003F4AD1 /* PlainNavigationLinkButton.swift in Sources */, E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */, + E19F6C5D28F5189300C5197E /* MediaStreamInfoView.swift in Sources */, + E1D8429329340B8300D1041A /* Utilities.swift in Sources */, E18CE0B428A22EDA0092E7F1 /* RepeatingTimer.swift in Sources */, + E1D5C39628DF90C100CDBEFB /* Slider.swift in Sources */, + E187A60229AB28F0008387E6 /* RotateContentView.swift in Sources */, 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */, + E1721FAE28FB801C00762992 /* SmallPlaybackButtons.swift in Sources */, 5D64683D277B1649009E09AE /* PreferenceUIHostingSwizzling.swift in Sources */, - C45942C927F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift in Sources */, E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */, - E13DD3C827164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */, - C45640D0281A43EF007096DE /* LiveTVNativePlayerViewController.swift in Sources */, - E10EAA53277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, + E13DD3C827164B1E009D4DAF /* UIDevice.swift in Sources */, E1D3044228D1976600587289 /* CastAndCrewItemRow.swift in Sources */, - E1AD104D26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */, + E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, + E1549668296CA2EF00C4EF88 /* PlaybackManager.swift in Sources */, E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */, 535870AD2669D8DD00D05A09 /* ItemFilters.swift in Sources */, - E1AD105F26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */, + E1AD105F26D9ADDD003E4A08 /* NameGuidPair.swift in Sources */, + E18A8E7D28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, E18E01F1288747230022598C /* PlayButton.swift in Sources */, - E13DD3D5271693CD009D4DAF /* SwiftfinStoreDefaults.swift in Sources */, - 62E1DCC3273CE19800C9AE76 /* URLExtensions.swift in Sources */, + E129429028F0BDC300796AC6 /* TimeStampType.swift in Sources */, + E1B490442967E26300D3EDCE /* PersistentLogHandler.swift in Sources */, + E1D5C39928DF914700CDBEFB /* CapsuleSlider.swift in Sources */, + 62E1DCC3273CE19800C9AE76 /* URL.swift in Sources */, + E170D0E4294CC8AB0017224C /* VideoPlayer+KeyCommands.swift in Sources */, E1267D3E271A1F46003C492E /* PreferenceUIHostingController.swift in Sources */, E18E01E6288747230022598C /* CollectionItemView.swift in Sources */, 6220D0BA26D6092100B8E046 /* FilterCoordinator.swift in Sources */, E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */, E111D8F528D03B7500400001 /* PagingLibraryViewModel.swift in Sources */, - E1E00A35278628A40022235B /* DoubleExtensions.swift in Sources */, + E16AF11C292C98A7001422A8 /* GestureSettingsView.swift in Sources */, + E1581E27291EF59800D6C640 /* SplitContentView.swift in Sources */, E1D4BF8A2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, E13DD3F92717E961009D4DAF /* UserListViewModel.swift in Sources */, - E126F741278A656C00A522BF /* ServerStreamType.swift in Sources */, E12CC1BF28D1260600678D5D /* ItemTypeLibraryViewModel.swift in Sources */, + E1BDF2E52951475300CC0294 /* VideoPlayerActionButton.swift in Sources */, + E133328F2953B71000EE76AB /* DownloadTaskView.swift in Sources */, + E1E6C44029AECC6D0064123F /* ActionButtons.swift in Sources */, 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, + E1549660296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, + E1D8429529346C6400D1041A /* BasicStepper.swift in Sources */, E18E01EA288747230022598C /* MovieItemView.swift in Sources */, E12CC1B528D1124400678D5D /* BasicLibraryCoordinator.swift in Sources */, - 09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */, - E1D4BF872719D27100A11E64 /* Bitrates.swift in Sources */, 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */, - E148128528C15472003B8787 /* APISortOrderExtensions.swift in Sources */, + E148128528C15472003B8787 /* APISortOrder.swift in Sources */, + E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */, E1D3043528D1763100587289 /* SeeAllButton.swift in Sources */, E13F05F328BC9016003499D2 /* LibraryView.swift in Sources */, - E13DD3EF27178F87009D4DAF /* SwiftfinNotificationCenter.swift in Sources */, E13F05EC28BC9000003499D2 /* LibraryViewType.swift in Sources */, - 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */, + E1356E0329A730B200382563 /* SeparatorHStack.swift in Sources */, + 5377CBF5263B596A003A4E83 /* SwiftfinApp.swift in Sources */, E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */, + E11245B128D919CD00D8A977 /* Overlay.swift in Sources */, E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */, 53EE24E6265060780068F029 /* SearchView.swift in Sources */, E192608028D28AAD002314B4 /* UserProfileButton.swift in Sources */, + E1DC9841296DEBD800982F06 /* WatchedIndicator.swift in Sources */, 625CB5752678C33500530A6E /* MediaViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3072,12 +3690,12 @@ 535870722669D21700D05A09 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "Dev App Icon & Top Shelf Image"; + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 70; - DEVELOPMENT_ASSET_PATHS = "\"Swiftfin tvOS/Preview Content\""; + DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -3090,7 +3708,7 @@ PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 3; @@ -3103,10 +3721,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 70; - DEVELOPMENT_ASSET_PATHS = "\"Swiftfin tvOS/Preview Content\""; + DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -3251,31 +3869,34 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-Dev"; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-primary-primary"; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = Swiftfin/Swiftfin.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Swiftfin/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Swiftfin; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = pip.jellyfin.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -3286,27 +3907,30 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-primary-primary"; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = Swiftfin/Swiftfin.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 78; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Swiftfin/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Swiftfin; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = pip.jellyfin.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; @@ -3358,14 +3982,6 @@ minimumVersion = 0.1.3; }; }; - 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/duyquang91/ActivityIndicator"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.1.0; - }; - }; 62666E3727E502CE00EC0ECD /* XCRemoteSwiftPackageReference "SwizzleSwift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MarioIannotta/SwizzleSwift"; @@ -3382,14 +3998,6 @@ kind = branch; }; }; - C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/defagos/SwiftUICollection"; - requirement = { - branch = master; - kind = branch; - }; - }; E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-algorithms.git"; @@ -3398,28 +4006,20 @@ minimumVersion = 1.0.0; }; }; - E10EAA4B277BB716000269ED /* XCRemoteSwiftPackageReference "swiftui-sliders" */ = { + E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/spacenation/swiftui-sliders"; - requirement = { - branch = master; - kind = branch; - }; - }; - E1101175281B1E8A006A3584 /* XCRemoteSwiftPackageReference "Puppy" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/sushichop/Puppy"; + repositoryURL = "https://github.com/kean/Pulse"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.5.0; + minimumVersion = 2.0.0; }; }; - E1347DB0279E3C6200BC6161 /* XCRemoteSwiftPackageReference "Puppy" */ = { + E12B930B2948329D00CE0BD9 /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/LePips/Puppy"; + repositoryURL = "https://github.com/LePips/jellyfin-sdk-swift"; requirement = { - branch = main; - kind = branch; + kind = upToNextMajorVersion; + minimumVersion = 0.3.0; }; }; E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */ = { @@ -3435,20 +4035,28 @@ repositoryURL = "https://github.com/sindresorhus/Defaults"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 6.0.0; + minimumVersion = 7.0.0; }; }; - E15B235229B7025400DAFDDD /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */ = { + E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/LePips/jellyfin-sdk-swift/"; + repositoryURL = "https://github.com/kean/Pulse"; requirement = { - branch = "temp-data"; - kind = branch; + kind = upToNextMajorVersion; + minimumVersion = 3.0.0; }; }; - E1734D7A28B9577700C66367 /* XCRemoteSwiftPackageReference "CollectionView" */ = { + E1575E3A293C6B15001665B1 /* XCRemoteSwiftPackageReference "Files" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/LePips/CollectionView"; + repositoryURL = "https://github.com/JohnSundell/Files"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.0.0; + }; + }; + E18A8E7828D5FEDF00333B9A /* XCRemoteSwiftPackageReference "VLCUI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/LePips/VLCUI"; requirement = { branch = main; kind = branch; @@ -3462,6 +4070,14 @@ minimumVersion = 1.0.0; }; }; + E19DDEC52948EF9900954E10 /* XCRemoteSwiftPackageReference "swift-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-collections.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; E19E6E0328A0B958005C10C8 /* XCRemoteSwiftPackageReference "Nuke" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kean/Nuke"; @@ -3478,12 +4094,20 @@ minimumVersion = 1.0.0; }; }; - E1C16B89271A2180009A5D25 /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = { + E1DC9812296DC06200982F06 /* XCRemoteSwiftPackageReference "PulseLogHandler" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/SwiftyJSON/SwiftyJSON"; + repositoryURL = "https://github.com/kean/PulseLogHandler"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 5.0.0; + branch = main; + kind = branch; + }; + }; + E1DC981F296DDBE600982F06 /* XCRemoteSwiftPackageReference "CollectionView" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/LePips/CollectionView"; + requirement = { + branch = main; + kind = branch; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -3499,21 +4123,11 @@ package = 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; productName = Introspect; }; - 53ABFDEC26799D7700886593 /* ActivityIndicator */ = { - isa = XCSwiftPackageProductDependency; - package = 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */; - productName = ActivityIndicator; - }; 6220D0C826D63F3700B8E046 /* Stinsen */ = { isa = XCSwiftPackageProductDependency; package = 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */; productName = Stinsen; }; - 625CB5792678C4A400530A6E /* ActivityIndicator */ = { - isa = XCSwiftPackageProductDependency; - package = 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */; - productName = ActivityIndicator; - }; 62666E3827E502CE00EC0ECD /* SwizzleSwift */ = { isa = XCSwiftPackageProductDependency; package = 62666E3727E502CE00EC0ECD /* XCRemoteSwiftPackageReference "SwizzleSwift" */; @@ -3524,16 +4138,6 @@ package = 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */; productName = Stinsen; }; - C409CE9B284EA6EA00CABC12 /* SwiftUICollection */ = { - isa = XCSwiftPackageProductDependency; - package = C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */; - productName = SwiftUICollection; - }; - C409CE9D285044C800CABC12 /* SwiftUICollection */ = { - isa = XCSwiftPackageProductDependency; - package = C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */; - productName = SwiftUICollection; - }; E1002B672793CFBA00E47059 /* Algorithms */ = { isa = XCSwiftPackageProductDependency; package = E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */; @@ -3544,30 +4148,35 @@ package = E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */; productName = Algorithms; }; - E10EAA4C277BB716000269ED /* Sliders */ = { + E107060F2942F57D00646DAF /* Pulse */ = { isa = XCSwiftPackageProductDependency; - package = E10EAA4B277BB716000269ED /* XCRemoteSwiftPackageReference "swiftui-sliders" */; - productName = Sliders; + package = E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */; + productName = Pulse; }; - E1101176281B1E8A006A3584 /* Puppy */ = { + E10706112942F57D00646DAF /* PulseLogHandler */ = { isa = XCSwiftPackageProductDependency; - package = E1101175281B1E8A006A3584 /* XCRemoteSwiftPackageReference "Puppy" */; - productName = Puppy; + package = E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */; + productName = PulseLogHandler; + }; + E10706132942F57D00646DAF /* PulseUI */ = { + isa = XCSwiftPackageProductDependency; + package = E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */; + productName = PulseUI; }; E12186DD2718F1C50010884C /* Defaults */ = { isa = XCSwiftPackageProductDependency; package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */; productName = Defaults; }; - E1347DB1279E3C6200BC6161 /* Puppy */ = { + E12B930C2948369F00CE0BD9 /* JellyfinAPI */ = { isa = XCSwiftPackageProductDependency; - package = E1347DB0279E3C6200BC6161 /* XCRemoteSwiftPackageReference "Puppy" */; - productName = Puppy; + package = E12B930B2948329D00CE0BD9 /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; + productName = JellyfinAPI; }; - E1347DB5279E3CA500BC6161 /* Puppy */ = { + E1388A45293F0ABA009721B1 /* SwizzleSwift */ = { isa = XCSwiftPackageProductDependency; - package = E1347DB0279E3C6200BC6161 /* XCRemoteSwiftPackageReference "Puppy" */; - productName = Puppy; + package = 62666E3727E502CE00EC0ECD /* XCRemoteSwiftPackageReference "SwizzleSwift" */; + productName = SwizzleSwift; }; E13AF3B528A0C598009093AB /* Nuke */ = { isa = XCSwiftPackageProductDependency; @@ -3604,25 +4213,40 @@ package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */; productName = Defaults; }; - E15B235329B7025400DAFDDD /* JellyfinAPI */ = { + E15210532946DF1B00375CC2 /* Pulse */ = { isa = XCSwiftPackageProductDependency; - package = E15B235229B7025400DAFDDD /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; - productName = JellyfinAPI; + package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */; + productName = Pulse; }; - E15B235529B7029E00DAFDDD /* JellyfinAPI */ = { + E15210552946DF1B00375CC2 /* PulseLogHandler */ = { isa = XCSwiftPackageProductDependency; - package = E15B235229B7025400DAFDDD /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; - productName = JellyfinAPI; + package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */; + productName = PulseLogHandler; }; - E1734D7B28B9577700C66367 /* CollectionView */ = { + E15210572946DF1B00375CC2 /* PulseUI */ = { isa = XCSwiftPackageProductDependency; - package = E1734D7A28B9577700C66367 /* XCRemoteSwiftPackageReference "CollectionView" */; - productName = CollectionView; + package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */; + productName = PulseUI; }; - E1734D7D28B9578100C66367 /* CollectionView */ = { + E1575E3B293C6B15001665B1 /* Files */ = { isa = XCSwiftPackageProductDependency; - package = E1734D7A28B9577700C66367 /* XCRemoteSwiftPackageReference "CollectionView" */; - productName = CollectionView; + package = E1575E3A293C6B15001665B1 /* XCRemoteSwiftPackageReference "Files" */; + productName = Files; + }; + E1575E55293E7650001665B1 /* VLCUI */ = { + isa = XCSwiftPackageProductDependency; + package = E18A8E7828D5FEDF00333B9A /* XCRemoteSwiftPackageReference "VLCUI" */; + productName = VLCUI; + }; + E1575E57293E7685001665B1 /* Files */ = { + isa = XCSwiftPackageProductDependency; + package = E1575E3A293C6B15001665B1 /* XCRemoteSwiftPackageReference "Files" */; + productName = Files; + }; + E18A8E7928D5FEDF00333B9A /* VLCUI */ = { + isa = XCSwiftPackageProductDependency; + package = E18A8E7828D5FEDF00333B9A /* XCRemoteSwiftPackageReference "VLCUI" */; + productName = VLCUI; }; E192608228D2D0DB002314B4 /* Factory */ = { isa = XCSwiftPackageProductDependency; @@ -3634,6 +4258,11 @@ package = E192608128D2D0DB002314B4 /* XCRemoteSwiftPackageReference "Factory" */; productName = Factory; }; + E19DDEC62948EF9900954E10 /* OrderedCollections */ = { + isa = XCSwiftPackageProductDependency; + package = E19DDEC52948EF9900954E10 /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = OrderedCollections; + }; E19E6E0428A0B958005C10C8 /* Nuke */ = { isa = XCSwiftPackageProductDependency; package = E19E6E0328A0B958005C10C8 /* XCRemoteSwiftPackageReference "Nuke" */; @@ -3649,10 +4278,44 @@ package = E19E6E0828A0BEFF005C10C8 /* XCRemoteSwiftPackageReference "BlurHashKit" */; productName = BlurHashKit; }; - E1B6DCE9271A23880015B715 /* SwiftyJSON */ = { + E1B5F7A429577BB8004B26CF /* JellyfinAPI */ = { isa = XCSwiftPackageProductDependency; - package = E1C16B89271A2180009A5D25 /* XCRemoteSwiftPackageReference "SwiftyJSON" */; - productName = SwiftyJSON; + package = E12B930B2948329D00CE0BD9 /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; + productName = JellyfinAPI; + }; + E1B5F7A629577BCE004B26CF /* Pulse */ = { + isa = XCSwiftPackageProductDependency; + package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */; + productName = Pulse; + }; + E1B5F7A829577BCE004B26CF /* PulseLogHandler */ = { + isa = XCSwiftPackageProductDependency; + package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */; + productName = PulseLogHandler; + }; + E1B5F7AA29577BCE004B26CF /* PulseUI */ = { + isa = XCSwiftPackageProductDependency; + package = E15210522946DF1B00375CC2 /* XCRemoteSwiftPackageReference "Pulse" */; + productName = PulseUI; + }; + E1B5F7AC29577BDD004B26CF /* OrderedCollections */ = { + isa = XCSwiftPackageProductDependency; + package = E19DDEC52948EF9900954E10 /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = OrderedCollections; + }; + E1DC9813296DC06200982F06 /* PulseLogHandler */ = { + isa = XCSwiftPackageProductDependency; + package = E1DC9812296DC06200982F06 /* XCRemoteSwiftPackageReference "PulseLogHandler" */; + productName = PulseLogHandler; + }; + E1DC981D296DD91900982F06 /* CollectionView */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionView; + }; + E1DC9820296DDBE600982F06 /* CollectionView */ = { + isa = XCSwiftPackageProductDependency; + package = E1DC981F296DDBE600982F06 /* XCRemoteSwiftPackageReference "CollectionView" */; + productName = CollectionView; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9204791c..d208715b 100644 --- a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,30 +1,12 @@ { "pins" : [ - { - "identity" : "activityindicator", - "kind" : "remoteSourceControl", - "location" : "https://github.com/duyquang91/ActivityIndicator", - "state" : { - "revision" : "0101a02196f6a67cf26f6434b007d3db6bd07fee", - "version" : "1.1.0" - } - }, - { - "identity" : "anycodable", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Flight-School/AnyCodable", - "state" : { - "revision" : "862808b2070cd908cb04f9aafe7de83d35f81b05", - "version" : "0.6.7" - } - }, { "identity" : "blurhashkit", "kind" : "remoteSourceControl", "location" : "https://github.com/LePips/BlurHashKit", "state" : { - "revision" : "3c23237f1f2b62741bce70bd2e4ef2aa7799ea85", - "version" : "1.1.0" + "revision" : "c0bd7423398de68cbeb3f99bff70f79c38bf36ab", + "version" : "1.2.0" } }, { @@ -33,7 +15,7 @@ "location" : "https://github.com/LePips/CollectionView", "state" : { "branch" : "main", - "revision" : "b05ad718700cc99a4b88009ede6cf04c7326cd99" + "revision" : "70a44bd1a8864f88213be69613554a9d5a8fb779" } }, { @@ -50,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sindresorhus/Defaults", "state" : { - "revision" : "981ccb0a01c54abbe3c12ccb8226108527bbf115", - "version" : "6.3.0" + "revision" : "d71bfd8ffbf944ef08eacbca5fb96d6f69bf7696", + "version" : "7.1.0" } }, { @@ -59,17 +41,35 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/hmlongco/Factory", "state" : { - "revision" : "8557426f3286e20b631ecdac8115242f888656e0", - "version" : "1.2.8" + "revision" : "39ff6a675cd0272d833d184d35add0f8fddd63de", + "version" : "1.3.7" + } + }, + { + "identity" : "files", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JohnSundell/Files", + "state" : { + "revision" : "d273b5b7025d386feef79ef6bad7de762e106eaf", + "version" : "4.2.0" + } + }, + { + "identity" : "get", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kean/Get", + "state" : { + "revision" : "a7dd8e0233d4041330591445de21b7e5d4c953c6", + "version" : "1.0.4" } }, { "identity" : "jellyfin-sdk-swift", "kind" : "remoteSourceControl", - "location" : "https://github.com/LePips/jellyfin-sdk-swift/", + "location" : "https://github.com/LePips/jellyfin-sdk-swift", "state" : { - "branch" : "temp-data", - "revision" : "e4ecfdff0210cc9474dfcf55d060963a291eea1c" + "revision" : "0878c236b5f15fea6b4034f88b698c2746349c9c", + "version" : "0.3.0" } }, { @@ -77,17 +77,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/kean/Nuke", "state" : { - "revision" : "8ea514737c2011ff7d7544aa065ad44905bdae0b", - "version" : "11.1.0" + "revision" : "33f7e93be5d4ec027d42af77a8ec4680d1862ad2", + "version" : "11.6.4" } }, { - "identity" : "puppy", + "identity" : "pulse", "kind" : "remoteSourceControl", - "location" : "https://github.com/sushichop/Puppy", + "location" : "https://github.com/kean/Pulse", "state" : { - "revision" : "7cfae42becac2d8916cb1a866dd12d9843199df9", - "version" : "0.5.0" + "revision" : "36cb6f05affbf7840fa27cde1b65257f54fdc88c", + "version" : "3.5.7" + } + }, + { + "identity" : "pulseloghandler", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kean/PulseLogHandler", + "state" : { + "branch" : "main", + "revision" : "3ca42eada318ad8ed9c3246f5e32c19413dae3ce" } }, { @@ -96,7 +105,7 @@ "location" : "https://github.com/rundfunk47/stinsen", "state" : { "branch" : "master", - "revision" : "17afde3c4763e014c7505da15258a5d44821c91a" + "revision" : "6dda57096e16020342b36ebea86dc4bdf6783426" } }, { @@ -108,13 +117,22 @@ "version" : "1.0.0" } }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" + } + }, { "identity" : "swift-log", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "5d66f7ba25daf4f94100e7022febf3c75e37a6c7", - "version" : "1.4.2" + "revision" : "32e8d724467f8fe623624570367e3d50c5638e46", + "version" : "1.5.2" } }, { @@ -131,35 +149,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/siteline/SwiftUI-Introspect", "state" : { - "revision" : "f2616860a41f9d9932da412a8978fec79c06fe24", - "version" : "0.1.4" - } - }, - { - "identity" : "swiftui-sliders", - "kind" : "remoteSourceControl", - "location" : "https://github.com/spacenation/swiftui-sliders", - "state" : { - "branch" : "master", - "revision" : "5ba8614462a7ed4bd47a93fbca6c281599f74337" - } - }, - { - "identity" : "swiftuicollection", - "kind" : "remoteSourceControl", - "location" : "https://github.com/defagos/SwiftUICollection", - "state" : { - "branch" : "master", - "revision" : "5b9f14eb3ec5d48cec8b3e4462dcc554d4bff2a8" - } - }, - { - "identity" : "swiftyjson", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SwiftyJSON/SwiftyJSON", - "state" : { - "revision" : "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07", - "version" : "5.0.1" + "revision" : "c18951c747ab62af7c15e17a81bd37d4fd5a9979", + "version" : "0.2.3" } }, { @@ -170,6 +161,24 @@ "revision" : "337dd5f158182620b2bb53c6847f8874a0117b2f", "version" : "1.0.0" } + }, + { + "identity" : "urlqueryencoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CreateAPI/URLQueryEncoder", + "state" : { + "revision" : "4ce950479707ea109f229d7230ec074a133b15d7", + "version" : "0.2.1" + } + }, + { + "identity" : "vlcui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LePips/VLCUI", + "state" : { + "branch" : "main", + "revision" : "50d4f6ec05a2d8333952def0d8e45019a4207132" + } } ], "version" : 2 diff --git a/Swiftfin.xcodeproj/xcshareddata/xcschemes/Swiftfin tvOS.xcscheme b/Swiftfin.xcodeproj/xcshareddata/xcschemes/Swiftfin tvOS.xcscheme index 9502f365..0ad6eb54 100644 --- a/Swiftfin.xcodeproj/xcshareddata/xcschemes/Swiftfin tvOS.xcscheme +++ b/Swiftfin.xcodeproj/xcshareddata/xcschemes/Swiftfin tvOS.xcscheme @@ -1,6 +1,6 @@ Bool { - // Lazily initialize datastack - _ = SwiftfinStore.dataStack - let audioSession = AVAudioSession.sharedInstance() do { try audioSession.setCategory(.playback) @@ -34,4 +37,18 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { AppDelegate.orientationLock } + + static func changeOrientation(_ orientation: UIInterfaceOrientationMask) { + +// guard UIDevice.isPhone else { return } +// +// Self.orientationLock = orientation +// +// if #available(iOS 16, *) { +// let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene +// windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientation)) +// } else { +// UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation") +// } + } } diff --git a/Swiftfin/App/JellyfinPlayerApp.swift b/Swiftfin/App/JellyfinPlayerApp.swift deleted file mode 100644 index 24234776..00000000 --- a/Swiftfin/App/JellyfinPlayerApp.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Defaults -import MessageUI -import Stinsen -import SwiftUI - -// MARK: JellyfinPlayerApp - -@main -struct JellyfinPlayerApp: App { - - @UIApplicationDelegateAdaptor(AppDelegate.self) - var appDelegate - - var body: some Scene { - WindowGroup { - EmptyView() - .ignoresSafeArea() - .withHostingWindow { window in - window?.rootViewController = PreferenceUIHostingController { - MainCoordinator() - .view() - } - } - .onAppear { - JellyfinPlayerApp.setupAppearance() - } - .onOpenURL { url in - AppURLHandler.shared.processDeepLink(url: url) - } - } - } - - static func setupAppearance() { - let scenes = UIApplication.shared.connectedScenes - let windowScene = scenes.first as? UIWindowScene - windowScene?.windows.first?.overrideUserInterfaceStyle = Defaults[.appAppearance].style - } -} - -// MARK: Hosting Window - -struct HostingWindowFinder: UIViewRepresentable { - var callback: (UIWindow?) -> Void - - func makeUIView(context: Context) -> UIView { - let view = UIView() - DispatchQueue.main.async { [weak view] in - callback(view?.window) - } - return view - } - - func updateUIView(_ uiView: UIView, context: Context) {} -} - -extension View { - func withHostingWindow(_ callback: @escaping (UIWindow?) -> Void) -> some View { - background(HostingWindowFinder(callback: callback)) - } -} - -extension UINavigationController { - // Remove back button text - override open func viewWillLayoutSubviews() { - navigationBar.topItem?.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) - } -} diff --git a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift index abc2aa4a..4663e04a 100644 --- a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift +++ b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift @@ -3,15 +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 -// MARK: PreferenceUIHostingController - class PreferenceUIHostingController: UIHostingController { + init(@ViewBuilder wrappedView: @escaping () -> V) { let box = Box() super.init(rootView: AnyView( @@ -22,6 +21,11 @@ class PreferenceUIHostingController: UIHostingController { box.value?._orientations = $0 }.onPreferenceChange(ViewPreferenceKey.self) { box.value?._viewPreference = $0 + }.onPreferenceChange(KeyCommandsPreferenceKey.self) { + box.value?._keyCommands = $0 + }.onPreferenceChange(AddingKeyCommandPreferenceKey.self) { + guard let newAction = $0 else { return } + box.value?._keyCommands.append(newAction) } )) box.value = self @@ -40,7 +44,7 @@ class PreferenceUIHostingController: UIHostingController { // MARK: Prefers Home Indicator Auto Hidden - public var _prefersHomeIndicatorAutoHidden = false { + var _prefersHomeIndicatorAutoHidden = false { didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() } } @@ -50,13 +54,19 @@ class PreferenceUIHostingController: UIHostingController { // MARK: Lock orientation - public var _orientations: UIInterfaceOrientationMask = .allButUpsideDown { + var _orientations: UIInterfaceOrientationMask = .allButUpsideDown { didSet { - if _orientations == .landscape { - let value = UIInterfaceOrientation.landscapeRight.rawValue - UIDevice.current.setValue(value, forKey: "orientation") - UIViewController.attemptRotationToDeviceOrientation() + print("didset orientations: \(_orientations)") + if #available(iOS 16.0, *) { +// setNeedsUpdateOfSupportedInterfaceOrientations() + } else { + // Fallback on earlier versions } +// if _orientations == .landscape { +// let value = UIInterfaceOrientation.landscapeRight.rawValue +// UIDevice.current.setValue(value, forKey: "orientation") +// UIViewController.attemptRotationToDeviceOrientation() +// } } } @@ -64,37 +74,68 @@ class PreferenceUIHostingController: UIHostingController { _orientations } - public var _viewPreference: UIUserInterfaceStyle = .unspecified { + var _viewPreference: UIUserInterfaceStyle = .unspecified { didSet { overrideUserInterfaceStyle = _viewPreference } } + + var _keyCommands: [KeyCommandAction] = [] + + override var keyCommands: [UIKeyCommand]? { + let castedCommands: [UIKeyCommand] = _keyCommands.map { .init( + title: $0.title, + action: #selector(keyCommandHit), + input: $0.input, + modifierFlags: $0.modifierFlags + ) } + + castedCommands.forEach { $0.wantsPriorityOverSystemBehavior = true } + + return castedCommands + } + + @objc + private func keyCommandHit(keyCommand: UIKeyCommand) { + guard let action = _keyCommands.first(where: { $0.input == keyCommand.input }) else { return } + + action.action() + } } // MARK: Preference Keys -struct PrefersHomeIndicatorAutoHiddenPreferenceKey: PreferenceKey { - typealias Value = Bool +// TODO: look at namespacing? - static var defaultValue: Value = false +struct AddingKeyCommandPreferenceKey: PreferenceKey { - static func reduce(value: inout Value, nextValue: () -> Value) { - value = nextValue() || value - } -} + static var defaultValue: KeyCommandAction? -struct ViewPreferenceKey: PreferenceKey { - typealias Value = UIUserInterfaceStyle - - static var defaultValue: UIUserInterfaceStyle = .unspecified - - static func reduce(value: inout UIUserInterfaceStyle, nextValue: () -> UIUserInterfaceStyle) { + static func reduce(value: inout KeyCommandAction?, nextValue: () -> KeyCommandAction?) { value = nextValue() } } +struct KeyCommandsPreferenceKey: PreferenceKey { + + static var defaultValue: [KeyCommandAction] = [] + + static func reduce(value: inout [KeyCommandAction], nextValue: () -> [KeyCommandAction]) { + value = nextValue() + } +} + +struct PrefersHomeIndicatorAutoHiddenPreferenceKey: PreferenceKey { + + static var defaultValue: Bool = false + + static func reduce(value: inout Bool, nextValue: () -> Bool) { + value = nextValue() || value + } +} + struct SupportedOrientationsPreferenceKey: PreferenceKey { - typealias Value = UIInterfaceOrientationMask + static var defaultValue: UIInterfaceOrientationMask = .allButUpsideDown static func reduce(value: inout UIInterfaceOrientationMask, nextValue: () -> UIInterfaceOrientationMask) { @@ -103,21 +144,44 @@ struct SupportedOrientationsPreferenceKey: PreferenceKey { } } +struct ViewPreferenceKey: PreferenceKey { + + static var defaultValue: UIUserInterfaceStyle = .unspecified + + static func reduce(value: inout UIUserInterfaceStyle, nextValue: () -> UIUserInterfaceStyle) { + value = nextValue() + } +} + // MARK: Preference Key View Extension extension View { - // Controls the application's preferred home indicator auto-hiding when this view is shown. + + func keyCommands(_ commands: [KeyCommandAction]) -> some View { + preference(key: KeyCommandsPreferenceKey.self, value: commands) + } + + func addingKeyCommand( + title: String, + input: String, + modifierFlags: UIKeyModifierFlags = [], + action: @escaping () -> Void + ) -> some View { + preference( + key: AddingKeyCommandPreferenceKey.self, + value: .init(title: title, input: input, modifierFlags: modifierFlags, action: action) + ) + } + func prefersHomeIndicatorAutoHidden(_ value: Bool) -> some View { preference(key: PrefersHomeIndicatorAutoHiddenPreferenceKey.self, value: value) } func supportedOrientations(_ supportedOrientations: UIInterfaceOrientationMask) -> some View { - // When rendered, export the requested orientations upward to Root preference(key: SupportedOrientationsPreferenceKey.self, value: supportedOrientations) } func overrideViewPreference(_ viewPreference: UIUserInterfaceStyle) -> some View { - // When rendered, export the requested orientations upward to Root preference(key: ViewPreferenceKey.self, value: viewPreference) } } diff --git a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift index 5fb925fa..b56d5926 100644 --- a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift +++ b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.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/Swiftfin/App/SwiftfinApp.swift b/Swiftfin/App/SwiftfinApp.swift new file mode 100644 index 00000000..f6938905 --- /dev/null +++ b/Swiftfin/App/SwiftfinApp.swift @@ -0,0 +1,78 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2023 Jellyfin & Jellyfin Contributors +// + +import CoreStore +import Defaults +import Logging +import Pulse +import PulseLogHandler +import SwiftUI + +@main +struct SwiftfinApp: App { + + @UIApplicationDelegateAdaptor(AppDelegate.self) + var appDelegate + + init() { + + // Defaults + Task { + for await newValue in Defaults.updates(.accentColor) { + UIApplication.shared.setAccentColor(newValue.uiColor) + UIApplication.shared.setNavigationBackButtonAccentColor(newValue.uiColor) + } + } + + Task { + for await newValue in Defaults.updates(.appAppearance) { + UIApplication.shared.setAppearance(newValue.style) + } + } + + // Logging + LoggingSystem.bootstrap { label in + + var loggers: [LogHandler] = [PersistentLogHandler(label: label).withLogLevel(.trace)] + + #if DEBUG + loggers.append(SwiftfinConsoleLogger()) + #endif + + return MultiplexLogHandler(loggers) + } + + CoreStoreDefaults.dataStack = SwiftfinStore.dataStack + CoreStoreDefaults.logger = SwiftfinCorestoreLogger() + + // Don't let the tab bar disappear when a new view is pushed + UITabBar.appearance().scrollEdgeAppearance = UITabBarAppearance(idiom: .unspecified) + } + + var body: some Scene { + WindowGroup { + PreferenceUIHostingControllerView { + MainCoordinator() + .view() + .supportedOrientations(.portrait) + } + .ignoresSafeArea() + .onOpenURL { url in + AppURLHandler.shared.processDeepLink(url: url) + } + } + } +} + +extension UINavigationController { + + // Remove back button text + override open func viewWillLayoutSubviews() { + navigationBar.topItem?.backButtonDisplayMode = .minimal + } +} diff --git a/Swiftfin/AppURLHandler/AppURLHandler.swift b/Swiftfin/AppURLHandler/AppURLHandler.swift index e1e06f03..a1b822bd 100644 --- a/Swiftfin/AppURLHandler/AppURLHandler.swift +++ b/Swiftfin/AppURLHandler/AppURLHandler.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 @@ -93,17 +93,17 @@ extension AppURLHandler { extension AppURLHandler { func getItem(userID: String, itemID: String, completion: @escaping (BaseItemDto?) -> Void) { - UserLibraryAPI.getItem(userId: userID, itemId: itemID) - .sink(receiveCompletion: { innerCompletion in - switch innerCompletion { - case .failure: - completion(nil) - default: - break - } - }, receiveValue: { item in - completion(item) - }) - .store(in: &cancellables) +// UserLibraryAPI.getItem(userId: userID, itemId: itemID) +// .sink(receiveCompletion: { innerCompletion in +// switch innerCompletion { +// case .failure: +// completion(nil) +// default: +// break +// } +// }, receiveValue: { item in +// completion(item) +// }) +// .store(in: &cancellables) } } diff --git a/Swiftfin/AppURLHandler/DeepLink.swift b/Swiftfin/AppURLHandler/DeepLink.swift index 4d4631d5..76a95c94 100644 --- a/Swiftfin/AppURLHandler/DeepLink.swift +++ b/Swiftfin/AppURLHandler/DeepLink.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/Swiftfin/Assets.xcassets/AccentColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index d383c2d1..00000000 --- a/Swiftfin/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.765", - "green" : "0.361", - "red" : "0.667" - } - }, - "idiom" : "iphone" - }, - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.765", - "green" : "0.361", - "red" : "0.667" - } - }, - "idiom" : "ipad" - }, - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.765", - "green" : "0.361", - "red" : "0.667" - } - }, - "idiom" : "tv" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/100.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/100.png deleted file mode 100755 index 5f412bcd..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/100.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/1024.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/1024.png deleted file mode 100755 index 74aea638..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/1024.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/114.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/114.png deleted file mode 100755 index b4caaaa1..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/114.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/120.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/120.png deleted file mode 100755 index ac85a191..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/120.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/144.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/144.png deleted file mode 100755 index 795d39ca..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/144.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/152.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/152.png deleted file mode 100755 index 8ba00881..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/152.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/167.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/167.png deleted file mode 100755 index d23e2dbd..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/167.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/180.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/180.png deleted file mode 100755 index 71d6a10d..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/180.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/20.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/20.png deleted file mode 100755 index 53baa4a8..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/20.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/29.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/29.png deleted file mode 100755 index e9f48888..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/29.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/40.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/40.png deleted file mode 100755 index c60b692c..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/40.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/50.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/50.png deleted file mode 100755 index e6991944..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/50.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/57.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/57.png deleted file mode 100755 index 57b02361..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/57.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/58.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/58.png deleted file mode 100755 index 94956434..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/58.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/60.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/60.png deleted file mode 100755 index 5f466f04..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/60.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/72.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/72.png deleted file mode 100755 index dad26e7d..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/72.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/76.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/76.png deleted file mode 100755 index 007e2616..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/76.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/80.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/80.png deleted file mode 100755 index 9f9bc982..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/80.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/87.png b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/87.png deleted file mode 100755 index a50c9440..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/87.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/Contents.json deleted file mode 100755 index 65b74d7e..00000000 --- a/Swiftfin/Assets.xcassets/AppIcon-Dev.appiconset/Contents.json +++ /dev/null @@ -1 +0,0 @@ -{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"}]} \ No newline at end of file diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/100.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/100.png deleted file mode 100644 index 3344d384..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/100.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/1024.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/1024.png deleted file mode 100644 index 39934f79..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/1024.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/114.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/114.png deleted file mode 100644 index 2bf3e86d..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/114.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/120.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/120.png deleted file mode 100644 index 747d0a70..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/120.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/144.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/144.png deleted file mode 100644 index ac27ff59..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/144.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/152.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/152.png deleted file mode 100644 index 416ad786..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/152.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/167.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/167.png deleted file mode 100644 index 45e73a86..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/167.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/180.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/180.png deleted file mode 100644 index 2ca9c5c5..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/180.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/20.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/20.png deleted file mode 100644 index ce097830..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/20.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/29.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/29.png deleted file mode 100644 index bfcf1009..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/29.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/40.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/40.png deleted file mode 100644 index acbb7cba..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/40.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/50.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/50.png deleted file mode 100644 index ffb6fd49..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/50.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/57.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/57.png deleted file mode 100644 index 3978d6f9..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/57.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/58.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/58.png deleted file mode 100644 index 899d55a8..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/58.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/60.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/60.png deleted file mode 100644 index 7a324dca..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/60.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/72.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/72.png deleted file mode 100644 index a425ce88..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/72.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/76.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/76.png deleted file mode 100644 index 1b174312..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/76.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/80.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/80.png deleted file mode 100644 index 8d4ea26b..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/80.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/87.png b/Swiftfin/Assets.xcassets/AppIcon.appiconset/87.png deleted file mode 100644 index 9ef41a73..00000000 Binary files a/Swiftfin/Assets.xcassets/AppIcon.appiconset/87.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/AppIcon.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 65b74d7e..00000000 --- a/Swiftfin/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1 +0,0 @@ -{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"}]} \ No newline at end of file diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Contents.json similarity index 100% rename from Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json rename to Swiftfin/Assets.xcassets/AppIcons/Contents.json diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/Contents.json new file mode 100644 index 00000000..f576456f --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "blue.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/blue.png b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/blue.png new file mode 100644 index 00000000..833df3f7 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/blue.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/Contents.json new file mode 100644 index 00000000..bdf1b75e --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "green.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/green.png b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/green.png new file mode 100644 index 00000000..8e2f25f2 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/green.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/Contents.json new file mode 100644 index 00000000..b6aee0a8 --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "jellyfin.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/jellyfin.png b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/jellyfin.png new file mode 100644 index 00000000..988a97ec Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/jellyfin.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/Contents.json new file mode 100644 index 00000000..43c3f0b1 --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "orange.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/orange.png b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/orange.png new file mode 100644 index 00000000..6d95620d Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/orange.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/Contents.json new file mode 100644 index 00000000..a3eb9a85 --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "red.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/red.png b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/red.png new file mode 100644 index 00000000..db99e9a0 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/red.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/Contents.json new file mode 100644 index 00000000..2b34f525 --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "yellow.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/yellow.png b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/yellow.png new file mode 100644 index 00000000..bd72e8ee Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/yellow.png differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/Contents.json similarity index 100% rename from Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json rename to Swiftfin/Assets.xcassets/AppIcons/Inverted-Dark/Contents.json diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/Contents.json new file mode 100644 index 00000000..f576456f --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "blue.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/blue.png b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/blue.png new file mode 100644 index 00000000..545ffe83 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/blue.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/Contents.json new file mode 100644 index 00000000..bdf1b75e --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "green.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/green.png b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/green.png new file mode 100644 index 00000000..ba312208 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/green.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/Contents.json new file mode 100644 index 00000000..b6aee0a8 --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "jellyfin.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/jellyfin.png b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/jellyfin.png new file mode 100644 index 00000000..91aa2dd4 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/jellyfin.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/Contents.json new file mode 100644 index 00000000..43c3f0b1 --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "orange.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/orange.png b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/orange.png new file mode 100644 index 00000000..c25a02d9 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/orange.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/Contents.json new file mode 100644 index 00000000..a3eb9a85 --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "red.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/red.png b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/red.png new file mode 100644 index 00000000..c96f50ad Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/red.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/Contents.json new file mode 100644 index 00000000..2b34f525 --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "yellow.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/yellow.png b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/yellow.png new file mode 100644 index 00000000..29bc419b Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/yellow.png differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/Contents.json similarity index 100% rename from Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json rename to Swiftfin/Assets.xcassets/AppIcons/Inverted-Light/Contents.json diff --git a/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/AppIcon-light-blue.png b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/AppIcon-light-blue.png new file mode 100644 index 00000000..964a0c99 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/AppIcon-light-blue.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/Contents.json new file mode 100644 index 00000000..a1ed8c80 --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-blue.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/AppIcon-light-green.png b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/AppIcon-light-green.png new file mode 100644 index 00000000..8918bc60 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/AppIcon-light-green.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/Contents.json new file mode 100644 index 00000000..7a435a7a --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-green.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/AppIcon-light-jellyfin.png b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/AppIcon-light-jellyfin.png new file mode 100644 index 00000000..93512970 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/AppIcon-light-jellyfin.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/Contents.json new file mode 100644 index 00000000..63e1b06b --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-jellyfin.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/AppIcon-light-orange.png b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/AppIcon-light-orange.png new file mode 100644 index 00000000..8b774a89 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/AppIcon-light-orange.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/Contents.json new file mode 100644 index 00000000..bdec0e39 --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-orange.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/AppIcon-light-red.png b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/AppIcon-light-red.png new file mode 100644 index 00000000..a7957d09 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/AppIcon-light-red.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/Contents.json new file mode 100644 index 00000000..b42150ac --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-red.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/AppIcon-light-yellow.png b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/AppIcon-light-yellow.png new file mode 100644 index 00000000..a42b65a7 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/AppIcon-light-yellow.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/Contents.json new file mode 100644 index 00000000..43413c67 --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-light-yellow.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Light/Contents.json similarity index 100% rename from Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json rename to Swiftfin/Assets.xcassets/AppIcons/Light/Contents.json diff --git a/Swiftfin/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/AppIcon-primary-primary.png b/Swiftfin/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/AppIcon-primary-primary.png new file mode 100644 index 00000000..3b527bd7 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/AppIcon-primary-primary.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/Contents.json new file mode 100644 index 00000000..b9bffe0d --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-primary-primary.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin tvOS/Preview Content/Preview Assets.xcassets/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/Primary/Contents.json similarity index 100% rename from Swiftfin tvOS/Preview Content/Preview Assets.xcassets/Contents.json rename to Swiftfin/Assets.xcassets/AppIcons/Primary/Contents.json diff --git a/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-blue.appiconset/AppIcon-dark-blue.png b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-blue.appiconset/AppIcon-dark-blue.png new file mode 100644 index 00000000..f56341f3 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-blue.appiconset/AppIcon-dark-blue.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-blue.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-blue.appiconset/Contents.json new file mode 100644 index 00000000..5e53a352 --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-blue.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-blue.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-green.appiconset/AppIcon-dark-green.png b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-green.appiconset/AppIcon-dark-green.png new file mode 100644 index 00000000..8fc342f6 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-green.appiconset/AppIcon-dark-green.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-green.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-green.appiconset/Contents.json new file mode 100644 index 00000000..2698c56b --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-green.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-green.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-jellyfin.appiconset/AppIcon-dark-jellyfin.png b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-jellyfin.appiconset/AppIcon-dark-jellyfin.png new file mode 100644 index 00000000..a1de8f95 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-jellyfin.appiconset/AppIcon-dark-jellyfin.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-jellyfin.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-jellyfin.appiconset/Contents.json new file mode 100644 index 00000000..43c18692 --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-jellyfin.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-jellyfin.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-orange.appiconset/AppIcon-dark-orange.png b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-orange.appiconset/AppIcon-dark-orange.png new file mode 100644 index 00000000..04c2b7fa Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-orange.appiconset/AppIcon-dark-orange.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-orange.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-orange.appiconset/Contents.json new file mode 100644 index 00000000..0cce5543 --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-orange.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-orange.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-red.appiconset/AppIcon-dark-red.png b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-red.appiconset/AppIcon-dark-red.png new file mode 100644 index 00000000..b576b17e Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-red.appiconset/AppIcon-dark-red.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-red.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-red.appiconset/Contents.json new file mode 100644 index 00000000..93aa3d95 --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-red.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-red.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-yellow.appiconset/AppIcon-dark-yellow.png b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-yellow.appiconset/AppIcon-dark-yellow.png new file mode 100644 index 00000000..a81a04f9 Binary files /dev/null and b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-yellow.appiconset/AppIcon-dark-yellow.png differ diff --git a/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-yellow.appiconset/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-yellow.appiconset/Contents.json new file mode 100644 index 00000000..d228c5f4 --- /dev/null +++ b/Swiftfin/Assets.xcassets/AppIcons/dark/AppIcon-dark-yellow.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-dark-yellow.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Preview Content/Preview Assets.xcassets/Contents.json b/Swiftfin/Assets.xcassets/AppIcons/dark/Contents.json similarity index 100% rename from Swiftfin/Preview Content/Preview Assets.xcassets/Contents.json rename to Swiftfin/Assets.xcassets/AppIcons/dark/Contents.json diff --git a/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json deleted file mode 100644 index 737e9109..00000000 --- a/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "light" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.000", - "green" : "0.000", - "red" : "0.000" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Swiftfin/Assets.xcassets/BackgroundSecondaryColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/BackgroundSecondaryColor.colorset/Contents.json deleted file mode 100644 index 5d336734..00000000 --- a/Swiftfin/Assets.xcassets/BackgroundSecondaryColor.colorset/Contents.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "light" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "0.100", - "blue" : "0.000", - "green" : "0.000", - "red" : "0.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "0.100", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Swiftfin/Assets.xcassets/CastConnected.imageset/Contents.json b/Swiftfin/Assets.xcassets/CastConnected.imageset/Contents.json deleted file mode 100644 index 1ecf6fb1..00000000 --- a/Swiftfin/Assets.xcassets/CastConnected.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "ic_cast_connected_white_24dp-2.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "ic_cast_connected_white_24dp-1.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "ic_cast_connected_white_24dp.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Swiftfin/Assets.xcassets/CastConnected.imageset/ic_cast_connected_white_24dp-1.png b/Swiftfin/Assets.xcassets/CastConnected.imageset/ic_cast_connected_white_24dp-1.png deleted file mode 100644 index 0b0d9447..00000000 Binary files a/Swiftfin/Assets.xcassets/CastConnected.imageset/ic_cast_connected_white_24dp-1.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/CastConnected.imageset/ic_cast_connected_white_24dp-2.png b/Swiftfin/Assets.xcassets/CastConnected.imageset/ic_cast_connected_white_24dp-2.png deleted file mode 100644 index 0b0d9447..00000000 Binary files a/Swiftfin/Assets.xcassets/CastConnected.imageset/ic_cast_connected_white_24dp-2.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/CastConnected.imageset/ic_cast_connected_white_24dp.png b/Swiftfin/Assets.xcassets/CastConnected.imageset/ic_cast_connected_white_24dp.png deleted file mode 100644 index 0b0d9447..00000000 Binary files a/Swiftfin/Assets.xcassets/CastConnected.imageset/ic_cast_connected_white_24dp.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/CastConnecting0.imageset/Contents.json b/Swiftfin/Assets.xcassets/CastConnecting0.imageset/Contents.json deleted file mode 100644 index 1bd4c545..00000000 --- a/Swiftfin/Assets.xcassets/CastConnecting0.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "ic_cast0_white_24dp.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "ic_cast0_white_24dp-1.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "ic_cast0_white_24dp-2.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Swiftfin/Assets.xcassets/CastConnecting0.imageset/ic_cast0_white_24dp-1.png b/Swiftfin/Assets.xcassets/CastConnecting0.imageset/ic_cast0_white_24dp-1.png deleted file mode 100644 index dfda8e78..00000000 Binary files a/Swiftfin/Assets.xcassets/CastConnecting0.imageset/ic_cast0_white_24dp-1.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/CastConnecting0.imageset/ic_cast0_white_24dp-2.png b/Swiftfin/Assets.xcassets/CastConnecting0.imageset/ic_cast0_white_24dp-2.png deleted file mode 100644 index dfda8e78..00000000 Binary files a/Swiftfin/Assets.xcassets/CastConnecting0.imageset/ic_cast0_white_24dp-2.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/CastConnecting0.imageset/ic_cast0_white_24dp.png b/Swiftfin/Assets.xcassets/CastConnecting0.imageset/ic_cast0_white_24dp.png deleted file mode 100644 index dfda8e78..00000000 Binary files a/Swiftfin/Assets.xcassets/CastConnecting0.imageset/ic_cast0_white_24dp.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/CastConnecting1.imageset/Contents.json b/Swiftfin/Assets.xcassets/CastConnecting1.imageset/Contents.json deleted file mode 100644 index 9f65b6bb..00000000 --- a/Swiftfin/Assets.xcassets/CastConnecting1.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "ic_cast1_white_24dp.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "ic_cast1_white_24dp-1.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "ic_cast1_white_24dp-2.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Swiftfin/Assets.xcassets/CastConnecting1.imageset/ic_cast1_white_24dp-1.png b/Swiftfin/Assets.xcassets/CastConnecting1.imageset/ic_cast1_white_24dp-1.png deleted file mode 100644 index 3e3ede88..00000000 Binary files a/Swiftfin/Assets.xcassets/CastConnecting1.imageset/ic_cast1_white_24dp-1.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/CastConnecting1.imageset/ic_cast1_white_24dp-2.png b/Swiftfin/Assets.xcassets/CastConnecting1.imageset/ic_cast1_white_24dp-2.png deleted file mode 100644 index 3e3ede88..00000000 Binary files a/Swiftfin/Assets.xcassets/CastConnecting1.imageset/ic_cast1_white_24dp-2.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/CastConnecting1.imageset/ic_cast1_white_24dp.png b/Swiftfin/Assets.xcassets/CastConnecting1.imageset/ic_cast1_white_24dp.png deleted file mode 100644 index 3e3ede88..00000000 Binary files a/Swiftfin/Assets.xcassets/CastConnecting1.imageset/ic_cast1_white_24dp.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/CastConnecting2.imageset/Contents.json b/Swiftfin/Assets.xcassets/CastConnecting2.imageset/Contents.json deleted file mode 100644 index 84f3f872..00000000 --- a/Swiftfin/Assets.xcassets/CastConnecting2.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "ic_cast2_white_24dp.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "ic_cast2_white_24dp-1.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "ic_cast2_white_24dp-2.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Swiftfin/Assets.xcassets/CastConnecting2.imageset/ic_cast2_white_24dp-1.png b/Swiftfin/Assets.xcassets/CastConnecting2.imageset/ic_cast2_white_24dp-1.png deleted file mode 100644 index 1668ffdb..00000000 Binary files a/Swiftfin/Assets.xcassets/CastConnecting2.imageset/ic_cast2_white_24dp-1.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/CastConnecting2.imageset/ic_cast2_white_24dp-2.png b/Swiftfin/Assets.xcassets/CastConnecting2.imageset/ic_cast2_white_24dp-2.png deleted file mode 100644 index 1668ffdb..00000000 Binary files a/Swiftfin/Assets.xcassets/CastConnecting2.imageset/ic_cast2_white_24dp-2.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/CastConnecting2.imageset/ic_cast2_white_24dp.png b/Swiftfin/Assets.xcassets/CastConnecting2.imageset/ic_cast2_white_24dp.png deleted file mode 100644 index 1668ffdb..00000000 Binary files a/Swiftfin/Assets.xcassets/CastConnecting2.imageset/ic_cast2_white_24dp.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/CastDisconnected.imageset/Contents.json b/Swiftfin/Assets.xcassets/CastDisconnected.imageset/Contents.json deleted file mode 100644 index 23b70ec4..00000000 --- a/Swiftfin/Assets.xcassets/CastDisconnected.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "ic_cast_white_24dp.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "ic_cast_white_24dp-1.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "ic_cast_white_24dp-2.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Swiftfin/Assets.xcassets/CastDisconnected.imageset/ic_cast_white_24dp-1.png b/Swiftfin/Assets.xcassets/CastDisconnected.imageset/ic_cast_white_24dp-1.png deleted file mode 100644 index ce167c7f..00000000 Binary files a/Swiftfin/Assets.xcassets/CastDisconnected.imageset/ic_cast_white_24dp-1.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/CastDisconnected.imageset/ic_cast_white_24dp-2.png b/Swiftfin/Assets.xcassets/CastDisconnected.imageset/ic_cast_white_24dp-2.png deleted file mode 100644 index ce167c7f..00000000 Binary files a/Swiftfin/Assets.xcassets/CastDisconnected.imageset/ic_cast_white_24dp-2.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/CastDisconnected.imageset/ic_cast_white_24dp.png b/Swiftfin/Assets.xcassets/CastDisconnected.imageset/ic_cast_white_24dp.png deleted file mode 100644 index ce167c7f..00000000 Binary files a/Swiftfin/Assets.xcassets/CastDisconnected.imageset/ic_cast_white_24dp.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/LaunchScreenBackground.colorset/Contents.json b/Swiftfin/Assets.xcassets/LaunchScreenBackground.colorset/Contents.json deleted file mode 100644 index 04256378..00000000 --- a/Swiftfin/Assets.xcassets/LaunchScreenBackground.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.000", - "green" : "0.000", - "red" : "0.000" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Swiftfin/Assets.xcassets/git.commit.symbolset/Contents.json b/Swiftfin/Assets.xcassets/git.commit.symbolset/Contents.json new file mode 100644 index 00000000..c6230986 --- /dev/null +++ b/Swiftfin/Assets.xcassets/git.commit.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "git.commit.svg", + "idiom" : "universal" + } + ] +} diff --git a/Swiftfin/Assets.xcassets/git.commit.symbolset/git.commit.svg b/Swiftfin/Assets.xcassets/git.commit.symbolset/git.commit.svg new file mode 100644 index 00000000..392aa6f1 --- /dev/null +++ b/Swiftfin/Assets.xcassets/git.commit.symbolset/git.commit.svg @@ -0,0 +1,161 @@ + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.3.0 + Requires Xcode 13 or greater + Generated from git.commit + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Swiftfin/Assets.xcassets/github-logo.imageset/Contents.json b/Swiftfin/Assets.xcassets/github-logo.imageset/Contents.json deleted file mode 100644 index 1a4ad58f..00000000 --- a/Swiftfin/Assets.xcassets/github-logo.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "githubLogo.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "githubLogo-1.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "githubLogo-2.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Swiftfin/Assets.xcassets/github-logo.imageset/githubLogo-1.png b/Swiftfin/Assets.xcassets/github-logo.imageset/githubLogo-1.png deleted file mode 100644 index 9490ffc6..00000000 Binary files a/Swiftfin/Assets.xcassets/github-logo.imageset/githubLogo-1.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/github-logo.imageset/githubLogo-2.png b/Swiftfin/Assets.xcassets/github-logo.imageset/githubLogo-2.png deleted file mode 100644 index 9490ffc6..00000000 Binary files a/Swiftfin/Assets.xcassets/github-logo.imageset/githubLogo-2.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/github-logo.imageset/githubLogo.png b/Swiftfin/Assets.xcassets/github-logo.imageset/githubLogo.png deleted file mode 100644 index 9490ffc6..00000000 Binary files a/Swiftfin/Assets.xcassets/github-logo.imageset/githubLogo.png and /dev/null differ diff --git a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/Swiftfin/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json similarity index 58% rename from Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json rename to Swiftfin/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json index dc3e9968..618e9ae3 100644 --- a/Swiftfin tvOS/Assets.xcassets/Dev App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json +++ b/Swiftfin/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json @@ -1,8 +1,8 @@ { "images" : [ { - "filename" : "512.png", - "idiom" : "tv" + "filename" : "jellyfin-blob.svg", + "idiom" : "universal" } ], "info" : { diff --git a/Swiftfin/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg b/Swiftfin/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg new file mode 100644 index 00000000..db72d151 --- /dev/null +++ b/Swiftfin/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg @@ -0,0 +1,15 @@ + + + Combined Shape + + + + + + + + + + + + \ No newline at end of file diff --git a/Swiftfin/Assets.xcassets/logo.github.symbolset/Contents.json b/Swiftfin/Assets.xcassets/logo.github.symbolset/Contents.json new file mode 100644 index 00000000..9ccfab3b --- /dev/null +++ b/Swiftfin/Assets.xcassets/logo.github.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "logo.github.svg", + "idiom" : "universal" + } + ] +} diff --git a/Swiftfin/Assets.xcassets/logo.github.symbolset/logo.github.svg b/Swiftfin/Assets.xcassets/logo.github.symbolset/logo.github.svg new file mode 100644 index 00000000..2588c07a --- /dev/null +++ b/Swiftfin/Assets.xcassets/logo.github.symbolset/logo.github.svg @@ -0,0 +1,161 @@ + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.3.0 + Requires Xcode 13 or greater + Generated from logo.github + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Swiftfin/Assets.xcassets/swiftfin-logo.imageset/Contents.json b/Swiftfin/Assets.xcassets/swiftfin-logo.imageset/Contents.json deleted file mode 100644 index e708d061..00000000 --- a/Swiftfin/Assets.xcassets/swiftfin-logo.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "swiftfin-logo.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "swiftfin-logo-1.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "swiftfin-logo-2.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Swiftfin/Assets.xcassets/swiftfin-logo.imageset/swiftfin-logo-1.png b/Swiftfin/Assets.xcassets/swiftfin-logo.imageset/swiftfin-logo-1.png deleted file mode 100644 index efdfe428..00000000 Binary files a/Swiftfin/Assets.xcassets/swiftfin-logo.imageset/swiftfin-logo-1.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/swiftfin-logo.imageset/swiftfin-logo-2.png b/Swiftfin/Assets.xcassets/swiftfin-logo.imageset/swiftfin-logo-2.png deleted file mode 100644 index efdfe428..00000000 Binary files a/Swiftfin/Assets.xcassets/swiftfin-logo.imageset/swiftfin-logo-2.png and /dev/null differ diff --git a/Swiftfin/Assets.xcassets/swiftfin-logo.imageset/swiftfin-logo.png b/Swiftfin/Assets.xcassets/swiftfin-logo.imageset/swiftfin-logo.png deleted file mode 100644 index efdfe428..00000000 Binary files a/Swiftfin/Assets.xcassets/swiftfin-logo.imageset/swiftfin-logo.png and /dev/null differ diff --git a/Swiftfin/Components/BasicStepper.swift b/Swiftfin/Components/BasicStepper.swift new file mode 100644 index 00000000..b81e1d45 --- /dev/null +++ b/Swiftfin/Components/BasicStepper.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 SwiftUI + +struct BasicStepper: View { + + @Binding + private var value: Value + + private let title: String + private let range: ClosedRange + private let step: Value.Stride + private var formatter: (Value) -> String + + var body: some View { + Stepper(value: $value, in: range, step: step) { + HStack { + Text(title) + + Spacer() + + formatter(value).text + .foregroundColor(.secondary) + } + } + } +} + +extension BasicStepper { + + init( + title: String, + value: Binding, + range: ClosedRange, + step: Value.Stride + ) { + self.init( + value: value, + title: title, + range: range, + step: step, + formatter: { $0.description } + ) + } + + func valueFormatter(_ formatter: @escaping (Value) -> String) -> Self { + copy(modifying: \.formatter, with: formatter) + } +} diff --git a/Swiftfin/Components/ChevronButton.swift b/Swiftfin/Components/ChevronButton.swift new file mode 100644 index 00000000..59551ddb --- /dev/null +++ b/Swiftfin/Components/ChevronButton.swift @@ -0,0 +1,63 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2023 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct ChevronButton: View { + + private let title: String + private let subtitle: String? + private var leadingView: () -> any View + private var onSelect: () -> Void + + var body: some View { + Button { + onSelect() + } label: { + HStack { + + leadingView() + .eraseToAnyView() + + Text(title) + .foregroundColor(.primary) + + Spacer() + + if let subtitle { + Text(subtitle) + .foregroundColor(.secondary) + } + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + } + } +} + +extension ChevronButton { + + init(title: String, subtitle: String? = nil) { + self.init( + title: title, + subtitle: subtitle, + leadingView: { EmptyView() }, + onSelect: {} + ) + } + + func leadingView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.leadingView, with: content) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin/Components/CircularProgressView.swift b/Swiftfin/Components/CircularProgressView.swift new file mode 100644 index 00000000..9cfb8e6b --- /dev/null +++ b/Swiftfin/Components/CircularProgressView.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 SwiftUI + +struct CircularProgressView: View { + + @State + private var lineWidth: CGFloat = 1 + + let progress: Double + + var body: some View { + ZStack { + Circle() + .stroke( + Color.green.opacity(0.5), + lineWidth: lineWidth + ) + Circle() + .trim(from: 0, to: progress) + .stroke( + Color.green, + style: StrokeStyle( + lineWidth: lineWidth, + lineCap: .round + ) + ) + .rotationEffect(.degrees(-90)) + } + .onSizeChanged { size in + lineWidth = size.width / 3.5 + } + } +} diff --git a/Swiftfin/Components/DotHStack.swift b/Swiftfin/Components/DotHStack.swift index 703b5802..779895d8 100644 --- a/Swiftfin/Components/DotHStack.swift +++ b/Swiftfin/Components/DotHStack.swift @@ -3,233 +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 SwiftUI -// TODO: Check for if statements, look at ViewBuilder's buildIf - struct DotHStack: View { - private let items: [AnyView] - private let restItems: [AnyView] - private let alignment: HorizontalAlignment + @ViewBuilder + var content: () -> any View var body: some View { - HStack { - items.first - - ForEach(0 ..< restItems.count, id: \.self) { i in - + SeparatorHStack(content) + .separator { Circle() .frame(width: 2, height: 2) - - restItems[i] } - } - } -} - -extension DotHStack { - - init( - _ data: Data, - id: KeyPath = \.self, - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: @escaping (Data.Element) -> Content - ) { - self.alignment = alignment - self.items = data.map { content($0[keyPath: id]).eraseToAnyView() } - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () -> A - ) { - self.alignment = alignment - self.items = [content().eraseToAnyView()] - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () -> TupleView<(A, B)> - ) { - self.alignment = alignment - let _content = content() - - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () -> TupleView<(A, B, C)> - ) { - self.alignment = alignment - let _content = content() - - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - _content.value.2.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () -> TupleView<(A, B, C, D)> - ) { - self.alignment = alignment - let _content = content() - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - _content.value.2.eraseToAnyView(), - _content.value.3.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () -> TupleView<(A, B, C, D, E)> - ) { - self.alignment = alignment - let _content = content() - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - _content.value.2.eraseToAnyView(), - _content.value.3.eraseToAnyView(), - _content.value.4.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () -> TupleView<(A, B, C, D, E, F)> - ) { - self.alignment = alignment - let _content = content() - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - _content.value.2.eraseToAnyView(), - _content.value.3.eraseToAnyView(), - _content.value.4.eraseToAnyView(), - _content.value.5.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () -> TupleView<(A, B, C, D, E, F, G)> - ) { - self.alignment = alignment - let _content = content() - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - _content.value.2.eraseToAnyView(), - _content.value.3.eraseToAnyView(), - _content.value.4.eraseToAnyView(), - _content.value.5.eraseToAnyView(), - _content.value.6.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () - -> TupleView<(A, B, C, D, E, F, G, H)> - ) { - self.alignment = alignment - let _content = content() - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - _content.value.2.eraseToAnyView(), - _content.value.3.eraseToAnyView(), - _content.value.4.eraseToAnyView(), - _content.value.5.eraseToAnyView(), - _content.value.6.eraseToAnyView(), - _content.value.7.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) - } - - init( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () - -> TupleView<(A, B, C, D, E, F, G, H, I)> - ) { - self.alignment = alignment - let _content = content() - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - _content.value.2.eraseToAnyView(), - _content.value.3.eraseToAnyView(), - _content.value.4.eraseToAnyView(), - _content.value.5.eraseToAnyView(), - _content.value.6.eraseToAnyView(), - _content.value.7.eraseToAnyView(), - _content.value.8.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) - } - - init< - A: View, - B: View, - C: View, - D: View, - E: View, - F: View, - G: View, - H: View, - I: View, - J: View - >( - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: () - -> TupleView<( - A, - B, - C, - D, - E, - F, - G, - H, - I, - J - )> - ) { - self.alignment = alignment - let _content = content() - self.items = [ - _content.value.0.eraseToAnyView(), - _content.value.1.eraseToAnyView(), - _content.value.2.eraseToAnyView(), - _content.value.3.eraseToAnyView(), - _content.value.4.eraseToAnyView(), - _content.value.5.eraseToAnyView(), - _content.value.6.eraseToAnyView(), - _content.value.7.eraseToAnyView(), - _content.value.8.eraseToAnyView(), - _content.value.9.eraseToAnyView(), - ] - self.restItems = Array(items.dropFirst()) } } diff --git a/Swiftfin/Components/FilterDrawerHStack/FilterDrawerButton.swift b/Swiftfin/Components/FilterDrawerHStack/FilterDrawerButton.swift index 98cb9eac..724717d1 100644 --- a/Swiftfin/Components/FilterDrawerHStack/FilterDrawerButton.swift +++ b/Swiftfin/Components/FilterDrawerHStack/FilterDrawerButton.swift @@ -3,14 +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 extension FilterDrawerHStack { + struct FilterDrawerButton: View { + @Default(.accentColor) + private var accentColor + private let systemName: String? private let title: String private let activated: Bool @@ -50,12 +55,12 @@ extension FilterDrawerHStack { .padding(.vertical, 5) .background { Capsule() - .foregroundColor(activated ? .jellyfinPurple : Color(UIColor.secondarySystemFill)) + .foregroundColor(activated ? accentColor : Color(UIColor.secondarySystemFill)) .opacity(0.5) } .overlay { Capsule() - .stroke(activated ? .purple : Color(UIColor.secondarySystemFill), lineWidth: 1) + .stroke(activated ? accentColor : Color(UIColor.secondarySystemFill), lineWidth: 1) } } } @@ -82,8 +87,6 @@ extension FilterDrawerHStack.FilterDrawerButton { } func onSelect(_ action: @escaping () -> Void) -> Self { - var copy = self - copy.onSelect = action - return copy + copy(modifying: \.onSelect, with: action) } } diff --git a/Swiftfin/Components/FilterDrawerHStack/FilterDrawerHStack.swift b/Swiftfin/Components/FilterDrawerHStack/FilterDrawerHStack.swift index 38c6f0ae..b2a626e8 100644 --- a/Swiftfin/Components/FilterDrawerHStack/FilterDrawerHStack.swift +++ b/Swiftfin/Components/FilterDrawerHStack/FilterDrawerHStack.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 @@ -80,9 +80,7 @@ extension FilterDrawerHStack { self.onSelect = { _ in } } - func onSelect(_ onSelect: @escaping (FilterCoordinator.Parameters) -> Void) -> Self { - var copy = self - copy.onSelect = onSelect - return copy + func onSelect(_ action: @escaping (FilterCoordinator.Parameters) -> Void) -> Self { + copy(modifying: \.onSelect, with: action) } } diff --git a/Swiftfin/Components/GestureView.swift b/Swiftfin/Components/GestureView.swift new file mode 100644 index 00000000..f37cde8a --- /dev/null +++ b/Swiftfin/Components/GestureView.swift @@ -0,0 +1,320 @@ +// +// 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 Foundation +import SwiftUI + +// TODO: change swipe to directional +// TODO: figure out way for multitap near the middle be distinguished as different sides + +// state, point, velocity, translation +typealias PanGestureHandler = (UIGestureRecognizer.State, UnitPoint, CGFloat, CGFloat) -> Void +// state, point, scale +typealias PinchGestureHandler = (UIGestureRecognizer.State, UnitPoint, CGFloat) -> Void +// point, direction, amount +typealias SwipeGestureHandler = (UnitPoint, Bool, Int) -> Void +// point, amount +typealias TapGestureHandler = (UnitPoint, Int) -> Void + +struct GestureView: UIViewRepresentable { + + private var onHorizontalPan: PanGestureHandler? + private var onHorizontalSwipe: SwipeGestureHandler? + private var onLongPress: ((UnitPoint) -> Void)? + private var onPinch: PinchGestureHandler? + private var onTap: TapGestureHandler? + private var onDoubleTouch: TapGestureHandler? + private var onVerticalPan: PanGestureHandler? + + private var longPressMinimumDuration: TimeInterval + private var samePointPadding: CGFloat + private var samePointTimeout: TimeInterval + private var swipeTranslation: CGFloat + private var swipeVelocity: CGFloat + private var sameSwipeDirectionTimeout: TimeInterval + + func makeUIView(context: Context) -> UIGestureView { + UIGestureView( + onHorizontalPan: onHorizontalPan, + onHorizontalSwipe: onHorizontalSwipe, + onLongPress: onLongPress, + onPinch: onPinch, + onTap: onTap, + onDoubleTouch: onDoubleTouch, + onVerticalPan: onVerticalPan, + longPressMinimumDuration: longPressMinimumDuration, + samePointPadding: samePointPadding, + samePointTimeout: samePointTimeout, + swipeTranslation: swipeTranslation, + swipeVelocity: swipeVelocity, + sameSwipeDirectionTimeout: sameSwipeDirectionTimeout + ) + } + + func updateUIView(_ uiView: UIGestureView, context: Context) {} +} + +extension GestureView { + + init() { + self.init( + longPressMinimumDuration: 0, + samePointPadding: 0, + samePointTimeout: 0, + swipeTranslation: 0, + swipeVelocity: 0, + sameSwipeDirectionTimeout: 0 + ) + } + + func onHorizontalPan(_ action: @escaping PanGestureHandler) -> Self { + copy(modifying: \.onHorizontalPan, with: action) + } + + func onHorizontalSwipe( + translation: CGFloat, + velocity: CGFloat, + sameSwipeDirectionTimeout: TimeInterval = 0, + _ action: @escaping SwipeGestureHandler + ) -> Self { + copy(modifying: \.swipeTranslation, with: translation) + .copy(modifying: \.swipeVelocity, with: velocity) + .copy(modifying: \.sameSwipeDirectionTimeout, with: sameSwipeDirectionTimeout) + .copy(modifying: \.onHorizontalSwipe, with: action) + } + + func onPinch(_ action: @escaping PinchGestureHandler) -> Self { + copy(modifying: \.onPinch, with: action) + } + + func onTap( + samePointPadding: CGFloat, + samePointTimeout: TimeInterval, + _ action: @escaping TapGestureHandler + ) -> Self { + copy(modifying: \.samePointPadding, with: samePointPadding) + .copy(modifying: \.samePointTimeout, with: samePointTimeout) + .copy(modifying: \.onTap, with: action) + } + + func onDoubleTouch(_ action: @escaping TapGestureHandler) -> Self { + copy(modifying: \.onDoubleTouch, with: action) + } + + func onLongPress(minimumDuration: TimeInterval, _ action: @escaping (UnitPoint) -> Void) -> Self { + copy(modifying: \.longPressMinimumDuration, with: minimumDuration) + .copy(modifying: \.onLongPress, with: action) + } + + func onVerticalPan(_ action: @escaping PanGestureHandler) -> Self { + copy(modifying: \.onVerticalPan, with: action) + } +} + +class UIGestureView: UIView { + + private let onHorizontalPan: PanGestureHandler? + private let onHorizontalSwipe: SwipeGestureHandler? + private let onLongPress: ((UnitPoint) -> Void)? + private let onPinch: PinchGestureHandler? + private let onTap: TapGestureHandler? + private let onDoubleTouch: TapGestureHandler? + private let onVerticalPan: PanGestureHandler? + + private let longPressMinimumDuration: TimeInterval + private let samePointPadding: CGFloat + private let samePointTimeout: TimeInterval + private let swipeTranslation: CGFloat + private let swipeVelocity: CGFloat + private var sameSwipeDirectionTimeout: TimeInterval + + private var hasSwiped: Bool = false + private var lastSwipeDirection: Bool? + private var lastTouchLocation: CGPoint? + private var multiTapWorkItem: DispatchWorkItem? + private var sameSwipeWorkItem: DispatchWorkItem? + private var multiTapAmount: Int = 0 + private var sameSwipeAmount: Int = 0 + + init( + onHorizontalPan: PanGestureHandler?, + onHorizontalSwipe: SwipeGestureHandler?, + onLongPress: ((UnitPoint) -> Void)?, + onPinch: PinchGestureHandler?, + onTap: TapGestureHandler?, + onDoubleTouch: TapGestureHandler?, + onVerticalPan: PanGestureHandler?, + longPressMinimumDuration: TimeInterval, + samePointPadding: CGFloat, + samePointTimeout: TimeInterval, + swipeTranslation: CGFloat, + swipeVelocity: CGFloat, + sameSwipeDirectionTimeout: TimeInterval + ) { + self.onHorizontalPan = onHorizontalPan + self.onHorizontalSwipe = onHorizontalSwipe + self.onLongPress = onLongPress + self.onPinch = onPinch + self.onTap = onTap + self.onDoubleTouch = onDoubleTouch + self.onVerticalPan = onVerticalPan + self.longPressMinimumDuration = longPressMinimumDuration + self.samePointPadding = samePointPadding + self.samePointTimeout = samePointTimeout + self.swipeTranslation = swipeTranslation + self.swipeVelocity = swipeVelocity + self.sameSwipeDirectionTimeout = sameSwipeDirectionTimeout + super.init(frame: .zero) + + let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(didPerformPinch)) + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didPerformTap)) + let doubleTouchGesture = UITapGestureRecognizer(target: self, action: #selector(didPerformTap)) + doubleTouchGesture.numberOfTouchesRequired = 2 + let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(didPerformLongPress)) + longPressGesture.minimumPressDuration = longPressMinimumDuration + let verticalPanGesture = PanDirectionGestureRecognizer( + direction: .vertical, + target: self, + action: #selector(didPerformVerticalPan) + ) + let horizontalPanGesture = PanDirectionGestureRecognizer( + direction: .horizontal, + target: self, + action: #selector(didPerformHorizontalPan) + ) + + addGestureRecognizer(pinchGesture) + addGestureRecognizer(tapGesture) + addGestureRecognizer(doubleTouchGesture) + addGestureRecognizer(longPressGesture) + addGestureRecognizer(verticalPanGesture) + addGestureRecognizer(horizontalPanGesture) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc + private func didPerformHorizontalPan(_ gestureRecognizer: PanDirectionGestureRecognizer) { + let translation = gestureRecognizer.translation(in: self).x + let unitPoint = gestureRecognizer.unitPoint(in: self) + let velocity = gestureRecognizer.velocity(in: self).x + + onHorizontalPan?(gestureRecognizer.state, unitPoint, velocity, translation) + + if !hasSwiped, + abs(translation) >= swipeTranslation, + abs(velocity) >= swipeVelocity + { + didPerformSwipe(unitPoint: unitPoint, direction: translation > 0) + + hasSwiped = true + } + + if gestureRecognizer.state == .ended { + hasSwiped = false + } + } + + private func didPerformSwipe(unitPoint: UnitPoint, direction: Bool) { + + if lastSwipeDirection == direction { + sameSwipeOccurred(unitPoint: unitPoint, direction: direction) + onHorizontalSwipe?(unitPoint, direction, sameSwipeAmount) + } else { + sameSwipeOccurred(unitPoint: unitPoint, direction: direction) + onHorizontalSwipe?(unitPoint, direction, 1) + } + } + + private func sameSwipeOccurred(unitPoint: UnitPoint, direction: Bool) { + guard sameSwipeDirectionTimeout > 0 else { return } + lastSwipeDirection = direction + + sameSwipeAmount += 1 + + sameSwipeWorkItem?.cancel() + let task = DispatchWorkItem { + self.sameSwipeAmount = 0 + self.lastSwipeDirection = nil + } + + sameSwipeWorkItem = task + + DispatchQueue.main.asyncAfter(deadline: .now() + sameSwipeDirectionTimeout, execute: task) + } + + @objc + private func didPerformLongPress(_ gestureRecognizer: UILongPressGestureRecognizer) { + guard let onLongPress, gestureRecognizer.state == .began else { return } + let unitPoint = gestureRecognizer.unitPoint(in: self) + + onLongPress(unitPoint) + } + + @objc + private func didPerformPinch(_ gestureRecognizer: UIPinchGestureRecognizer) { + guard let onPinch else { return } + let unitPoint = gestureRecognizer.unitPoint(in: self) + + onPinch(gestureRecognizer.state, unitPoint, gestureRecognizer.scale) + } + + @objc + private func didPerformTap(_ gestureRecognizer: UITapGestureRecognizer) { + guard let onTap else { return } + let location = gestureRecognizer.location(in: self) + let unitPoint = gestureRecognizer.unitPoint(in: self) + + if let lastTouchLocation, lastTouchLocation.isNear(lastTouchLocation, padding: samePointPadding) { + multiTapOccurred(at: location) + onTap(unitPoint, multiTapAmount) + } else { + multiTapOccurred(at: location) + onTap(unitPoint, 1) + } + } + + @objc + private func didPerformDoubleTouch(_ gestureRecognizer: UITapGestureRecognizer) { + guard let onDoubleTouch else { return } + let unitPoint = gestureRecognizer.unitPoint(in: self) + + onDoubleTouch(unitPoint, 1) + } + + @objc + private func didPerformVerticalPan(_ gestureRecognizer: PanDirectionGestureRecognizer) { + guard let onVerticalPan else { return } + let translation = gestureRecognizer.translation(in: self).y + let unitPoint = gestureRecognizer.unitPoint(in: self) + let velocity = gestureRecognizer.velocity(in: self).y + + onVerticalPan(gestureRecognizer.state, unitPoint, velocity, translation) + } + + private func multiTapOccurred(at location: CGPoint) { + guard samePointTimeout > 0 else { return } + lastTouchLocation = location + + multiTapAmount += 1 + + multiTapWorkItem?.cancel() + let task = DispatchWorkItem { + self.multiTapAmount = 0 + self.lastTouchLocation = nil + } + + multiTapWorkItem = task + + DispatchQueue.main.asyncAfter(deadline: .now() + samePointTimeout, execute: task) + } +} diff --git a/Swiftfin/Components/LandscapePosterProgressBar.swift b/Swiftfin/Components/LandscapePosterProgressBar.swift index e541b307..69361a56 100644 --- a/Swiftfin/Components/LandscapePosterProgressBar.swift +++ b/Swiftfin/Components/LandscapePosterProgressBar.swift @@ -3,13 +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 Defaults import SwiftUI struct LandscapePosterProgressBar: View { + @Default(.accentColor) + private var accentColor + let title: String let progress: CGFloat @@ -39,6 +43,7 @@ struct LandscapePosterProgressBar: View { .foregroundColor(.white) ProgressBar(progress: progress) + .foregroundColor(accentColor) .frame(height: 3) } .padding(.horizontal, 5 * paddingScale) diff --git a/Swiftfin/Components/LibraryItemRow.swift b/Swiftfin/Components/LibraryItemRow.swift index 3102a95d..f828f67d 100644 --- a/Swiftfin/Components/LibraryItemRow.swift +++ b/Swiftfin/Components/LibraryItemRow.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 @@ -14,7 +14,7 @@ struct LibraryItemRow: View { @EnvironmentObject private var router: LibraryCoordinator.Router - let item: BaseItemDto + private let item: BaseItemDto private var onSelect: () -> Void var body: some View { @@ -24,9 +24,10 @@ struct LibraryItemRow: View { HStack(alignment: .bottom) { ImageView(item.portraitPosterImageSource(maxWidth: 60)) .posterStyle(type: .portrait, width: 60) + .posterShadow() VStack(alignment: .leading) { - Text(item.displayName) + Text(item.displayTitle) .foregroundColor(.primary) .fontWeight(.semibold) .lineLimit(2) @@ -66,8 +67,6 @@ extension LibraryItemRow { } func onSelect(_ action: @escaping () -> Void) -> Self { - var copy = self - copy.onSelect = action - return copy + copy(modifying: \.onSelect, with: action) } } diff --git a/Swiftfin/Components/LibraryViewTypeToggle.swift b/Swiftfin/Components/LibraryViewTypeToggle.swift index b05f1f9d..efd1ff13 100644 --- a/Swiftfin/Components/LibraryViewTypeToggle.swift +++ b/Swiftfin/Components/LibraryViewTypeToggle.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/Swiftfin/Components/MenuPosterHStack.swift b/Swiftfin/Components/MenuPosterHStack.swift new file mode 100644 index 00000000..9290a2d1 --- /dev/null +++ b/Swiftfin/Components/MenuPosterHStack.swift @@ -0,0 +1,119 @@ +// +// 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 SwiftUI + +struct MenuPosterHStack: View { + + @ObservedObject + private var manager: Model + + private let type: PosterType + private var itemScale: CGFloat + private let singleImage: Bool + private var content: (PosterButtonType) -> any View + private var imageOverlay: (PosterButtonType) -> any View + private var contextMenu: (PosterButtonType) -> any View + private var onSelect: (Model.Item) -> Void + + @ViewBuilder + private var selectorMenu: some View { + Menu { + ForEach(manager.menuSections.keys.sorted(by: { manager.menuSectionSort($0, $1) }), id: \.displayTitle) { section in + Button { + manager.select(section: section) + } label: { + if section == manager.menuSelection { + Label(section.displayTitle, systemImage: "checkmark") + } else { + Text(section.displayTitle) + } + } + } + } label: { + HStack(spacing: 5) { + Group { + Text(manager.menuSelection?.displayTitle ?? L10n.unknown) + .fixedSize() + Image(systemName: "chevron.down") + } + .font(.title3.weight(.semibold)) + } + } + .padding(.bottom) + .fixedSize() + } + + private var items: [PosterButtonType] { + guard let selection = manager.menuSelection, + let items = manager.menuSections[selection] else { return [.noResult] } + return items + } + + var body: some View { + PosterHStack( + type: type, + items: items, + singleImage: singleImage + ) + .header { + selectorMenu + } + .scaleItems(itemScale) + .content(content) + .imageOverlay(imageOverlay) + .contextMenu(contextMenu) + .onSelect { item in + onSelect(item) + } + } +} + +extension MenuPosterHStack { + + init( + type: PosterType, + manager: Model, + singleImage: Bool = false + ) { + self.init( + manager: manager, + type: type, + itemScale: 1, + singleImage: singleImage, + content: { _ in EmptyView() }, + imageOverlay: { _ in EmptyView() }, + contextMenu: { _ in EmptyView() }, + onSelect: { _ in } + ) + } +} + +extension MenuPosterHStack { + + func scaleItems(_ scale: CGFloat) -> Self { + copy(modifying: \.itemScale, with: scale) + } + + func content(@ViewBuilder _ content: @escaping (PosterButtonType) -> any View) -> Self { + copy(modifying: \.content, with: content) + } + + func imageOverlay(@ViewBuilder _ content: @escaping (PosterButtonType) -> any View) -> Self { + copy(modifying: \.imageOverlay, with: content) + } + + func contextMenu(@ViewBuilder _ content: @escaping (PosterButtonType) -> any View) -> Self { + copy(modifying: \.contextMenu, with: content) + } + + func onSelect(_ action: @escaping (Model.Item) -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin/Components/PagingLibraryView.swift b/Swiftfin/Components/PagingLibraryView.swift index 98782cf1..9ac8c7df 100644 --- a/Swiftfin/Components/PagingLibraryView.swift +++ b/Swiftfin/Components/PagingLibraryView.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 CollectionView @@ -13,15 +13,16 @@ import SwiftUI struct PagingLibraryView: View { - @ObservedObject - var viewModel: PagingLibraryViewModel - private var onSelect: (BaseItemDto) -> Void - @Default(.Customization.Library.gridPosterType) private var libraryGridPosterType @Default(.Customization.Library.viewType) private var libraryViewType + @ObservedObject + var viewModel: PagingLibraryViewModel + + private var onSelect: (BaseItemDto) -> Void + private var gridLayout: NSCollectionLayoutSection.GridLayoutMode { if libraryGridPosterType == .landscape && UIDevice.isPhone { return .fixedNumberOfColumns(2) @@ -32,7 +33,7 @@ struct PagingLibraryView: View { @ViewBuilder private var libraryListView: some View { - CollectionView(items: viewModel.items) { _, item, _ in + CollectionView(items: viewModel.items.elements) { _, item, _ in LibraryItemRow(item: item) .onSelect { onSelect(item) @@ -59,8 +60,8 @@ struct PagingLibraryView: View { @ViewBuilder private var libraryGridView: some View { - CollectionView(items: viewModel.items) { _, item, _ in - PosterButton(item: item, type: libraryGridPosterType) + CollectionView(items: viewModel.items.elements) { _, item, _ in + PosterButton(state: .item(item), type: libraryGridPosterType) .scaleItem(libraryGridPosterType == .landscape && UIDevice.isPhone ? 0.85 : 1) .onSelect { onSelect(item) @@ -100,13 +101,13 @@ struct PagingLibraryView: View { extension PagingLibraryView { init(viewModel: PagingLibraryViewModel) { - self.viewModel = viewModel - self.onSelect = { _ in } + self.init( + viewModel: viewModel, + onSelect: { _ in } + ) } func onSelect(_ action: @escaping (BaseItemDto) -> Void) -> Self { - var copy = self - copy.onSelect = action - return copy + copy(modifying: \.onSelect, with: action) } } diff --git a/Swiftfin/Components/PillHStack.swift b/Swiftfin/Components/PillHStack.swift index 525299a5..b843fd45 100644 --- a/Swiftfin/Components/PillHStack.swift +++ b/Swiftfin/Components/PillHStack.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 @@ -14,16 +14,6 @@ struct PillHStack: View { private var items: [Item] private var onSelect: (Item) -> Void - private init( - title: String, - items: [Item], - onSelect: @escaping (Item) -> Void - ) { - self.title = title - self.items = items - self.onSelect = onSelect - } - var body: some View { VStack(alignment: .leading) { Text(title) @@ -37,11 +27,11 @@ struct PillHStack: View { ScrollView(.horizontal, showsIndicators: false) { HStack { - ForEach(items, id: \.displayName) { item in + ForEach(items, id: \.displayTitle) { item in Button { onSelect(item) } label: { - Text(item.displayName) + Text(item.displayTitle) .font(.caption) .fontWeight(.semibold) .foregroundColor(.primary) @@ -65,12 +55,14 @@ struct PillHStack: View { extension PillHStack { init(title: String, items: [Item]) { - self.init(title: title, items: items, onSelect: { _ in }) + self.init( + title: title, + items: items, + onSelect: { _ in } + ) } - func onSelect(_ onSelect: @escaping (Item) -> Void) -> Self { - var copy = self - copy.onSelect = onSelect - return copy + func onSelect(_ action: @escaping (Item) -> Void) -> Self { + copy(modifying: \.onSelect, with: action) } } diff --git a/Swiftfin/Components/PosterButton.swift b/Swiftfin/Components/PosterButton.swift index 06355a20..28da24c6 100644 --- a/Swiftfin/Components/PosterButton.swift +++ b/Swiftfin/Components/PosterButton.swift @@ -3,20 +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 Defaults +import JellyfinAPI import SwiftUI -struct PosterButton: View { +// TODO: Look at something better for accomadating loading/noResults/other types - private var item: Item +struct PosterButton: View { + + private var state: PosterButtonType private var type: PosterType private var itemScale: CGFloat private var horizontalAlignment: HorizontalAlignment - private var content: () -> Content - private var imageOverlay: () -> ImageOverlay - private var contextMenu: () -> ContextMenu + private var content: (PosterButtonType) -> any View + private var imageOverlay: (PosterButtonType) -> any View + private var contextMenu: (PosterButtonType) -> any View private var onSelect: () -> Void private var singleImage: Bool @@ -24,170 +28,219 @@ struct PosterButton Content, - @ViewBuilder imageOverlay: @escaping () -> ImageOverlay, - @ViewBuilder contextMenu: @escaping () -> ContextMenu, - onSelect: @escaping () -> Void, - singleImage: Bool - ) { - self.item = item - self.type = type - self.itemScale = itemScale - self.horizontalAlignment = horizontalAlignment - self.content = content - self.imageOverlay = imageOverlay - self.contextMenu = contextMenu - self.onSelect = onSelect - self.singleImage = singleImage + @ViewBuilder + private var loadingPoster: some View { + Color.secondarySystemFill + .posterStyle(type: type, width: itemWidth) + } + + @ViewBuilder + private var noResultsPoster: some View { + Color.secondarySystemFill + .posterStyle(type: type, width: itemWidth) + } + + @ViewBuilder + private func poster(from item: any Poster) -> some View { + Group { + switch type { + case .portrait: + ImageView(item.portraitPosterImageSource(maxWidth: itemWidth)) + .failure { + InitialFailureView(item.displayTitle.initials) + } + case .landscape: + ImageView(item.landscapePosterImageSources(maxWidth: itemWidth, single: singleImage)) + .failure { + InitialFailureView(item.displayTitle.initials) + } + } + } } var body: some View { VStack(alignment: horizontalAlignment) { + Button { onSelect() } label: { Group { - switch type { - case .portrait: - ImageView(item.portraitPosterImageSource(maxWidth: itemWidth)) - .failure { - InitialFailureView(item.displayName.initials) - } - case .landscape: - ImageView(item.landscapePosterImageSources(maxWidth: itemWidth, single: singleImage)) - .failure { - InitialFailureView(item.displayName.initials) - } + switch state { + case .loading: + loadingPoster + case .noResult: + noResultsPoster + case let .item(item): + poster(from: item) } } .overlay { - imageOverlay() + imageOverlay(state) + .eraseToAnyView() .posterStyle(type: type, width: itemWidth) } } .contextMenu(menuItems: { - contextMenu() + contextMenu(state) + .eraseToAnyView() }) .posterStyle(type: type, width: itemWidth) .posterShadow() - content() + content(state) + .eraseToAnyView() } .frame(width: itemWidth) } } -extension PosterButton where Content == PosterButtonDefaultContentView, - ImageOverlay == EmptyView, - ContextMenu == EmptyView -{ - init(item: Item, type: PosterType, singleImage: Bool = false) { +extension PosterButton { + + init( + state: PosterButtonType, + type: PosterType, + singleImage: Bool = false + ) { self.init( - item: item, + state: state, type: type, itemScale: 1, horizontalAlignment: .leading, - content: { PosterButtonDefaultContentView(item: item) }, - imageOverlay: { EmptyView() }, - contextMenu: { EmptyView() }, + content: { DefaultContentView(state: $0) }, + imageOverlay: { DefaultOverlay(state: $0) }, + contextMenu: { _ in EmptyView() }, onSelect: {}, singleImage: singleImage ) } -} -extension PosterButton { func horizontalAlignment(_ alignment: HorizontalAlignment) -> Self { - var copy = self - copy.horizontalAlignment = alignment - return copy + copy(modifying: \.horizontalAlignment, with: alignment) } func scaleItem(_ scale: CGFloat) -> Self { - var copy = self - copy.itemScale = scale - return copy + copy(modifying: \.itemScale, with: scale) } - @ViewBuilder - func content(@ViewBuilder _ content: @escaping () -> C) -> PosterButton { - PosterButton( - item: item, - type: type, - itemScale: itemScale, - horizontalAlignment: horizontalAlignment, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - onSelect: onSelect, - singleImage: singleImage - ) + func content(@ViewBuilder _ content: @escaping (PosterButtonType) -> any View) -> Self { + copy(modifying: \.content, with: content) } - @ViewBuilder - func imageOverlay(@ViewBuilder _ imageOverlay: @escaping () -> O) -> PosterButton { - PosterButton( - item: item, - type: type, - itemScale: itemScale, - horizontalAlignment: horizontalAlignment, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - onSelect: onSelect, - singleImage: singleImage - ) + func imageOverlay(@ViewBuilder _ content: @escaping (PosterButtonType) -> any View) -> Self { + copy(modifying: \.imageOverlay, with: content) } - @ViewBuilder - func contextMenu(@ViewBuilder _ contextMenu: @escaping () -> M) -> PosterButton { - PosterButton( - item: item, - type: type, - itemScale: itemScale, - horizontalAlignment: horizontalAlignment, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - onSelect: onSelect, - singleImage: singleImage - ) + func contextMenu(@ViewBuilder _ content: @escaping (PosterButtonType) -> any View) -> Self { + copy(modifying: \.contextMenu, with: content) } func onSelect(_ action: @escaping () -> Void) -> Self { - var copy = self - copy.onSelect = action - return copy + copy(modifying: \.onSelect, with: action) } } -// MARK: default content view +extension PosterButton { -struct PosterButtonDefaultContentView: View { + // MARK: Default Content - let item: Item + struct DefaultContentView: View { - var body: some View { - VStack(alignment: .leading) { - if item.showTitle { - Text(item.displayName) - .font(.footnote) - .fontWeight(.regular) - .foregroundColor(.primary) - .lineLimit(2) + let state: PosterButtonType + + @ViewBuilder + private var title: some View { + Group { + switch state { + case .loading: + String(repeating: "a", count: Int.random(in: 5 ..< 8)).text + .redacted(reason: .placeholder) + case .noResult: + L10n.noResults.text + case let .item(item): + if item.showTitle { + Text(item.displayTitle) + } else { + EmptyView() + } + } } + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + .lineLimit(2) + } - if let description = item.subtitle { - Text(description) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(2) + @ViewBuilder + private var subtitle: some View { + Group { + switch state { + case .loading: + String(repeating: "a", count: Int.random(in: 8 ..< 15)).text + .redacted(reason: .placeholder) + case .noResult: + L10n.noResults.text + case let .item(item): + if let subtitle = item.subtitle { + Text(subtitle) + } else { + EmptyView() + } + } + } + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + .lineLimit(2) + } + + var body: some View { + VStack(alignment: .leading) { + title + + subtitle + } + } + } + + // MARK: Default Overlay + + struct DefaultOverlay: View { + + @Default(.accentColor) + private var accentColor + @Default(.Customization.Indicators.showFavorited) + private var showFavorited + @Default(.Customization.Indicators.showProgress) + private var showProgress + @Default(.Customization.Indicators.showUnplayed) + private var showUnplayed + @Default(.Customization.Indicators.showPlayed) + private var showPlayed + + let state: PosterButtonType + + var body: some View { + if case let PosterButtonType.item(item) = state { + ZStack { + if let item = item as? BaseItemDto { + if item.userData?.isPlayed ?? false { + WatchedIndicator(size: 25) + .visible(showPlayed) + } else { + if (item.userData?.playbackPositionTicks ?? 0) > 0 { + ProgressIndicator(progress: (item.userData?.playedPercentage ?? 0) / 100, height: 5) + .visible(showProgress) + } else { + UnwatchedIndicator(size: 25) + .foregroundColor(accentColor) + .visible(showUnplayed) + } + } + + if item.userData?.isFavorite ?? false { + FavoriteIndicator(size: 25) + .visible(showFavorited) + } + } + } } } } diff --git a/Swiftfin/Components/PosterHStack.swift b/Swiftfin/Components/PosterHStack.swift index db7a6851..feedddc3 100644 --- a/Swiftfin/Components/PosterHStack.swift +++ b/Swiftfin/Components/PosterHStack.swift @@ -3,57 +3,38 @@ // License, v2.0. If 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 CollectionView import SwiftUI -struct PosterHStack: View { +// TODO: Remove `Header` and `TrailingContent` and create `HeaderPosterHStack` - private var title: String +struct PosterHStack: View { + + private var header: () -> any View + private var title: String? private var type: PosterType - private var items: [Item] + private var items: [PosterButtonType] + private var singleImage: Bool private var itemScale: CGFloat - private var content: (Item) -> Content - private var imageOverlay: (Item) -> ImageOverlay - private var contextMenu: (Item) -> ContextMenu - private var trailingContent: () -> TrailingContent + private var content: (PosterButtonType) -> any View + private var imageOverlay: (PosterButtonType) -> any View + private var contextMenu: (PosterButtonType) -> any View + private var trailingContent: () -> any View private var onSelect: (Item) -> Void - private init( - title: String, - type: PosterType, - items: [Item], - itemScale: CGFloat, - @ViewBuilder content: @escaping (Item) -> Content, - @ViewBuilder imageOverlay: @escaping (Item) -> ImageOverlay, - @ViewBuilder contextMenu: @escaping (Item) -> ContextMenu, - @ViewBuilder trailingContent: @escaping () -> TrailingContent, - onSelect: @escaping (Item) -> Void - ) { - self.title = title - self.type = type - self.items = items - self.itemScale = itemScale - self.content = content - self.imageOverlay = imageOverlay - self.contextMenu = contextMenu - self.trailingContent = trailingContent - self.onSelect = onSelect - } - var body: some View { VStack(alignment: .leading) { + HStack { - Text(title) - .font(.title2) - .fontWeight(.semibold) - .accessibility(addTraits: [.isHeader]) + header() + .eraseToAnyView() Spacer() trailingContent() + .eraseToAnyView() } .padding(.horizontal) .if(UIDevice.isIPad) { view in @@ -62,13 +43,22 @@ struct PosterHStack, - ImageOverlay == EmptyView, - ContextMenu == EmptyView, - TrailingContent == EmptyView -{ +extension PosterHStack { + + // TODO: Remove init( title: String, type: PosterType, - items: [Item] + items: [Item], + singleImage: Bool = false ) { self.init( + header: { DefaultHeader(title: title) }, title: title, type: type, - items: items, + items: items.map { PosterButtonType.item($0) }, + singleImage: singleImage, itemScale: 1, - content: { PosterButtonDefaultContentView(item: $0) }, - imageOverlay: { _ in EmptyView() }, + content: { PosterButton.DefaultContentView(state: $0) }, + imageOverlay: { PosterButton.DefaultOverlay(state: $0) }, contextMenu: { _ in EmptyView() }, trailingContent: { EmptyView() }, onSelect: { _ in } ) } -} -extension PosterHStack { + init( + title: String, + type: PosterType, + items: [PosterButtonType], + singleImage: Bool = false + ) { + self.init( + header: { DefaultHeader(title: title) }, + title: title, + type: type, + items: items, + singleImage: singleImage, + itemScale: 1, + content: { PosterButton.DefaultContentView(state: $0) }, + imageOverlay: { PosterButton.DefaultOverlay(state: $0) }, + contextMenu: { _ in EmptyView() }, + trailingContent: { EmptyView() }, + onSelect: { _ in } + ) + } + + init( + type: PosterType, + items: [PosterButtonType], + singleImage: Bool = false + ) { + self.init( + header: { DefaultHeader(title: nil) }, + title: nil, + type: type, + items: items, + singleImage: singleImage, + itemScale: 1, + content: { PosterButton.DefaultContentView(state: $0) }, + imageOverlay: { PosterButton.DefaultOverlay(state: $0) }, + contextMenu: { _ in EmptyView() }, + trailingContent: { EmptyView() }, + onSelect: { _ in } + ) + } + + func header(@ViewBuilder _ header: @escaping () -> any View) -> Self { + copy(modifying: \.header, with: header) + } func scaleItems(_ scale: CGFloat) -> Self { - var copy = self - copy.itemScale = scale - return copy + copy(modifying: \.itemScale, with: scale) } - @ViewBuilder - func content(@ViewBuilder _ content: @escaping (Item) -> C) - -> PosterHStack { - PosterHStack( - title: title, - type: type, - items: items, - itemScale: itemScale, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - trailingContent: trailingContent, - onSelect: onSelect - ) + func content(@ViewBuilder _ content: @escaping (PosterButtonType) -> any View) -> Self { + copy(modifying: \.content, with: content) } - @ViewBuilder - func imageOverlay(@ViewBuilder _ imageOverlay: @escaping (Item) -> O) - -> PosterHStack { - PosterHStack( - title: title, - type: type, - items: items, - itemScale: itemScale, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - trailingContent: trailingContent, - onSelect: onSelect - ) + func imageOverlay(@ViewBuilder _ content: @escaping (PosterButtonType) -> any View) -> Self { + copy(modifying: \.imageOverlay, with: content) } - @ViewBuilder - func contextMenu(@ViewBuilder _ contextMenu: @escaping (Item) -> M) - -> PosterHStack { - PosterHStack( - title: title, - type: type, - items: items, - itemScale: itemScale, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - trailingContent: trailingContent, - onSelect: onSelect - ) + func contextMenu(@ViewBuilder _ content: @escaping (PosterButtonType) -> any View) -> Self { + copy(modifying: \.contextMenu, with: content) } - @ViewBuilder - func trailing(@ViewBuilder _ trailingContent: @escaping () -> T) - -> PosterHStack { - PosterHStack( - title: title, - type: type, - items: items, - itemScale: itemScale, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - trailingContent: trailingContent, - onSelect: onSelect - ) + func trailing(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.trailingContent, with: content) } func onSelect(_ action: @escaping (Item) -> Void) -> Self { - var copy = self - copy.onSelect = action - return copy + copy(modifying: \.onSelect, with: action) + } +} + +// MARK: Default Header + +extension PosterHStack { + + struct DefaultHeader: View { + + let title: String? + + var body: some View { + if let title { + Text(title) + .font(.title2) + .fontWeight(.semibold) + .accessibility(addTraits: [.isHeader]) + } + } } } diff --git a/Swiftfin/Components/PrimaryButton.swift b/Swiftfin/Components/PrimaryButton.swift index fba33235..75107ff5 100644 --- a/Swiftfin/Components/PrimaryButton.swift +++ b/Swiftfin/Components/PrimaryButton.swift @@ -3,13 +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 Defaults import SwiftUI struct PrimaryButton: View { + @Default(.accentColor) + private var accentColor + private let title: String private let action: () -> Void @@ -24,13 +28,13 @@ struct PrimaryButton: View { } label: { ZStack { Rectangle() - .foregroundColor(Color.jellyfinPurple) + .foregroundColor(accentColor) .frame(maxWidth: 400) .frame(height: 50) .cornerRadius(10) Text(title) - .foregroundColor(Color.white) + .foregroundColor(accentColor.overlayColor) .bold() } } diff --git a/Swiftfin/Components/RefreshableScrollView.swift b/Swiftfin/Components/RefreshableScrollView.swift index 12857675..af0e2d39 100644 --- a/Swiftfin/Components/RefreshableScrollView.swift +++ b/Swiftfin/Components/RefreshableScrollView.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 diff --git a/Swiftfin/Components/SeeAllButton.swift b/Swiftfin/Components/SeeAllButton.swift index f10be39a..13396089 100644 --- a/Swiftfin/Components/SeeAllButton.swift +++ b/Swiftfin/Components/SeeAllButton.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 @@ -26,13 +26,14 @@ struct SeeAllButton: View { } extension SeeAllButton { + init() { - self.onSelect = {} + self.init( + onSelect: {} + ) } func onSelect(_ action: @escaping () -> Void) -> Self { - var copy = self - copy.onSelect = action - return copy + copy(modifying: \.onSelect, with: action) } } diff --git a/Swiftfin/Components/Slider/CapsuleSlider.swift b/Swiftfin/Components/Slider/CapsuleSlider.swift new file mode 100644 index 00000000..b4b42b32 --- /dev/null +++ b/Swiftfin/Components/Slider/CapsuleSlider.swift @@ -0,0 +1,91 @@ +// +// 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 SwiftUI + +struct CapsuleSlider: View { + + @Default(.VideoPlayer.Overlay.sliderColor) + private var sliderColor + + @Binding + private var isEditing: Bool + @Binding + private var progress: CGFloat + + private var trackMask: () -> any View + private var topContent: () -> any View + private var bottomContent: () -> any View + private var leadingContent: () -> any View + private var trailingContent: () -> any View + + var body: some View { + Slider(progress: $progress) + .gestureBehavior(.track) + .trackGesturePadding(.init(top: 10, leading: 0, bottom: 30, trailing: 0)) + .onEditingChanged { isEditing in + self.isEditing = isEditing + } + .track { + Capsule() + .frame(height: isEditing ? 20 : 10) + .foregroundColor(isEditing ? sliderColor : sliderColor.opacity(0.8)) + } + .trackBackground { + Capsule() + .frame(height: isEditing ? 20 : 10) + .foregroundColor(Color.gray) + .opacity(0.5) + } + .trackMask(trackMask) + .topContent(topContent) + .bottomContent(bottomContent) + .leadingContent(leadingContent) + .trailingContent(trailingContent) + } +} + +extension CapsuleSlider { + + init(progress: Binding) { + self.init( + isEditing: .constant(false), + progress: progress, + trackMask: { Color.white }, + topContent: { EmptyView() }, + bottomContent: { EmptyView() }, + leadingContent: { EmptyView() }, + trailingContent: { EmptyView() } + ) + } + + func isEditing(_ isEditing: Binding) -> Self { + copy(modifying: \._isEditing, with: isEditing) + } + + func trackMask(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.trackMask, with: content) + } + + func topContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.topContent, with: content) + } + + func bottomContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.bottomContent, with: content) + } + + func leadingContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.leadingContent, with: content) + } + + func trailingContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.trailingContent, with: content) + } +} diff --git a/Swiftfin/Components/Slider/Slider.swift b/Swiftfin/Components/Slider/Slider.swift new file mode 100644 index 00000000..505d59eb --- /dev/null +++ b/Swiftfin/Components/Slider/Slider.swift @@ -0,0 +1,206 @@ +// +// 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 Slider: View { + + enum Behavior { + case thumb + case track + } + + @Binding + private var progress: CGFloat + + @State + private var isEditing: Bool = false + @State + private var totalWidth: CGFloat = 0 + @State + private var dragStartProgress: CGFloat = 0 + @State + private var currentTranslationStartLocation: CGPoint = .zero + @State + private var currentTranslation: CGFloat = 0 + @State + private var thumbSize: CGSize = .zero + + private var sliderBehavior: Behavior + private var trackGesturePadding: EdgeInsets + private var track: () -> any View + private var trackBackground: () -> any View + private var trackMask: () -> any View + private var thumb: () -> any View + private var topContent: () -> any View + private var bottomContent: () -> any View + private var leadingContent: () -> any View + private var trailingContent: () -> any View + private var onEditingChanged: (Bool) -> Void + private var progressAnimation: Animation + + private var trackDrag: some Gesture { + DragGesture(coordinateSpace: .global) + .onChanged { value in + if !isEditing { + isEditing = true + onEditingChanged(true) + dragStartProgress = progress + currentTranslationStartLocation = value.location + currentTranslation = 0 + } + + currentTranslation = currentTranslationStartLocation.x - value.location.x + + let newProgress: CGFloat = dragStartProgress - currentTranslation / totalWidth + progress = min(max(0, newProgress), 1) + } + .onEnded { _ in + isEditing = false + onEditingChanged(false) + } + } + + var body: some View { + HStack(alignment: .sliderCenterAlignmentGuide, spacing: 0) { + leadingContent() + .eraseToAnyView() + .alignmentGuide(.sliderCenterAlignmentGuide) { context in + context[VerticalAlignment.center] + } + + VStack(spacing: 0) { + topContent() + .eraseToAnyView() + + ZStack(alignment: .leading) { + + ZStack { + trackBackground() + .eraseToAnyView() + + track() + .eraseToAnyView() + .mask(alignment: .leading) { + Color.white + .frame(width: progress * totalWidth) + } + } + .mask { + trackMask() + .eraseToAnyView() + } + + thumb() + .eraseToAnyView() + .if(sliderBehavior == .thumb) { view in + view.gesture(trackDrag) + } + .onSizeChanged { newSize in + thumbSize = newSize + } + .offset(x: progress * totalWidth - thumbSize.width / 2) + } + .onSizeChanged { size in + totalWidth = size.width + } + .if(sliderBehavior == .track) { view in + view.overlay { + Color.clear + .padding(trackGesturePadding) + .contentShape(Rectangle()) + .highPriorityGesture(trackDrag) + } + } + .alignmentGuide(.sliderCenterAlignmentGuide) { context in + context[VerticalAlignment.center] + } + + bottomContent() + .eraseToAnyView() + } + + trailingContent() + .eraseToAnyView() + .alignmentGuide(.sliderCenterAlignmentGuide) { context in + context[VerticalAlignment.center] + } + } + .animation(progressAnimation, value: progress) + .animation(.linear(duration: 0.2), value: isEditing) + } +} + +extension Slider { + + init(progress: Binding) { + self.init( + progress: progress, + sliderBehavior: .track, + trackGesturePadding: .zero, + track: { EmptyView() }, + trackBackground: { EmptyView() }, + trackMask: { EmptyView() }, + thumb: { EmptyView() }, + topContent: { EmptyView() }, + bottomContent: { EmptyView() }, + leadingContent: { EmptyView() }, + trailingContent: { EmptyView() }, + onEditingChanged: { _ in }, + progressAnimation: .linear(duration: 0.05) + ) + } + + func track(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.track, with: content) + } + + func trackBackground(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.trackBackground, with: content) + } + + func trackMask(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.trackMask, with: content) + } + + func thumb(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.thumb, with: content) + } + + func topContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.topContent, with: content) + } + + func bottomContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.bottomContent, with: content) + } + + func leadingContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.leadingContent, with: content) + } + + func trailingContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.trailingContent, with: content) + } + + func trackGesturePadding(_ insets: EdgeInsets) -> Self { + copy(modifying: \.trackGesturePadding, with: insets) + } + + func onEditingChanged(_ action: @escaping (Bool) -> Void) -> Self { + copy(modifying: \.onEditingChanged, with: action) + } + + func gestureBehavior(_ sliderBehavior: Behavior) -> Self { + copy(modifying: \.sliderBehavior, with: sliderBehavior) + } + + func progressAnimation(_ animation: Animation) -> Self { + copy(modifying: \.progressAnimation, with: animation) + } +} diff --git a/Swiftfin/Components/Slider/ThumbSlider.swift b/Swiftfin/Components/Slider/ThumbSlider.swift new file mode 100644 index 00000000..96125735 --- /dev/null +++ b/Swiftfin/Components/Slider/ThumbSlider.swift @@ -0,0 +1,105 @@ +// +// 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 SwiftUI + +struct ThumbSlider: View { + + @Default(.VideoPlayer.Overlay.sliderColor) + private var sliderColor + + @Binding + private var isEditing: Bool + @Binding + private var progress: CGFloat + + private var trackMask: () -> any View + private var topContent: () -> any View + private var bottomContent: () -> any View + private var leadingContent: () -> any View + private var trailingContent: () -> any View + + var body: some View { + Slider(progress: $progress) + .gestureBehavior(.thumb) + .onEditingChanged { isEditing in + self.isEditing = isEditing + } + .track { + Capsule() + .foregroundColor(sliderColor) + .frame(height: 5) + } + .trackBackground { + Capsule() + .foregroundColor(Color.gray) + .opacity(0.5) + .frame(height: 5) + } + .thumb { + ZStack { + Color.clear + .frame(height: 25) + + Circle() + .foregroundColor(sliderColor) + .frame(width: isEditing ? 25 : 20) + } + .overlay { + Color.clear + .frame(width: 50, height: 50) + .contentShape(Rectangle()) + } + } + .trackMask(trackMask) + .topContent(topContent) + .bottomContent(bottomContent) + .leadingContent(leadingContent) + .trailingContent(trailingContent) + } +} + +extension ThumbSlider { + + init(progress: Binding) { + self.init( + isEditing: .constant(false), + progress: progress, + trackMask: { Color.white }, + topContent: { EmptyView() }, + bottomContent: { EmptyView() }, + leadingContent: { EmptyView() }, + trailingContent: { EmptyView() } + ) + } + + func isEditing(_ isEditing: Binding) -> Self { + copy(modifying: \._isEditing, with: isEditing) + } + + func trackMask(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.trackMask, with: content) + } + + func topContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.topContent, with: content) + } + + func bottomContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.bottomContent, with: content) + } + + func leadingContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.leadingContent, with: content) + } + + func trailingContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.trailingContent, with: content) + } +} diff --git a/Swiftfin/Components/SplitContentView.swift b/Swiftfin/Components/SplitContentView.swift new file mode 100644 index 00000000..1a450ee1 --- /dev/null +++ b/Swiftfin/Components/SplitContentView.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 SwiftUI + +class SplitContentViewProxy: ObservableObject { + + @Published + private(set) var isPresentingSplitView: Bool = false + + func present() { + isPresentingSplitView = true + } + + func hide() { + isPresentingSplitView = false + } +} + +struct SplitContentView: View { + + @ObservedObject + private var proxy: SplitContentViewProxy + + private var content: () -> any View + private var splitContent: () -> any View + private var splitContentWidth: CGFloat + + var body: some View { + HStack(spacing: 0) { + + content() + .eraseToAnyView() + .frame(maxWidth: .infinity) + + if proxy.isPresentingSplitView { + splitContent() + .eraseToAnyView() + .transition(.move(edge: .bottom)) + .frame(width: splitContentWidth) + .zIndex(100) + } + } + .animation(.easeInOut(duration: 0.35), value: proxy.isPresentingSplitView) + } +} + +extension SplitContentView { + + init(splitContentWidth: CGFloat = 400) { + self.init( + proxy: .init(), + content: { EmptyView() }, + splitContent: { EmptyView() }, + splitContentWidth: splitContentWidth + ) + } + + func proxy(_ proxy: SplitContentViewProxy) -> Self { + copy(modifying: \.proxy, with: proxy) + } + + func content(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.content, with: content) + } + + func splitContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.splitContent, with: content) + } +} diff --git a/Swiftfin/Components/UpdateView.swift b/Swiftfin/Components/UpdateView.swift new file mode 100644 index 00000000..70ae7e2c --- /dev/null +++ b/Swiftfin/Components/UpdateView.swift @@ -0,0 +1,97 @@ +// +// 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: Better name +// TODO: don't use pushed to indicate a presented value + +class UpdateViewProxy: ObservableObject { + + @Published + private(set) var systemName: String? = nil + @Published + private(set) var iconSize: CGSize = .init(width: 25, height: 25) + @Published + private(set) var title: String = "" + @Published + private(set) var pushed: Bool = false + + func present(systemName: String, title: String, iconSize: CGSize = .init(width: 25, height: 25)) { + self.systemName = systemName + self.iconSize = iconSize + self.title = title + pushed.toggle() + } +} + +struct UpdateView: View { + + @ObservedObject + private var proxy: UpdateViewProxy + + @State + private var isPresenting: Bool = false + @State + private var workItem: DispatchWorkItem? + + init(proxy: UpdateViewProxy) { + self.proxy = proxy + } + + var body: some View { + ZStack { + if isPresenting { + HStack { + if let systemName = proxy.systemName { + Image(systemName: systemName) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: proxy.iconSize.width, maxHeight: proxy.iconSize.height, alignment: .center) + } + + Text(proxy.title) + .font(.body) + .fontWeight(.bold) + .monospacedDigit() + } + .padding(.horizontal, 24) + .padding(.vertical, 8) + .frame(minHeight: 50) + .background(BlurView()) + .clipShape(Capsule()) + .overlay(Capsule().stroke(Color.gray.opacity(0.2), lineWidth: 1)) + .shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 6) + .compositingGroup() + .transition(.opacity) + } + } + .animation(.linear(duration: 0.1), value: proxy.systemName) + .animation(.linear(duration: 0.1), value: proxy.iconSize) + .onChange(of: proxy.pushed) { _ in + + if !isPresenting { + withAnimation { + isPresenting = true + } + } + + workItem?.cancel() + + let task = DispatchWorkItem { + withAnimation(.spring()) { + isPresenting = false + } + } + workItem = task + + DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: task) + } + } +} diff --git a/Swiftfin/Components/UserProfileButton.swift b/Swiftfin/Components/UserProfileButton.swift index 6f2cae38..2c2ba753 100644 --- a/Swiftfin/Components/UserProfileButton.swift +++ b/Swiftfin/Components/UserProfileButton.swift @@ -3,32 +3,39 @@ // License, v2.0. If 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 import SwiftUI +// TODO: remove client passing and mirror how other images are made + struct UserProfileButton: View { - let user: UserDto - private var action: () -> Void + private let client: JellyfinClient + private let user: UserDto + private var onSelect: () -> Void - init(user: UserDto) { + // TODO: Why both? + init(user: UserDto, client: JellyfinClient) { + self.client = client self.user = user - self.action = {} + self.onSelect = {} } - init(user: SwiftfinStore.State.User) { - self.init(user: .init(name: user.username, id: user.id)) + init(user: UserState, client: JellyfinClient) { + self.client = client + self.user = .init(id: user.id, name: user.username) + self.onSelect = {} } var body: some View { VStack(alignment: .center) { Button { - action() + onSelect() } label: { - ImageView(user.profileImageSource(maxWidth: 120, maxHeight: 120)) + ImageView(user.profileImageSource(client: client, maxWidth: 120, maxHeight: 120)) .failure { ZStack { Color.secondarySystemFill @@ -49,9 +56,8 @@ struct UserProfileButton: View { } extension UserProfileButton { + func onSelect(_ action: @escaping () -> Void) -> Self { - var copy = self - copy.action = action - return copy + copy(modifying: \.onSelect, with: action) } } diff --git a/Swiftfin/Extensions/View/DetectOrientationModifier.swift b/Swiftfin/Extensions/View/DetectOrientationModifier.swift new file mode 100644 index 00000000..abf772e5 --- /dev/null +++ b/Swiftfin/Extensions/View/DetectOrientationModifier.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 DetectOrientation: ViewModifier { + + @Binding + var orientation: UIDeviceOrientation + + func body(content: Content) -> some View { + content + .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in + orientation = UIDevice.current.orientation + } + } +} diff --git a/Swiftfin/Extensions/iOSViewExtensions/NavBarDrawerButtons/NavBarDrawerModifier.swift b/Swiftfin/Extensions/View/NavBarDrawerButtons/NavBarDrawerModifier.swift similarity index 67% rename from Swiftfin/Extensions/iOSViewExtensions/NavBarDrawerButtons/NavBarDrawerModifier.swift rename to Swiftfin/Extensions/View/NavBarDrawerButtons/NavBarDrawerModifier.swift index 25199200..9475268b 100644 --- a/Swiftfin/Extensions/iOSViewExtensions/NavBarDrawerButtons/NavBarDrawerModifier.swift +++ b/Swiftfin/Extensions/View/NavBarDrawerButtons/NavBarDrawerModifier.swift @@ -3,22 +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 SwiftUI -struct NavBarDrawerModifier: ViewModifier { +struct NavBarDrawerModifier: ViewModifier { - let drawer: () -> Drawer + let drawer: () -> any View - init(@ViewBuilder drawer: @escaping () -> Drawer) { + init(@ViewBuilder drawer: @escaping () -> any View) { self.drawer = drawer } func body(content: Content) -> some View { NavBarDrawerView { drawer() + .eraseToAnyView() .ignoresSafeArea() } content: { content diff --git a/Swiftfin/Extensions/iOSViewExtensions/NavBarDrawerButtons/NavBarDrawerView.swift b/Swiftfin/Extensions/View/NavBarDrawerButtons/NavBarDrawerView.swift similarity index 75% rename from Swiftfin/Extensions/iOSViewExtensions/NavBarDrawerButtons/NavBarDrawerView.swift rename to Swiftfin/Extensions/View/NavBarDrawerButtons/NavBarDrawerView.swift index 09ba9876..d818dfb3 100644 --- a/Swiftfin/Extensions/iOSViewExtensions/NavBarDrawerButtons/NavBarDrawerView.swift +++ b/Swiftfin/Extensions/View/NavBarDrawerButtons/NavBarDrawerView.swift @@ -3,37 +3,36 @@ // License, v2.0. If 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 -private let drawerHeight: CGFloat = 36 +struct NavBarDrawerView: UIViewControllerRepresentable { -struct NavBarDrawerView: UIViewControllerRepresentable { - - private let buttons: () -> Buttons - private let content: () -> Content + private let buttons: () -> any View + private let content: () -> any View init( - @ViewBuilder buttons: @escaping () -> Buttons, - @ViewBuilder content: @escaping () -> Content + @ViewBuilder buttons: @escaping () -> any View, + @ViewBuilder content: @escaping () -> any View ) { self.buttons = buttons self.content = content } - func makeUIViewController(context: Context) -> UINavBarDrawerHostingController { + func makeUIViewController(context: Context) -> UINavBarDrawerHostingController { UINavBarDrawerHostingController(buttons: buttons, content: content) } - func updateUIViewController(_ uiViewController: UINavBarDrawerHostingController, context: Context) {} + func updateUIViewController(_ uiViewController: UINavBarDrawerHostingController, context: Context) {} } -class UINavBarDrawerHostingController: UIViewController { +class UINavBarDrawerHostingController: UIViewController { - private let buttons: () -> Buttons - private let content: () -> Content + private let buttons: () -> any View + private let content: () -> any View + private let drawerHeight: CGFloat = 36 private lazy var navBarBlurView: UIVisualEffectView = { let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemThinMaterial)) @@ -41,23 +40,23 @@ class UINavBarDrawerHostingController: UIViewContr return blurView }() - private lazy var contentView: UIHostingController = { - let contentView = UIHostingController(rootView: content()) + private lazy var contentView: UIHostingController = { + let contentView = UIHostingController(rootView: content().eraseToAnyView()) contentView.view.translatesAutoresizingMaskIntoConstraints = false contentView.view.backgroundColor = nil return contentView }() - private lazy var drawerButtonsView: UIHostingController = { - let drawerButtonsView = UIHostingController(rootView: buttons()) + private lazy var drawerButtonsView: UIHostingController = { + let drawerButtonsView = UIHostingController(rootView: buttons().eraseToAnyView()) drawerButtonsView.view.translatesAutoresizingMaskIntoConstraints = false drawerButtonsView.view.backgroundColor = nil return drawerButtonsView }() init( - buttons: @escaping () -> Buttons, - content: @escaping () -> Content + buttons: @escaping () -> any View, + content: @escaping () -> any View ) { self.buttons = buttons self.content = content @@ -106,14 +105,14 @@ class UINavBarDrawerHostingController: UIViewContr override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - self.navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default) - self.navigationController?.navigationBar.shadowImage = UIImage() + navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default) + navigationController?.navigationBar.shadowImage = UIImage() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - self.navigationController?.navigationBar.setBackgroundImage(nil, for: .default) - self.navigationController?.navigationBar.shadowImage = nil + navigationController?.navigationBar.setBackgroundImage(nil, for: .default) + navigationController?.navigationBar.shadowImage = nil } override var additionalSafeAreaInsets: UIEdgeInsets { diff --git a/Swiftfin/Extensions/iOSViewExtensions/NavBarOffset/NavBarOffsetModifier.swift b/Swiftfin/Extensions/View/NavBarOffset/NavBarOffsetModifier.swift similarity index 90% rename from Swiftfin/Extensions/iOSViewExtensions/NavBarOffset/NavBarOffsetModifier.swift rename to Swiftfin/Extensions/View/NavBarOffset/NavBarOffsetModifier.swift index 65534c9f..3d80092f 100644 --- a/Swiftfin/Extensions/iOSViewExtensions/NavBarOffset/NavBarOffsetModifier.swift +++ b/Swiftfin/Extensions/View/NavBarOffset/NavBarOffsetModifier.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/Swiftfin/Extensions/iOSViewExtensions/NavBarOffset/NavBarOffsetView.swift b/Swiftfin/Extensions/View/NavBarOffset/NavBarOffsetView.swift similarity index 67% rename from Swiftfin/Extensions/iOSViewExtensions/NavBarOffset/NavBarOffsetView.swift rename to Swiftfin/Extensions/View/NavBarOffset/NavBarOffsetView.swift index 0f015eda..7e289908 100644 --- a/Swiftfin/Extensions/iOSViewExtensions/NavBarOffset/NavBarOffsetView.swift +++ b/Swiftfin/Extensions/View/NavBarOffset/NavBarOffsetView.swift @@ -3,44 +3,55 @@ // License, v2.0. If 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 -struct NavBarOffsetView: UIViewControllerRepresentable { +struct NavBarOffsetView: UIViewControllerRepresentable { @Binding private var scrollViewOffset: CGFloat private let start: CGFloat private let end: CGFloat - private let content: () -> Content + private let content: () -> any View - init(scrollViewOffset: Binding, start: CGFloat, end: CGFloat, @ViewBuilder content: @escaping () -> Content) { + init( + scrollViewOffset: Binding, + start: CGFloat, + end: CGFloat, + @ViewBuilder content: @escaping () -> any View + ) { self._scrollViewOffset = scrollViewOffset self.start = start self.end = end self.content = content } - init(start: CGFloat, end: CGFloat, @ViewBuilder body: @escaping () -> Content) { + init( + start: CGFloat, + end: CGFloat, + @ViewBuilder body: @escaping () -> any View + ) { self._scrollViewOffset = Binding(get: { 0 }, set: { _ in }) self.start = start self.end = end self.content = body } - func makeUIViewController(context: Context) -> UINavBarOffsetHostingController { - UINavBarOffsetHostingController(rootView: content()) + func makeUIViewController(context: Context) -> UINavBarOffsetHostingController { + let a = UINavBarOffsetHostingController(rootView: content().eraseToAnyView()) + a.additionalSafeAreaInsets = .zero + return a } - func updateUIViewController(_ uiViewController: UINavBarOffsetHostingController, context: Context) { + func updateUIViewController(_ uiViewController: UINavBarOffsetHostingController, context: Context) { uiViewController.scrollViewDidScroll(scrollViewOffset, start: start, end: end) } } -class UINavBarOffsetHostingController: UIHostingController { +class UINavBarOffsetHostingController: UIHostingController { private var lastScrollViewOffset: CGFloat = 0 @@ -71,7 +82,7 @@ class UINavBarOffsetHostingController: UIHostingController: UIHostingController) -> some View { + modifier(DetectOrientation(orientation: orientation)) + } + + func navBarOffset(_ scrollViewOffset: Binding, start: CGFloat, end: CGFloat) -> some View { + modifier(NavBarOffsetModifier(scrollViewOffset: scrollViewOffset, start: start, end: end)) + } + + func navBarDrawer(@ViewBuilder _ drawer: @escaping () -> Drawer) -> some View { + modifier(NavBarDrawerModifier(drawer: drawer)) + } + + func onAppDidEnterBackground(_ action: @escaping () -> Void) -> some View { + modifier( + OnReceiveNotificationModifier( + notification: UIApplication.didEnterBackgroundNotification, + onReceive: action + ) + ) + } + + func onAppWillResignActive(_ action: @escaping () -> Void) -> some View { + modifier( + OnReceiveNotificationModifier( + notification: UIApplication.willResignActiveNotification, + onReceive: action + ) + ) + } + + func onAppWillTerminate(_ action: @escaping () -> Void) -> some View { + modifier( + OnReceiveNotificationModifier( + notification: UIApplication.willTerminateNotification, + onReceive: action + ) + ) + } + + func navigationCloseButton(accentColor: Color = Defaults[.accentColor], _ action: @escaping () -> Void) -> some View { + toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + action() + } label: { + Image(systemName: "xmark.circle.fill") + .accentSymbolRendering(accentColor: accentColor) + } + } + } + } +} diff --git a/Swiftfin/Extensions/iOSViewExtensions/iOSViewExtensions.swift b/Swiftfin/Extensions/iOSViewExtensions/iOSViewExtensions.swift deleted file mode 100644 index caa46caf..00000000 --- a/Swiftfin/Extensions/iOSViewExtensions/iOSViewExtensions.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 SwiftUI - -extension View { - func navBarOffset(_ scrollViewOffset: Binding, start: CGFloat, end: CGFloat) -> some View { - self.modifier(NavBarOffsetModifier(scrollViewOffset: scrollViewOffset, start: start, end: end)) - } - - func navBarDrawer(@ViewBuilder _ drawer: @escaping () -> Drawer) -> some View { - self.modifier(NavBarDrawerModifier(drawer: drawer)) - } -} diff --git a/Swiftfin/Info.plist b/Swiftfin/Info.plist index e78d7487..8cb51fee 100644 --- a/Swiftfin/Info.plist +++ b/Swiftfin/Info.plist @@ -4,8 +4,6 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Swiftfin CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -31,6 +29,8 @@ CFBundleVersion $(CURRENT_PROJECT_VERSION) + CFBundledisplayTitle + Swiftfin ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS @@ -67,10 +67,8 @@ network. UILaunchScreen - UIColorName - LaunchScreenBackground UIImageName - swiftfin-logo + jellyfin-blob-blue UIImageRespectsSafeAreaInsets diff --git a/Swiftfin/Objects/KeyCommandAction.swift b/Swiftfin/Objects/KeyCommandAction.swift new file mode 100644 index 00000000..05cdd84f --- /dev/null +++ b/Swiftfin/Objects/KeyCommandAction.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 Foundation +import UIKit + +struct KeyCommandAction { + + let title: String + let input: String + let modifierFlags: UIKeyModifierFlags + let action: () -> Void + + init( + title: String, + input: String, + modifierFlags: UIKeyModifierFlags = [], + action: @escaping () -> Void + ) { + self.title = title + self.input = input + self.modifierFlags = modifierFlags + self.action = action + } +} + +extension KeyCommandAction: Equatable { + + static func == (lhs: KeyCommandAction, rhs: KeyCommandAction) -> Bool { + lhs.input == rhs.input + } +} diff --git a/Swiftfin/Objects/RefreshHelper.swift b/Swiftfin/Objects/RefreshHelper.swift index eba76c04..26146f0a 100644 --- a/Swiftfin/Objects/RefreshHelper.swift +++ b/Swiftfin/Objects/RefreshHelper.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 UIKit diff --git a/Swiftfin/Views/AboutAppView.swift b/Swiftfin/Views/AboutAppView.swift index 2c1781f9..d8a74596 100644 --- a/Swiftfin/Views/AboutAppView.swift +++ b/Swiftfin/Views/AboutAppView.swift @@ -3,89 +3,79 @@ // License, v2.0. If 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 struct AboutAppView: View { + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @ObservedObject + var viewModel: SettingsViewModel + var body: some View { List { Section { - HStack { - Spacer() + VStack(alignment: .center) { - VStack(alignment: .center) { - AppIcon() - .cornerRadius(11) - .frame(width: 150, height: 150) + Image(uiImage: viewModel.currentAppIcon.iconPreview) + .resizable() + .frame(width: 150, height: 150) + .cornerRadius(150 / 6.4) + .shadow(radius: 5) - // App name, not to be localized - Text("Swiftfin") - .fontWeight(.semibold) - .font(.title2) - } - - Spacer() + // App name, not to be localized + Text("Swiftfin") + .fontWeight(.semibold) + .font(.title2) } + .frame(maxWidth: .infinity) .listRowBackground(Color.clear) } Section { - HStack { - L10n.about.text - Spacer() - Text("\(UIApplication.appVersion ?? .emptyDash) (\(UIApplication.bundleVersion ?? .emptyDash))") - .foregroundColor(.secondary) - } + TextPairView( + leading: L10n.version, + trailing: "\(UIApplication.appVersion ?? .emptyDash) (\(UIApplication.bundleVersion ?? .emptyDash))" + ) - HStack { - Image("github-logo") - .renderingMode(.template) - .resizable() - .frame(width: 20, height: 20) - .foregroundColor(.primary) - Link( - L10n.sourceCode, - destination: URL(string: "https://github.com/jellyfin/Swiftfin")! - ) - .foregroundColor(.primary) + ChevronButton(title: L10n.sourceCode) + .leadingView { + Image("logo.github") + .resizable() + .frame(width: 20, height: 20) + .foregroundColor(.primary) + } + .onSelect { + UIApplication.shared.open(.swiftfinGithub) + } - Spacer() + ChevronButton(title: L10n.bugsAndFeatures) + .leadingView { + Image(systemName: "plus.circle.fill") + .resizable() + .frame(width: 20, height: 20) + .foregroundColor(.primary) + } + .onSelect { + UIApplication.shared.open(.swiftfinGithubIssues) + } - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - } - - HStack { - Image(systemName: "plus.circle.fill") - Link( - L10n.requestFeature, - destination: URL(string: "https://github.com/jellyfin/Swiftfin/issues")! - ) - .foregroundColor(.primary) - - Spacer() - - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - } - - HStack { - Image(systemName: "xmark.circle.fill") - Link( - L10n.reportIssue, - destination: URL(string: "https://github.com/jellyfin/Swiftfin/issues")! - ) - .foregroundColor(.primary) - - Spacer() - - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - } + ChevronButton(title: L10n.settings) + .leadingView { + Image(systemName: "gearshape.fill") + .resizable() + .frame(width: 20, height: 20) + .foregroundColor(.primary) + } + .onSelect { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } } } } diff --git a/Swiftfin/Views/AppIconSelectorView.swift b/Swiftfin/Views/AppIconSelectorView.swift new file mode 100644 index 00000000..74beb59f --- /dev/null +++ b/Swiftfin/Views/AppIconSelectorView.swift @@ -0,0 +1,96 @@ +// +// 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 SwiftUI + +struct AppIconSelectorView: View { + + @ObservedObject + var viewModel: SettingsViewModel + + var body: some View { + + Form { + + Section { + ForEach(PrimaryAppIcon.allCases) { icon in + AppIconRow(viewModel: viewModel, icon: icon) + } + } + + Section(L10n.dark) { + ForEach(DarkAppIcon.allCases) { icon in + AppIconRow(viewModel: viewModel, icon: icon) + } + } + + Section(L10n.light) { + ForEach(LightAppIcon.allCases) { icon in + AppIconRow(viewModel: viewModel, icon: icon) + } + } + + Section(L10n.invertedDark) { + ForEach(InvertedDarkAppIcon.allCases) { icon in + AppIconRow(viewModel: viewModel, icon: icon) + } + } + + Section(L10n.invertedLight) { + ForEach(InvertedLightAppIcon.allCases) { icon in + AppIconRow(viewModel: viewModel, icon: icon) + } + } + } + .navigationTitle(L10n.appIcon) + } +} + +extension AppIconSelectorView { + + struct AppIconRow: View { + + @Default(.accentColor) + private var accentColor + + @ObservedObject + var viewModel: SettingsViewModel + + let icon: any AppIcon + + var body: some View { + Button { + viewModel.select(icon: icon) + } label: { + HStack { + + Image(uiImage: icon.iconPreview) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 60, height: 60) + .cornerRadius(12) + .shadow(radius: 2) + + Text(icon.displayTitle) + .foregroundColor(.primary) + + Spacer() + + if icon.iconName == viewModel.currentAppIcon.iconName { + Image(systemName: "checkmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .accentSymbolRendering() + } + } + } + } + } +} diff --git a/Swiftfin/Views/BasicAppSettingsView.swift b/Swiftfin/Views/BasicAppSettingsView.swift index 6b9615fb..1e4916f2 100644 --- a/Swiftfin/Views/BasicAppSettingsView.swift +++ b/Swiftfin/Views/BasicAppSettingsView.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 @@ -12,104 +12,84 @@ import SwiftUI struct BasicAppSettingsView: View { - @EnvironmentObject - private var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router - @ObservedObject - var viewModel: BasicAppSettingsViewModel - @State - var resetUserSettingsTapped: Bool = false - @State - var resetAppSettingsTapped: Bool = false - @State - var removeAllUsersTapped: Bool = false - + @Default(.accentColor) + private var accentColor @Default(.appAppearance) - var appAppearance - @Default(.defaultHTTPScheme) - var defaultHTTPScheme + private var appAppearance + + @EnvironmentObject + private var router: BasicAppSettingsCoordinator.Router + + @ObservedObject + var viewModel: SettingsViewModel + + @State + private var resetUserSettingsSelected: Bool = false + @State + private var resetAppSettingsSelected: Bool = false + @State + private var removeAllServersSelected: Bool = false var body: some View { Form { - Button { - basicAppSettingsRouter.route(to: \.about) - } label: { - HStack { - L10n.about.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") + ChevronButton(title: L10n.about) + .onSelect { + router.route(to: \.about) } - } Section { - Picker(L10n.appearance, selection: $appAppearance) { - ForEach(self.viewModel.appearances, id: \.self) { appearance in - Text(appearance.localizedName).tag(appearance.rawValue) + EnumPicker(title: L10n.appearance, selection: $appAppearance) + + ChevronButton(title: L10n.appIcon) + .onSelect { + router.route(to: \.appIconSelector) } - } } header: { L10n.accessibility.text } Section { - Picker(L10n.defaultScheme, selection: $defaultHTTPScheme) { - ForEach(HTTPScheme.allCases, id: \.self) { scheme in - Text("\(scheme.rawValue)") - } + ColorPicker(L10n.accentColor, selection: $accentColor, supportsOpacity: false) + } footer: { + L10n.accentColorDescription.text + } + + ChevronButton(title: "Logs") + .onSelect { + router.route(to: \.log) } - } header: { - L10n.networking.text - } - Button { - resetUserSettingsTapped = true - } label: { - L10n.resetUserSettings.text - } + Section { + Button { + resetUserSettingsSelected = true + } label: { + L10n.resetUserSettings.text + } - Button { - resetAppSettingsTapped = true - } label: { - L10n.resetAppSettings.text - } - - Button { - removeAllUsersTapped = true - } label: { - L10n.removeAllUsers.text + Button { + removeAllServersSelected = true + } label: { + Text("Remove All Servers") + } } } - .alert(L10n.resetUserSettings, isPresented: $resetUserSettingsTapped, actions: { - Button(role: .destructive) { + .alert(L10n.resetUserSettings, isPresented: $resetUserSettingsSelected) { + Button(L10n.reset, role: .destructive) { viewModel.resetUserSettings() - } label: { - L10n.reset.text } - }) - .alert(L10n.resetAppSettings, isPresented: $resetAppSettingsTapped, actions: { - Button(role: .destructive) { - viewModel.resetAppSettings() - } label: { - L10n.reset.text - } - }) - .alert(L10n.removeAllUsers, isPresented: $removeAllUsersTapped, actions: { - Button(role: .destructive) { - viewModel.removeAllUsers() - } label: { - L10n.reset.text - } - }) - .navigationBarTitle(L10n.settings, displayMode: .inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - basicAppSettingsRouter.dismissCoordinator() - } label: { - Image(systemName: "xmark.circle.fill") - } + } message: { + Text("Reset all settings back to defaults.") + } + .alert("Remove All Servers", isPresented: $removeAllServersSelected) { + Button(L10n.reset, role: .destructive) { + viewModel.removeAllServers() } } + .navigationBarTitle(L10n.settings) + .navigationBarTitleDisplayMode(.inline) + .navigationCloseButton { + router.dismissCoordinator() + } } } diff --git a/Swiftfin/Views/BasicLibraryView.swift b/Swiftfin/Views/BasicLibraryView.swift index 5087d6d1..ee6dd06a 100644 --- a/Swiftfin/Views/BasicLibraryView.swift +++ b/Swiftfin/Views/BasicLibraryView.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 CollectionView @@ -18,6 +18,7 @@ struct BasicLibraryView: View { @EnvironmentObject private var router: BasicLibraryCoordinator.Router + @ObservedObject var viewModel: PagingLibraryViewModel diff --git a/Swiftfin/Views/CastAndCrewLibraryView/CastAndCrewItemRow.swift b/Swiftfin/Views/CastAndCrewLibraryView/CastAndCrewItemRow.swift index 26d4ec43..ef98e228 100644 --- a/Swiftfin/Views/CastAndCrewLibraryView/CastAndCrewItemRow.swift +++ b/Swiftfin/Views/CastAndCrewLibraryView/CastAndCrewItemRow.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 @@ -16,7 +16,7 @@ extension CastAndCrewLibraryView { @EnvironmentObject private var router: CastAndCrewLibraryCoordinator.Router - let person: BaseItemPerson + private let person: BaseItemPerson private var onSelect: () -> Void var body: some View { @@ -28,7 +28,7 @@ extension CastAndCrewLibraryView { .posterStyle(type: .portrait, width: 60) VStack(alignment: .leading) { - Text(person.displayName) + Text(person.displayTitle) .foregroundColor(.primary) .fontWeight(.semibold) .lineLimit(2) @@ -57,8 +57,6 @@ extension CastAndCrewLibraryView.CastAndCrewItemRow { } func onSelect(_ action: @escaping () -> Void) -> Self { - var copy = self - copy.onSelect = action - return copy + copy(modifying: \.onSelect, with: action) } } diff --git a/Swiftfin/Views/CastAndCrewLibraryView/CastAndCrewLibraryView.swift b/Swiftfin/Views/CastAndCrewLibraryView/CastAndCrewLibraryView.swift index 5a83a56e..8e1f74e3 100644 --- a/Swiftfin/Views/CastAndCrewLibraryView/CastAndCrewLibraryView.swift +++ b/Swiftfin/Views/CastAndCrewLibraryView/CastAndCrewLibraryView.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 CollectionView @@ -18,6 +18,7 @@ struct CastAndCrewLibraryView: View { @EnvironmentObject private var router: CastAndCrewLibraryCoordinator.Router + let people: [BaseItemPerson] @ViewBuilder @@ -45,7 +46,7 @@ struct CastAndCrewLibraryView: View { @ViewBuilder private var libraryGridView: some View { CollectionView(items: people) { _, person, _ in - PosterButton(item: person, type: .portrait) + PosterButton(state: .item(person), type: .portrait) .onSelect { router.route(to: \.library, .init(parent: person, type: .person, filters: .init())) } diff --git a/Swiftfin/Views/ConnectToServerView.swift b/Swiftfin/Views/ConnectToServerView.swift index 24fefd08..e29332b2 100644 --- a/Swiftfin/Views/ConnectToServerView.swift +++ b/Swiftfin/Views/ConnectToServerView.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 @@ -12,125 +12,173 @@ import SwiftUI struct ConnectToServerView: View { + @EnvironmentObject + private var router: ConnectToServerCoodinator.Router + @ObservedObject var viewModel: ConnectToServerViewModel - @State - var uri = "" - @Default(.defaultHTTPScheme) - var defaultHTTPScheme + @State + private var connectionError: Error? + @State + private var connectionTask: Task? + @State + private var duplicateServer: (server: ServerState, url: URL)? + @State + private var isConnecting: Bool = false + @State + private var isPresentingConnectionError: Bool = false + @State + private var isPresentingDuplicateServerAlert: Bool = false + @State + private var isPresentingError: Bool = false + @State + private var url = "http://" + + private func connectToServer() { + let task = Task { + isConnecting = true + connectionError = nil + + do { + let serverConnection = try await viewModel.connectToServer(url: url) + + if viewModel.isDuplicate(server: serverConnection.server) { + duplicateServer = serverConnection + isPresentingDuplicateServerAlert = true + } else { + try viewModel.save(server: serverConnection.server) + router.route(to: \.userSignIn, serverConnection.server) + } + } catch { + connectionError = error + isPresentingConnectionError = true + } + + isConnecting = false + } + + connectionTask = task + } + + @ViewBuilder + private var connectSection: some View { + Section { + TextField(L10n.serverURL, text: $url) + .disableAutocorrection(true) + .autocapitalization(.none) + .keyboardType(.URL) + + if isConnecting { + Button(role: .destructive) { + connectionTask?.cancel() + isConnecting = false + } label: { + L10n.cancel.text + } + } else { + Button { + connectToServer() + } label: { + L10n.connect.text + } + .disabled(URL(string: url) == nil || isConnecting) + } + } header: { + L10n.connectToJellyfinServer.text + } + } + + @ViewBuilder + private var publicServerSection: some View { + Section { + if viewModel.isSearching { + L10n.searchingDots.text + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } else { + if viewModel.discoveredServers.isEmpty { + L10n.noLocalServersFound.text + .font(.callout) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } else { + ForEach(viewModel.discoveredServers, id: \.id) { server in + Button { + url = server.currentURL.absoluteString + connectToServer() + } label: { + VStack(alignment: .leading, spacing: 5) { + Text(server.name) + .font(.title3) + + Text(server.currentURL.absoluteString) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .disabled(isConnecting) + } + } + } + } header: { + HStack { + L10n.localServers.text + Spacer() + + Button { + viewModel.discoverServers() + } label: { + Image(systemName: "arrow.clockwise.circle.fill") + } + .disabled(viewModel.isSearching || isConnecting) + } + } + .headerProminence(.increased) + } var body: some View { List { - Section { - TextField(L10n.serverURL, text: $uri) - .disableAutocorrection(true) - .autocapitalization(.none) - .keyboardType(.URL) - .onAppear { - if uri == "" { - uri = "\(defaultHTTPScheme.rawValue)://" - } - } - if viewModel.isLoading { - Button(role: .destructive) { - viewModel.cancelConnection() - } label: { - L10n.cancel.text - } - } else { - Button { - viewModel.connectToServer(uri: uri) - } label: { - HStack { - L10n.connect.text + connectSection - Spacer() - - if viewModel.isLoading { - ProgressView() - } - } - } - .disabled(uri.isEmpty || viewModel.isLoading) - } - } header: { - L10n.connectToJellyfinServer.text + publicServerSection + } + .alert( + L10n.error, + isPresented: $isPresentingConnectionError + ) { + Button(L10n.dismiss, role: .cancel) + } message: { + Text(connectionError?.localizedDescription ?? .emptyDash) + } + .alert( + L10n.existingServer, + isPresented: $isPresentingDuplicateServerAlert + ) { + Button { + guard let duplicateServer else { return } + viewModel.add( + url: duplicateServer.url, + server: duplicateServer.server + ) + router.dismissCoordinator() + } label: { + L10n.addURL.text } - Section { - if viewModel.searching { - HStack(alignment: .center, spacing: 5) { - Spacer() - L10n.searchingDots.text - .foregroundColor(.secondary) - Spacer() - } - } else { - if viewModel.discoveredServers.isEmpty { - HStack(alignment: .center) { - Spacer() - L10n.noLocalServersFound.text - .font(.callout) - .foregroundColor(.secondary) - Spacer() - } - } else { - ForEach(viewModel.discoveredServers, id: \.id) { server in - Button { - uri = server.currentURI - viewModel.connectToServer(uri: server.currentURI) - } label: { - VStack(alignment: .leading, spacing: 5) { - Text(server.name) - .font(.title3) - Text(server.currentURI) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - .disabled(viewModel.isLoading) - } - } - } - } header: { - HStack { - L10n.localServers.text - Spacer() - - Button { - viewModel.discoverServers() - } label: { - Image(systemName: "arrow.clockwise.circle.fill") - } - .disabled(viewModel.searching || viewModel.isLoading) - } + Button(L10n.dismiss, role: .cancel) + } message: { + if let duplicateServer { + L10n.serverAlreadyExistsPrompt(duplicateServer.server.name).text } - .headerProminence(.increased) - } - .alert(item: $viewModel.errorMessage) { _ in - Alert( - title: Text(viewModel.alertTitle), - message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), - dismissButton: .cancel() - ) - } - .alert(item: $viewModel.addServerURIPayload) { _ in - Alert( - title: L10n.existingServer.text, - message: L10n.serverAlreadyExistsPrompt(viewModel.addServerURIPayload?.server.name ?? "").text, - primaryButton: .default(L10n.addURL.text, action: { - viewModel.addURIToServer(addServerURIPayload: viewModel.backAddServerURIPayload!) - }), - secondaryButton: .cancel() - ) } .navigationTitle(L10n.connect) .onAppear { viewModel.discoverServers() - AppURLHandler.shared.appURLState = .allowedInLogin } - .navigationBarBackButtonHidden(viewModel.isLoading) + .onDisappear { + isConnecting = false + } } } diff --git a/Swiftfin/Views/DownloadListView.swift b/Swiftfin/Views/DownloadListView.swift new file mode 100644 index 00000000..5ab2ae03 --- /dev/null +++ b/Swiftfin/Views/DownloadListView.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 CollectionView +import SwiftUI + +struct DownloadListView: View { + + @ObservedObject + var viewModel: DownloadListViewModel + + var body: some View { + ScrollView(showsIndicators: false) { + ForEach(viewModel.items) { item in + DownloadTaskRow(downloadTask: item) + } + } + .navigationTitle("Downloads") + .navigationBarTitleDisplayMode(.inline) + } +} + +extension DownloadListView { + + struct DownloadTaskRow: View { + + @EnvironmentObject + private var router: DownloadListCoordinator.Router + + let downloadTask: DownloadTask + + var body: some View { + Button { + router.route(to: \.downloadTask, downloadTask) + } label: { + HStack(alignment: .bottom) { + ImageView(downloadTask.getImageURL(name: "Primary")) + .failure { + Color.secondary + .opacity(0.8) + } + .posterStyle(type: .portrait, width: 60) + .posterShadow() + + VStack(alignment: .leading) { + Text(downloadTask.item.displayTitle) + .foregroundColor(.primary) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical) + + Spacer() + } + } + } + } +} diff --git a/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift b/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift new file mode 100644 index 00000000..6ec864aa --- /dev/null +++ b/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift @@ -0,0 +1,174 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension DownloadTaskView { + + struct ContentView: View { + + @Default(.accentColor) + private var accentColor + + @Injected(Container.downloadManager) + private var downloadManager + + @EnvironmentObject + private var mainCoordinator: MainCoordinator.Router + @EnvironmentObject + private var router: DownloadTaskCoordinator.Router + + @ObservedObject + var downloadTask: DownloadTask + + @State + private var isPresentingVideoPlayerTypeError: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + VStack(alignment: .center) { + ImageView(downloadTask.item.landscapePosterImageSources(maxWidth: 600, single: true)) + .frame(maxHeight: 300) + .aspectRatio(1.77, contentMode: .fill) + .cornerRadius(10) + .padding(.horizontal) + .posterShadow() + + ShelfView(downloadTask: downloadTask) + + // TODO: Break into subview + switch downloadTask.state { + case .ready, .cancelled: + PrimaryButton(title: "Download") { + downloadManager.download(task: downloadTask) + } + .frame(maxWidth: 300) + .frame(height: 50) + case let .downloading(progress): + HStack { + CircularProgressView(progress: progress) + .buttonStyle(.plain) + .frame(width: 30, height: 30) + + Text("\(Int(progress * 100))%") + .foregroundColor(.secondary) + + Spacer() + + Button { + downloadManager.cancel(task: downloadTask) + } label: { + Image(systemName: "stop.circle") + .foregroundColor(.red) + } + } + .padding(.horizontal) + case let .error(error): + VStack { + PrimaryButton(title: "Retry") { + downloadManager.download(task: downloadTask) + } + .frame(maxWidth: 300) + .frame(height: 50) + + Text("Error: \(error.localizedDescription)") + .padding(.horizontal) + } + case .complete: + PrimaryButton(title: "Play") { + if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { + router.dismissCoordinator { + mainCoordinator.route(to: \.videoPlayer, DownloadVideoPlayerManager(downloadTask: downloadTask)) + } + } else { + isPresentingVideoPlayerTypeError = true + } + } + .frame(maxWidth: 300) + .frame(height: 50) + } + } + +// Text("Media Info") +// .font(.title2) +// .fontWeight(.semibold) +// .padding(.horizontal) + } + .alert( + L10n.error, + isPresented: $isPresentingVideoPlayerTypeError + ) { + Button { + isPresentingVideoPlayerTypeError = false + } label: { + Text("Dismiss") + } + } message: { + Text("Downloaded items are only playable through the Swiftfin video player.") + } + } + } +} + +extension DownloadTaskView.ContentView { + + struct ShelfView: View { + + @ObservedObject + var downloadTask: DownloadTask + + var body: some View { + VStack(alignment: .center, spacing: 10) { + + if let seriesName = downloadTask.item.seriesName { + Text(seriesName) + .font(.headline) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + .lineLimit(2) + .padding(.horizontal) + .foregroundColor(.secondary) + } + + Text(downloadTask.item.displayTitle) + .font(.title2) + .fontWeight(.bold) + .multilineTextAlignment(.center) + .lineLimit(2) + .padding(.horizontal) + + DotHStack { + if downloadTask.item.type == .episode { + if let episodeLocation = downloadTask.item.episodeLocator { + Text(episodeLocation) + } + } else { + if let firstGenre = downloadTask.item.genres?.first { + Text(firstGenre) + } + } + + if let productionYear = downloadTask.item.premiereDateYear { + Text(productionYear) + } + + if let runtime = downloadTask.item.getItemRuntime() { + Text(runtime) + } + } + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal) + } + } + } +} diff --git a/Swiftfin/Views/DownloadTaskView/DownloadTaskView.swift b/Swiftfin/Views/DownloadTaskView/DownloadTaskView.swift new file mode 100644 index 00000000..6a7ede28 --- /dev/null +++ b/Swiftfin/Views/DownloadTaskView/DownloadTaskView.swift @@ -0,0 +1,28 @@ +// +// 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 SwiftUI + +struct DownloadTaskView: View { + + @EnvironmentObject + private var router: DownloadTaskCoordinator.Router + + @ObservedObject + var downloadTask: DownloadTask + + var body: some View { + ScrollView(showsIndicators: false) { + ContentView(downloadTask: downloadTask) + } + .navigationCloseButton { + router.dismissCoordinator() + } + } +} diff --git a/Swiftfin/Views/FilterView.swift b/Swiftfin/Views/FilterView.swift index d293ffb4..4ba2b065 100644 --- a/Swiftfin/Views/FilterView.swift +++ b/Swiftfin/Views/FilterView.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 @@ -41,24 +41,15 @@ struct FilterView: View { } var body: some View { - - VStack { - SelectorView( - type: selectorType, - allItems: viewModel.allFilters[keyPath: filter], - selectedItems: selectedFiltersBinding - ) - } + SelectorView( + selection: selectedFiltersBinding, + allItems: viewModel.allFilters[keyPath: filter], + type: selectorType + ) .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - router.dismissCoordinator() - } label: { - Image(systemName: "xmark.circle.fill") - } - } + .navigationCloseButton { + router.dismissCoordinator() } } } diff --git a/Swiftfin/Views/FontPicker.swift b/Swiftfin/Views/FontPicker.swift deleted file mode 100644 index 5c98354b..00000000 --- a/Swiftfin/Views/FontPicker.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI -import UIKit - -struct FontPickerView: UIViewControllerRepresentable { - func makeUIViewController(context: Context) -> UIFontPickerViewController { - let configuration = UIFontPickerViewController.Configuration() - configuration.includeFaces = true - - let fontViewController = UIFontPickerViewController(configuration: configuration) - fontViewController.delegate = context.coordinator - return fontViewController - } - - func updateUIViewController(_ uiViewController: UIFontPickerViewController, context: Context) {} - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - class Coordinator: NSObject, UIFontPickerViewControllerDelegate { - func fontPickerViewControllerDidPickFont(_ viewController: UIFontPickerViewController) { - guard let descriptor = viewController.selectedFontDescriptor else { return } - Defaults[.subtitleFontName] = descriptor.postscriptName - } - } -} diff --git a/Swiftfin/Views/FontPickerView.swift b/Swiftfin/Views/FontPickerView.swift new file mode 100644 index 00000000..99d3fc2a --- /dev/null +++ b/Swiftfin/Views/FontPickerView.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 Defaults +import SwiftUI +import UIKit + +struct FontPickerView: View { + + @Binding + private var selection: String + + @State + private var updateSelection: String + + init(selection: Binding) { + self._selection = selection + self.updateSelection = selection.wrappedValue + } + + var body: some View { + SelectorView( + selection: $updateSelection, + allItems: UIFont.familyNames + ) + .label { fontFamily in + Text(fontFamily) + .foregroundColor(.white) + .font(.custom(fontFamily, size: 18)) + } + .onChange(of: updateSelection) { newValue in + selection = newValue + } + } +} diff --git a/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift b/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift index 82114b71..17aa6b71 100644 --- a/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift +++ b/Swiftfin/Views/HomeView/Components/ContinueWatchingView.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 @@ -15,28 +15,42 @@ extension HomeView { @EnvironmentObject private var router: HomeCoordinator.Router + @ObservedObject var viewModel: HomeViewModel var body: some View { - PosterHStack(title: "", type: .landscape, items: viewModel.resumeItems) - .scaleItems(1.5) - .onSelect { item in - router.route(to: \.item, item) - } - .contextMenu { item in - Button(role: .destructive) { - viewModel.removeItemFromResume(item) + PosterHStack( + type: .landscape, + items: viewModel.resumeItems.map { .item($0) } + ) + .scaleItems(1.5) + .contextMenu { state in + if case let PosterButtonType.item(item) = state { + Button { + viewModel.markItemPlayed(item) } label: { - Label(L10n.removeFromResume, systemImage: "minus.circle") + Label(L10n.played, systemImage: "checkmark.circle") + } + + Button(role: .destructive) { + viewModel.markItemUnplayed(item) + } label: { + Label(L10n.unplayed, systemImage: "minus.circle") } } - .imageOverlay { item in + } + .imageOverlay { state in + if case let PosterButtonType.item(item) = state { LandscapePosterProgressBar( - title: item.progress ?? L10n.continue, + title: item.progressLabel ?? L10n.continue, progress: (item.userData?.playedPercentage ?? 0) / 100 ) } + } + .onSelect { item in + router.route(to: \.item, item) + } } } } diff --git a/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift b/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift index fbf64307..35dc33ef 100644 --- a/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift +++ b/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift @@ -3,29 +3,35 @@ // License, v2.0. If 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 JellyfinAPI import SwiftUI extension HomeView { struct LatestInLibraryView: View { + @Default(.Customization.latestInLibraryPosterType) + private var latestInLibraryPosterType + @EnvironmentObject private var router: HomeCoordinator.Router + @ObservedObject var viewModel: LibraryViewModel - @Default(.Customization.latestInLibraryPosterType) - var latestInLibraryPosterType + private var items: [PosterButtonType] { + viewModel.items.prefix(20).asArray.map { .item($0) } + } var body: some View { PosterHStack( - title: L10n.latestWithString(viewModel.parent?.displayName ?? .emptyDash), + title: L10n.latestWithString(viewModel.parent?.displayTitle ?? .emptyDash), type: latestInLibraryPosterType, - items: viewModel.items.prefix(20).asArray + items: items ) .trailing { SeeAllButton() diff --git a/Swiftfin/Views/HomeView/Components/NextUpView.swift b/Swiftfin/Views/HomeView/Components/NextUpView.swift index ffcb42f9..a8eddb2e 100644 --- a/Swiftfin/Views/HomeView/Components/NextUpView.swift +++ b/Swiftfin/Views/HomeView/Components/NextUpView.swift @@ -3,29 +3,35 @@ // License, v2.0. If 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 JellyfinAPI import SwiftUI extension HomeView { struct NextUpView: View { + @Default(.Customization.nextUpPosterType) + private var nextUpPosterType + @EnvironmentObject private var router: HomeCoordinator.Router + @ObservedObject var viewModel: NextUpLibraryViewModel - @Default(.Customization.nextUpPosterType) - private var nextUpPosterType + private var items: [PosterButtonType] { + viewModel.items.prefix(20).asArray.map { .item($0) } + } var body: some View { PosterHStack( title: L10n.nextUp, type: nextUpPosterType, - items: viewModel.items.prefix(20).asArray + items: items ) .trailing { SeeAllButton() @@ -33,6 +39,15 @@ extension HomeView { router.route(to: \.basicLibrary, .init(title: L10n.nextUp, viewModel: viewModel)) } } + .contextMenu { state in + if case let PosterButtonType.item(item) = state { + Button { + viewModel.markPlayed(item: item) + } label: { + Label(L10n.played, systemImage: "checkmark.circle") + } + } + } .onSelect { item in router.route(to: \.item, item) } diff --git a/Swiftfin/Views/HomeView/Components/RecentlyAddedView.swift b/Swiftfin/Views/HomeView/Components/RecentlyAddedView.swift index 6851eac3..64758a88 100644 --- a/Swiftfin/Views/HomeView/Components/RecentlyAddedView.swift +++ b/Swiftfin/Views/HomeView/Components/RecentlyAddedView.swift @@ -3,29 +3,35 @@ // License, v2.0. If 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 JellyfinAPI import SwiftUI extension HomeView { struct RecentlyAddedView: View { + @Default(.Customization.recentlyAddedPosterType) + private var recentlyAddedPosterType + @EnvironmentObject private var router: HomeCoordinator.Router + @ObservedObject var viewModel: ItemTypeLibraryViewModel - @Default(.Customization.recentlyAddedPosterType) - private var recentlyAddedPosterType + private var items: [PosterButtonType] { + viewModel.items.prefix(20).asArray.map { .item($0) } + } var body: some View { PosterHStack( title: L10n.recentlyAdded, type: recentlyAddedPosterType, - items: viewModel.items.prefix(20).asArray + items: items ) .trailing { SeeAllButton() diff --git a/Swiftfin/Views/HomeView/HomeContentView.swift b/Swiftfin/Views/HomeView/HomeContentView.swift index 4f7deb8f..1b52c8e2 100644 --- a/Swiftfin/Views/HomeView/HomeContentView.swift +++ b/Swiftfin/Views/HomeView/HomeContentView.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 CollectionView @@ -15,16 +15,14 @@ extension HomeView { struct ContentView: View { - @EnvironmentObject - private var homeRouter: HomeCoordinator.Router + @Default(.Customization.nextUpPosterType) + private var nextUpPosterType + @Default(.Customization.recentlyAddedPosterType) + private var recentlyAddedPosterType + @ObservedObject var viewModel: HomeViewModel - @Default(.Customization.nextUpPosterType) - var nextUpPosterType - @Default(.Customization.recentlyAddedPosterType) - var recentlyAddedPosterType - var body: some View { RefreshableScrollView { VStack(alignment: .leading, spacing: 20) { diff --git a/Swiftfin/Views/HomeView/HomeErrorView.swift b/Swiftfin/Views/HomeView/HomeErrorView.swift index e24f0f35..10fe8c61 100644 --- a/Swiftfin/Views/HomeView/HomeErrorView.swift +++ b/Swiftfin/Views/HomeView/HomeErrorView.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 @@ -30,7 +30,9 @@ extension HomeView { .frame(width: 100, height: 100) } - Text("\(errorMessage.code)") + if let code = errorMessage.code { + Text("\(code)") + } Text(errorMessage.message) .frame(minWidth: 50, maxWidth: 240) diff --git a/Swiftfin/Views/HomeView/HomeView.swift b/Swiftfin/Views/HomeView/HomeView.swift index 6fc31879..74d70775 100644 --- a/Swiftfin/Views/HomeView/HomeView.swift +++ b/Swiftfin/Views/HomeView/HomeView.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 @@ -13,13 +13,17 @@ struct HomeView: View { @EnvironmentObject private var router: HomeCoordinator.Router + @ObservedObject var viewModel: HomeViewModel var body: some View { Group { if let errorMessage = viewModel.errorMessage { - ErrorView(viewModel: viewModel, errorMessage: errorMessage) + ErrorView( + viewModel: viewModel, + errorMessage: .init(message: errorMessage, code: -1) + ) } else if viewModel.isLoading { ProgressView() } else { diff --git a/Swiftfin/Views/ItemOverviewView.swift b/Swiftfin/Views/ItemOverviewView.swift index 29f0f200..87cf14ba 100644 --- a/Swiftfin/Views/ItemOverviewView.swift +++ b/Swiftfin/Views/ItemOverviewView.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 @@ -12,24 +12,19 @@ import SwiftUI struct ItemOverviewView: View { @EnvironmentObject - private var itemOverviewRouter: ItemOverviewCoordinator.Router + private var router: ItemOverviewCoordinator.Router + let item: BaseItemDto var body: some View { ScrollView(showsIndicators: false) { - Text(item.overview ?? "") - .font(.body) + ItemView.OverviewView(item: item) .padding() } - .navigationBarTitle(L10n.overview, displayMode: .inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - itemOverviewRouter.dismissCoordinator() - } label: { - Image(systemName: "xmark.circle.fill") - } - } + .navigationTitle(L10n.overview) + .navigationBarTitleDisplayMode(.inline) + .navigationCloseButton { + router.dismissCoordinator() } } } diff --git a/Swiftfin/Views/ItemView/Components/AboutView.swift b/Swiftfin/Views/ItemView/Components/AboutView.swift index ce03e1bf..70125ce5 100644 --- a/Swiftfin/Views/ItemView/Components/AboutView.swift +++ b/Swiftfin/Views/ItemView/Components/AboutView.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 Defaults import JellyfinAPI import SwiftUI @@ -13,8 +14,12 @@ extension ItemView { struct AboutView: View { + @Default(.accentColor) + private var accentColor + @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router + @ObservedObject var viewModel: ItemViewModel @@ -38,37 +43,55 @@ extension ItemView { .posterStyle(type: .portrait, width: 130) .accessibilityIgnoresInvertColors() - Button { - itemRouter.route(to: \.itemOverview, viewModel.item) - } label: { - ZStack { - - Color.secondarySystemFill - .cornerRadius(10) - - VStack(alignment: .leading, spacing: 10) { - Text(viewModel.item.displayName) - .font(.title2) - .fontWeight(.semibold) - - Spacer() - - if let overview = viewModel.item.overview { - Text(overview) - .lineLimit(4) - .font(.footnote) - .foregroundColor(.secondary) - } else { - L10n.noOverviewAvailable.text - .font(.footnote) - .foregroundColor(.secondary) - } + Card(title: viewModel.item.displayTitle) + .content { + if let overview = viewModel.item.overview { + TruncatedTextView(text: overview) + .lineLimit(4) + .font(.footnote) + .seeMoreAction { + router.route(to: \.itemOverview, viewModel.item) + } + .foregroundColor(.secondary) + } else { + L10n.noOverviewAvailable.text + .font(.footnote) + .foregroundColor(.secondary) } - .padding() } - .frame(width: 330, height: 195) + .onSelect { + router.route(to: \.itemOverview, viewModel.item) + } + + if viewModel.item.type == .episode || + viewModel.item.type == .movie, + let mediaSources = viewModel.item.mediaSources + { + ForEach(mediaSources) { source in + Card(title: L10n.media, subtitle: mediaSources.count > 1 ? source.displayTitle : nil) + .content { + if let mediaStreams = source.mediaStreams { + VStack(alignment: .leading) { + ForEach(mediaStreams.prefix(4), id: \.index) { mediaStream in + Text(mediaStream.displayTitle ?? .emptyDash) + .lineLimit(1) + .font(.footnote) + .foregroundColor(.secondary) + } + + if mediaStreams.count > 4 { + L10n.seeMore.text + .font(.footnote) + .foregroundColor(accentColor) + } + } + } + } + .onSelect { + router.route(to: \.mediaSourceInfo, source) + } + } } - .buttonStyle(.plain) } .padding(.horizontal) .if(UIDevice.isIPad) { view in @@ -79,3 +102,66 @@ extension ItemView { } } } + +extension ItemView.AboutView { + + struct Card: View { + + private var content: () -> any View + private var onSelect: () -> Void + private let title: String + private let subtitle: String? + + var body: some View { + Button { + onSelect() + } label: { + ZStack(alignment: .leading) { + + Color.secondarySystemFill + .cornerRadius(10) + + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.title2) + .fontWeight(.semibold) + .lineLimit(2) + + if let subtitle { + Text(subtitle) + .font(.subheadline) + } + + Spacer() + + content() + .eraseToAnyView() + } + .padding() + } + .frame(width: 330, height: 195) + } + .buttonStyle(.plain) + } + } +} + +extension ItemView.AboutView.Card { + + init(title: String, subtitle: String? = nil) { + self.init( + content: { EmptyView() }, + onSelect: {}, + title: title, + subtitle: subtitle + ) + } + + func content(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.content, with: content) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift index 6f0e096f..e41cc0fa 100644 --- a/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift +++ b/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift @@ -3,22 +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 Defaults +import Factory +import JellyfinAPI import SwiftUI extension ItemView { struct ActionButtonHStack: View { + @EnvironmentObject + private var router: ItemCoordinator.Router + + @ObservedObject + private var downloadManager: DownloadManager @ObservedObject private var viewModel: ItemViewModel + private let equalSpacing: Bool init(viewModel: ItemViewModel, equalSpacing: Bool = true) { self.viewModel = viewModel self.equalSpacing = equalSpacing + + self.downloadManager = Container.downloadManager.callAsFunction() } var body: some View { @@ -27,7 +38,7 @@ extension ItemView { UIDevice.impact(.light) viewModel.toggleWatchState() } label: { - if viewModel.isWatched { + if viewModel.isPlayed { Image(systemName: "checkmark.circle.fill") .symbolRenderingMode(.palette) .foregroundStyle( @@ -60,28 +71,45 @@ extension ItemView { view.frame(maxWidth: .infinity) } - if viewModel.videoPlayerViewModels.count > 1 { + if let playButtonItem = viewModel.playButtonItem, + let mediaSources = playButtonItem.mediaSources, + mediaSources.count > 1 + { Menu { - ForEach(viewModel.videoPlayerViewModels, id: \.versionName) { viewModelOption in + ForEach(mediaSources, id: \.hashValue) { mediaSource in Button { - viewModel.selectedVideoPlayerViewModel = viewModelOption + viewModel.selectedMediaSource = mediaSource } label: { - if viewModelOption.versionName == viewModel.selectedVideoPlayerViewModel?.versionName { - Label(viewModelOption.versionName ?? L10n.noTitle, systemImage: "checkmark") + if let selectedMediaSource = viewModel.selectedMediaSource, selectedMediaSource == mediaSource { + Label(selectedMediaSource.displayTitle, systemImage: "checkmark") } else { - Text(viewModelOption.versionName ?? L10n.noTitle) + Text(mediaSource.displayTitle) } } } } label: { - HStack(spacing: 5) { - Image(systemName: "list.dash") - } + Image(systemName: "list.dash") } + .buttonStyle(.plain) .if(equalSpacing) { view in view.frame(maxWidth: .infinity) } } + + if viewModel.item.type == .movie || + viewModel.item.type == .episode, + Defaults[.Experimental.downloads] + { + DownloadTaskButton(item: viewModel.item) + .onSelect { task in + router.route(to: \.downloadTask, task) + } + .buttonStyle(.plain) + .frame(width: 25, height: 25) + .if(equalSpacing) { view in + view.frame(maxWidth: .infinity) + } + } } } } diff --git a/Swiftfin/Views/ItemView/Components/AttributeHStack.swift b/Swiftfin/Views/ItemView/Components/AttributeHStack.swift index a2c76cb0..ea0f1596 100644 --- a/Swiftfin/Views/ItemView/Components/AttributeHStack.swift +++ b/Swiftfin/Views/ItemView/Components/AttributeHStack.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 @@ -18,28 +18,36 @@ extension ItemView { var body: some View { HStack { if let officialRating = viewModel.item.officialRating { - AttributeOutlineView(text: officialRating) + Text(officialRating) + .asAttributeStyle(.outline) } - if let selectedPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - if selectedPlayerViewModel.item.isHD ?? false { - AttributeFillView(text: "HD") + // TODO: Have stream indicate this instead? + if viewModel.item.isHD ?? false { + Text("HD") + .asAttributeStyle(.fill) + } + + if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { + + if mediaStreams.has4KVideo { + Text("4K") + .asAttributeStyle(.fill) } - if (selectedPlayerViewModel.videoStream.width ?? 0) > 3800 { - AttributeFillView(text: "4K") + if mediaStreams.has51AudioChannelLayout { + Text("5.1") + .asAttributeStyle(.fill) } - if selectedPlayerViewModel.audioStreams.contains(where: { $0.channelLayout == "5.1" }) { - AttributeFillView(text: "5.1") + if mediaStreams.has71AudioChannelLayout { + Text("7.1") + .asAttributeStyle(.fill) } - if selectedPlayerViewModel.audioStreams.contains(where: { $0.channelLayout == "7.1" }) { - AttributeFillView(text: "7.1") - } - - if !selectedPlayerViewModel.subtitleStreams.isEmpty { - AttributeOutlineView(text: "CC") + if mediaStreams.hasSubtitles { + Text("CC") + .asAttributeStyle(.outline) } } } diff --git a/Swiftfin/Views/ItemView/Components/CastAndCrewHStack.swift b/Swiftfin/Views/ItemView/Components/CastAndCrewHStack.swift index 8db6193c..d867e895 100644 --- a/Swiftfin/Views/ItemView/Components/CastAndCrewHStack.swift +++ b/Swiftfin/Views/ItemView/Components/CastAndCrewHStack.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 @@ -15,13 +15,18 @@ extension ItemView { @EnvironmentObject private var router: ItemCoordinator.Router + let people: [BaseItemPerson] var body: some View { PosterHStack( title: L10n.castAndCrew, type: .portrait, - items: people.filter(\.isDisplayed).prefix(20).asArray + items: people + .filter(\.isDisplayed) + .prefix(20) + .asArray + .map { .item($0) } ) .trailing { SeeAllButton() diff --git a/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift b/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift new file mode 100644 index 00000000..a72b72f3 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift @@ -0,0 +1,58 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct DownloadTaskButton: View { + + @ObservedObject + private var downloadManager: DownloadManager + @ObservedObject + private var downloadTask: DownloadTask + + private var onSelect: (DownloadTask) -> Void + + var body: some View { + Button { + onSelect(downloadTask) + } label: { + switch downloadTask.state { + case .cancelled: + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + case .complete: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + case let .downloading(progress): + CircularProgressView(progress: progress) + case .error: + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + case .ready: + Image(systemName: "arrow.down.circle") + } + } + } +} + +extension DownloadTaskButton { + + init(item: BaseItemDto) { + let downloadManager = Container.downloadManager.callAsFunction() + + self.downloadTask = downloadManager.task(for: item) ?? .init(item: item) + self.onSelect = { _ in } + self.downloadManager = downloadManager + } + + func onSelect(_ action: @escaping (DownloadTask) -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin/Views/ItemView/Components/EpisodesRowView/EpisodeCard.swift b/Swiftfin/Views/ItemView/Components/EpisodesRowView/EpisodeCard.swift deleted file mode 100644 index 50939321..00000000 --- a/Swiftfin/Views/ItemView/Components/EpisodesRowView/EpisodeCard.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 JellyfinAPI -import SwiftUI - -struct EpisodeCard: View { - - @EnvironmentObject - private var router: ItemCoordinator.Router - @ScaledMetric - private var staticOverviewHeight: CGFloat = 50 - - let viewModel: RowManager - let episode: BaseItemDto - - var body: some View { - PosterButton(item: episode, type: .landscape, singleImage: true) - .scaleItem(1.2) - .imageOverlay { - if let progress = episode.progress { - LandscapePosterProgressBar( - title: progress, - progress: (episode.userData?.playedPercentage ?? 0) / 100 - ) - } else if episode.userData?.played ?? false { - ZStack(alignment: .bottomTrailing) { - Color.clear - - Image(systemName: "checkmark.circle.fill") - .resizable() - .frame(width: 30, height: 30, alignment: .bottomTrailing) - .foregroundColor(.white) - .padding() - } - } - } - .content { - Button { - router.route(to: \.item, episode) - } label: { - VStack(alignment: .leading) { - Text(episode.episodeLocator ?? L10n.unknown) - .font(.footnote) - .foregroundColor(.secondary) - - Text(episode.displayName) - .font(.body) - .foregroundColor(.primary) - .padding(.bottom, 1) - .lineLimit(2) - .multilineTextAlignment(.leading) - - ZStack(alignment: .topLeading) { - Color.clear - .frame(height: staticOverviewHeight) - - if episode.unaired { - Text(episode.airDateLabel ?? L10n.noOverviewAvailable) - } else { - Text(episode.overview ?? L10n.noOverviewAvailable) - } - } - .font(.caption.weight(.light)) - .foregroundColor(.secondary) - .lineLimit(4) - .multilineTextAlignment(.leading) - - L10n.seeMore.text - .font(.footnote) - .fontWeight(.medium) - .foregroundColor(.jellyfinPurple) - } - } - } - .onSelect { - episode.createVideoPlayerViewModel() - .sink { completion in - self.viewModel.handleAPIRequestError(completion: completion) - } receiveValue: { viewModels in - if let episodeViewModel = viewModels.first { - router.route(to: \.videoPlayer, episodeViewModel) - } - } - .store(in: &viewModel.cancellables) - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/EpisodesRowView/SeriesEpisodesView.swift b/Swiftfin/Views/ItemView/Components/EpisodesRowView/SeriesEpisodesView.swift deleted file mode 100644 index fc065bd2..00000000 --- a/Swiftfin/Views/ItemView/Components/EpisodesRowView/SeriesEpisodesView.swift +++ /dev/null @@ -1,85 +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 - -struct SeriesEpisodesView: View { - - @EnvironmentObject - private var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: RowManager - - @ViewBuilder - private var headerView: some View { - HStack { - Menu { - ForEach(viewModel.sortedSeasons) { season in - Button { - viewModel.select(season: season) - } label: { - if season.id == viewModel.selectedSeason?.id { - Label(season.name ?? L10n.unknown, systemImage: "checkmark") - } else { - Text(season.name ?? L10n.unknown) - } - } - } - } label: { - HStack(spacing: 5) { - Group { - Text(viewModel.selectedSeason?.name ?? L10n.unknown) - .fixedSize() - Image(systemName: "chevron.down") - } - .font(.title3.weight(.semibold)) - } - } - - Spacer() - } - } - - var body: some View { - VStack(alignment: .leading, spacing: 20) { - - headerView - .padding(.horizontal) - .if(UIDevice.isIPad) { view in - view.padding(.horizontal) - } - - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 15) { - if viewModel.isLoading { - ForEach(0 ..< 5) { _ in - EpisodeCard(viewModel: viewModel, episode: .placeHolder) - .redacted(reason: .placeholder) - } - } else if let selectedSeason = viewModel.selectedSeason { - if let seasonEpisodes = viewModel.seasonsEpisodes[selectedSeason] { - if seasonEpisodes.isEmpty { - EpisodeCard(viewModel: viewModel, episode: .noResults) - } else { - ForEach(seasonEpisodes) { episode in - EpisodeCard(viewModel: viewModel, episode: episode) - .id(episode.id) - } - } - } - } - } - .padding(.horizontal) - .if(UIDevice.isIPad) { view in - view.padding(.horizontal) - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/GenresHStack.swift b/Swiftfin/Views/ItemView/Components/GenresHStack.swift index d4f8cd98..2a06eda4 100644 --- a/Swiftfin/Views/ItemView/Components/GenresHStack.swift +++ b/Swiftfin/Views/ItemView/Components/GenresHStack.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 @@ -15,6 +15,7 @@ extension ItemView { @EnvironmentObject private var router: ItemCoordinator.Router + let genres: [NameGuidPair] var body: some View { diff --git a/Swiftfin/Views/ItemView/Components/ListDetailsView.swift b/Swiftfin/Views/ItemView/Components/ListDetailsView.swift deleted file mode 100644 index a0905082..00000000 --- a/Swiftfin/Views/ItemView/Components/ListDetailsView.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 - -struct ListDetailsView: View { - - let title: String - let items: [BaseItemDto.ItemDetail] - - var body: some View { - VStack(alignment: .leading) { - - VStack(alignment: .leading, spacing: 20) { - Text(title) - .font(.title3) - .fontWeight(.bold) - .accessibility(addTraits: [.isHeader]) - - ForEach(items, id: \.self.title) { item in - VStack(alignment: .leading, spacing: 2) { - Text(item.title) - .font(.subheadline) - Text(item.content) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .font(.subheadline) - .foregroundColor(Color.secondary) - } - .accessibilityElement(children: .combine) - } - } - .padding(.bottom, 20) - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/MediaSourceInfoView.swift b/Swiftfin/Views/ItemView/Components/MediaSourceInfoView.swift new file mode 100644 index 00000000..34470007 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/MediaSourceInfoView.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 JellyfinAPI +import SwiftUI + +extension ItemView { + + struct MediaSourceInfoView: View { + + @EnvironmentObject + private var router: MediaSourceInfoCoordinator.Router + + let mediaSource: MediaSourceInfo + + var body: some View { + Form { + if let videoStreams = mediaSource.videoStreams, + !videoStreams.isEmpty + { + Section(L10n.video) { + ForEach(videoStreams, id: \.self) { mediaStream in + ChevronButton(title: mediaStream.displayTitle ?? .emptyDash) + .onSelect { + router.route(to: \.mediaStreamInfo, mediaStream) + } + } + } + } + + if let audioStreams = mediaSource.audioStreams, + !audioStreams.isEmpty + { + Section(L10n.audio) { + ForEach(audioStreams, id: \.self) { mediaStream in + ChevronButton(title: mediaStream.displayTitle ?? .emptyDash) + .onSelect { + router.route(to: \.mediaStreamInfo, mediaStream) + } + } + } + } + + if let subtitleStreams = mediaSource.subtitleStreams, + !subtitleStreams.isEmpty + { + Section(L10n.subtitle) { + ForEach(subtitleStreams, id: \.self) { mediaStream in + ChevronButton(title: mediaStream.displayTitle ?? .emptyDash) + .onSelect { + router.route(to: \.mediaStreamInfo, mediaStream) + } + } + } + } + } + .navigationTitle(mediaSource.displayTitle) + .navigationBarTitleDisplayMode(.inline) + .navigationCloseButton { + router.dismissCoordinator() + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/OverviewView.swift b/Swiftfin/Views/ItemView/Components/OverviewView.swift new file mode 100644 index 00000000..69ff1cb9 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/OverviewView.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 JellyfinAPI +import SwiftUI + +extension ItemView { + + struct OverviewView: View { + + @EnvironmentObject + private var router: ItemCoordinator.Router + + let item: BaseItemDto + private var overviewLineLimit: Int + private var taglineLineLimit: Int + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + if let firstTagline = item.taglines?.first { + Text(firstTagline) + .font(.body) + .fontWeight(.semibold) + .multilineTextAlignment(.leading) + .lineLimit(taglineLineLimit) + } + + if let itemOverview = item.overview { + TruncatedTextView(text: itemOverview) + .seeMoreAction { + router.route(to: \.itemOverview, item) + } + .font(.footnote) + .lineLimit(overviewLineLimit) + } + } + } + } +} + +extension ItemView.OverviewView { + + init(item: BaseItemDto) { + self.init( + item: item, + overviewLineLimit: 1000, + taglineLineLimit: 1000 + ) + } + + func overviewLineLimit(_ limit: Int) -> Self { + copy(modifying: \.overviewLineLimit, with: limit) + } + + func taglineLineLimit(_ limit: Int) -> Self { + copy(modifying: \.taglineLineLimit, with: limit) + } +} diff --git a/Swiftfin/Views/ItemView/Components/PlayButton.swift b/Swiftfin/Views/ItemView/Components/PlayButton.swift index f765ff8e..850d5ecd 100644 --- a/Swiftfin/Views/ItemView/Components/PlayButton.swift +++ b/Swiftfin/Views/ItemView/Components/PlayButton.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 Defaults import Factory import SwiftUI @@ -13,50 +14,56 @@ extension ItemView { struct PlayButton: View { + @Default(.accentColor) + private var accentColor + @Injected(LogManager.service) private var logger + @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var mainRouter: MainCoordinator.Router + @ObservedObject var viewModel: ItemViewModel var body: some View { Button { - if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) + if let playButtonItem = viewModel.playButtonItem, let selectedMediaSource = viewModel.selectedMediaSource { + mainRouter.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: playButtonItem, mediaSource: selectedMediaSource)) } else { - logger.error("Attempted to play item but no playback information available") + logger.error("No media source available") } } label: { ZStack { Rectangle() - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : accentColor) .cornerRadius(10) HStack { Image(systemName: "play.fill") .font(.system(size: 20)) + Text(viewModel.playButtonText()) .font(.callout) .fontWeight(.semibold) } - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) - } - } - .contextMenu { - if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { - Button { - if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true) - itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) - } else { - logger.error("Attempted to play item but no playback information available") - } - } label: { - Label(L10n.playFromBeginning, systemImage: "gobackward") - } + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : accentColor.overlayColor) } } +// .contextMenu { +// if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { +// Button { +// if let selectedVideoPlayerViewModel = viewModel.legacyselectedVideoPlayerViewModel { +// selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true) +// router.route(to: \.legacyVideoPlayer, selectedVideoPlayerViewModel) +// } else { +// logger.error("Attempted to play item but no playback information available") +// } +// } label: { +// Label(L10n.playFromBeginning, systemImage: "gobackward") +// } +// } +// } } } } diff --git a/Swiftfin/Views/ItemView/Components/SeriesEpisodeSelector.swift b/Swiftfin/Views/ItemView/Components/SeriesEpisodeSelector.swift new file mode 100644 index 00000000..80a292e3 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/SeriesEpisodeSelector.swift @@ -0,0 +1,169 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct SeriesEpisodeSelector: View { + + @EnvironmentObject + private var mainRouter: MainCoordinator.Router + + @ObservedObject + var viewModel: SeriesItemViewModel + + var body: some View { + MenuPosterHStack( + type: .landscape, + manager: viewModel, + singleImage: true + ) + .scaleItems(1.2) + .imageOverlay { type in + EpisodeOverlay(type: type) + } + .content { type in + EpisodeContent(type: type) + } + .onSelect { item in + guard let mediaSource = item.mediaSources?.first else { return } + mainRouter.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: item, mediaSource: mediaSource)) + } + } +} + +extension SeriesEpisodeSelector { + + struct EpisodeOverlay: View { + + let type: PosterButtonType + + var body: some View { + if case let PosterButtonType.item(episode) = type { + if let progressLabel = episode.progressLabel { + LandscapePosterProgressBar( + title: progressLabel, + progress: (episode.userData?.playedPercentage ?? 0) / 100 + ) + } else if episode.userData?.isPlayed ?? false { + ZStack(alignment: .bottomTrailing) { + Color.clear + + Image(systemName: "checkmark.circle.fill") + .resizable() + .frame(width: 30, height: 30, alignment: .bottomTrailing) + .accentSymbolRendering(accentColor: .white) + .padding() + } + } + } else { + EmptyView() + } + } + } + + struct EpisodeContent: View { + + @Default(.accentColor) + private var accentColor + + @EnvironmentObject + private var router: ItemCoordinator.Router + @ScaledMetric + private var staticOverviewHeight: CGFloat = 50 + + let type: PosterButtonType + + @ViewBuilder + private var subHeader: some View { + Group { + switch type { + case .loading: + String(repeating: "a", count: 5).text + .redacted(reason: .placeholder) + case .noResult: + String.emptyDash.text + case let .item(episode): + Text(episode.episodeLocator ?? L10n.unknown) + } + } + .font(.footnote) + .foregroundColor(.secondary) + } + + @ViewBuilder + private var header: some View { + Group { + switch type { + case .loading: + String(repeating: "a", count: Int.random(in: 8 ..< 18)).text + .redacted(reason: .placeholder) + case .noResult: + L10n.noResults.text + case let .item(episode): + Text(episode.displayTitle) + } + } + .font(.body) + .foregroundColor(.primary) + .padding(.bottom, 1) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + + @ViewBuilder + private var content: some View { + Group { + switch type { + case .loading: + String(repeating: "a", count: Int.random(in: 50 ..< 100)).text + .redacted(reason: .placeholder) + case .noResult: + L10n.noOverviewAvailable.text + case let .item(episode): + ZStack(alignment: .topLeading) { + Color.clear + .frame(height: staticOverviewHeight) + + if episode.isUnaired { + Text(episode.airDateLabel ?? L10n.noOverviewAvailable) + } else { + Text(episode.overview ?? L10n.noOverviewAvailable) + } + } + + L10n.seeMore.text + .font(.footnote) + .fontWeight(.medium) + .foregroundColor(accentColor) + } + } + .font(.caption.weight(.light)) + .foregroundColor(.secondary) + .lineLimit(4) + .multilineTextAlignment(.leading) + } + + var body: some View { + Button { + if case let PosterButtonType.item(item) = type { + router.route(to: \.item, item) + } + } label: { + VStack(alignment: .leading) { + subHeader + + header + + content + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/SimilarItemsHStack.swift b/Swiftfin/Views/ItemView/Components/SimilarItemsHStack.swift index 09ca1e8a..b59da6d8 100644 --- a/Swiftfin/Views/ItemView/Components/SimilarItemsHStack.swift +++ b/Swiftfin/Views/ItemView/Components/SimilarItemsHStack.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 @@ -19,13 +19,14 @@ extension ItemView { @EnvironmentObject private var router: ItemCoordinator.Router + let items: [BaseItemDto] var body: some View { PosterHStack( title: L10n.recommended, type: similarPosterType, - items: items + items: items.map { .item($0) } ) .trailing { SeeAllButton() diff --git a/Swiftfin/Views/ItemView/Components/SpecialFeatureHStack.swift b/Swiftfin/Views/ItemView/Components/SpecialFeatureHStack.swift new file mode 100644 index 00000000..9b6312f7 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/SpecialFeatureHStack.swift @@ -0,0 +1,33 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2023 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension ItemView { + + struct SpecialFeaturesHStack: View { + + @EnvironmentObject + private var router: MainCoordinator.Router + + let items: [BaseItemDto] + + var body: some View { + PosterHStack( + title: L10n.specialFeatures, + type: .landscape, + items: items.map { .item($0) } + ) + .onSelect { item in + guard let mediaSource = item.mediaSources?.first else { return } + router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: item, mediaSource: mediaSource)) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/StudiosHStack.swift b/Swiftfin/Views/ItemView/Components/StudiosHStack.swift index 0a7ec1ef..dd938f9e 100644 --- a/Swiftfin/Views/ItemView/Components/StudiosHStack.swift +++ b/Swiftfin/Views/ItemView/Components/StudiosHStack.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 @@ -15,6 +15,7 @@ extension ItemView { @EnvironmentObject private var router: ItemCoordinator.Router + let studios: [NameGuidPair] var body: some View { diff --git a/Swiftfin/Views/ItemView/ItemView.swift b/Swiftfin/Views/ItemView/ItemView.swift index 4156e66e..ed16f110 100644 --- a/Swiftfin/Views/ItemView/ItemView.swift +++ b/Swiftfin/Views/ItemView/ItemView.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 @@ -43,13 +43,12 @@ struct ItemView: View { CollectionItemView(viewModel: .init(item: item)) } case .person: - LibraryView(viewModel: LibraryViewModel(parent: item, type: .person)) -// LibraryView(viewModel: .init(parent: item, type: .person)) + LibraryView(viewModel: .init(parent: item, type: .person)) default: Text(L10n.notImplementedYetWithType(item.type ?? "--")) } } .navigationBarTitleDisplayMode(.inline) - .navigationTitle(item.displayName) + .navigationTitle(item.displayTitle) } } diff --git a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift index 8e8d9ccc..21f0289a 100644 --- a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.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 JellyfinAPI import SwiftUI extension CollectionItemView { @@ -13,10 +14,19 @@ extension CollectionItemView { struct ContentView: View { @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router + @ObservedObject var viewModel: CollectionItemViewModel + private var items: [PosterButtonType] { + if viewModel.isLoading { + return PosterButtonType.loading.random(in: 3 ..< 8) + } else { + return viewModel.collectionItems.map { .item($0) } + } + } + var body: some View { VStack(alignment: .leading, spacing: 20) { @@ -38,11 +48,13 @@ extension CollectionItemView { // MARK: Items - if !viewModel.collectionItems.isEmpty { - PosterHStack(title: L10n.items, type: .portrait, items: viewModel.collectionItems) - .onSelect { item in - itemRouter.route(to: \.item, item) - } + PosterHStack( + title: L10n.items, + type: .portrait, + items: items + ) + .onSelect { item in + router.route(to: \.item, item) } } } diff --git a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.swift b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.swift index c558f563..def9b65d 100644 --- a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.swift +++ b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.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 @@ -11,11 +11,12 @@ import SwiftUI struct CollectionItemView: View { - @ObservedObject - var viewModel: CollectionItemViewModel @Default(.Customization.itemViewType) private var itemViewType + @ObservedObject + var viewModel: CollectionItemViewModel + var body: some View { switch itemViewType { case .compactPoster: diff --git a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift index f251f529..a0151345 100644 --- a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.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 @@ -15,6 +15,7 @@ extension EpisodeItemView { @EnvironmentObject private var router: ItemCoordinator.Router + @ObservedObject var viewModel: EpisodeItemViewModel @@ -27,20 +28,16 @@ extension EpisodeItemView { .aspectRatio(1.77, contentMode: .fill) .cornerRadius(10) .padding(.horizontal) + .posterShadow() ShelfView(viewModel: viewModel) } // MARK: Overview - if let itemOverview = viewModel.item.overview { - TruncatedTextView(text: itemOverview) { - router.route(to: \.itemOverview, viewModel.item) - } - .font(.footnote) - .lineLimit(5) + ItemView.OverviewView(item: viewModel.item) + .overviewLineLimit(4) .padding(.horizontal) - } // MARK: Genres @@ -77,12 +74,7 @@ extension EpisodeItemView { } } - // MARK: Details - - if let informationItems = viewModel.item.createInformationItems(), !informationItems.isEmpty { - ListDetailsView(title: L10n.information, items: informationItems) - .padding(.horizontal) - } + ItemView.AboutView(viewModel: viewModel) } } } @@ -93,7 +85,8 @@ extension EpisodeItemView.ContentView { struct ShelfView: View { @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router + @ObservedObject var viewModel: EpisodeItemViewModel @@ -107,7 +100,7 @@ extension EpisodeItemView.ContentView { .padding(.horizontal) .foregroundColor(.secondary) - Text(viewModel.item.displayName) + Text(viewModel.item.displayTitle) .font(.title2) .fontWeight(.bold) .multilineTextAlignment(.center) diff --git a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift index 3781d560..db6581df 100644 --- a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift +++ b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.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 @@ -12,12 +12,14 @@ import SwiftUI struct EpisodeItemView: View { @EnvironmentObject - private var itemRouter: ItemCoordinator.Router - @State - private var scrollViewOffset: CGFloat = 0 + private var router: ItemCoordinator.Router + @ObservedObject var viewModel: EpisodeItemViewModel + @State + private var scrollViewOffset: CGFloat = 0 + var body: some View { ScrollView(showsIndicators: false) { ContentView(viewModel: viewModel) @@ -28,5 +30,12 @@ struct EpisodeItemView: View { start: 0, end: 30 ) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + if viewModel.isLoading { + ProgressView() + } + } + } } } diff --git a/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift b/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift index 8981d638..85599a33 100644 --- a/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.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 @@ -14,8 +14,6 @@ extension MovieItemView { struct ContentView: View { - @EnvironmentObject - private var itemRouter: ItemCoordinator.Router @ObservedObject var viewModel: MovieItemViewModel @@ -48,6 +46,14 @@ extension MovieItemView { Divider() } + // MARK: Special Features + + if !viewModel.specialFeatures.isEmpty { + ItemView.SpecialFeaturesHStack(items: viewModel.specialFeatures) + + Divider() + } + // MARK: Similar if !viewModel.similarItems.isEmpty { @@ -57,11 +63,6 @@ extension MovieItemView { } ItemView.AboutView(viewModel: viewModel) - - if let informationItems = viewModel.item.createInformationItems(), !informationItems.isEmpty { - ListDetailsView(title: L10n.information, items: informationItems) - .padding(.horizontal) - } } } } diff --git a/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemView.swift b/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemView.swift index 7f137300..2f09877b 100644 --- a/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemView.swift +++ b/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemView.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 @@ -12,11 +12,12 @@ import SwiftUI struct MovieItemView: View { - @ObservedObject - var viewModel: MovieItemViewModel @Default(.Customization.itemViewType) private var itemViewType + @ObservedObject + var viewModel: MovieItemViewModel + var body: some View { switch itemViewType { case .compactPoster: diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift index 60ec245d..800f62ef 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.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 BlurHashKit +import Defaults import SwiftUI extension ItemView { struct CinematicScrollView: View { + @Default(.Customization.CinematicItemViewType.usePrimaryImage) + private var cinematicItemViewTypeUsePrimaryImage + @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router + + @ObservedObject + var viewModel: ItemViewModel + @State private var scrollViewOffset: CGFloat = 0 @State private var blurHashBottomEdgeColor: Color = .secondarySystemFill - @ObservedObject - var viewModel: ItemViewModel let content: () -> Content @@ -28,25 +34,28 @@ extension ItemView { let start = UIScreen.main.bounds.height * 0.5 let end = UIScreen.main.bounds.height * 0.65 let diff = end - start - let opacity = min(max((scrollViewOffset - start) / diff, 0), 1) + let opacity = clamp((scrollViewOffset - start) / diff, min: 0, max: 1) return opacity } @ViewBuilder private var headerView: some View { - ImageView(viewModel.item.imageSource(.backdrop, maxWidth: UIScreen.main.bounds.width)) - .frame(height: UIScreen.main.bounds.height * 0.6) - .bottomEdgeGradient(bottomColor: blurHashBottomEdgeColor) - .onAppear { - if let headerBlurHash = viewModel.item.blurHash(.backdrop) { - let bottomRGB = BlurHash(string: headerBlurHash)!.averageLinearRGB - blurHashBottomEdgeColor = Color( - red: Double(bottomRGB.0), - green: Double(bottomRGB.1), - blue: Double(bottomRGB.2) - ) - } + ImageView(viewModel.item.imageSource( + cinematicItemViewTypeUsePrimaryImage ? .primary : .backdrop, + maxWidth: UIScreen.main.bounds.width + )) + .frame(height: UIScreen.main.bounds.height * 0.6) + .bottomEdgeGradient(bottomColor: blurHashBottomEdgeColor) + .onAppear { + if let headerBlurHash = viewModel.item.blurHash(.backdrop) { + let bottomRGB = BlurHash(string: headerBlurHash)!.averageLinearRGB + blurHashBottomEdgeColor = Color( + red: Double(bottomRGB.0), + green: Double(bottomRGB.1), + blue: Double(bottomRGB.2) + ) } + } } var body: some View { @@ -94,11 +103,18 @@ extension ItemView { ) .backgroundParallaxHeader( $scrollViewOffset, - height: UIScreen.main.bounds.height * 0.6, + height: UIScreen.main.bounds.height * 0.8, multiplier: 0.3 ) { headerView } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + if viewModel.isLoading { + ProgressView() + } + } + } } } } @@ -107,8 +123,11 @@ extension ItemView.CinematicScrollView { struct OverlayView: View { + @Default(.Customization.CinematicItemViewType.usePrimaryImage) + private var cinematicItemViewTypeUsePrimaryImage + @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router @ObservedObject var viewModel: ItemViewModel @@ -116,20 +135,25 @@ extension ItemView.CinematicScrollView { VStack(alignment: .leading, spacing: 10) { VStack(alignment: .center, spacing: 10) { - ImageView(viewModel.item.imageURL(.logo, maxWidth: UIScreen.main.bounds.width)) - .resizingMode(.aspectFit) - .placeholder { - EmptyView() - } - .failure { - Text(viewModel.item.displayName) - .font(.largeTitle) - .fontWeight(.semibold) - .multilineTextAlignment(.center) - .foregroundColor(.white) - } - .frame(height: 100) - .frame(maxWidth: .infinity) + if !cinematicItemViewTypeUsePrimaryImage { + ImageView(viewModel.item.imageURL(.logo, maxWidth: UIScreen.main.bounds.width)) + .resizingMode(.aspectFit) + .placeholder { + EmptyView() + } + .failure { + Text(viewModel.item.displayTitle) + .font(.largeTitle) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + .foregroundColor(.white) + } + .frame(height: 100) + .frame(maxWidth: .infinity) + } else { + Spacer() + .frame(height: 50) + } DotHStack { if let firstGenre = viewModel.item.genres?.first { @@ -159,25 +183,10 @@ extension ItemView.CinematicScrollView { } .frame(maxWidth: .infinity) - if let firstTagline = viewModel.item.taglines?.first { - Text(firstTagline) - .font(.body) - .fontWeight(.semibold) - .multilineTextAlignment(.leading) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.white) - } - - if let itemOverview = viewModel.item.overview { - TruncatedTextView(text: itemOverview) { - itemRouter.route(to: \.itemOverview, viewModel.item) - } - .font(.footnote) - .lineLimit(4) + ItemView.OverviewView(item: viewModel.item) + .overviewLineLimit(4) + .taglineLineLimit(2) .foregroundColor(.white) - .fixedSize(horizontal: false, vertical: true) - } ItemView.AttributesHStack(viewModel: viewModel) } diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift index f333c987..6cc633d9 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.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 BlurHashKit @@ -14,21 +14,23 @@ extension ItemView { struct CompactLogoScrollView: View { @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router + + @ObservedObject + var viewModel: ItemViewModel + @State private var scrollViewOffset: CGFloat = 0 @State private var blurHashBottomEdgeColor: Color = .secondarySystemFill - @ObservedObject - var viewModel: ItemViewModel let content: () -> Content private var topOpacity: CGFloat { let start = UIScreen.main.bounds.height * 0.25 - let end = UIScreen.main.bounds.height * 0.44 + let end = UIScreen.main.bounds.height * 0.42 - 50 let diff = end - start - let opacity = min(max((scrollViewOffset - start) / diff, 0), 1) + let opacity = clamp((scrollViewOffset - start) / diff, min: 0, max: 1) return opacity } @@ -51,75 +53,65 @@ extension ItemView { var body: some View { ScrollView(showsIndicators: false) { - VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { - Color.clear - .frame(height: UIScreen.main.bounds.height * 0.25) + VStack { + Spacer() - OverlayView(scrollViewOffset: $scrollViewOffset, viewModel: viewModel) - .padding(.horizontal) - .padding(.bottom) - .background { - BlurView(style: .systemThinMaterialDark) - .mask { - LinearGradient( - stops: [ - .init(color: .clear, location: 0), - .init(color: .white, location: 0.15), - ], - startPoint: .top, - endPoint: .bottom - ) - } - } - .overlay { - Color.systemBackground - .opacity(topOpacity) - } - - VStack(alignment: .leading, spacing: 10) { - if let firstTagline = viewModel.item.taglines?.first { - Text(firstTagline) - .font(.body) - .fontWeight(.semibold) - .multilineTextAlignment(.leading) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - } - - if let itemOverview = viewModel.item.overview { - TruncatedTextView(text: itemOverview) { - itemRouter.route(to: \.itemOverview, viewModel.item) + OverlayView(viewModel: viewModel, scrollViewOffset: $scrollViewOffset) + .padding(.horizontal) + .padding(.bottom) + .background { + BlurView(style: .systemThinMaterialDark) + .mask { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black, location: 0.3), + ], + startPoint: .top, + endPoint: .bottom + ) + } + } + .overlay { + Color.systemBackground + .opacity(topOpacity) } - .font(.footnote) - .lineLimit(4) - .fixedSize(horizontal: false, vertical: true) - } } - .padding(.horizontal) - .padding(.top) - .frame(maxWidth: .infinity) - .background(Color.systemBackground) + .frame(height: UIScreen.main.bounds.height * 0.5) + + ItemView.OverviewView(item: viewModel.item) + .overviewLineLimit(4) + .taglineLineLimit(2) + .padding(.top) + .padding(.horizontal) content() .padding(.vertical) - .background(Color.systemBackground) } } .edgesIgnoringSafeArea(.top) .scrollViewOffset($scrollViewOffset) .navBarOffset( $scrollViewOffset, - start: UIScreen.main.bounds.height * 0.43, - end: UIScreen.main.bounds.height * 0.43 + 50 + start: UIScreen.main.bounds.height * 0.42 - 50, + end: UIScreen.main.bounds.height * 0.42 ) .backgroundParallaxHeader( $scrollViewOffset, - height: UIScreen.main.bounds.height * 0.35, + height: UIScreen.main.bounds.height * 0.5, multiplier: 0.3 ) { headerView } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + if viewModel.isLoading { + ProgressView() + } + } + } } } } @@ -129,19 +121,13 @@ extension ItemView.CompactLogoScrollView { struct OverlayView: View { @EnvironmentObject - private var itemRouter: ItemCoordinator.Router - @Binding - var scrollViewOffset: CGFloat + private var router: ItemCoordinator.Router + @ObservedObject var viewModel: ItemViewModel - private var topOpacity: CGFloat { - let start = UIScreen.main.bounds.height * 0.25 - let end = UIScreen.main.bounds.height * 0.44 - let diff = end - start - let opacity = min(max((scrollViewOffset - start) / diff, 0), 1) - return 1 - opacity - } + @Binding + var scrollViewOffset: CGFloat var body: some View { VStack(alignment: .center, spacing: 10) { @@ -151,7 +137,7 @@ extension ItemView.CompactLogoScrollView { EmptyView() } .failure { - Text(viewModel.item.displayName) + Text(viewModel.item.displayTitle) .font(.largeTitle) .fontWeight(.semibold) .multilineTextAlignment(.center) diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift index bae8672c..3026bba0 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.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 BlurHashKit @@ -14,13 +14,15 @@ extension ItemView { struct CompactPosterScrollView: View { @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router + + @ObservedObject + var viewModel: ItemViewModel + @State private var scrollViewOffset: CGFloat = 0 @State private var blurHashBottomEdgeColor: Color = .secondarySystemFill - @ObservedObject - var viewModel: ItemViewModel let content: () -> Content @@ -28,7 +30,7 @@ extension ItemView { let start = UIScreen.main.bounds.height * 0.20 let end = UIScreen.main.bounds.height * 0.4 let diff = end - start - let opacity = min(max((scrollViewOffset - start) / diff, 0), 1) + let opacity = clamp((scrollViewOffset - start) / diff, min: 0, max: 1) return opacity } @@ -41,60 +43,43 @@ extension ItemView { var body: some View { ScrollView(showsIndicators: false) { - VStack(spacing: 0) { - Color.clear - .frame(height: UIScreen.main.bounds.height * 0.15) + VStack(alignment: .leading, spacing: 0) { - OverlayView(scrollViewOffset: $scrollViewOffset, viewModel: viewModel) - .padding(.horizontal) - .padding(.bottom) - .background { - BlurView(style: .systemThinMaterialDark) - .mask { - LinearGradient( - stops: [ - .init(color: .white.opacity(0), location: 0.2), - .init(color: .white.opacity(0.5), location: 0.3), - .init(color: .white, location: 0.55), - ], - startPoint: .top, - endPoint: .bottom - ) - } - } - .overlay { - Color.systemBackground - .opacity(topOpacity) - } + VStack { + Spacer() - VStack(alignment: .leading, spacing: 10) { - if let firstTagline = viewModel.item.taglines?.first { - Text(firstTagline) - .font(.body) - .fontWeight(.semibold) - .multilineTextAlignment(.leading) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - } - - if let itemOverview = viewModel.item.overview { - TruncatedTextView(text: itemOverview) { - itemRouter.route(to: \.itemOverview, viewModel.item) + OverlayView(viewModel: viewModel, scrollViewOffset: $scrollViewOffset) + .padding(.horizontal) + .padding(.bottom) + .background { + BlurView(style: .systemThinMaterialDark) + .mask { + LinearGradient( + stops: [ + .init(color: .white.opacity(0), location: 0.2), + .init(color: .white.opacity(0.5), location: 0.3), + .init(color: .white, location: 0.55), + ], + startPoint: .top, + endPoint: .bottom + ) + } + } + .overlay { + Color.systemBackground + .opacity(topOpacity) } - .font(.footnote) - .lineLimit(4) - .fixedSize(horizontal: false, vertical: true) - } } - .padding(.horizontal) - .padding(.top) - .frame(maxWidth: .infinity) - .background(Color.systemBackground) - .foregroundColor(.white) + .frame(height: UIScreen.main.bounds.height * 0.45) + + ItemView.OverviewView(item: viewModel.item) + .overviewLineLimit(4) + .taglineLineLimit(2) + .padding(.top) + .padding(.horizontal) content() .padding(.vertical) - .background(Color.systemBackground) } } .edgesIgnoringSafeArea(.top) @@ -106,11 +91,18 @@ extension ItemView { ) .backgroundParallaxHeader( $scrollViewOffset, - height: UIScreen.main.bounds.height * 0.35, + height: UIScreen.main.bounds.height * 0.45, multiplier: 0.8 ) { headerView } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + if viewModel.isLoading { + ProgressView() + } + } + } .onAppear { if let backdropBlurHash = viewModel.item.blurHash(.backdrop) { let bottomRGB = BlurHash(string: backdropBlurHash)!.averageLinearRGB @@ -130,19 +122,21 @@ extension ItemView.CompactPosterScrollView { struct OverlayView: View { @EnvironmentObject - private var itemRouter: ItemCoordinator.Router - @Binding - var scrollViewOffset: CGFloat + private var router: ItemCoordinator.Router + @ObservedObject var viewModel: ItemViewModel + @Binding + var scrollViewOffset: CGFloat + @ViewBuilder private var rightShelfView: some View { VStack(alignment: .leading) { // MARK: Name - Text(viewModel.item.displayName) + Text(viewModel.item.displayTitle) .font(.title2) .fontWeight(.semibold) .foregroundColor(.white) @@ -150,7 +144,7 @@ extension ItemView.CompactPosterScrollView { // MARK: Details DotHStack { - if viewModel.item.unaired { + if viewModel.item.isUnaired { if let premiereDateLabel = viewModel.item.airDateLabel { Text(premiereDateLabel) } diff --git a/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift b/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift index 3bff2d30..eeebca09 100644 --- a/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.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 @@ -14,8 +14,6 @@ extension SeriesItemView { struct ContentView: View { - @EnvironmentObject - private var itemRouter: ItemCoordinator.Router @ObservedObject var viewModel: SeriesItemViewModel @@ -24,7 +22,7 @@ extension SeriesItemView { // MARK: Episodes - SeriesEpisodesView(viewModel: viewModel) + SeriesEpisodeSelector(viewModel: viewModel) // MARK: Genres diff --git a/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemView.swift b/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemView.swift index 2df68ff3..67ca5ff9 100644 --- a/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemView.swift +++ b/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemView.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 @@ -12,11 +12,12 @@ import SwiftUI struct SeriesItemView: View { - @ObservedObject - var viewModel: SeriesItemViewModel @Default(.Customization.itemViewType) private var itemViewType + @ObservedObject + var viewModel: SeriesItemViewModel + var body: some View { switch itemViewType { case .compactPoster: diff --git a/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift b/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift index 0c462f4d..3aaf5876 100644 --- a/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.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 JellyfinAPI import SwiftUI extension iPadOSCollectionItemView { @@ -13,10 +14,19 @@ extension iPadOSCollectionItemView { struct ContentView: View { @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router + @ObservedObject var viewModel: CollectionItemViewModel + private var items: [PosterButtonType] { + if viewModel.isLoading { + return PosterButtonType.loading.random(in: 3 ..< 8) + } else { + return viewModel.collectionItems.map { .item($0) } + } + } + var body: some View { VStack(alignment: .leading, spacing: 20) { @@ -38,11 +48,13 @@ extension iPadOSCollectionItemView { // MARK: Items - if !viewModel.collectionItems.isEmpty { - PosterHStack(title: L10n.items, type: .portrait, items: viewModel.collectionItems) - .onSelect { item in - itemRouter.route(to: \.item, item) - } + PosterHStack( + title: L10n.items, + type: .portrait, + items: items + ) + .onSelect { item in + router.route(to: \.item, item) } ItemView.AboutView(viewModel: viewModel) diff --git a/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemView.swift b/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemView.swift index e8cc46fb..0c04cbb8 100644 --- a/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemView.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/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift b/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift index fc5ff156..a5cbe6fe 100644 --- a/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.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 @@ -15,6 +15,7 @@ extension iPadOSEpisodeItemView { @EnvironmentObject private var router: ItemCoordinator.Router + @ObservedObject var viewModel: EpisodeItemViewModel diff --git a/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeItemView.swift b/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeItemView.swift index 89827837..d21665ee 100644 --- a/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeItemView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeItemView.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 @@ -12,7 +12,8 @@ import SwiftUI struct iPadOSEpisodeItemView: View { @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router + @ObservedObject var viewModel: EpisodeItemViewModel diff --git a/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift b/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift index 5ef41fe8..08878e99 100644 --- a/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.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 @@ -14,7 +14,8 @@ extension iPadOSMovieItemView { struct ContentView: View { @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router + @ObservedObject var viewModel: MovieItemViewModel @@ -47,13 +48,18 @@ extension iPadOSMovieItemView { Divider() } + // MARK: Special Features + + if !viewModel.specialFeatures.isEmpty { + ItemView.SpecialFeaturesHStack(items: viewModel.specialFeatures) + + Divider() + } + // MARK: Similar if !viewModel.similarItems.isEmpty { - PosterHStack(title: L10n.recommended, type: .portrait, items: viewModel.similarItems) - .onSelect { item in - itemRouter.route(to: \.item, item) - } + ItemView.SimilarItemsHStack(items: viewModel.similarItems) Divider() } @@ -63,3 +69,5 @@ extension iPadOSMovieItemView { } } } + +// diff --git a/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemView.swift b/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemView.swift index 179eabdf..02693bd7 100644 --- a/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemView.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/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift b/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift index febafe26..82d0b934 100644 --- a/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.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 @@ -13,19 +13,21 @@ extension ItemView { struct iPadOSCinematicScrollView: View { @EnvironmentObject - private var itemRouter: ItemCoordinator.Router - @State - private var scrollViewOffset: CGFloat = 0 + private var router: ItemCoordinator.Router + @ObservedObject var viewModel: ItemViewModel + @State + private var scrollViewOffset: CGFloat = 0 + let content: () -> Content private var topOpacity: CGFloat { let start = UIScreen.main.bounds.height * 0.45 let end = UIScreen.main.bounds.height * 0.65 let diff = end - start - let opacity = min(max((scrollViewOffset - start) / diff, 0), 1) + let opacity = clamp((scrollViewOffset - start) / diff, min: 0, max: 1) return opacity } @@ -90,6 +92,13 @@ extension ItemView { ) { headerView } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + if viewModel.isLoading { + ProgressView() + } + } + } } } } @@ -99,7 +108,8 @@ extension ItemView.iPadOSCinematicScrollView { struct OverlayView: View { @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router + @ObservedObject var viewModel: ItemViewModel @@ -114,8 +124,11 @@ extension ItemView.iPadOSCinematicScrollView { maxHeight: 150 )) .resizingMode(.bottomLeft) + .placeholder { + EmptyView() + } .failure { - Text(viewModel.item.displayName) + Text(viewModel.item.displayTitle) .font(.largeTitle) .fontWeight(.semibold) .lineLimit(2) @@ -123,11 +136,10 @@ extension ItemView.iPadOSCinematicScrollView { .foregroundColor(.white) } - TruncatedTextView(text: viewModel.item.overview ?? L10n.noOverviewAvailable) { - itemRouter.route(to: \.itemOverview, viewModel.item) - } - .lineLimit(3) - .foregroundColor(.white) + ItemView.OverviewView(item: viewModel.item) + .overviewLineLimit(3) + .taglineLineLimit(1) + .foregroundColor(.white) HStack(spacing: 30) { ItemView.AttributesHStack(viewModel: viewModel) diff --git a/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift b/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift index 47dc7c9e..bc6b76be 100644 --- a/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.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 @@ -14,7 +14,8 @@ extension iPadOSSeriesItemView { struct ContentView: View { @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router + @ObservedObject var viewModel: SeriesItemViewModel @@ -23,7 +24,7 @@ extension iPadOSSeriesItemView { // MARK: Episodes - SeriesEpisodesView(viewModel: viewModel) + SeriesEpisodeSelector(viewModel: viewModel) // MARK: Genres @@ -54,10 +55,7 @@ extension iPadOSSeriesItemView { // MARK: Similar if !viewModel.similarItems.isEmpty { - PosterHStack(title: L10n.recommended, type: .portrait, items: viewModel.similarItems) - .onSelect { item in - itemRouter.route(to: \.item, item) - } + ItemView.SimilarItemsHStack(items: viewModel.similarItems) Divider() } diff --git a/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemView.swift b/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemView.swift index ebd36872..59e6c78e 100644 --- a/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemView.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/Swiftfin/Views/LibraryView.swift b/Swiftfin/Views/LibraryView.swift index 2ea842e9..eb883c29 100644 --- a/Swiftfin/Views/LibraryView.swift +++ b/Swiftfin/Views/LibraryView.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 CollectionView @@ -13,14 +13,15 @@ import SwiftUI struct LibraryView: View { - @EnvironmentObject - private var router: LibraryCoordinator.Router - @ObservedObject - var viewModel: LibraryViewModel - @Default(.Customization.Library.viewType) private var libraryViewType + @EnvironmentObject + private var router: LibraryCoordinator.Router + + @ObservedObject + var viewModel: LibraryViewModel + @ViewBuilder private var loadingView: some View { ProgressView() @@ -64,7 +65,7 @@ struct LibraryView: View { libraryItemsView } } - .navigationTitle(viewModel.parent?.displayName ?? "") + .navigationTitle(viewModel.parent?.displayTitle ?? "") .navigationBarTitleDisplayMode(.inline) .navBarDrawer { ScrollView(.horizontal, showsIndicators: false) { diff --git a/Swiftfin/Views/LiveTVChannelItemElement.swift b/Swiftfin/Views/LiveTVChannelItemElement.swift index 7409486b..13edb14e 100644 --- a/Swiftfin/Views/LiveTVChannelItemElement.swift +++ b/Swiftfin/Views/LiveTVChannelItemElement.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/Swiftfin/Views/LiveTVChannelItemWideElement.swift b/Swiftfin/Views/LiveTVChannelItemWideElement.swift index 9b1421f4..c5ebb032 100644 --- a/Swiftfin/Views/LiveTVChannelItemWideElement.swift +++ b/Swiftfin/Views/LiveTVChannelItemWideElement.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 JellyfinAPI import SwiftUI struct LiveTVChannelItemWideElement: View { + @FocusState private var focused: Bool @State diff --git a/Swiftfin/Views/LiveTVChannelsView.swift b/Swiftfin/Views/LiveTVChannelsView.swift index c6d25fcc..44994369 100644 --- a/Swiftfin/Views/LiveTVChannelsView.swift +++ b/Swiftfin/Views/LiveTVChannelsView.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 CollectionView @@ -14,66 +14,17 @@ import SwiftUI typealias LiveTVChannelViewProgram = (timeDisplay: String, title: String) struct LiveTVChannelsView: View { + @EnvironmentObject private var liveTVRouter: LiveTVCoordinator.Router + @EnvironmentObject + private var mainRouter: MainCoordinator.Router + @StateObject var viewModel = LiveTVChannelsViewModel() - @State - private var isPortrait = false - private var columns: Int { - if UIDevice.current.userInterfaceIdiom == .pad { - return 2 - } else { - if isPortrait { - return 1 - } else { - return 2 - } - } - } - - var body: some View { - if viewModel.isLoading == true { - ProgressView() - } else if !viewModel.channelPrograms.isEmpty { - CollectionView(items: viewModel.channelPrograms) { _, program, _ in - makeCellView(program) - } - .layout { _, layoutEnvironment in - .grid( - layoutEnvironment: layoutEnvironment, - layoutMode: .adaptive(withMinItemSize: 144), - itemSpacing: 16, - lineSpacing: 4, - itemSize: .absolute(144) - ) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea() - .onAppear { - viewModel.startScheduleCheckTimer() - self.checkOrientation() - } - .onDisappear { - viewModel.stopScheduleCheckTimer() - } - .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in - self.checkOrientation() - } - } else { - VStack { - Text("No results.") - Button { - viewModel.getChannels() - } label: { - Text("Reload") - } - } - } - } @ViewBuilder - func makeCellView(_ channelProgram: LiveTVChannelProgram) -> some View { + private func channelCell(for channelProgram: LiveTVChannelProgram) -> some View { let channel = channelProgram.channel let currentProgramDisplayText = channelProgram.currentProgram? .programDisplayText(timeFormatter: viewModel.timeFormatter) ?? LiveTVChannelViewProgram(timeDisplay: "", title: "") @@ -86,6 +37,7 @@ struct LiveTVChannelsView: View { } return start > currentStart } + LiveTVChannelItemWideElement( channel: channel, currentProgram: channelProgram.currentProgram, @@ -94,23 +46,48 @@ struct LiveTVChannelsView: View { nextItems: nextItems, timeFormatter: viewModel.timeFormatter ), - onSelect: { loadingAction in - loadingAction(true) - self.viewModel.fetchVideoPlayerViewModel(item: channel) { playerViewModel in - self.liveTVRouter.route(to: \.videoPlayer, playerViewModel) - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - loadingAction(false) - } - } + onSelect: { _ in + mainRouter.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: channel.mediaSources!.first!)) } ) } - private func checkOrientation() { - let scenes = UIApplication.shared.connectedScenes - let windowScene = scenes.first as? UIWindowScene - guard let scene = windowScene else { return } - self.isPortrait = scene.interfaceOrientation.isPortrait + var body: some View { + + if viewModel.isLoading { + ProgressView() + } else if !viewModel.channelPrograms.isEmpty { + + CollectionView(items: viewModel.channelPrograms) { _, program, _ in + channelCell(for: program) + } + .layout { _, layoutEnvironment in + .grid( + layoutEnvironment: layoutEnvironment, + layoutMode: .adaptive(withMinItemSize: 250), + itemSpacing: 16, + lineSpacing: 4, + itemSize: .fractionalWidth(1 / 3) + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + .onAppear { + viewModel.startScheduleCheckTimer() + } + .onDisappear { + viewModel.stopScheduleCheckTimer() + } + } else { + VStack { + Text("No results.") + Button { + viewModel.getChannels() + } label: { + Text("Reload") + } + } + } } private func nextProgramsDisplayText(nextItems: [BaseItemDto], timeFormatter: DateFormatter) -> [LiveTVChannelViewProgram] { diff --git a/Swiftfin/Views/LiveTVHomeView.swift b/Swiftfin/Views/LiveTVHomeView.swift index 535e36fd..d7a1c8cb 100644 --- a/Swiftfin/Views/LiveTVHomeView.swift +++ b/Swiftfin/Views/LiveTVHomeView.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 Stinsen diff --git a/Swiftfin/Views/LiveTVProgramsView.swift b/Swiftfin/Views/LiveTVProgramsView.swift index 5d4ea314..101d0752 100644 --- a/Swiftfin/Views/LiveTVProgramsView.swift +++ b/Swiftfin/Views/LiveTVProgramsView.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 Stinsen import SwiftUI struct LiveTVProgramsView: View { + @EnvironmentObject private var programsRouter: LiveTVProgramsCoordinator.Router @StateObject @@ -22,85 +23,85 @@ struct LiveTVProgramsView: View { let items = viewModel.recommendedItems { PosterHStack(title: "On Now", type: .portrait, items: items) - .onSelect { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } +// .onSelect { item in +// if let chanId = item.channelId, +// let chan = viewModel.findChannel(id: chanId) +// { + //// self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + //// self.programsRouter.route(to: \.videoPlayer, playerViewModel) + //// } +// } +// } } if !viewModel.seriesItems.isEmpty, let items = viewModel.seriesItems { PosterHStack(title: "Shows", type: .portrait, items: items) - .onSelect { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } +// .onSelect { item in +// if let chanId = item.channelId, +// let chan = viewModel.findChannel(id: chanId) +// { + //// self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + //// self.programsRouter.route(to: \.videoPlayer, playerViewModel) + //// } +// } +// } } if !viewModel.movieItems.isEmpty, let items = viewModel.movieItems { PosterHStack(title: "Movies", type: .portrait, items: items) - .onSelect { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } +// .onSelect { item in +// if let chanId = item.channelId, +// let chan = viewModel.findChannel(id: chanId) +// { + //// self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + //// self.programsRouter.route(to: \.videoPlayer, playerViewModel) + //// } +// } +// } } if !viewModel.sportsItems.isEmpty, let items = viewModel.sportsItems { PosterHStack(title: "Sports", type: .portrait, items: items) - .onSelect { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } +// .onSelect { item in +// if let chanId = item.channelId, +// let chan = viewModel.findChannel(id: chanId) +// { + //// self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + //// self.programsRouter.route(to: \.videoPlayer, playerViewModel) + //// } +// } +// } } if !viewModel.kidsItems.isEmpty, let items = viewModel.kidsItems { PosterHStack(title: "Kids", type: .portrait, items: items) - .onSelect { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } +// .onSelect { item in +// if let chanId = item.channelId, +// let chan = viewModel.findChannel(id: chanId) +// { + //// self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + //// self.programsRouter.route(to: \.videoPlayer, playerViewModel) + //// } +// } +// } } if !viewModel.newsItems.isEmpty, let items = viewModel.newsItems { PosterHStack(title: "News", type: .portrait, items: items) - .onSelect { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } +// .onSelect { item in +// if let chanId = item.channelId, +// let chan = viewModel.findChannel(id: chanId) +// { + //// self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + //// self.programsRouter.route(to: \.videoPlayer, playerViewModel) + //// } +// } +// } } } } diff --git a/Swiftfin/Views/MediaStreamInfoView.swift b/Swiftfin/Views/MediaStreamInfoView.swift new file mode 100644 index 00000000..1edc70b2 --- /dev/null +++ b/Swiftfin/Views/MediaStreamInfoView.swift @@ -0,0 +1,42 @@ +// +// 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 SwiftUI + +struct MediaStreamInfoView: View { + + let mediaStream: MediaStream + + var body: some View { + Form { + Section { + ForEach(mediaStream.metadataProperties) { property in + TextPairView(property) + } + } + + if !mediaStream.colorProperties.isEmpty { + Section(L10n.color) { + ForEach(mediaStream.colorProperties) { property in + TextPairView(property) + } + } + } + + if !mediaStream.deliveryProperties.isEmpty { + Section(L10n.delivery) { + ForEach(mediaStream.deliveryProperties) { property in + TextPairView(property) + } + } + } + } + .navigationTitle(mediaStream.displayTitle ?? .emptyDash) + } +} diff --git a/Swiftfin/Views/MediaView.swift b/Swiftfin/Views/MediaView.swift index 27206dd3..b9faaccf 100644 --- a/Swiftfin/Views/MediaView.swift +++ b/Swiftfin/Views/MediaView.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 CollectionView +import Defaults import JellyfinAPI import Stinsen import SwiftUI @@ -15,6 +16,7 @@ struct MediaView: View { @EnvironmentObject private var router: MediaCoordinator.Router + @ObservedObject var viewModel: MediaViewModel @@ -27,33 +29,20 @@ struct MediaView: View { } var body: some View { - CollectionView(items: viewModel.libraryItems) { _, item, _ in - PosterButton(item: item, type: .landscape) - .scaleItem(UIDevice.isPhone ? 0.85 : 1) + CollectionView(items: viewModel.libraryItems) { _, viewModel, _ in + LibraryCard(viewModel: viewModel) .onSelect { - switch item.library.collectionType { + switch viewModel.item.collectionType { + case "downloads": + router.route(to: \.downloads) case "favorites": - router.route(to: \.library, .init(parent: item.library, type: .library, filters: .favorites)) + router.route(to: \.library, .init(parent: viewModel.item, type: .library, filters: .favorites)) case "folders": - router.route(to: \.library, .init(parent: item.library, type: .folders, filters: .init())) + router.route(to: \.library, .init(parent: viewModel.item, type: .folders, filters: .init())) case "liveTV": router.route(to: \.liveTV) default: - router.route(to: \.library, .init(parent: item.library, type: .library, filters: .init())) - } - } - .imageOverlay { - ZStack { - Color.black - .opacity(0.5) - - Text(item.library.displayName) - .foregroundColor(.white) - .font(.title2) - .fontWeight(.semibold) - .lineLimit(2) - .multilineTextAlignment(.center) - .frame(alignment: .center) + router.route(to: \.library, .init(parent: viewModel.item, type: .library, filters: .init())) } } } @@ -78,3 +67,66 @@ struct MediaView: View { } } } + +extension MediaView { + + struct LibraryCard: View { + + @ObservedObject + var viewModel: MediaItemViewModel + + private var onSelect: () -> Void + + private var itemWidth: CGFloat { + PosterType.landscape.width * (UIDevice.isPhone ? 0.85 : 1) + } + + var body: some View { + Button { + onSelect() + } label: { + Group { + if let imageSources = viewModel.imageSources { + ImageView(imageSources) + } else { + ImageView(nil) + } + } + .overlay { + if Defaults[.Customization.Library.randomImage] || + viewModel.item.collectionType == "favorites" || + viewModel.item.collectionType == "downloads" + { + ZStack { + Color.black + .opacity(0.5) + + Text(viewModel.item.displayTitle) + .foregroundColor(.white) + .font(.title2) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(alignment: .center) + } + } + } + .posterStyle(type: .landscape, width: itemWidth) + } + } + } +} + +extension MediaView.LibraryCard { + + init(viewModel: MediaItemViewModel) { + self.init( + viewModel: viewModel, + onSelect: {} + ) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin/Views/QuickConnectView.swift b/Swiftfin/Views/QuickConnectView.swift index 9ec4dd6e..91b44cf2 100644 --- a/Swiftfin/Views/QuickConnectView.swift +++ b/Swiftfin/Views/QuickConnectView.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 @@ -12,6 +12,7 @@ struct QuickConnectView: View { @EnvironmentObject private var router: QuickConnectCoordinator.Router + @ObservedObject var viewModel: UserSignInViewModel @@ -35,21 +36,19 @@ struct QuickConnectView: View { .padding(.horizontal) .navigationTitle(L10n.quickConnect) .onAppear { - viewModel.startQuickConnect { - self.router.dismissCoordinator() + Task { + for await result in viewModel.startQuickConnect() { + guard let secret = result.secret else { continue } + try? await viewModel.signIn(quickConnectSecret: secret) + router.dismissCoordinator() + } } } .onDisappear { viewModel.stopQuickConnectAuthCheck() } - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - router.dismissCoordinator() - } label: { - Image(systemName: "xmark.circle.fill") - } - } + .navigationCloseButton { + router.dismissCoordinator() } } } diff --git a/Swiftfin/Views/SearchView.swift b/Swiftfin/Views/SearchView.swift index 1737e407..55257b3f 100644 --- a/Swiftfin/Views/SearchView.swift +++ b/Swiftfin/Views/SearchView.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 CollectionView @@ -13,13 +13,15 @@ import SwiftUI struct SearchView: View { + @Default(.Customization.searchPosterType) + private var searchPosterType + @EnvironmentObject private var router: SearchCoordinator.Router + @ObservedObject var viewModel: SearchViewModel - @Default(.Customization.searchPosterType) - private var searchPosterType @State private var searchText = "" @@ -28,9 +30,9 @@ struct SearchView: View { VStack(spacing: 20) { ForEach(viewModel.suggestions, id: \.id) { item in Button { - searchText = item.displayName + searchText = item.displayTitle } label: { - Text(item.displayName) + Text(item.displayTitle) .font(.body) } } @@ -46,8 +48,7 @@ struct SearchView: View { } if !viewModel.collections.isEmpty { - // TODO: Localize after organization - itemsSection(title: "Collections", keyPath: \.collections, posterType: searchPosterType) + itemsSection(title: L10n.collections, keyPath: \.collections, posterType: searchPosterType) } if !viewModel.series.isEmpty { @@ -59,8 +60,7 @@ struct SearchView: View { } if !viewModel.people.isEmpty { - // TODO: Localize after organization - itemsSection(title: "People", keyPath: \.people, posterType: .portrait) + itemsSection(title: L10n.people, keyPath: \.people, posterType: .portrait) } } } @@ -83,7 +83,7 @@ struct SearchView: View { PosterHStack( title: title, type: posterType, - items: viewModel[keyPath: keyPath] + items: viewModel[keyPath: keyPath].map { .item($0) } ) .onSelect { item in baseItemOnSelect(item) diff --git a/Swiftfin/Views/ServerDetailView.swift b/Swiftfin/Views/ServerDetailView.swift index ce56dcb9..61b3608b 100644 --- a/Swiftfin/Views/ServerDetailView.swift +++ b/Swiftfin/Views/ServerDetailView.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 @@ -12,17 +12,18 @@ struct ServerDetailView: View { @ObservedObject var viewModel: ServerDetailViewModel + @State - var currentServerURI: String + private var currentServerURI: String init(viewModel: ServerDetailViewModel) { self.viewModel = viewModel - self.currentServerURI = viewModel.server.currentURI + self._currentServerURI = State(initialValue: viewModel.server.currentURL.absoluteString) } var body: some View { Form { - Section(header: L10n.serverDetails.text) { + Section { HStack { L10n.name.text Spacer() @@ -31,11 +32,13 @@ struct ServerDetailView: View { } Picker(L10n.url, selection: $currentServerURI) { - ForEach(viewModel.server.uris.sorted(), id: \.self) { uri in - Text(uri).tag(uri) + ForEach(viewModel.server.urls.sorted(using: \.absoluteString)) { url in + Text(url.absoluteString) + .tag(url) .foregroundColor(.secondary) - }.onChange(of: currentServerURI) { newValue in - viewModel.setServerCurrentURI(uri: newValue) + } + .onChange(of: currentServerURI) { _ in + // TODO: change server url } } @@ -54,5 +57,7 @@ struct ServerDetailView: View { } } } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(L10n.serverDetails.text) } } diff --git a/Swiftfin/Views/ServerListView.swift b/Swiftfin/Views/ServerListView.swift index 0ba8526f..2ad15984 100644 --- a/Swiftfin/Views/ServerListView.swift +++ b/Swiftfin/Views/ServerListView.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 CoreStore @@ -12,7 +12,8 @@ import SwiftUI struct ServerListView: View { @EnvironmentObject - private var serverListRouter: ServerListCoordinator.Router + private var router: ServerListCoordinator.Router + @ObservedObject var viewModel: ServerListViewModel @@ -21,7 +22,7 @@ struct ServerListView: View { LazyVStack { ForEach(viewModel.servers, id: \.id) { server in Button { - serverListRouter.route(to: \.userList, server) + router.route(to: \.userList, server) } label: { ZStack(alignment: Alignment.leading) { Rectangle() @@ -38,7 +39,7 @@ struct ServerListView: View { .font(.title2) .foregroundColor(.primary) - Text(server.currentURI) + Text(server.currentURL.absoluteString) .font(.footnote) .disabled(true) .foregroundColor(.secondary) @@ -47,10 +48,11 @@ struct ServerListView: View { .font(.footnote) .foregroundColor(.primary) } - }.padding() + } + .padding() } - .padding() } + .padding() .contextMenu { Button(role: .destructive) { viewModel.remove(server: server) @@ -69,8 +71,8 @@ struct ServerListView: View { .frame(minWidth: 50, maxWidth: 240) .multilineTextAlignment(.center) - PrimaryButton(title: L10n.connect.stringValue) { - serverListRouter.route(to: \.connectToServer) + PrimaryButton(title: L10n.connect) { + router.route(to: \.connectToServer) } .frame(maxWidth: 300) .frame(height: 50) @@ -89,20 +91,19 @@ struct ServerListView: View { @ViewBuilder private var trailingToolbarContent: some View { - if viewModel.servers.isEmpty { - EmptyView() - } else { + if !viewModel.servers.isEmpty { Button { - serverListRouter.route(to: \.connectToServer) + router.route(to: \.connectToServer) } label: { Image(systemName: "plus.circle.fill") } } } + @ViewBuilder private var leadingToolbarContent: some View { Button { - serverListRouter.route(to: \.basicAppSettings) + router.route(to: \.basicAppSettings) } label: { Image(systemName: "gearshape.fill") .accessibilityLabel(L10n.settings) @@ -117,11 +118,11 @@ struct ServerListView: View { trailingToolbarContent } } - .toolbar(content: { + .toolbar { ToolbarItemGroup(placement: .navigationBarLeading) { leadingToolbarContent } - }) + } .onAppear { viewModel.fetchServers() } diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift index f6432434..431d728d 100644 --- a/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings.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,10 +13,15 @@ struct CustomizeViewsSettings: View { @Default(.Customization.itemViewType) var itemViewType + @Default(.Customization.CinematicItemViewType.usePrimaryImage) + private var cinematicItemViewTypeUsePrimaryImage - @Default(.shouldShowMissingSeasons) + @Default(.hapticFeedback) + private var hapticFeedback + + @Default(.Customization.shouldShowMissingSeasons) var shouldShowMissingSeasons - @Default(.shouldShowMissingEpisodes) + @Default(.Customization.shouldShowMissingEpisodes) var shouldShowMissingEpisodes @Default(.Customization.showPosterLabels) @@ -37,17 +42,40 @@ struct CustomizeViewsSettings: View { @Default(.Customization.Episodes.useSeriesLandscapeBackdrop) var useSeriesLandscapeBackdrop + @Default(.Customization.Library.showFavorites) + private var showFavorites + @Default(.Customization.Library.randomImage) + private var libraryRandomImage + + @EnvironmentObject + private var router: SettingsCoordinator.Router + var body: some View { List { - Section { - Picker(L10n.items, selection: $itemViewType) { - ForEach(ItemViewType.allCases, id: \.self) { type in - Text(type.localizedName).tag(type.rawValue) + + if UIDevice.isPhone { + Section { + EnumPicker(title: L10n.items, selection: $itemViewType) + } + + if itemViewType == .cinematic { + Section { + Toggle(L10n.usePrimaryImage, isOn: $cinematicItemViewTypeUsePrimaryImage) + } footer: { + L10n.usePrimaryImageDescription.text } } + Toggle(L10n.hapticFeedback, isOn: $hapticFeedback) + } + + Section { + + Toggle(L10n.favorites, isOn: $showFavorites) + + Toggle(L10n.randomImage, isOn: $libraryRandomImage) } header: { - EmptyView() + L10n.library.text } Section { @@ -59,54 +87,33 @@ struct CustomizeViewsSettings: View { Section { + ChevronButton(title: L10n.indicators) + .onSelect { + router.route(to: \.indicatorSettings) + } + Toggle(L10n.showPosterLabels, isOn: $showPosterLabels) - Picker(L10n.nextUp, selection: $nextUpPosterType) { - ForEach(PosterType.allCases, id: \.self) { type in - Text(type.localizedName).tag(type.rawValue) - } - } + EnumPicker(title: L10n.next, selection: $nextUpPosterType) - Picker(L10n.recentlyAdded, selection: $recentlyAddedPosterType) { - ForEach(PosterType.allCases, id: \.self) { type in - Text(type.localizedName).tag(type.rawValue) - } - } + EnumPicker(title: L10n.recentlyAdded, selection: $recentlyAddedPosterType) - Picker(L10n.latestWithString(L10n.library), selection: $latestInLibraryPosterType) { - ForEach(PosterType.allCases, id: \.self) { type in - Text(type.localizedName).tag(type.rawValue) - } - } + EnumPicker(title: L10n.latestWithString(L10n.library), selection: $latestInLibraryPosterType) - Picker(L10n.recommended, selection: $similarPosterType) { - ForEach(PosterType.allCases, id: \.self) { type in - Text(type.localizedName).tag(type.rawValue) - } - } + EnumPicker(title: L10n.recommended, selection: $similarPosterType) - Picker(L10n.search, selection: $searchPosterType) { - ForEach(PosterType.allCases, id: \.self) { type in - Text(type.localizedName).tag(type.rawValue) - } - } + EnumPicker(title: L10n.search, selection: $searchPosterType) - Picker(L10n.library, selection: $libraryGridPosterType) { - ForEach(PosterType.allCases, id: \.self) { type in - Text(type.localizedName).tag(type.rawValue) - } - } + EnumPicker(title: L10n.library, selection: $libraryGridPosterType) } header: { - // TODO: localize after organization - Text("Posters") + L10n.posters.text } Section { - Toggle("Series Backdrop", isOn: $useSeriesLandscapeBackdrop) + Toggle(L10n.seriesBackdrop, isOn: $useSeriesLandscapeBackdrop) } header: { // TODO: think of a better name - // TODO: localize after organization - Text("Episode Landscape Poster") + L10n.episodeLandscapePoster.text } } .navigationTitle(L10n.customize) diff --git a/Swiftfin/Views/SettingsView/DebugSettingsView.swift b/Swiftfin/Views/SettingsView/DebugSettingsView.swift new file mode 100644 index 00000000..a1667680 --- /dev/null +++ b/Swiftfin/Views/SettingsView/DebugSettingsView.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 SwiftUI + +#if DEBUG +struct DebugSettingsView: View { + + @Default(.sendProgressReports) + private var sendProgressReports + + var body: some View { + Form { + + Toggle("Send Progress Reports", isOn: $sendProgressReports) + } + } +} +#endif diff --git a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift index f0bf2e5b..8e05dbed 100644 --- a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.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 @@ -12,20 +12,13 @@ import SwiftUI struct ExperimentalSettingsView: View { @Default(.Experimental.forceDirectPlay) - var forceDirectPlay + private var forceDirectPlay @Default(.Experimental.syncSubtitleStateWithAdjacent) - var syncSubtitleStateWithAdjacent - @Default(.Experimental.nativePlayer) - var nativePlayer - @Default(.Experimental.usefmp4Hls) - var usefmp4Hls - + private var syncSubtitleStateWithAdjacent @Default(.Experimental.liveTVAlphaEnabled) - var liveTVAlphaEnabled + private var liveTVAlphaEnabled @Default(.Experimental.liveTVForceDirectPlay) - var liveTVForceDirectPlay - @Default(.Experimental.liveTVNativePlayer) - var liveTVNativePlayer + private var liveTVForceDirectPlay var body: some View { Form { @@ -33,14 +26,7 @@ struct ExperimentalSettingsView: View { Toggle("Force Direct Play", isOn: $forceDirectPlay) - Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent) - - Toggle("Native Player", isOn: $nativePlayer) - - Toggle("Use fmp4 with HLS", isOn: $usefmp4Hls) - - } header: { - L10n.experimental.text +// Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent) } Section { @@ -49,11 +35,10 @@ struct ExperimentalSettingsView: View { Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay) - Toggle("Live TV Native Player", isOn: $liveTVNativePlayer) - } header: { Text("Live TV") } } + .navigationTitle(L10n.experimental) } } diff --git a/Swiftfin/Views/SettingsView/GestureSettingsView.swift b/Swiftfin/Views/SettingsView/GestureSettingsView.swift new file mode 100644 index 00000000..40b8adf1 --- /dev/null +++ b/Swiftfin/Views/SettingsView/GestureSettingsView.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 Defaults +import SwiftUI + +struct GestureSettingsView: View { + + @Default(.VideoPlayer.Gesture.horizontalPanGesture) + private var horizontalPanGesture + @Default(.VideoPlayer.Gesture.horizontalSwipeGesture) + private var horizontalSwipeGesture + @Default(.VideoPlayer.Gesture.longPressGesture) + private var longPressGesture + @Default(.VideoPlayer.Gesture.multiTapGesture) + private var multiTapGesture + @Default(.VideoPlayer.Gesture.doubleTouchGesture) + private var doubleTouchGesture + @Default(.VideoPlayer.Gesture.pinchGesture) + private var pinchGesture + @Default(.VideoPlayer.Gesture.verticalPanGestureLeft) + private var verticalPanGestureLeft + @Default(.VideoPlayer.Gesture.verticalPanGestureRight) + private var verticalPanGestureRight + + var body: some View { + Form { + + Section { + + EnumPicker(title: "Horizontal Pan", selection: $horizontalPanGesture) + .disabled(horizontalSwipeGesture != .none && horizontalPanGesture == .none) + + EnumPicker(title: "Horizontal Swipe", selection: $horizontalSwipeGesture) + .disabled(horizontalPanGesture != .none && horizontalSwipeGesture == .none) + + EnumPicker(title: "Long Press", selection: $longPressGesture) + + EnumPicker(title: "Multi Tap", selection: $multiTapGesture) + + EnumPicker(title: "Double Touch", selection: $doubleTouchGesture) + + EnumPicker(title: "Pinch", selection: $pinchGesture) + + EnumPicker(title: "Left Vertical Pan", selection: $verticalPanGestureLeft) + + EnumPicker(title: "Right Vertical Pan", selection: $verticalPanGestureRight) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/IndicatorSettingsView.swift b/Swiftfin/Views/SettingsView/IndicatorSettingsView.swift new file mode 100644 index 00000000..9ba3cfd5 --- /dev/null +++ b/Swiftfin/Views/SettingsView/IndicatorSettingsView.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 Defaults +import SwiftUI + +// TODO: show a sample poster to model indicators + +struct IndicatorSettingsView: View { + + @Default(.Customization.Indicators.showFavorited) + private var showFavorited + @Default(.Customization.Indicators.showProgress) + private var showProgress + @Default(.Customization.Indicators.showUnplayed) + private var showUnplayed + @Default(.Customization.Indicators.showPlayed) + private var showPlayed + + var body: some View { + Form { + Section { + + Toggle(L10n.favorited, isOn: $showFavorited) + + Toggle(L10n.progress, isOn: $showProgress) + + Toggle(L10n.unplayed, isOn: $showUnplayed) + + Toggle(L10n.played, isOn: $showPlayed) + } + } + .navigationTitle(L10n.indicators) + } +} diff --git a/Swiftfin/Views/SettingsView/NativeVideoPlayerSettingsView.swift b/Swiftfin/Views/SettingsView/NativeVideoPlayerSettingsView.swift new file mode 100644 index 00000000..ec2ba7ff --- /dev/null +++ b/Swiftfin/Views/SettingsView/NativeVideoPlayerSettingsView.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 Defaults +import SwiftUI + +struct NativeVideoPlayerSettingsView: View { + + @Default(.VideoPlayer.Native.fMP4Container) + private var fMP4Container + @Default(.VideoPlayer.resumeOffset) + private var resumeOffset + + var body: some View { + Form { + + Section { + + BasicStepper( + title: "Resume Offset", + value: $resumeOffset, + range: 0 ... 30, + step: 1 + ) + .valueFormatter { + $0.secondFormat + } + } footer: { + Text("Resume content seconds before the recorded resume time") + } + + Section { + + Toggle("fMP4 Container", isOn: $fMP4Container) + } footer: { + Text("Use fMP4 container to allow hevc content on supported devices") + } + } + .navigationTitle("Native Player") + } +} diff --git a/Swiftfin/Views/SettingsView/OverlaySettingsView.swift b/Swiftfin/Views/SettingsView/OverlaySettingsView.swift deleted file mode 100644 index af620221..00000000 --- a/Swiftfin/Views/SettingsView/OverlaySettingsView.swift +++ /dev/null @@ -1,68 +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 SwiftUI - -struct OverlaySettingsView: View { - - @Default(.overlayType) - var overlayType - @Default(.shouldShowPlayPreviousItem) - var shouldShowPlayPreviousItem - @Default(.shouldShowPlayNextItem) - var shouldShowPlayNextItem - @Default(.shouldShowAutoPlay) - var shouldShowAutoPlay - @Default(.shouldShowJumpButtonsInOverlayMenu) - var shouldShowJumpButtonsInOverlayMenu - @Default(.shouldShowChaptersInfoInBottomOverlay) - var shouldShowChaptersInfoInBottomOverlay - - var body: some View { - Form { - Section(header: L10n.overlay.text) { - Picker(L10n.overlayType, selection: $overlayType) { - ForEach(OverlayType.allCases, id: \.self) { overlay in - Text(overlay.label).tag(overlay) - } - } - - Toggle(isOn: $shouldShowPlayPreviousItem) { - HStack { - Image(systemName: "chevron.left.circle") - L10n.playPreviousItem.text - } - } - - Toggle(isOn: $shouldShowPlayNextItem) { - HStack { - Image(systemName: "chevron.right.circle") - L10n.playNextItem.text - } - } - - Toggle(isOn: $shouldShowAutoPlay) { - HStack { - Image(systemName: "play.circle.fill") - L10n.autoPlay.text - } - } - - Toggle(isOn: $shouldShowChaptersInfoInBottomOverlay) { - HStack { - Image(systemName: "photo.on.rectangle") - L10n.showChaptersInfoInBottomOverlay.text - } - } - - Toggle(L10n.editJumpLengths, isOn: $shouldShowJumpButtonsInOverlayMenu) - } - } - } -} diff --git a/Swiftfin/Views/SettingsView/QuickConnectSettingsView.swift b/Swiftfin/Views/SettingsView/QuickConnectSettingsView.swift index 520ab619..c78b4666 100644 --- a/Swiftfin/Views/SettingsView/QuickConnectSettingsView.swift +++ b/Swiftfin/Views/SettingsView/QuickConnectSettingsView.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,35 +14,55 @@ struct QuickConnectSettingsView: View { @ObservedObject var viewModel: QuickConnectSettingsViewModel + @State + private var code: String = "" + @State + private var error: Error? + @State + private var isPresentingError: Bool = false + @State + private var isPresentingSuccess: Bool = false + var body: some View { Form { - Section(header: L10n.quickConnect.text) { - TextField(L10n.quickConnectCode, text: $viewModel.quickConnectCode) + Section { + TextField(L10n.quickConnectCode, text: $code) .keyboardType(.numberPad) .disabled(viewModel.isLoading) Button { - viewModel.sendQuickConnect() + Task { + do { + try await viewModel.authorize(code: code) + isPresentingSuccess = true + } catch { + self.error = error + isPresentingError = true + } + } } label: { L10n.authorize.text .font(.callout) - .disabled((viewModel.quickConnectCode.count != 6) || viewModel.isLoading) + .disabled(code.count != 6 || viewModel.isLoading) } } - .alert(isPresented: $viewModel.showSuccessMessage) { - Alert( - title: L10n.quickConnect.text, - message: L10n.quickConnectSuccessMessage.text, - dismissButton: .default(L10n.ok.text) - ) - } } - .alert(item: $viewModel.errorMessage) { _ in - Alert( - title: Text(viewModel.alertTitle), - message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), - dismissButton: .cancel() - ) + .navigationTitle(L10n.quickConnect.text) + .alert( + L10n.error, + isPresented: $isPresentingError + ) { + Button(L10n.dismiss, role: .cancel) + } message: { + Text(error?.localizedDescription ?? .emptyDash) + } + .alert( + L10n.quickConnect, + isPresented: $isPresentingSuccess + ) { + Button(L10n.dismiss, role: .cancel) + } message: { + L10n.quickConnectSuccessMessage.text } } } diff --git a/Swiftfin/Views/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView.swift index aa41559c..dbb63218 100644 --- a/Swiftfin/Views/SettingsView/SettingsView.swift +++ b/Swiftfin/Views/SettingsView/SettingsView.swift @@ -3,82 +3,57 @@ // License, v2.0. If 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 CoreData import Defaults +import Factory import Stinsen import SwiftUI struct SettingsView: View { + @Default(.accentColor) + private var accentColor + @Default(.appAppearance) + private var appAppearance + @Default(.VideoPlayer.videoPlayerType) + private var videoPlayerType + + @Injected(Container.userSession) + private var userSession + @EnvironmentObject - private var settingsRouter: SettingsCoordinator.Router + private var router: SettingsCoordinator.Router + @ObservedObject var viewModel: SettingsViewModel - @Default(.appAppearance) - var appAppearance - @Default(.overlayType) - var overlayType - @Default(.videoPlayerJumpForward) - var jumpForwardLength - @Default(.videoPlayerJumpBackward) - var jumpBackwardLength - @Default(.jumpGesturesEnabled) - var jumpGesturesEnabled - @Default(.systemControlGesturesEnabled) - var systemControlGesturesEnabled - @Default(.playerGesturesLockGestureEnabled) - var playerGesturesLockGestureEnabled - @Default(.seekSlideGestureEnabled) - var seekSlideGestureEnabled - @Default(.resumeOffset) - var resumeOffset - @Default(.subtitleSize) - var subtitleSize - @Default(.subtitleFontName) - var subtitleFontName - var body: some View { Form { - Section(header: EmptyView()) { + + Section { HStack { L10n.user.text Spacer() - Text(viewModel.user.username) - .foregroundColor(.jellyfinPurple) + Text(userSession.user.username) + .foregroundColor(accentColor) } - Button { - settingsRouter.route(to: \.serverDetail) - } label: { - HStack { - L10n.server.text - .foregroundColor(.primary) - Spacer() - Text(viewModel.server.name) - .foregroundColor(.jellyfinPurple) - - Image(systemName: "chevron.right") + ChevronButton(title: L10n.server, subtitle: userSession.server.name) + .onSelect { + router.route(to: \.serverDetail, userSession.server) } - } - Button { - settingsRouter.route(to: \.quickConnect) - } label: { - HStack { - L10n.quickConnect.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") + ChevronButton(title: L10n.quickConnect) + .onSelect { + router.route(to: \.quickConnect) } - } Button { - settingsRouter.dismissCoordinator { - SessionManager.main.logout() + router.dismissCoordinator { + viewModel.signOut() } } label: { L10n.switchUser.text @@ -86,112 +61,66 @@ struct SettingsView: View { } } - Section(header: L10n.videoPlayer.text) { - Picker(L10n.jumpForwardLength, selection: $jumpForwardLength) { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in - Text(length.label).tag(length.rawValue) + Section { + EnumPicker( + title: "Video Player Type", + selection: $videoPlayerType + ) + + ChevronButton(title: "Native Player") + .onSelect { + router.route(to: \.nativePlayerSettings) } - } - Picker(L10n.jumpBackwardLength, selection: $jumpBackwardLength) { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in - Text(length.label).tag(length.rawValue) + ChevronButton(title: L10n.videoPlayer) + .onSelect { + router.route(to: \.videoPlayerSettings) } - } - - Toggle(L10n.jumpGesturesEnabled, isOn: $jumpGesturesEnabled) - - Toggle(L10n.systemControlGesturesEnabled, isOn: $systemControlGesturesEnabled) - - Toggle(L10n.seekSlideGestureEnabled, isOn: $seekSlideGestureEnabled) - - Toggle(L10n.playerGesturesLockGestureEnabled, isOn: $playerGesturesLockGestureEnabled) - - Toggle(L10n.resume5SecondOffset, isOn: $resumeOffset) - - Button { - settingsRouter.route(to: \.overlaySettings) - } label: { - HStack { - L10n.overlay.text - .foregroundColor(.primary) - Spacer() - Text(overlayType.label) - Image(systemName: "chevron.right") - } - } - - Button { - settingsRouter.route(to: \.experimentalSettings) - } label: { - HStack { - L10n.experimental.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } + } header: { + L10n.videoPlayer.text } - Section(header: L10n.accessibility.text) { + Section { + EnumPicker(title: L10n.appearance, selection: $appAppearance) - Button { - settingsRouter.route(to: \.customizeViewsSettings) - } label: { - HStack { - L10n.customize.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") + ChevronButton(title: "App Icon") + .onSelect { + router.route(to: \.appIconSelector) } - } - Picker(L10n.appearance, selection: $appAppearance) { - ForEach(AppAppearance.allCases, id: \.self) { appearance in - Text(appearance.localizedName).tag(appearance.rawValue) + ChevronButton(title: L10n.customize) + .onSelect { + router.route(to: \.customizeViewsSettings) } - } - Button { - settingsRouter.route(to: \.fontPicker) - } label: { - HStack { - L10n.subtitleFont.text - .foregroundColor(.primary) - Spacer() - Text(subtitleFontName) - .foregroundColor(.gray) - Image(systemName: "chevron.right") + ChevronButton(title: L10n.experimental) + .onSelect { + router.route(to: \.experimentalSettings) } - } - - Picker(L10n.subtitleSize, selection: $subtitleSize) { - ForEach(SubtitleSize.allCases, id: \.self) { size in - Text(size.label).tag(size.rawValue) - } - } + } header: { + L10n.accessibility.text } - Button { - settingsRouter.route(to: \.about) - } label: { - HStack { - L10n.about.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } + Section { + ColorPicker("Accent Color", selection: $accentColor, supportsOpacity: false) + } footer: { + Text("Some views may need an app restart to update.") } + + ChevronButton(title: L10n.about) + .onSelect { + router.route(to: \.about) + } + + ChevronButton(title: "Logs") + .onSelect { + router.route(to: \.log) + } } - .navigationBarTitle(L10n.settings, displayMode: .inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - settingsRouter.dismissCoordinator() - } label: { - Image(systemName: "xmark.circle.fill") - } - } + .navigationBarTitle(L10n.settings) + .navigationBarTitleDisplayMode(.inline) + .navigationCloseButton { + router.dismissCoordinator() } } } diff --git a/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/ActionButtonSelectorView.swift b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/ActionButtonSelectorView.swift new file mode 100644 index 00000000..85eeefb3 --- /dev/null +++ b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/Components/ActionButtonSelectorView.swift @@ -0,0 +1,124 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2023 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +// TODO: Look at moving across sections +// TODO: Look at general implementation in SelectorView +struct ActionButtonSelectorView: View { + + @Binding + var selectedButtonsBinding: [VideoPlayerActionButton] + + @Environment(\.editMode) + private var editMode + + @State + private var _selectedButtons: [VideoPlayerActionButton] + + private var disabledButtons: [VideoPlayerActionButton] { + VideoPlayerActionButton.allCases.filter { !_selectedButtons.contains($0) } + } + + var body: some View { + List { + Section { + ForEach(_selectedButtons) { item in + Button { + if !(editMode?.wrappedValue.isEditing ?? true) { + select(item: item) + } + } label: { + HStack { + Image(systemName: item.settingsSystemImage) + Text(item.displayTitle) + + Spacer() + + if !(editMode?.wrappedValue.isEditing ?? false) { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + } + .foregroundColor(.primary) + } + } + .onMove(perform: move) + + if _selectedButtons.isEmpty { + Text("None") + .foregroundColor(.secondary) + } + } header: { + Text("Enabled") + } + + Section { + ForEach(disabledButtons) { item in + Button { + if !(editMode?.wrappedValue.isEditing ?? true) { + select(item: item) + } + } label: { + HStack { + Image(systemName: item.settingsSystemImage) + Text(item.displayTitle) + + Spacer() + + if !(editMode?.wrappedValue.isEditing ?? false) { + Image(systemName: "plus.circle.fill") + .foregroundColor(.green) + } + } + .foregroundColor(.primary) + } + } + + if disabledButtons.isEmpty { + Text("None") + .foregroundColor(.secondary) + } + } header: { + Text("Disabled") + } + } + .animation(.linear(duration: 0.2), value: _selectedButtons) + .toolbar { + EditButton() + } + .onChange(of: _selectedButtons) { newValue in + selectedButtonsBinding = newValue + } + } + + func move(from source: IndexSet, to destination: Int) { + _selectedButtons.move(fromOffsets: source, toOffset: destination) + } + + private func select(item: VideoPlayerActionButton) { + if _selectedButtons.contains(item) { + _selectedButtons.removeAll(where: { $0.id == item.id }) + } else { + _selectedButtons.append(item) + } + } +} + +extension ActionButtonSelectorView { + + init(selectedButtonsBinding: Binding<[VideoPlayerActionButton]>) { + self.init( + selectedButtonsBinding: selectedButtonsBinding, + _selectedButtons: selectedButtonsBinding.wrappedValue + ) +// self._selectedButtonsBinding = selectedButtonsBinding +// self._selectedButtons = selectedButtonsBinding.wrappedValue + } +} diff --git a/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/VideoPlayerSettingsView.swift b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/VideoPlayerSettingsView.swift new file mode 100644 index 00000000..3e4de4c6 --- /dev/null +++ b/Swiftfin/Views/SettingsView/VideoPlayerSettingsView/VideoPlayerSettingsView.swift @@ -0,0 +1,161 @@ +// +// 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 SwiftUI + +struct VideoPlayerSettingsView: View { + + // TODO: Organize + + @Default(.VideoPlayer.autoPlayEnabled) + private var autoPlayEnabled + + @Default(.VideoPlayer.jumpBackwardLength) + private var jumpBackwardLength + @Default(.VideoPlayer.jumpForwardLength) + private var jumpForwardLength + @Default(.VideoPlayer.resumeOffset) + private var resumeOffset + + @Default(.VideoPlayer.showJumpButtons) + private var showJumpButtons + + @Default(.VideoPlayer.barActionButtons) + private var barActionButtons + @Default(.VideoPlayer.menuActionButtons) + private var menuActionButtons + + @Default(.VideoPlayer.Subtitle.subtitleFontName) + private var subtitleFontName + @Default(.VideoPlayer.Subtitle.subtitleSize) + private var subtitleSize + @Default(.VideoPlayer.Subtitle.subtitleColor) + private var subtitleColor + + @Default(.VideoPlayer.Overlay.chapterSlider) + private var chapterSlider + @Default(.VideoPlayer.Overlay.playbackButtonType) + private var playbackButtonType + @Default(.VideoPlayer.Overlay.sliderColor) + private var sliderColor + @Default(.VideoPlayer.Overlay.sliderType) + private var sliderType + + @Default(.VideoPlayer.Overlay.trailingTimestampType) + private var trailingTimestampType + @Default(.VideoPlayer.Overlay.showCurrentTimeWhileScrubbing) + private var showCurrentTimeWhileScrubbing + @Default(.VideoPlayer.Overlay.timestampType) + private var timestampType + + @EnvironmentObject + private var router: VideoPlayerSettingsCoordinator.Router + + var body: some View { + Form { + + ChevronButton(title: "Gestures") + .onSelect { + router.route(to: \.gestureSettings) + } + + EnumPicker(title: L10n.jumpBackwardLength, selection: $jumpBackwardLength) + + EnumPicker(title: L10n.jumpForwardLength, selection: $jumpForwardLength) + + Section { + + BasicStepper( + title: "Resume Offset", + value: $resumeOffset, + range: 0 ... 30, + step: 1 + ) + .valueFormatter { + $0.secondFormat + } + } footer: { + Text("Resume content seconds before the recorded resume time") + } + + Section("Buttons") { + + EnumPicker(title: "Playback Buttons", selection: $playbackButtonType) + + Toggle(isOn: $showJumpButtons) { + HStack { + Image(systemName: "goforward") + Text("Jump") + } + } + + ChevronButton(title: "Bar Buttons") + .onSelect { + router.route(to: \.actionButtonSelector, $barActionButtons) + } + + ChevronButton(title: "Menu Buttons") + .onSelect { + router.route(to: \.actionButtonSelector, $menuActionButtons) + } + } + + Section("Slider") { + + Toggle("Chapter Slider", isOn: $chapterSlider) + + ColorPicker(selection: $sliderColor, supportsOpacity: false) { + Text("Slider Color") + } + + EnumPicker(title: "Slider Type", selection: $sliderType) + } + + Section { + + ChevronButton(title: L10n.subtitleFont, subtitle: subtitleFontName) + .onSelect { + router.route(to: \.fontPicker, $subtitleFontName) + } + + BasicStepper( + title: L10n.subtitleSize, + value: $subtitleSize, + range: 8 ... 24, + step: 1 + ) + + ColorPicker(selection: $subtitleColor, supportsOpacity: false) { + Text("Subtitle Color") + } + } header: { + Text("Subtitle") + } footer: { + // TODO: better wording + Text("Settings only affect some subtitle types") + } + + Section("Timestamp") { + + Toggle("Scrub Current Time", isOn: $showCurrentTimeWhileScrubbing) + + EnumPicker(title: "Timestamp Type", selection: $timestampType) + + EnumPicker(title: "Trailing Value", selection: $trailingTimestampType) + } + } + .navigationTitle("Video Player") + .onChange(of: barActionButtons) { newValue in + autoPlayEnabled = newValue.contains(.autoPlay) || menuActionButtons.contains(.autoPlay) + } + .onChange(of: menuActionButtons) { newValue in + autoPlayEnabled = newValue.contains(.autoPlay) || barActionButtons.contains(.autoPlay) + } + } +} diff --git a/Swiftfin/Views/UserListView.swift b/Swiftfin/Views/UserListView.swift index afcd833b..d06ac8e5 100644 --- a/Swiftfin/Views/UserListView.swift +++ b/Swiftfin/Views/UserListView.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 CollectionView @@ -12,7 +12,8 @@ import SwiftUI struct UserListView: View { @EnvironmentObject - private var userListRouter: UserListCoordinator.Router + private var router: UserListCoordinator.Router + @ObservedObject var viewModel: UserListViewModel @@ -23,7 +24,7 @@ struct UserListView: View { .multilineTextAlignment(.center) PrimaryButton(title: L10n.signIn) { - userListRouter.route(to: \.userSignIn, viewModel.server) + router.route(to: \.userSignIn, viewModel.server) } .frame(maxWidth: 300) .frame(height: 50) @@ -33,7 +34,7 @@ struct UserListView: View { @ViewBuilder private var gridView: some View { CollectionView(items: viewModel.users) { _, user, _ in - UserProfileButton(user: user) + UserProfileButton(user: user, client: viewModel.client) .onSelect { viewModel.signIn(user: user) } @@ -69,14 +70,14 @@ struct UserListView: View { ToolbarItemGroup(placement: .navigationBarTrailing) { if !viewModel.users.isEmpty { Button { - userListRouter.route(to: \.userSignIn, viewModel.server) + router.route(to: \.userSignIn, viewModel.server) } label: { Image(systemName: "person.crop.circle.fill.badge.plus") } } Button { - userListRouter.route(to: \.serverDetail, viewModel.server) + router.route(to: \.serverDetail, viewModel.server) } label: { Image(systemName: "info.circle.fill") } diff --git a/Swiftfin/Views/UserSignInView/Components/PublicUserSignInView.swift b/Swiftfin/Views/UserSignInView/Components/PublicUserSignInView.swift index 1c390262..41397957 100644 --- a/Swiftfin/Views/UserSignInView/Components/PublicUserSignInView.swift +++ b/Swiftfin/Views/UserSignInView/Components/PublicUserSignInView.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 @@ -17,21 +17,24 @@ extension UserSignInView { var viewModel: UserSignInViewModel @State - private var enteredPassword: String = "" + private var password: String = "" let publicUser: UserDto var body: some View { DisclosureGroup { - SecureField(L10n.password, text: $enteredPassword) + SecureField(L10n.password, text: $password) Button { - viewModel.signIn(username: publicUser.name ?? .emptyDash, password: enteredPassword) + Task { + guard let username = publicUser.name else { return } + try? await viewModel.signIn(username: username, password: password) + } } label: { L10n.signIn.text } } label: { HStack { - ImageView(publicUser.profileImageSource(maxWidth: 50, maxHeight: 50)) + ImageView(publicUser.profileImageSource(client: viewModel.client, maxWidth: 50, maxHeight: 50)) .failure { Image(systemName: "person.circle") .resizable() diff --git a/Swiftfin/Views/UserSignInView/UserSignInView.swift b/Swiftfin/Views/UserSignInView/UserSignInView.swift index b6211f49..97a1175a 100644 --- a/Swiftfin/Views/UserSignInView/UserSignInView.swift +++ b/Swiftfin/Views/UserSignInView/UserSignInView.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 Stinsen @@ -13,41 +13,101 @@ struct UserSignInView: View { @EnvironmentObject private var router: UserSignInCoordinator.Router + @ObservedObject var viewModel: UserSignInViewModel + @State - private var username: String = "" + private var isPresentingSignInError: Bool = false @State private var password: String = "" + @State + private var signInError: Error? + @State + private var signInTask: Task? + @State + private var username: String = "" + + @ViewBuilder + private var signInSection: some View { + Section { + TextField(L10n.username, text: $username) + .disableAutocorrection(true) + .autocapitalization(.none) + + SecureField(L10n.password, text: $password) + .disableAutocorrection(true) + .autocapitalization(.none) + + if viewModel.isLoading { + Button(role: .destructive) { + viewModel.isLoading = false + signInTask?.cancel() + } label: { + L10n.cancel.text + } + } else { + Button { + let task = Task { + viewModel.isLoading = true + + do { + try await viewModel.signIn(username: username, password: password) + } catch { + signInError = error + isPresentingSignInError = true + } + + viewModel.isLoading = false + } + signInTask = task + } label: { + L10n.signIn.text + } + .disabled(username.isEmpty) + } + } header: { + L10n.signInToServer(viewModel.server.name).text + } + } + + @ViewBuilder + private var publicUsersSection: some View { + Section { + if viewModel.publicUsers.isEmpty { + L10n.noPublicUsers.text + .font(.callout) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } else { + ForEach(viewModel.publicUsers, id: \.id) { user in + PublicUserSignInView(viewModel: viewModel, publicUser: user) + .disabled(viewModel.isLoading) + } + } + } header: { + HStack { + L10n.publicUsers.text + + Spacer() + + Button { + Task { + try? await viewModel.getPublicUsers() + } + } label: { + Image(systemName: "arrow.clockwise.circle.fill") + } + .disabled(viewModel.isLoading) + } + } + .headerProminence(.increased) + } var body: some View { List { - Section { - TextField(L10n.username, text: $username) - .disableAutocorrection(true) - .autocapitalization(.none) - SecureField(L10n.password, text: $password) - .disableAutocorrection(true) - .autocapitalization(.none) - - if viewModel.isLoading { - Button(role: .destructive) { - viewModel.cancelSignIn() - } label: { - L10n.cancel.text - } - } else { - Button { - viewModel.signIn(username: username, password: password) - } label: { - L10n.signIn.text - } - .disabled(username.isEmpty) - } - } header: { - L10n.signInToServer(viewModel.server.name).text - } + signInSection if viewModel.quickConnectEnabled { Button { @@ -57,47 +117,25 @@ struct UserSignInView: View { } } - Section { - if !viewModel.publicUsers.isEmpty { - ForEach(viewModel.publicUsers, id: \.id) { user in - PublicUserSignInView(viewModel: viewModel, publicUser: user) - .disabled(viewModel.isLoading) - } - } else { - HStack(alignment: .center) { - Spacer() - L10n.noPublicUsers.text - .font(.callout) - .foregroundColor(.secondary) - Spacer() - } - } - } header: { - HStack { - L10n.publicUsers.text - - Spacer() - - Button { - viewModel.getPublicUsers() - } label: { - Image(systemName: "arrow.clockwise.circle.fill") - } - .disabled(viewModel.isLoading) - } - } - .headerProminence(.increased) + publicUsersSection } - .alert(item: $viewModel.errorMessage) { _ in - Alert( - title: Text(viewModel.alertTitle), - message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), - dismissButton: .cancel() - ) + .alert( + L10n.error, + isPresented: $isPresentingSignInError + ) { + Button(L10n.dismiss, role: .cancel) + } message: { + Text(signInError?.localizedDescription ?? .emptyDash) } .navigationTitle(L10n.signIn) - .navigationBarBackButtonHidden(viewModel.isLoading) + .onAppear { + Task { + try? await viewModel.checkQuickConnect() + try? await viewModel.getPublicUsers() + } + } .onDisappear { + viewModel.isLoading = false viewModel.stopQuickConnectAuthCheck() } } diff --git a/Swiftfin/Views/VideoPlayer/Components/LoadingView.swift b/Swiftfin/Views/VideoPlayer/Components/LoadingView.swift new file mode 100644 index 00000000..26761193 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Components/LoadingView.swift @@ -0,0 +1,45 @@ +// +// 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 + +extension VideoPlayer { + + struct LoadingView: View { + + @EnvironmentObject + private var router: VideoPlayerCoordinator.Router + + var body: some View { + ZStack { + Color.black + + VStack(spacing: 10) { + + Text("Retrieving media information") + .foregroundColor(.white) + + ProgressView() + + Button { + router.dismissCoordinator() + } label: { + Text("Cancel") + .foregroundColor(.red) + .padding() + .overlay { + Capsule() + .stroke(Color.red, lineWidth: 1) + } + } + } + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/Components/PlaybackSettingsView.swift b/Swiftfin/Views/VideoPlayer/Components/PlaybackSettingsView.swift new file mode 100644 index 00000000..dd8e909b --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Components/PlaybackSettingsView.swift @@ -0,0 +1,106 @@ +// +// 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 JellyfinAPI +import Stinsen +import SwiftUI +import VLCUI + +// TODO: organize + +struct PlaybackSettingsView: View { + + @EnvironmentObject + private var router: PlaybackSettingsCoordinator.Router + @EnvironmentObject + private var splitContentViewProxy: SplitContentViewProxy + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @Environment(\.audioOffset) + @Binding + private var audioOffset + @Environment(\.subtitleOffset) + @Binding + private var subtitleOffset + + var body: some View { + Form { + Section { + + ChevronButton(title: L10n.videoPlayer) + .onSelect { + router.route(to: \.videoPlayerSettings) + } + + // TODO: playback information + } header: { + EmptyView() + } + + BasicStepper( + title: "Audio Offset", + value: _audioOffset.wrappedValue, + range: -30000 ... 30000, + step: 100 + ) + .valueFormatter { + $0.millisecondFormat + } + + BasicStepper( + title: "Subtitle Offset", + value: _subtitleOffset.wrappedValue, + range: -30000 ... 30000, + step: 100 + ) + .valueFormatter { + $0.millisecondFormat + } + + if !viewModel.videoStreams.isEmpty { + Section("Video") { + ForEach(viewModel.videoStreams, id: \.displayTitle) { mediaStream in + ChevronButton(title: mediaStream.displayTitle ?? .emptyDash) + .onSelect { + router.route(to: \.mediaStreamInfo, mediaStream) + } + } + } + } + + if !viewModel.audioStreams.isEmpty { + Section("Audio") { + ForEach(viewModel.audioStreams, id: \.displayTitle) { mediaStream in + ChevronButton(title: mediaStream.displayTitle ?? .emptyDash) + .onSelect { + router.route(to: \.mediaStreamInfo, mediaStream) + } + } + } + } + + if !viewModel.subtitleStreams.isEmpty { + Section("Subtitle") { + ForEach(viewModel.subtitleStreams, id: \.displayTitle) { mediaStream in + ChevronButton(title: mediaStream.displayTitle ?? .emptyDash) + .onSelect { + router.route(to: \.mediaStreamInfo, mediaStream) + } + } + } + } + } + .navigationTitle("Playback") + .navigationBarTitleDisplayMode(.inline) + .navigationCloseButton { + splitContentViewProxy.hide() + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift b/Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift deleted file mode 100644 index eb7fa3bc..00000000 --- a/Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift +++ /dev/null @@ -1,118 +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 AVKit -import Combine -import JellyfinAPI -import UIKit - -class LiveTVNativePlayerViewController: AVPlayerViewController { - - let viewModel: VideoPlayerViewModel - - var timeObserverToken: Any? - - var lastProgressTicks: Int64 = 0 - - private var cancellables = Set() - - init(viewModel: VideoPlayerViewModel) { - - self.viewModel = viewModel - - super.init(nibName: nil, bundle: nil) - - let player: AVPlayer - - if let transcodedStreamURL = viewModel.transcodedStreamURL { - player = AVPlayer(url: transcodedStreamURL) - } else { - player = AVPlayer(url: viewModel.hlsStreamURL) - } - - player.appliesMediaSelectionCriteriaAutomatically = false - - let timeScale = CMTimeScale(NSEC_PER_SEC) - let time = CMTime(seconds: 5, preferredTimescale: timeScale) - - timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in - if time.seconds != 0 { - self?.sendProgressReport(seconds: time.seconds) - } - } - - self.player = player - - self.allowsPictureInPicturePlayback = true - self.player?.allowsExternalPlayback = true - } - - private func createMetadataItem( - for identifier: AVMetadataIdentifier, - value: Any - ) -> AVMetadataItem { - let item = AVMutableMetadataItem() - item.identifier = identifier - item.value = value as? NSCopying & NSObjectProtocol - // Specify "und" to indicate an undefined language. - item.extendedLanguageTag = "und" - return item.copy() as! AVMetadataItem - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - stop() - removePeriodicTimeObserver() - } - - func removePeriodicTimeObserver() { - if let timeObserverToken = timeObserverToken { - player?.removeTimeObserver(timeObserverToken) - self.timeObserverToken = nil - } - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - player?.seek( - to: CMTimeMake(value: viewModel.currentSecondTicks, timescale: 10_000_000), - toleranceBefore: CMTimeMake(value: 1, timescale: 1), - toleranceAfter: CMTimeMake(value: 1, timescale: 1), - completionHandler: { _ in - self.play() - } - ) - } - - private func play() { - player?.play() - - viewModel.sendPlayReport() - } - - private func sendProgressReport(seconds: Double) { - viewModel.setSeconds(Int64(seconds)) - viewModel.sendProgressReport() - } - - private func stop() { - self.player?.pause() - viewModel.sendStopReport() - } -} diff --git a/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift b/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift deleted file mode 100644 index addc9b3b..00000000 --- a/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift +++ /dev/null @@ -1,38 +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 -import UIKit - -struct LiveTVNativePlayerView: UIViewControllerRepresentable { - - let viewModel: VideoPlayerViewModel - - typealias UIViewControllerType = LiveTVNativePlayerViewController - - func makeUIViewController(context: Context) -> LiveTVNativePlayerViewController { - - LiveTVNativePlayerViewController(viewModel: viewModel) - } - - func updateUIViewController(_ uiViewController: LiveTVNativePlayerViewController, context: Context) {} -} - -struct LiveTVPlayerView: UIViewControllerRepresentable { - - let viewModel: VideoPlayerViewModel - - typealias UIViewControllerType = LiveTVPlayerViewController - - func makeUIViewController(context: Context) -> LiveTVPlayerViewController { - - LiveTVPlayerViewController(viewModel: viewModel) - } - - func updateUIViewController(_ uiViewController: LiveTVPlayerViewController, context: Context) {} -} diff --git a/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift b/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift deleted file mode 100644 index 0bf9c072..00000000 --- a/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift +++ /dev/null @@ -1,1075 +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 AVFoundation -import AVKit -import Combine -import Defaults -import Factory -import JellyfinAPI -import MediaPlayer -import MobileVLCKit -import SwiftUI -import UIKit - -// TODO: Look at making the VLC player layer a view - -class LiveTVPlayerViewController: UIViewController { - - @Injected(LogManager.service) - private var logger - - // MARK: variables - - private var viewModel: VideoPlayerViewModel - private var vlcMediaPlayer: VLCMediaPlayer - private var lastPlayerTicks: Int64 = 0 - private var lastProgressReportTicks: Int64 = 0 - private var viewModelListeners = Set() - private var overlayDismissTimer: Timer? - private var isScreenFilled: Bool = false - private var pinchScale: CGFloat = 1 - - private var currentPlayerTicks: Int64 { - Int64(vlcMediaPlayer.time.intValue) * 100_000 - } - - private var displayingOverlay: Bool { - currentOverlayHostingController?.view.alpha ?? 0 > 0 - } - - private var displayingChapterOverlay: Bool { - currentChapterOverlayHostingController?.view.alpha ?? 0 > 0 - } - - private var panBeganBrightness = CGFloat.zero - private var panBeganVolumeValue = Float.zero - private var panBeganPoint = CGPoint.zero - - private lazy var videoContentView = makeVideoContentView() - private lazy var mainGestureView = makeMainGestureView() - private lazy var systemControlOverlayLabel = makeSystemControlOverlayLabel() - private var currentOverlayHostingController: UIHostingController? - private var currentChapterOverlayHostingController: UIHostingController? - private var currentJumpBackwardOverlayView: UIImageView? - private var currentJumpForwardOverlayView: UIImageView? - private var volumeView = MPVolumeView() - - override var keyCommands: [UIKeyCommand]? { - var commands = [ - UIKeyCommand(title: L10n.playAndPause, action: #selector(didSelectMain), input: " "), - UIKeyCommand(title: L10n.jumpForward, action: #selector(didSelectForward), input: UIKeyCommand.inputRightArrow), - UIKeyCommand(title: L10n.jumpBackward, action: #selector(didSelectBackward), input: UIKeyCommand.inputLeftArrow), - UIKeyCommand( - title: L10n.nextItem, - action: #selector(didSelectPlayNextItem), - input: UIKeyCommand.inputRightArrow, - modifierFlags: .command - ), - UIKeyCommand( - title: L10n.previousItem, - action: #selector(didSelectPlayPreviousItem), - input: UIKeyCommand.inputLeftArrow, - modifierFlags: .command - ), - UIKeyCommand(title: L10n.close, action: #selector(didSelectClose), input: UIKeyCommand.inputEscape), - ] - if let previous = viewModel.playbackSpeed.previous { - commands.append(.init( - title: "\(L10n.playbackSpeed) \(previous.displayTitle)", - action: #selector(didSelectPreviousPlaybackSpeed), - input: "[", - modifierFlags: .command - )) - } - if let next = viewModel.playbackSpeed.next { - commands.append(.init( - title: "\(L10n.playbackSpeed) \(next.displayTitle)", - action: #selector(didSelectNextPlaybackSpeed), - input: "]", - modifierFlags: .command - )) - } - if viewModel.playbackSpeed != .one { - commands.append(.init( - title: "\(L10n.playbackSpeed) \(PlaybackSpeed.one.displayTitle)", - action: #selector(didSelectNormalPlaybackSpeed), - input: "\\", - modifierFlags: .command - )) - } - commands.forEach { $0.wantsPriorityOverSystemBehavior = true } - return commands - } - - // MARK: init - - init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel - self.vlcMediaPlayer = VLCMediaPlayer() - - super.init(nibName: nil, bundle: nil) - - viewModel.playerOverlayDelegate = self - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupSubviews() { - view.addSubview(videoContentView) - view.addSubview(mainGestureView) - view.addSubview(systemControlOverlayLabel) - } - - private func setupConstraints() { - NSLayoutConstraint.activate([ - videoContentView.topAnchor.constraint(equalTo: view.topAnchor), - videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), - videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor), - ]) - NSLayoutConstraint.activate([ - mainGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), - mainGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - mainGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - mainGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - NSLayoutConstraint.activate([ - systemControlOverlayLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), - systemControlOverlayLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - } - - // MARK: viewWillDisappear - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - NotificationCenter.default.removeObserver(self) - } - - // MARK: viewDidLoad - - override func viewDidLoad() { - super.viewDidLoad() - - setupSubviews() - setupConstraints() - - view.backgroundColor = .black - view.accessibilityIgnoresInvertColors = true - - setupMediaPlayer(newViewModel: viewModel) - - refreshJumpBackwardOverlayView(with: viewModel.jumpBackwardLength) - refreshJumpForwardOverlayView(with: viewModel.jumpForwardLength) - - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.addObserver( - self, - selector: #selector(appWillTerminate), - name: UIApplication.willTerminateNotification, - object: nil - ) - defaultNotificationCenter.addObserver( - self, - selector: #selector(appWillResignActive), - name: UIApplication.willResignActiveNotification, - object: nil - ) - defaultNotificationCenter.addObserver( - self, - selector: #selector(appWillResignActive), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - } - - @objc - private func appWillTerminate() { - viewModel.sendStopReport() - } - - @objc - private func appWillResignActive() { - hideChaptersOverlay() - - showOverlay() - - stopOverlayDismissTimer() - - vlcMediaPlayer.pause() - - viewModel.sendPauseReport(paused: true) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - startPlayback() - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - if isScreenFilled { - fillScreen(screenSize: size) - } - super.viewWillTransition(to: size, with: coordinator) - } - - // MARK: VideoContentView - - private func makeVideoContentView() -> UIView { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .black - - return view - } - - // MARK: MainGestureView - - private func makeMainGestureView() -> UIView { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - - let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) - - let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe)) - rightSwipeGesture.direction = .right - - let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe)) - leftSwipeGesture.direction = .left - - let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(didPinch(_:))) - - let panGesture = UIPanGestureRecognizer(target: self, action: #selector(didPan(_:))) - - view.addGestureRecognizer(singleTapGesture) - view.addGestureRecognizer(pinchGesture) - - if viewModel.jumpGesturesEnabled { - view.addGestureRecognizer(rightSwipeGesture) - view.addGestureRecognizer(leftSwipeGesture) - } - - if viewModel.systemControlGesturesEnabled { - view.addGestureRecognizer(panGesture) - } - - return view - } - - // MARK: SystemControlOverlayLabel - - private func makeSystemControlOverlayLabel() -> UILabel { - let label = UILabel() - label.alpha = 0 - label.translatesAutoresizingMaskIntoConstraints = false - label.font = .systemFont(ofSize: 48) - return label - } - - @objc - private func didTap() { - didGenerallyTap(point: nil) - } - - @objc - private func didRightSwipe() { - didSelectForward() - } - - @objc - private func didLeftSwipe() { - didSelectBackward() - } - - @objc - private func didPinch(_ gestureRecognizer: UIPinchGestureRecognizer) { - if gestureRecognizer.state == .began || gestureRecognizer.state == .changed { - pinchScale = gestureRecognizer.scale - } else { - if pinchScale > 1, !isScreenFilled { - isScreenFilled.toggle() - fillScreen() - } else if pinchScale < 1, isScreenFilled { - isScreenFilled.toggle() - shrinkScreen() - } - } - } - - @objc - private func didPan(_ gestureRecognizer: UIPanGestureRecognizer) { - switch gestureRecognizer.state { - case .began: - panBeganBrightness = UIScreen.main.brightness - if let view = volumeView.subviews.first as? UISlider { - panBeganVolumeValue = view.value - } - panBeganPoint = gestureRecognizer.location(in: mainGestureView) - case .changed: - let mainGestureViewHalfWidth = mainGestureView.frame.width * 0.5 - let mainGestureViewHalfHeight = mainGestureView.frame.height * 0.5 - - let pos = gestureRecognizer.location(in: mainGestureView) - let moveDelta = pos.y - panBeganPoint.y - let changedValue = moveDelta / mainGestureViewHalfHeight - - if panBeganPoint.x < mainGestureViewHalfWidth { - UIScreen.main.brightness = panBeganBrightness - changedValue - showBrightnessOverlay() - } else if let view = volumeView.subviews.first as? UISlider { - view.value = panBeganVolumeValue - Float(changedValue) - showVolumeOverlay() - } - default: - hideSystemControlOverlay() - } - } - - // MARK: setupOverlayHostingController - - private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { - // TODO: Look at injecting viewModel into the environment so it updates the current overlay - if let currentOverlayHostingController = currentOverlayHostingController { - // UX fade-out - UIView.animate(withDuration: 0.5) { - currentOverlayHostingController.view.alpha = 0 - } completion: { _ in - currentOverlayHostingController.view.isHidden = true - - currentOverlayHostingController.view.removeFromSuperview() - currentOverlayHostingController.removeFromParent() - } - } - - let newOverlayView = VLCPlayerOverlayView(viewModel: viewModel) - let newOverlayHostingController = UIHostingController(rootView: newOverlayView) - - newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newOverlayHostingController.view.backgroundColor = UIColor.clear - - // UX fade-in - newOverlayHostingController.view.alpha = 0 - - addChild(newOverlayHostingController) - view.addSubview(newOverlayHostingController.view) - newOverlayHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - // UX fade-in - UIView.animate(withDuration: 0.5) { - newOverlayHostingController.view.alpha = 1 - } - - currentOverlayHostingController = newOverlayHostingController - - if let currentChapterOverlayHostingController = currentChapterOverlayHostingController { - UIView.animate(withDuration: 0.5) { - currentChapterOverlayHostingController.view.alpha = 0 - } completion: { _ in - currentChapterOverlayHostingController.view.isHidden = true - - currentChapterOverlayHostingController.view.removeFromSuperview() - currentChapterOverlayHostingController.removeFromParent() - } - } - - let newChapterOverlayView = VLCPlayerChapterOverlayView(viewModel: viewModel) - let newChapterOverlayHostingController = UIHostingController(rootView: newChapterOverlayView) - - newChapterOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newChapterOverlayHostingController.view.backgroundColor = UIColor.clear - - newChapterOverlayHostingController.view.alpha = 0 - - addChild(newChapterOverlayHostingController) - view.addSubview(newChapterOverlayHostingController.view) - newChapterOverlayHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newChapterOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newChapterOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newChapterOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newChapterOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - currentChapterOverlayHostingController = newChapterOverlayHostingController - - // There is a weird behavior when after setting the new overlays that the navigation bar pops up, re-hide it - navigationController?.isNavigationBarHidden = true - } - - private func refreshJumpBackwardOverlayView(with jumpBackwardLength: VideoPlayerJumpLength) { - if let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView { - currentJumpBackwardOverlayView.removeFromSuperview() - } - - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) - let backwardSymbolImage = UIImage(systemName: jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) - let newJumpBackwardImageView = UIImageView(image: backwardSymbolImage) - - newJumpBackwardImageView.translatesAutoresizingMaskIntoConstraints = false - newJumpBackwardImageView.tintColor = .white - - newJumpBackwardImageView.alpha = 0 - - view.addSubview(newJumpBackwardImageView) - - NSLayoutConstraint.activate([ - newJumpBackwardImageView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 150), - newJumpBackwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - - currentJumpBackwardOverlayView = newJumpBackwardImageView - } - - private func refreshJumpForwardOverlayView(with jumpForwardLength: VideoPlayerJumpLength) { - if let currentJumpForwardOverlayView = currentJumpForwardOverlayView { - currentJumpForwardOverlayView.removeFromSuperview() - } - - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) - let forwardSymbolImage = UIImage(systemName: jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) - let newJumpForwardImageView = UIImageView(image: forwardSymbolImage) - - newJumpForwardImageView.translatesAutoresizingMaskIntoConstraints = false - newJumpForwardImageView.tintColor = .white - - newJumpForwardImageView.alpha = 0 - - view.addSubview(newJumpForwardImageView) - - NSLayoutConstraint.activate([ - newJumpForwardImageView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -150), - newJumpForwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - - currentJumpForwardOverlayView = newJumpForwardImageView - } -} - -// MARK: setupMediaPlayer - -extension LiveTVPlayerViewController { - /// Main function that handles setting up the media player with the current VideoPlayerViewModel - /// and also takes the role of setting the 'viewModel' property with the given viewModel - /// - /// Use case for this is setting new media within the same VLCPlayerViewController - func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { - // remove old player - - if vlcMediaPlayer.media != nil { - viewModelListeners.forEach { $0.cancel() } - - vlcMediaPlayer.stop() - viewModel.sendStopReport() - viewModel.playerOverlayDelegate = nil - } - - vlcMediaPlayer = VLCMediaPlayer() - - // setup with new player and view model - - vlcMediaPlayer = VLCMediaPlayer() - - vlcMediaPlayer.delegate = self - vlcMediaPlayer.drawable = videoContentView - - vlcMediaPlayer.setSubtitleSize(Defaults[.subtitleSize]) - - stopOverlayDismissTimer() - - lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - - let media: VLCMedia - - if let transcodedURL = newViewModel.transcodedStreamURL, - !Defaults[.Experimental.forceDirectPlay] - { - media = VLCMedia(url: transcodedURL) - } else { - media = VLCMedia(url: newViewModel.directStreamURL) - } - - media.addOption("--prefetch-buffer-size=1048576") - media.addOption("--network-caching=5000") - - vlcMediaPlayer.media = media - - setupOverlayHostingController(viewModel: newViewModel) - setupViewModelListeners(viewModel: newViewModel) - - newViewModel.getAdjacentEpisodes() - newViewModel.playerOverlayDelegate = self - - let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 - - if startPercentage > 0 { - if viewModel.resumeOffset { - let runTimeTicks = viewModel.item.runTimeTicks ?? 0 - let videoDurationSeconds = Double(runTimeTicks / 10_000_000) - var startSeconds = round((startPercentage / 100) * videoDurationSeconds) - startSeconds = startSeconds.subtract(5, floor: 0) - let newStartPercentage = startSeconds / videoDurationSeconds - newViewModel.sliderPercentage = newStartPercentage - } else { - newViewModel.sliderPercentage = startPercentage / 100 - } - } - - viewModel = newViewModel - - if viewModel.streamType == .direct { - logger.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? .emptyDash)") - } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { - logger.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? .emptyDash)") - } else { - logger.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? .emptyDash)") - } - } - - // MARK: startPlayback - - func startPlayback() { - vlcMediaPlayer.play() - - // Setup external subtitles - for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { - if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { - vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) - } - } - - setMediaPlayerTimeAtCurrentSlider() - - viewModel.sendPlayReport() - - restartOverlayDismissTimer() - } - - // MARK: setupViewModelListeners - - private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { - viewModel.$playbackSpeed.sink { newSpeed in - self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) - }.store(in: &viewModelListeners) - - viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in - if sliderIsScrubbing { - self.didBeginScrubbing() - } else { - self.didEndScrubbing() - } - }.store(in: &viewModelListeners) - - viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in - self.didSelectAudioStream(index: newAudioStreamIndex) - }.store(in: &viewModelListeners) - - viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in - self.didSelectSubtitleStream(index: newSubtitleStreamIndex) - }.store(in: &viewModelListeners) - - viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in - self.didToggleSubtitles(newValue: newSubtitlesEnabled) - }.store(in: &viewModelListeners) - - viewModel.$jumpBackwardLength.sink { newJumpBackwardLength in - self.refreshJumpBackwardOverlayView(with: newJumpBackwardLength) - }.store(in: &viewModelListeners) - - viewModel.$jumpForwardLength.sink { newJumpForwardLength in - self.refreshJumpForwardOverlayView(with: newJumpForwardLength) - }.store(in: &viewModelListeners) - } - - func setMediaPlayerTimeAtCurrentSlider() { - // Necessary math as VLCMediaPlayer doesn't work well - // by just setting the position - let runTimeTicks = viewModel.item.runTimeTicks ?? 0 - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let videoDuration = Double(runTimeTicks / 10_000_000) - let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) - let newPositionOffset = secondsScrubbedTo - videoPosition - - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } - } -} - -// MARK: Show/Hide Overlay - -extension LiveTVPlayerViewController { - private func showOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 1 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 1 - } - } - - private func hideOverlay() { - guard !UIAccessibility.isVoiceOverRunning else { return } - - guard let overlayHostingController = currentOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 0 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 0 - } - } - - private func toggleOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } - - if overlayHostingController.view.alpha < 1 { - showOverlay() - } else { - hideOverlay() - } - } -} - -// MARK: Show/Hide System Control - -extension LiveTVPlayerViewController { - private func showBrightnessOverlay() { - guard !displayingOverlay else { return } - - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(systemName: "sun.max", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? - .withTintColor(.white) - - let attributedString = NSMutableAttributedString() - attributedString.append(.init(attachment: imageAttachment)) - attributedString.append(.init(string: " \(String(format: "%.0f", UIScreen.main.brightness * 100))%")) - systemControlOverlayLabel.attributedText = attributedString - systemControlOverlayLabel.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - self.systemControlOverlayLabel.alpha = 1 - } - } - - private func showVolumeOverlay() { - guard !displayingOverlay, - let value = (volumeView.subviews.first as? UISlider)?.value else { return } - - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(systemName: "speaker.wave.2", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? - .withTintColor(.white) - - let attributedString = NSMutableAttributedString() - attributedString.append(.init(attachment: imageAttachment)) - attributedString.append(.init(string: " \(String(format: "%.0f", value * 100))%")) - systemControlOverlayLabel.attributedText = attributedString - systemControlOverlayLabel.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - self.systemControlOverlayLabel.alpha = 1 - } - } - - private func hideSystemControlOverlay() { - UIView.animate(withDuration: 0.75) { - self.systemControlOverlayLabel.alpha = 0 - } - } -} - -// MARK: Show/Hide Jump - -extension LiveTVPlayerViewController { - private func flashJumpBackwardOverlay() { - guard !displayingOverlay, let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } - - currentJumpBackwardOverlayView.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - currentJumpBackwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpBackwardOverlay() - } - } - - private func hideJumpBackwardOverlay() { - guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } - - UIView.animate(withDuration: 0.3) { - currentJumpBackwardOverlayView.alpha = 0 - } - } - - private func flashJumpFowardOverlay() { - guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } - - currentJumpForwardOverlayView.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - currentJumpForwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpForwardOverlay() - } - } - - private func hideJumpForwardOverlay() { - guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } - - UIView.animate(withDuration: 0.3) { - currentJumpForwardOverlayView.alpha = 0 - } - } -} - -// MARK: Hide/Show Chapters - -extension LiveTVPlayerViewController { - private func showChaptersOverlay() { - guard let overlayHostingController = currentChapterOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 1 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 1 - } - } - - private func hideChaptersOverlay() { - guard let overlayHostingController = currentChapterOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 0 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 0 - } - } -} - -// MARK: OverlayTimer - -extension LiveTVPlayerViewController { - private func restartOverlayDismissTimer(interval: Double = 3) { - overlayDismissTimer?.invalidate() - overlayDismissTimer = Timer.scheduledTimer( - timeInterval: interval, - target: self, - selector: #selector(dismissTimerFired), - userInfo: nil, - repeats: false - ) - } - - @objc - private func dismissTimerFired() { - hideOverlay() - } - - private func stopOverlayDismissTimer() { - overlayDismissTimer?.invalidate() - } -} - -// MARK: VLCMediaPlayerDelegate - -extension LiveTVPlayerViewController: VLCMediaPlayerDelegate { - // MARK: mediaPlayerStateChanged - - func mediaPlayerStateChanged(_ aNotification: Notification) { - // Don't show buffering if paused, usually here while scrubbing - if vlcMediaPlayer.state == .buffering, viewModel.playerState == .paused { - return - } - - viewModel.playerState = vlcMediaPlayer.state - - if vlcMediaPlayer.state == VLCMediaPlayerState.ended { - if viewModel.autoplayEnabled, viewModel.nextItemVideoPlayerViewModel != nil { - didSelectPlayNextItem() - } else { - didSelectClose() - } - } - } - - // MARK: mediaPlayerTimeChanged - - func mediaPlayerTimeChanged(_ aNotification: Notification) { - if !viewModel.sliderIsScrubbing { - viewModel.sliderPercentage = Double(vlcMediaPlayer.position) - } - - // Have to manually set playing because VLCMediaPlayer doesn't - // properly set it itself - if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { - viewModel.playerState = VLCMediaPlayerState.playing - } - - // If needing to fix subtitle streams during playback - if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.videoSubtitleStreamIndex(of: viewModel.selectedSubtitleStreamIndex), - viewModel.subtitlesEnabled - { - didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) - } - - // If needing to fix audio stream during playback - if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { - didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) - } - - lastPlayerTicks = currentPlayerTicks - - // Send progress report every 5 seconds - if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - } -} - -// MARK: PlayerOverlayDelegate and more - -extension LiveTVPlayerViewController: PlayerOverlayDelegate { - func didSelectAudioStream(index: Int) { - vlcMediaPlayer.currentAudioTrackIndex = Int32(index) - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - /// Do not call when setting to index -1 - func didSelectSubtitleStream(index: Int) { - viewModel.subtitlesEnabled = true - vlcMediaPlayer.currentVideoSubTitleIndex = viewModel.videoSubtitleStreamIndex(of: index) - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - @objc - func didSelectClose() { - vlcMediaPlayer.stop() - - viewModel.sendStopReport() - - dismiss(animated: true, completion: nil) - } - - func didToggleSubtitles(newValue: Bool) { - if newValue { - vlcMediaPlayer.currentVideoSubTitleIndex = viewModel.videoSubtitleStreamIndex(of: viewModel.selectedSubtitleStreamIndex) - } else { - vlcMediaPlayer.currentVideoSubTitleIndex = -1 - } - } - - // TODO: Implement properly in overlays - func didSelectMenu() { - stopOverlayDismissTimer() - } - - // TODO: Implement properly in overlays - func didDeselectMenu() { - restartOverlayDismissTimer() - } - - @objc - func didSelectBackward() { - flashJumpBackwardOverlay() - - vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) - - if displayingOverlay { - restartOverlayDismissTimer() - } - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - @objc - func didSelectForward() { - flashJumpFowardOverlay() - - vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) - - if displayingOverlay { - restartOverlayDismissTimer() - } - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - @objc - func didSelectMain() { - switch viewModel.playerState { - case .buffering: - vlcMediaPlayer.play() - restartOverlayDismissTimer() - case .playing: - viewModel.sendPauseReport(paused: true) - vlcMediaPlayer.pause() - restartOverlayDismissTimer(interval: 5) - case .paused: - viewModel.sendPauseReport(paused: false) - vlcMediaPlayer.play() - restartOverlayDismissTimer() - default: () - } - } - - func didGenerallyTap(point: CGPoint?) { - toggleOverlay() - - restartOverlayDismissTimer(interval: 5) - } - - func didLongPress() {} - - func didBeginScrubbing() { - stopOverlayDismissTimer() - } - - func didEndScrubbing() { - setMediaPlayerTimeAtCurrentSlider() - - restartOverlayDismissTimer() - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - @objc - func didSelectPlayPreviousItem() { - if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) - startPlayback() - } - } - - @objc - func didSelectPlayNextItem() { - if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) - startPlayback() - } - } - - @objc - func didSelectPreviousPlaybackSpeed() { - if let previousPlaybackSpeed = viewModel.playbackSpeed.previous { - viewModel.playbackSpeed = previousPlaybackSpeed - } - } - - @objc - func didSelectNextPlaybackSpeed() { - if let nextPlaybackSpeed = viewModel.playbackSpeed.next { - viewModel.playbackSpeed = nextPlaybackSpeed - } - } - - @objc - func didSelectNormalPlaybackSpeed() { - viewModel.playbackSpeed = .one - } - - func didSelectChapters() { - if displayingChapterOverlay { - hideChaptersOverlay() - } else { - hideOverlay() - showChaptersOverlay() - } - } - - func didSelectChapter(_ chapter: ChapterInfo) { - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let chapterSeconds = Double((chapter.startPositionTicks ?? 0) / 10_000_000) - let newPositionOffset = chapterSeconds - videoPosition - - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } - - viewModel.sendProgressReport() - } - - func didSelectScreenFill() { - isScreenFilled.toggle() - - if isScreenFilled { - fillScreen() - } else { - shrinkScreen() - } - } - - private func fillScreen(screenSize: CGSize = UIScreen.main.bounds.size) { - let videoSize = vlcMediaPlayer.videoSize - let fillSize = CGSize.aspectFill(aspectRatio: videoSize, minimumSize: screenSize) - - let scale: CGFloat - - if fillSize.height > screenSize.height { - scale = fillSize.height / screenSize.height - } else { - scale = fillSize.width / screenSize.width - } - - UIView.animate(withDuration: 0.2) { - self.videoContentView.transform = CGAffineTransform(scaleX: scale, y: scale) - } - } - - private func shrinkScreen() { - UIView.animate(withDuration: 0.2) { - self.videoContentView.transform = .identity - } - } - - func getScreenFilled() -> Bool { - isScreenFilled - } - - func isVideoAspectRatioGreater() -> Bool { - let screenSize = UIScreen.main.bounds.size - let videoSize = vlcMediaPlayer.videoSize - - let screenAspectRatio = screenSize.width / screenSize.height - let videoAspectRatio = videoSize.width / videoSize.height - - return videoAspectRatio > screenAspectRatio - } -} diff --git a/Swiftfin/Views/VideoPlayer/NativePlayerViewController.swift b/Swiftfin/Views/VideoPlayer/NativePlayerViewController.swift deleted file mode 100644 index f2711b34..00000000 --- a/Swiftfin/Views/VideoPlayer/NativePlayerViewController.swift +++ /dev/null @@ -1,118 +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 AVKit -import Combine -import JellyfinAPI -import UIKit - -class NativePlayerViewController: AVPlayerViewController { - - let viewModel: VideoPlayerViewModel - - var timeObserverToken: Any? - - var lastProgressTicks: Int64 = 0 - - private var cancellables = Set() - - init(viewModel: VideoPlayerViewModel) { - - self.viewModel = viewModel - - super.init(nibName: nil, bundle: nil) - - let player: AVPlayer - - if let transcodedStreamURL = viewModel.transcodedStreamURL { - player = AVPlayer(url: transcodedStreamURL) - } else { - player = AVPlayer(url: viewModel.hlsStreamURL) - } - - player.appliesMediaSelectionCriteriaAutomatically = false - - let timeScale = CMTimeScale(NSEC_PER_SEC) - let time = CMTime(seconds: 5, preferredTimescale: timeScale) - - timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in - if time.seconds != 0 { - self?.sendProgressReport(seconds: time.seconds) - } - } - - self.player = player - - self.allowsPictureInPicturePlayback = true - self.player?.allowsExternalPlayback = true - } - - private func createMetadataItem( - for identifier: AVMetadataIdentifier, - value: Any - ) -> AVMetadataItem { - let item = AVMutableMetadataItem() - item.identifier = identifier - item.value = value as? NSCopying & NSObjectProtocol - // Specify "und" to indicate an undefined language. - item.extendedLanguageTag = "und" - return item.copy() as! AVMetadataItem - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - stop() - removePeriodicTimeObserver() - } - - func removePeriodicTimeObserver() { - if let timeObserverToken = timeObserverToken { - player?.removeTimeObserver(timeObserverToken) - self.timeObserverToken = nil - } - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - player?.seek( - to: CMTimeMake(value: viewModel.currentSecondTicks, timescale: 10_000_000), - toleranceBefore: CMTimeMake(value: 1, timescale: 1), - toleranceAfter: CMTimeMake(value: 1, timescale: 1), - completionHandler: { _ in - self.play() - } - ) - } - - private func play() { - player?.play() - - viewModel.sendPlayReport() - } - - private func sendProgressReport(seconds: Double) { - viewModel.setSeconds(Int64(seconds)) - viewModel.sendProgressReport() - } - - private func stop() { - self.player?.pause() - viewModel.sendStopReport() - } -} diff --git a/Swiftfin/Views/VideoPlayer/NativeVideoPlayer.swift b/Swiftfin/Views/VideoPlayer/NativeVideoPlayer.swift new file mode 100644 index 00000000..faced9ce --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/NativeVideoPlayer.swift @@ -0,0 +1,172 @@ +// +// 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 AVKit +import Combine +import Defaults +import JellyfinAPI +import SwiftUI + +struct NativeVideoPlayer: View { + + @EnvironmentObject + private var router: VideoPlayerCoordinator.Router + + @ObservedObject + private var videoPlayerManager: VideoPlayerManager + + init(manager: VideoPlayerManager) { + self.videoPlayerManager = manager + } + + @ViewBuilder + private var playerView: some View { + NativeVideoPlayerView(videoPlayerManager: videoPlayerManager) + } + + var body: some View { + Group { + if let _ = videoPlayerManager.currentViewModel { + playerView + } else { + VideoPlayer.LoadingView() + } + } + .navigationBarHidden() + .statusBarHidden() + .ignoresSafeArea() + } +} + +struct NativeVideoPlayerView: UIViewControllerRepresentable { + + let videoPlayerManager: VideoPlayerManager + + func makeUIViewController(context: Context) -> UINativeVideoPlayerViewController { + UINativeVideoPlayerViewController(manager: videoPlayerManager) + } + + func updateUIViewController(_ uiViewController: UINativeVideoPlayerViewController, context: Context) {} +} + +class UINativeVideoPlayerViewController: AVPlayerViewController { + + let videoPlayerManager: VideoPlayerManager + + private var rateObserver: NSKeyValueObservation! + private var timeObserverToken: Any! + + init(manager: VideoPlayerManager) { + + self.videoPlayerManager = manager + + super.init(nibName: nil, bundle: nil) + + let newPlayer: AVPlayer = .init(url: manager.currentViewModel.hlsPlaybackURL) + + newPlayer.allowsExternalPlayback = true + newPlayer.appliesMediaSelectionCriteriaAutomatically = false + newPlayer.currentItem?.externalMetadata = createMetadata() + + rateObserver = newPlayer.observe(\.rate, options: .new) { _, change in + guard let newValue = change.newValue else { return } + + if newValue == 0 { + self.videoPlayerManager.onStateUpdated(newState: .paused) + } else { + self.videoPlayerManager.onStateUpdated(newState: .playing) + } + } + + let time = CMTime(seconds: 0.1, preferredTimescale: 1000) + + timeObserverToken = newPlayer.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in + + guard let self else { return } + + if time.seconds >= 0 { + let newSeconds = Int(time.seconds) + let progress = CGFloat(newSeconds) / CGFloat(self.videoPlayerManager.currentViewModel.item.runTimeSeconds) + + self.videoPlayerManager.currentProgressHandler.progress = progress + self.videoPlayerManager.currentProgressHandler.scrubbedProgress = progress + self.videoPlayerManager.currentProgressHandler.seconds = newSeconds + self.videoPlayerManager.currentProgressHandler.scrubbedSeconds = newSeconds + } + } + + player = newPlayer + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + stop() + guard let timeObserverToken else { return } + player?.removeTimeObserver(timeObserverToken) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + player?.seek( + to: CMTimeMake( + value: Int64(videoPlayerManager.currentViewModel.item.startTimeSeconds - Defaults[.VideoPlayer.resumeOffset]), + timescale: 1 + ), + toleranceBefore: .zero, + toleranceAfter: .zero, + completionHandler: { _ in + self.play() + } + ) + } + + private func createMetadata() -> [AVMetadataItem] { + let allMetadata: [AVMetadataIdentifier: Any?] = [ + .commonIdentifierTitle: videoPlayerManager.currentViewModel.item.displayTitle, + .iTunesMetadataTrackSubTitle: videoPlayerManager.currentViewModel.item.subtitle, + ] + + return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) } + } + + private func createMetadataItem( + for identifier: AVMetadataIdentifier, + value: Any? + ) -> AVMetadataItem? { + guard let value else { return nil } + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + // Specify "und" to indicate an undefined language. + item.extendedLanguageTag = "und" + return item.copy() as? AVMetadataItem + } + + private func play() { + player?.play() + + videoPlayerManager.sendStartReport() + } + + private func stop() { + player?.pause() + + videoPlayerManager.sendStopReport() + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/ChapterOverlay.swift b/Swiftfin/Views/VideoPlayer/Overlays/ChapterOverlay.swift new file mode 100644 index 00000000..f4edb90c --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/ChapterOverlay.swift @@ -0,0 +1,147 @@ +// +// 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 JellyfinAPI +import SwiftUI +import VLCUI + +extension VideoPlayer.Overlay { + + struct ChapterOverlay: View { + + @Default(.accentColor) + private var accentColor + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + @Environment(\.safeAreaInsets) + private var safeAreaInsets + + @EnvironmentObject + private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + @EnvironmentObject + private var videoPlayerProxy: VLCVideoPlayer.Proxy + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @State + private var scrollViewProxy: ScrollViewProxy? = nil + + var body: some View { + VStack { + + Spacer() + .allowsHitTesting(false) + + HStack { + L10n.chapters.text + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.white) + .accessibility(addTraits: [.isHeader]) + + Spacer() + + Button { + if let currentChapter = viewModel.chapter(from: currentProgressHandler.seconds) { + withAnimation { + scrollViewProxy?.scrollTo(currentChapter.hashValue, anchor: .center) + } + } + } label: { + Text("Current") + .font(.title2) + .foregroundColor(accentColor) + } + } + .padding(.leading, safeAreaInsets.leading) + .padding(.trailing, safeAreaInsets.trailing) + + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 15) { + ForEach(viewModel.chapters, id: \.hashValue) { chapter in + PosterButton( + state: .item(chapter), + type: .landscape + ) + .imageOverlay { type in + if case let PosterButtonType.item(info) = type, + info.secondsRange.contains(currentProgressHandler.seconds) + { + RoundedRectangle(cornerRadius: 6) + .stroke(accentColor, lineWidth: 8) + } + } + .content { type in + if case let PosterButtonType.item(info) = type { + VStack(alignment: .leading, spacing: 5) { + Text(info.chapterInfo.displayTitle) + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(1) + .foregroundColor(.white) + + Text(info.chapterInfo.timestampLabel) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(Color(UIColor.systemBlue)) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background { + Color(UIColor.darkGray).opacity(0.2).cornerRadius(4) + } + } + } + } + .onSelect { + let seconds = chapter.chapterInfo.startTimeSeconds + videoPlayerProxy.setTime(.seconds(seconds)) + + if videoPlayerManager.state != .playing { + videoPlayerProxy.play() + } + } + } + } + .padding(.leading, safeAreaInsets.leading) + .padding(.trailing, safeAreaInsets.trailing) + .padding(.bottom) + } + .onChange(of: currentOverlayType) { newValue in + guard newValue == .chapters else { return } + if let currentChapter = viewModel.chapter(from: currentProgressHandler.seconds) { + scrollViewProxy?.scrollTo(currentChapter.hashValue, anchor: .center) + } + } + .onAppear { + scrollViewProxy = proxy + } + } + } + .background { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black.opacity(0.4), location: 0.4), + .init(color: .black.opacity(0.9), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .allowsHitTesting(false) + } + } + } +} diff --git a/Shared/Objects/Bitrates.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/ActionButtons.swift similarity index 60% rename from Shared/Objects/Bitrates.swift rename to Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/ActionButtons.swift index 9105d745..1d88a82d 100644 --- a/Shared/Objects/Bitrates.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/ActionButtons.swift @@ -3,12 +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 Foundation -struct Bitrates: Codable, Hashable { - public var name: String - public var value: Int +extension VideoPlayer.Overlay { + + enum ActionButtons {} } diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/AdvancedActionButton.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/AdvancedActionButton.swift new file mode 100644 index 00000000..837f6e7a --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/AdvancedActionButton.swift @@ -0,0 +1,42 @@ +// +// 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 VideoPlayer.Overlay.ActionButtons { + + struct Advanced: View { + + @Environment(\.aspectFilled) + @Binding + private var aspectFilled: Bool + + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var splitViewProxy: SplitContentViewProxy + + private var content: () -> any View + + var body: some View { + Button { + overlayTimer.start(5) + splitViewProxy.present() + } label: { + content().eraseToAnyView() + } + } + } +} + +extension VideoPlayer.Overlay.ActionButtons.Advanced { + + init(@ViewBuilder _ content: @escaping () -> any View) { + self.content = content + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/AspectFillActionButton.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/AspectFillActionButton.swift new file mode 100644 index 00000000..462eda5d --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/AspectFillActionButton.swift @@ -0,0 +1,54 @@ +// +// 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 SwiftUI +import VLCUI + +extension VideoPlayer.Overlay.ActionButtons { + + struct AspectFill: View { + + @Environment(\.aspectFilled) + @Binding + private var aspectFilled: Bool + + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerProxy: VLCVideoPlayer.Proxy + + private var content: (Bool) -> any View + + var body: some View { + Button { + overlayTimer.start(5) + if aspectFilled { + aspectFilled = false + UIView.animate(withDuration: 0.2) { + videoPlayerProxy.aspectFill(0) + } + } else { + aspectFilled = true + UIView.animate(withDuration: 0.2) { + videoPlayerProxy.aspectFill(1) + } + } + } label: { + content(aspectFilled).eraseToAnyView() + } + } + } +} + +extension VideoPlayer.Overlay.ActionButtons.AspectFill { + + init(@ViewBuilder _ content: @escaping (Bool) -> any View) { + self.content = content + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/AudioActionButton.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/AudioActionButton.swift new file mode 100644 index 00000000..9a894b29 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/AudioActionButton.swift @@ -0,0 +1,53 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2023 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI +import VLCUI + +extension VideoPlayer.Overlay.ActionButtons { + + struct Audio: View { + + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + @EnvironmentObject + private var videoPlayerProxy: VLCVideoPlayer.Proxy + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + private var content: (Bool) -> any View + + var body: some View { + Menu { + ForEach(viewModel.audioStreams.prepending(.none), id: \.index) { audioTrack in + Button { + videoPlayerManager.audioTrackIndex = audioTrack.index ?? -1 + videoPlayerProxy.setAudioTrack(.absolute(audioTrack.index ?? -1)) + } label: { + if videoPlayerManager.audioTrackIndex == audioTrack.index ?? -1 { + Label(audioTrack.displayTitle ?? .emptyDash, systemImage: "checkmark") + } else { + Text(audioTrack.displayTitle ?? .emptyDash) + } + } + } + } label: { + content(videoPlayerManager.audioTrackIndex != -1) + .eraseToAnyView() + } + } + } +} + +extension VideoPlayer.Overlay.ActionButtons.Audio { + + init(@ViewBuilder _ content: @escaping (Bool) -> any View) { + self.content = content + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift new file mode 100644 index 00000000..4318bc8e --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift @@ -0,0 +1,41 @@ +// +// 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 SwiftUI + +extension VideoPlayer.Overlay.ActionButtons { + + struct AutoPlay: View { + + @Default(.VideoPlayer.autoPlayEnabled) + private var autoPlayEnabled + + @EnvironmentObject + private var overlayTimer: TimerProxy + + private var content: (Bool) -> any View + + var body: some View { + Button { + autoPlayEnabled.toggle() + overlayTimer.start(5) + } label: { + content(autoPlayEnabled) + .eraseToAnyView() + } + } + } +} + +extension VideoPlayer.Overlay.ActionButtons.AutoPlay { + + init(@ViewBuilder _ content: @escaping (Bool) -> any View) { + self.content = content + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/ChaptersActionButton.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/ChaptersActionButton.swift new file mode 100644 index 00000000..58ddd58d --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/ChaptersActionButton.swift @@ -0,0 +1,45 @@ +// +// 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 SwiftUI + +extension VideoPlayer.Overlay.ActionButtons { + + struct Chapters: View { + + @Default(.VideoPlayer.autoPlayEnabled) + private var autoPlayEnabled + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + + @EnvironmentObject + private var overlayTimer: TimerProxy + + private var content: () -> any View + + var body: some View { + Button { + currentOverlayType = .chapters + overlayTimer.stop() + } label: { + content() + .eraseToAnyView() + } + } + } +} + +extension VideoPlayer.Overlay.ActionButtons.Chapters { + + init(@ViewBuilder _ content: @escaping () -> any View) { + self.content = content + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayNextItemActionButton.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayNextItemActionButton.swift new file mode 100644 index 00000000..626b6226 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayNextItemActionButton.swift @@ -0,0 +1,41 @@ +// +// 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 VideoPlayer.Overlay.ActionButtons { + + struct PlayNextItem: View { + + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + + private var content: () -> any View + + var body: some View { + Button { + videoPlayerManager.selectNextViewModel() + overlayTimer.start(5) + } label: { + content() + .eraseToAnyView() + } + .disabled(videoPlayerManager.nextViewModel == nil) + .foregroundColor(videoPlayerManager.nextViewModel == nil ? .gray : .white) + } + } +} + +extension VideoPlayer.Overlay.ActionButtons.PlayNextItem { + + init(@ViewBuilder _ content: @escaping () -> any View) { + self.content = content + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayPreviousItemActionButton.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayPreviousItemActionButton.swift new file mode 100644 index 00000000..79501c55 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayPreviousItemActionButton.swift @@ -0,0 +1,41 @@ +// +// 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 VideoPlayer.Overlay.ActionButtons { + + struct PlayPreviousItem: View { + + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + + private var content: () -> any View + + var body: some View { + Button { + videoPlayerManager.selectPreviousViewModel() + overlayTimer.start(5) + } label: { + content() + .eraseToAnyView() + } + .disabled(videoPlayerManager.previousViewModel == nil) + .foregroundColor(videoPlayerManager.previousViewModel == nil ? .gray : .white) + } + } +} + +extension VideoPlayer.Overlay.ActionButtons.PlayPreviousItem { + + init(@ViewBuilder _ content: @escaping () -> any View) { + self.content = content + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/PlaybackSpeedActionButton.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/PlaybackSpeedActionButton.swift new file mode 100644 index 00000000..5bade58f --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/PlaybackSpeedActionButton.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 SwiftUI +import VLCUI + +extension VideoPlayer.Overlay.ActionButtons { + + struct PlaybackSpeedMenu: View { + + @Environment(\.playbackSpeed) + @Binding + private var playbackSpeed + + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + @EnvironmentObject + private var videoPlayerProxy: VLCVideoPlayer.Proxy + + private var content: () -> any View + + var body: some View { + Menu { + ForEach(PlaybackSpeed.allCases, id: \.self) { speed in + Button { + playbackSpeed = Float(speed.rawValue) + videoPlayerProxy.setRate(.absolute(Float(speed.rawValue))) + } label: { + if Float(speed.rawValue) == playbackSpeed { + Label(speed.displayTitle, systemImage: "checkmark") + } else { + Text(speed.displayTitle) + } + } + } + + if !PlaybackSpeed.allCases.map(\.rawValue).contains(where: { $0 == Double(playbackSpeed) }) { + Label(String(format: "%.2f", playbackSpeed).appending("x"), systemImage: "checkmark") + } + } label: { + content().eraseToAnyView() + } + } + } +} + +extension VideoPlayer.Overlay.ActionButtons.PlaybackSpeedMenu { + + init(@ViewBuilder _ content: @escaping () -> any View) { + self.content = content + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/SubtitleActionButton.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/SubtitleActionButton.swift new file mode 100644 index 00000000..2e67b8ab --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/SubtitleActionButton.swift @@ -0,0 +1,53 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2023 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI +import VLCUI + +extension VideoPlayer.Overlay.ActionButtons { + + struct Subtitles: View { + + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + @EnvironmentObject + private var videoPlayerProxy: VLCVideoPlayer.Proxy + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + private var content: (Bool) -> any View + + var body: some View { + Menu { + ForEach(viewModel.subtitleStreams.prepending(.none), id: \.index) { subtitleTrack in + Button { + videoPlayerManager.subtitleTrackIndex = subtitleTrack.index ?? -1 + videoPlayerProxy.setSubtitleTrack(.absolute(subtitleTrack.index ?? -1)) + } label: { + if videoPlayerManager.subtitleTrackIndex == subtitleTrack.index ?? -1 { + Label(subtitleTrack.displayTitle ?? .emptyDash, systemImage: "checkmark") + } else { + Text(subtitleTrack.displayTitle ?? .emptyDash) + } + } + } + } label: { + content(videoPlayerManager.subtitleTrackIndex != -1) + .eraseToAnyView() + } + } + } +} + +extension VideoPlayer.Overlay.ActionButtons.Subtitles { + + init(@ViewBuilder _ content: @escaping (Bool) -> any View) { + self.content = content + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift new file mode 100644 index 00000000..470215f4 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift @@ -0,0 +1,169 @@ +// +// 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 SwiftUI +import VLCUI + +extension VideoPlayer.Overlay { + + struct BarActionButtons: View { + + @Default(.VideoPlayer.barActionButtons) + private var barActionButtons + @Default(.VideoPlayer.menuActionButtons) + private var menuActionButtons + + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @ViewBuilder + private var advancedButton: some View { + ActionButtons.Advanced { + Image(systemName: "gearshape.fill") + .frame(width: 45, height: 45) + .contentShape(Rectangle()) + } + } + + @ViewBuilder + private var aspectFillButton: some View { + ActionButtons.AspectFill { isAspectFilled in + Group { + if isAspectFilled { + Image(systemName: "arrow.down.right.and.arrow.up.left") + } else { + Image(systemName: "arrow.up.left.and.arrow.down.right") + } + } + .frame(width: 45, height: 45) + .contentShape(Rectangle()) + } + } + + @ViewBuilder + private var audioTrackMenu: some View { + ActionButtons.Audio { audioTrackSelected in + Group { + if audioTrackSelected { + Image(systemName: "speaker.wave.2.fill") + } else { + Image(systemName: "speaker.wave.2") + } + } + .frame(width: 45, height: 45) + .contentShape(Rectangle()) + } + } + + @ViewBuilder + private var autoPlayButton: some View { + if viewModel.item.type == .episode { + ActionButtons.AutoPlay { autoPlayEnabled in + Group { + if autoPlayEnabled { + Image(systemName: "play.circle.fill") + } else { + Image(systemName: "stop.circle") + } + } + .frame(width: 45, height: 45) + .contentShape(Rectangle()) + } + } + } + + @ViewBuilder + private var chaptersButton: some View { + if !viewModel.chapters.isEmpty { + ActionButtons.Chapters { + Image(systemName: "list.dash") + .frame(width: 45, height: 45) + .contentShape(Rectangle()) + } + } + } + + @ViewBuilder + private var playbackSpeedMenu: some View { + ActionButtons.PlaybackSpeedMenu { + Image(systemName: "speedometer") + .frame(width: 45, height: 45) + .contentShape(Rectangle()) + } + } + + @ViewBuilder + private var playNextItemButton: some View { + if viewModel.item.type == .episode { + ActionButtons.PlayNextItem { + Image(systemName: "chevron.right.circle") + .frame(width: 45, height: 45) + .contentShape(Rectangle()) + } + } + } + + @ViewBuilder + private var playPreviousItemButton: some View { + if viewModel.item.type == .episode { + ActionButtons.PlayPreviousItem { + Image(systemName: "chevron.left.circle") + .frame(width: 45, height: 45) + .contentShape(Rectangle()) + } + } + } + + @ViewBuilder + private var subtitleTrackMenu: some View { + ActionButtons.Subtitles { subtitleTrackSelected in + Group { + if subtitleTrackSelected { + Image(systemName: "captions.bubble.fill") + } else { + Image(systemName: "captions.bubble") + } + } + .frame(width: 45, height: 45) + .contentShape(Rectangle()) + } + } + + var body: some View { + HStack(spacing: 0) { + ForEach(barActionButtons) { actionButton in + switch actionButton { + case .advanced: + advancedButton + case .aspectFill: + aspectFillButton + case .audio: + audioTrackMenu + case .autoPlay: + autoPlayButton + case .chapters: + chaptersButton + case .playbackSpeed: + playbackSpeedMenu + case .playNextItem: + playNextItemButton + case .playPreviousItem: + playPreviousItemButton + case .subtitles: + subtitleTrackMenu + } + } + + if !menuActionButtons.isEmpty { + OverlayMenu() + } + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/BottomBarView.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/BottomBarView.swift new file mode 100644 index 00000000..3c08c76e --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/BottomBarView.swift @@ -0,0 +1,166 @@ +// +// 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 JellyfinAPI +import SwiftUI +import VLCUI + +extension VideoPlayer.Overlay { + + struct BottomBarView: View { + + @Default(.VideoPlayer.Overlay.chapterSlider) + private var chapterSlider + @Default(.VideoPlayer.jumpBackwardLength) + private var jumpBackwardLength + @Default(.VideoPlayer.jumpForwardLength) + private var jumpForwardLength + @Default(.VideoPlayer.Overlay.playbackButtonType) + private var playbackButtonType + @Default(.VideoPlayer.Overlay.sliderType) + private var sliderType + @Default(.VideoPlayer.Overlay.timestampType) + private var timestampType + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + @Environment(\.isScrubbing) + @Binding + private var isScrubbing: Bool + + @EnvironmentObject + private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerProxy: VLCVideoPlayer.Proxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @State + private var currentChapter: ChapterInfo.FullInfo? + + @ViewBuilder + private var capsuleSlider: some View { + CapsuleSlider(progress: $currentProgressHandler.scrubbedProgress) + .isEditing(_isScrubbing.wrappedValue) + .trackMask { + if chapterSlider && !viewModel.chapters.isEmpty { + ChapterTrack() + .clipShape(Capsule()) + } else { + Color.white + } + } + .bottomContent { + Group { + switch timestampType { + case .split: + SplitTimeStamp() + case .compact: + CompactTimeStamp() + } + } + .padding(5) + } + .leadingContent { + if playbackButtonType == .compact { + SmallPlaybackButtons() + .padding(.trailing) + .disabled(isScrubbing) + } + } + .frame(height: 50) + } + + @ViewBuilder + private var thumbSlider: some View { + ThumbSlider(progress: $currentProgressHandler.scrubbedProgress) + .isEditing(_isScrubbing.wrappedValue) + .trackMask { + if chapterSlider && !viewModel.chapters.isEmpty { + ChapterTrack() + .clipShape(Capsule()) + } else { + Color.white + } + } + .bottomContent { + Group { + switch timestampType { + case .split: + SplitTimeStamp() + case .compact: + CompactTimeStamp() + } + } + .padding(5) + } + .leadingContent { + if playbackButtonType == .compact { + SmallPlaybackButtons() + .padding(.trailing) + .disabled(isScrubbing) + } + } + } + + var body: some View { + VStack(spacing: 0) { + HStack { + if chapterSlider, let currentChapter { + Button { + currentOverlayType = .chapters + overlayTimer.stop() + } label: { + HStack { + Text(currentChapter.displayTitle) + .monospacedDigit() + + Image(systemName: "chevron.right") + } + .foregroundColor(.white) + .font(.subheadline.weight(.medium)) + } + .disabled(isScrubbing) + } + + Spacer() + } + .padding(.leading, 5) + .padding(.bottom, 15) + + Group { + switch sliderType { + case .capsule: capsuleSlider + case .thumb: thumbSlider + } + } + } + .onChange(of: currentProgressHandler.scrubbedSeconds) { newValue in + guard chapterSlider else { return } + let newChapter = viewModel.chapter(from: newValue) + if newChapter != currentChapter { + if isScrubbing && Defaults[.hapticFeedback] { + UIDevice.impact(.light) + } + + self.currentChapter = newChapter + } + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/ChapterTrack.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/ChapterTrack.swift new file mode 100644 index 00000000..5591ca7c --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/ChapterTrack.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 SwiftUI + +extension VideoPlayer.Overlay { + + struct ChapterTrack: View { + + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @State + private var width: CGFloat = 0 + + private func maxWidth(for chapter: ChapterInfo.FullInfo) -> CGFloat { + width * CGFloat(chapter.secondsRange.count) / CGFloat(viewModel.item.runTimeSeconds) + } + + var body: some View { + HStack(spacing: 0) { + ForEach(viewModel.chapters, id: \.self) { chapter in + HStack(spacing: 0) { + if chapter != viewModel.chapters.first { + Color.clear + .frame(width: 1.5) + } + + Color.white + } + .frame(maxWidth: maxWidth(for: chapter)) + } + } + .frame(maxWidth: .infinity) + .onSizeChanged { newSize in + width = newSize.width + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/OverlayMenu.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/OverlayMenu.swift new file mode 100644 index 00000000..816673c4 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/OverlayMenu.swift @@ -0,0 +1,180 @@ +// +// 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 SwiftUI +import VLCUI + +extension VideoPlayer.Overlay { + + struct OverlayMenu: View { + + @Default(.VideoPlayer.menuActionButtons) + private var menuActionButtons + + @EnvironmentObject + private var splitContentViewProxy: SplitContentViewProxy + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @ViewBuilder + private var advancedButton: some View { + Button { + splitContentViewProxy.present() + } label: { + HStack { + Image(systemName: "gearshape.fill") + + Text("Advanced") + } + } + } + + @ViewBuilder + private var aspectFillButton: some View { + ActionButtons.AspectFill { isAspectFilled in + HStack { + if isAspectFilled { + Image(systemName: "arrow.down.right.and.arrow.up.left") + } else { + Image(systemName: "arrow.up.left.and.arrow.down.right") + } + + Text("Aspect Fill") + } + } + } + + @ViewBuilder + private var audioTrackMenu: some View { + ActionButtons.Audio { audioTrackSelected in + HStack { + if audioTrackSelected { + Image(systemName: "speaker.wave.2.fill") + } else { + Image(systemName: "speaker.wave.2") + } + + L10n.audio.text + } + } + } + + @ViewBuilder + private var autoPlayButton: some View { + if viewModel.item.type == .episode { + ActionButtons.AutoPlay { autoPlayEnabled in + HStack { + if autoPlayEnabled { + Image(systemName: "play.circle.fill") + } else { + Image(systemName: "stop.circle") + } + + L10n.autoPlay.text + } + } + } + } + + @ViewBuilder + private var chaptersButton: some View { + if !viewModel.chapters.isEmpty { + ActionButtons.Chapters { + HStack { + Image(systemName: "list.dash") + + L10n.chapters.text + } + } + } + } + + @ViewBuilder + private var playbackSpeedMenu: some View { + ActionButtons.PlaybackSpeedMenu { + HStack { + Image(systemName: "speedometer") + + L10n.playbackSpeed.text + } + } + } + + @ViewBuilder + private var playNextItemButton: some View { + if viewModel.item.type == .episode { + ActionButtons.PlayNextItem { + HStack { + Image(systemName: "chevron.right.circle") + + Text("Play Next Item") + } + } + } + } + + @ViewBuilder + private var playPreviousItemButton: some View { + if viewModel.item.type == .episode { + ActionButtons.PlayPreviousItem { + HStack { + Image(systemName: "chevron.left.circle") + + Text("Play Previous Item") + } + } + } + } + + @ViewBuilder + private var subtitleTrackMenu: some View { + ActionButtons.Subtitles { subtitleTrackSelected in + HStack { + if subtitleTrackSelected { + Image(systemName: "captions.bubble.fill") + } else { + Image(systemName: "captions.bubble") + } + + L10n.subtitles.text + } + } + } + + var body: some View { + Menu { + ForEach(menuActionButtons) { actionButton in + switch actionButton { + case .advanced: + advancedButton + case .aspectFill: + aspectFillButton + case .audio: + audioTrackMenu + case .autoPlay: + autoPlayButton + case .chapters: + chaptersButton + case .playbackSpeed: + playbackSpeedMenu + case .playNextItem: + playNextItemButton + case .playPreviousItem: + playPreviousItemButton + case .subtitles: + subtitleTrackMenu + } + } + } label: { + Image(systemName: "ellipsis.circle") + } + .frame(width: 50, height: 50) + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/PlaybackButtons/LargePlaybackButtons.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/PlaybackButtons/LargePlaybackButtons.swift new file mode 100644 index 00000000..e77652a6 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/PlaybackButtons/LargePlaybackButtons.swift @@ -0,0 +1,110 @@ +// +// 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 SwiftUI +import VLCUI + +extension VideoPlayer.Overlay { + + struct LargePlaybackButtons: View { + + @Default(.VideoPlayer.jumpBackwardLength) + private var jumpBackwardLength + @Default(.VideoPlayer.jumpForwardLength) + private var jumpForwardLength + @Default(.VideoPlayer.showJumpButtons) + private var showJumpButtons + + @EnvironmentObject + private var timerProxy: TimerProxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + @EnvironmentObject + private var videoPlayerProxy: VLCVideoPlayer.Proxy + + @ViewBuilder + private var jumpBackwardButton: some View { + Button { + videoPlayerProxy.jumpBackward(Int(jumpBackwardLength.rawValue)) + timerProxy.start(5) + } label: { + Image(systemName: jumpBackwardLength.backwardImageLabel) + .font(.system(size: 36, weight: .regular, design: .default)) + .padding() + .contentShape(Rectangle()) + } + .contentShape(Rectangle()) + } + + @ViewBuilder + private var playButton: some View { + Button { + switch videoPlayerManager.state { + case .playing: + videoPlayerProxy.pause() + default: + videoPlayerProxy.play() + } + timerProxy.start(5) + } label: { + Group { + switch videoPlayerManager.state { + case .stopped, .paused: + Image(systemName: "play.fill") + case .playing: + Image(systemName: "pause.fill") + default: + ProgressView() + .scaleEffect(2) + } + } + .font(.system(size: 56, weight: .bold, design: .default)) + .padding() + .contentShape(Rectangle()) + } + .contentShape(Rectangle()) + } + + @ViewBuilder + private var jumpForwardButton: some View { + Button { + videoPlayerProxy.jumpForward(Int(jumpForwardLength.rawValue)) + timerProxy.start(5) + } label: { + Image(systemName: jumpForwardLength.forwardImageLabel) + .font(.system(size: 36, weight: .regular, design: .default)) + .padding() + .contentShape(Rectangle()) + } + .contentShape(Rectangle()) + } + + var body: some View { + HStack(spacing: 0) { + + Spacer(minLength: 100) + + if showJumpButtons { + jumpBackwardButton + } + + playButton + .frame(minWidth: 100, maxWidth: 300) + + if showJumpButtons { + jumpForwardButton + } + + Spacer(minLength: 100) + } + .tint(Color.white) + .foregroundColor(Color.white) + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/PlaybackButtons/SmallPlaybackButtons.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/PlaybackButtons/SmallPlaybackButtons.swift new file mode 100644 index 00000000..db2515f5 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/PlaybackButtons/SmallPlaybackButtons.swift @@ -0,0 +1,99 @@ +// +// 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 SwiftUI +import VLCUI + +extension VideoPlayer.Overlay { + + struct SmallPlaybackButtons: View { + + @Default(.VideoPlayer.jumpBackwardLength) + private var jumpBackwardLength + @Default(.VideoPlayer.jumpForwardLength) + private var jumpForwardLength + @Default(.VideoPlayer.showJumpButtons) + private var showJumpButtons + + @EnvironmentObject + private var timerProxy: TimerProxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + @EnvironmentObject + private var videoPlayerProxy: VLCVideoPlayer.Proxy + + @ViewBuilder + private var jumpBackwardButton: some View { + Button { + videoPlayerProxy.jumpBackward(Int(jumpBackwardLength.rawValue)) + timerProxy.start(5) + } label: { + Image(systemName: jumpBackwardLength.backwardImageLabel) + .font(.system(size: 24, weight: .bold, design: .default)) + } + .contentShape(Rectangle()) + } + + @ViewBuilder + private var playButton: some View { + Button { + switch videoPlayerManager.state { + case .playing: + videoPlayerProxy.pause() + default: + videoPlayerProxy.play() + } + timerProxy.start(5) + } label: { + Group { + switch videoPlayerManager.state { + case .stopped, .paused: + Image(systemName: "play.fill") + case .playing: + Image(systemName: "pause.fill") + default: + ProgressView() + } + } + .font(.system(size: 28, weight: .bold, design: .default)) + .frame(width: 50, height: 50) + } + .contentShape(Rectangle()) + } + + @ViewBuilder + private var jumpForwardButton: some View { + Button { + videoPlayerProxy.jumpForward(Int(jumpForwardLength.rawValue)) + timerProxy.start(5) + } label: { + Image(systemName: jumpForwardLength.forwardImageLabel) + .font(.system(size: 24, weight: .bold, design: .default)) + } + .contentShape(Rectangle()) + } + + var body: some View { + HStack(spacing: 15) { + + if showJumpButtons { + jumpBackwardButton + } + + playButton + + if showJumpButtons { + jumpForwardButton + } + } + .tint(Color.white) + .foregroundColor(Color.white) + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/Timestamp/CompactTimeStamp.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/Timestamp/CompactTimeStamp.swift new file mode 100644 index 00000000..3aeef560 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/Timestamp/CompactTimeStamp.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 Defaults +import SwiftUI + +extension VideoPlayer.Overlay { + + struct CompactTimeStamp: View { + + @Default(.VideoPlayer.Overlay.showCurrentTimeWhileScrubbing) + private var showCurrentTimeWhileScrubbing + @Default(.VideoPlayer.Overlay.trailingTimestampType) + private var trailingTimestampType + + @EnvironmentObject + private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @Environment(\.isScrubbing) + @Binding + private var isScrubbing: Bool + + @ViewBuilder + private var leadingTimestamp: some View { + Button { + switch trailingTimestampType { + case .timeLeft: + trailingTimestampType = .totalTime + case .totalTime: + trailingTimestampType = .timeLeft + } + } label: { + HStack(spacing: 2) { + + Text(currentProgressHandler.scrubbedSeconds.timeLabel) + .foregroundColor(.white) + + Text("/") + .foregroundColor(Color(UIColor.lightText)) + + switch trailingTimestampType { + case .timeLeft: + Text((viewModel.item.runTimeSeconds - currentProgressHandler.scrubbedSeconds).timeLabel.prepending("-")) + .foregroundColor(Color(UIColor.lightText)) + case .totalTime: + Text(viewModel.item.runTimeSeconds.timeLabel) + .foregroundColor(Color(UIColor.lightText)) + } + } + } + } + + @ViewBuilder + private var trailingTimestamp: some View { + HStack(spacing: 2) { + + Text(currentProgressHandler.seconds.timeLabel) + + Text("/") + + Text((viewModel.item.runTimeSeconds - currentProgressHandler.seconds).timeLabel) + } + .foregroundColor(Color(UIColor.lightText)) + } + + var body: some View { + HStack { + leadingTimestamp + + Spacer() + + if isScrubbing && showCurrentTimeWhileScrubbing { + trailingTimestamp + } + } + .monospacedDigit() + .font(.caption) + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/Timestamp/SplitTimestamp.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/Timestamp/SplitTimestamp.swift new file mode 100644 index 00000000..ed38ffe5 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/Timestamp/SplitTimestamp.swift @@ -0,0 +1,91 @@ +// +// 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 SwiftUI + +extension VideoPlayer.Overlay { + + struct SplitTimeStamp: View { + + @Default(.VideoPlayer.Overlay.showCurrentTimeWhileScrubbing) + private var showCurrentTimeWhileScrubbing + @Default(.VideoPlayer.Overlay.trailingTimestampType) + private var trailingTimestampType + + @EnvironmentObject + private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @Environment(\.isScrubbing) + @Binding + private var isScrubbing: Bool + + @ViewBuilder + private var leadingTimestamp: some View { + HStack(spacing: 2) { + + Text(currentProgressHandler.scrubbedSeconds.timeLabel) + .foregroundColor(.white) + + if isScrubbing && showCurrentTimeWhileScrubbing { + Text("/") + .foregroundColor(Color(UIColor.lightText)) + + Text(currentProgressHandler.seconds.timeLabel) + .foregroundColor(Color(UIColor.lightText)) + } + } + } + + @ViewBuilder + private var trailingTimestamp: some View { + HStack(spacing: 2) { + if isScrubbing && showCurrentTimeWhileScrubbing { + Text((viewModel.item.runTimeSeconds - currentProgressHandler.seconds).timeLabel.prepending("-")) + .foregroundColor(Color(UIColor.lightText)) + + Text("/") + .foregroundColor(Color(UIColor.lightText)) + } + + switch trailingTimestampType { + case .timeLeft: + Text((viewModel.item.runTimeSeconds - currentProgressHandler.scrubbedSeconds).timeLabel.prepending("-")) + .foregroundColor(.white) + case .totalTime: + Text(viewModel.item.runTimeSeconds.timeLabel) + .foregroundColor(.white) + } + } + } + + var body: some View { + Button { + switch trailingTimestampType { + case .timeLeft: + trailingTimestampType = .totalTime + case .totalTime: + trailingTimestampType = .timeLeft + } + } label: { + HStack { + leadingTimestamp + + Spacer() + + trailingTimestamp + } + .monospacedDigit() + .font(.caption) + .lineLimit(1) + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/TopBarView.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/TopBarView.swift new file mode 100644 index 00000000..8c61e8c6 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/TopBarView.swift @@ -0,0 +1,68 @@ +// +// 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 +import VLCUI + +extension VideoPlayer.Overlay { + + struct TopBarView: View { + + @EnvironmentObject + private var router: VideoPlayerCoordinator.Router + @EnvironmentObject + private var splitContentViewProxy: SplitContentViewProxy + @EnvironmentObject + private var videoPlayerProxy: VLCVideoPlayer.Proxy + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + var body: some View { + VStack(alignment: .VideoPlayerTitleAlignmentGuide, spacing: 0) { + HStack(alignment: .center) { + Button { + videoPlayerProxy.stop() + router.dismissCoordinator { + AppDelegate.changeOrientation(.portrait) + } + } label: { + Image(systemName: "xmark") + .padding() + } + + Text(viewModel.item.displayTitle) + .font(.title3) + .fontWeight(.bold) + .lineLimit(1) + .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in + dimensions[.leading] + } + + Spacer() + + VideoPlayer.Overlay.BarActionButtons() + } + .font(.system(size: 24)) + .tint(Color.white) + .foregroundColor(Color.white) + + if let subtitle = viewModel.item.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundColor(.white) + .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in + dimensions[.leading] + } + .offset(y: -10) + } + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/MainOverlay.swift b/Swiftfin/Views/VideoPlayer/Overlays/MainOverlay.swift new file mode 100644 index 00000000..02360f81 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/MainOverlay.swift @@ -0,0 +1,127 @@ +// +// 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 SwiftUI + +extension VideoPlayer { + + struct MainOverlay: View { + + @Default(.VideoPlayer.Overlay.playbackButtonType) + private var playbackButtonType + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + @Environment(\.isScrubbing) + @Binding + private var isScrubbing: Bool + @Environment(\.safeAreaInsets) + private var safeAreaInsets + + @EnvironmentObject + private var splitContentViewProxy: SplitContentViewProxy + + @StateObject + private var overlayTimer: TimerProxy = .init() + + var body: some View { + ZStack { + VStack { + Overlay.TopBarView() + .if(UIDevice.hasNotch) { view in + view.padding(safeAreaInsets.mutating(\.trailing, with: 0)) + .padding(.trailing, splitContentViewProxy.isPresentingSplitView ? 0 : safeAreaInsets.trailing) + } + .if(UIDevice.isIPad) { view in + view.padding(.top) + .padding2(.horizontal) + } + .background { + LinearGradient( + stops: [ + .init(color: .black.opacity(0.9), location: 0), + .init(color: .clear, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .visible(playbackButtonType == .compact) + } + .visible(!isScrubbing && isPresentingOverlay) + + Spacer() + .allowsHitTesting(false) + + Overlay.BottomBarView() + .if(UIDevice.hasNotch) { view in + view.padding(safeAreaInsets.mutating(\.trailing, with: 0)) + .padding(.trailing, splitContentViewProxy.isPresentingSplitView ? 0 : safeAreaInsets.trailing) + } + .if(UIDevice.isIPad) { view in + view.padding2(.bottom) + .padding2(.horizontal) + } + .background { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black.opacity(0.5), location: 0.5), + .init(color: .black.opacity(0.5), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .visible(isScrubbing || playbackButtonType == .compact) + } + .background { + Color.clear + .allowsHitTesting(true) + .contentShape(Rectangle()) + .allowsHitTesting(true) + } + .visible(isScrubbing || isPresentingOverlay) + } + + if playbackButtonType == .large { + Overlay.LargePlaybackButtons() + .visible(!isScrubbing && isPresentingOverlay) + } + } + .environmentObject(overlayTimer) + .background { + Color.black + .opacity(!isScrubbing && playbackButtonType == .large && isPresentingOverlay ? 0.5 : 0) + .allowsHitTesting(false) + } + .animation(.linear(duration: 0.1), value: isScrubbing) + .onChange(of: isPresentingOverlay) { newValue in + guard newValue, !isScrubbing else { return } + overlayTimer.start(5) + } + .onChange(of: isScrubbing) { newValue in + if newValue { + overlayTimer.stop() + } else { + overlayTimer.start(5) + } + } + .onChange(of: overlayTimer.isActive) { newValue in + guard !newValue, !isScrubbing else { return } + + withAnimation(.linear(duration: 0.3)) { + isPresentingOverlay = false + } + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Overlay.swift b/Swiftfin/Views/VideoPlayer/Overlays/Overlay.swift new file mode 100644 index 00000000..f6deaf83 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/Overlays/Overlay.swift @@ -0,0 +1,39 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2023 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension VideoPlayer { + + struct Overlay: View { + + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + + @State + private var currentOverlayType: VideoPlayer.OverlayType = .main + + var body: some View { + ZStack { + + MainOverlay() + .visible(currentOverlayType == .main) + + ChapterOverlay() + .visible(currentOverlayType == .chapters) + } + .animation(.linear(duration: 0.1), value: currentOverlayType) + .environment(\.currentOverlayType, $currentOverlayType) + .onChange(of: isPresentingOverlay) { newValue in + guard newValue else { return } + currentOverlayType = .main + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerChapterOverlayView.swift b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerChapterOverlayView.swift deleted file mode 100644 index 4a12f772..00000000 --- a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerChapterOverlayView.swift +++ /dev/null @@ -1,105 +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 - -struct VLCPlayerChapterOverlayView: View { - - @ObservedObject - var viewModel: VideoPlayerViewModel - private let chapterImages: [URL] - - init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel - self.chapterImages = viewModel.item.getChapterImage(maxWidth: 500) - } - - @ViewBuilder - private var mainBody: some View { - ZStack(alignment: .bottom) { - - LinearGradient( - gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), - startPoint: .top, - endPoint: .bottom - ) - .ignoresSafeArea() - .frame(height: 300) - - VStack { - Spacer() - - VStack(alignment: .leading, spacing: 0) { - - L10n.chapters.text - .font(.title3) - .fontWeight(.bold) - .padding(.leading) - - ScrollView(.horizontal, showsIndicators: false) { - ScrollViewReader { reader in - HStack { - ForEach(0 ..< viewModel.chapters.count) { chapterIndex in - VStack(alignment: .leading) { - Button { - viewModel.playerOverlayDelegate?.didSelectChapter(viewModel.chapters[chapterIndex]) - } label: { - ImageView(chapterImages[chapterIndex]) - .cornerRadius(10) - .frame(width: 150, height: 100) - .overlay { - if viewModel.chapters[chapterIndex] == viewModel.currentChapter { - RoundedRectangle(cornerRadius: 6) - .stroke(Color.jellyfinPurple, lineWidth: 4) - } - } - } - - VStack(alignment: .leading, spacing: 5) { - - Text(viewModel.chapters[chapterIndex].name ?? L10n.noTitle) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.white) - - Text(viewModel.chapters[chapterIndex].timestampLabel) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(Color(UIColor.systemBlue)) - .padding(.vertical, 2) - .padding(.horizontal, 4) - .background { - Color(UIColor.darkGray).opacity(0.2).cornerRadius(4) - } - } - } - .id(viewModel.chapters[chapterIndex]) - } - } - .padding(.top) - .onAppear { - reader.scrollTo(viewModel.currentChapter) - } - } - } - } - .padding(.bottom) - } - } - } - - var body: some View { - mainBody - .edgesIgnoringSafeArea(.bottom) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.playerOverlayDelegate?.didSelectChapters() - } - } -} diff --git a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift deleted file mode 100644 index ea29459b..00000000 --- a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift +++ /dev/null @@ -1,528 +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 MobileVLCKit -import Sliders -import SwiftUI - -struct VLCPlayerOverlayView: View { - @ObservedObject - var viewModel: VideoPlayerViewModel - - @ViewBuilder - private var mainButtonView: some View { - if viewModel.overlayType == .normal { - switch viewModel.playerState { - case .stopped, .paused: - Image(systemName: "play.fill") - .font(.system(size: 56, weight: .semibold, design: .default)) - case .playing: - Image(systemName: "pause") - .font(.system(size: 56, weight: .semibold, design: .default)) - default: - ProgressView() - .scaleEffect(2) - } - } else if viewModel.overlayType == .compact { - switch viewModel.playerState { - case .stopped, .paused: - Image(systemName: "play.fill") - .font(.system(size: 28, weight: .heavy, design: .default)) - case .playing: - Image(systemName: "pause") - .font(.system(size: 28, weight: .heavy, design: .default)) - default: - ProgressView() - } - } - } - - @ViewBuilder - private var mainBody: some View { - VStack { - // MARK: Top Bar - - ZStack(alignment: .top) { - if viewModel.overlayType == .compact { - LinearGradient( - gradient: Gradient(colors: [.black.opacity(0.8), .clear]), - startPoint: .top, - endPoint: .bottom - ) - .ignoresSafeArea() - .frame(height: 70) - } - - VStack(alignment: .EpisodeSeriesAlignmentGuide) { - HStack(alignment: .center) { - HStack { - Button { - viewModel.playerOverlayDelegate?.didSelectClose() - } label: { - Image(systemName: "chevron.backward") - .padding() - .padding(.trailing, -10) - } - - Text(viewModel.title) - .font(.title3) - .fontWeight(.bold) - .lineLimit(1) - .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in - context[.leading] - } - } - - Spacer() - - HStack(spacing: 20) { - // MARK: Previous Item - - if viewModel.shouldShowPlayPreviousItem { - Button { - viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() - } label: { - Image(systemName: "chevron.left.circle") - } - .disabled(viewModel.previousItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } - - // MARK: Next Item - - if viewModel.shouldShowPlayNextItem { - Button { - viewModel.playerOverlayDelegate?.didSelectPlayNextItem() - } label: { - Image(systemName: "chevron.right.circle") - } - .disabled(viewModel.nextItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } - - // MARK: Autoplay - - if viewModel.shouldShowAutoPlay { - Button { - viewModel.autoplayEnabled.toggle() - } label: { - if viewModel.autoplayEnabled { - Image(systemName: "play.circle.fill") - } else { - Image(systemName: "stop.circle") - } - } - } - - // MARK: Subtitle Toggle - - if !viewModel.subtitleStreams.isEmpty { - Button { - viewModel.subtitlesEnabled.toggle() - } label: { - if viewModel.subtitlesEnabled { - Image(systemName: "captions.bubble.fill") - } else { - Image(systemName: "captions.bubble") - } - } - .disabled(viewModel.selectedSubtitleStreamIndex == -1) - .foregroundColor(viewModel.selectedSubtitleStreamIndex == -1 ? .gray : .white) - } - - // MARK: Screen Fill - - Button { - viewModel.playerOverlayDelegate?.didSelectScreenFill() - } label: { - if viewModel.playerOverlayDelegate?.getScreenFilled() ?? true { - if viewModel.playerOverlayDelegate?.isVideoAspectRatioGreater() ?? true { - Image(systemName: "rectangle.arrowtriangle.2.inward") - } else { - Image(systemName: "rectangle.portrait.arrowtriangle.2.inward") - } - } else { - if viewModel.playerOverlayDelegate?.isVideoAspectRatioGreater() ?? true { - Image(systemName: "rectangle.arrowtriangle.2.outward") - } else { - Image(systemName: "rectangle.portrait.arrowtriangle.2.outward") - } - } - } - - // MARK: Settings Menu - - Menu { - // MARK: Audio Streams - - Menu { - ForEach(viewModel.audioStreams, id: \.self) { audioStream in - Button { - viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 - } label: { - if audioStream.index == viewModel.selectedAudioStreamIndex { - Label(audioStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") - } else { - Text(audioStream.displayTitle ?? L10n.noTitle) - } - } - } - } label: { - HStack { - Image(systemName: "speaker.wave.3") - L10n.audio.text - } - } - - // MARK: Subtitle Streams - - Menu { - ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in - Button { - viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 - } label: { - if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { - Label(subtitleStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") - } else { - Text(subtitleStream.displayTitle ?? L10n.noTitle) - } - } - } - } label: { - HStack { - Image(systemName: "captions.bubble") - L10n.subtitles.text - } - } - - // MARK: Playback Speed - - Menu { - ForEach(PlaybackSpeed.allCases, id: \.self) { speed in - Button { - viewModel.playbackSpeed = speed - } label: { - if speed == viewModel.playbackSpeed { - Label(speed.displayTitle, systemImage: "checkmark") - } else { - Text(speed.displayTitle) - } - } - } - } label: { - HStack { - Image(systemName: "speedometer") - L10n.playbackSpeed.text - } - } - - // MARK: Chapters - - if !viewModel.chapters.isEmpty { - Button { - viewModel.playerOverlayDelegate?.didSelectChapters() - } label: { - HStack { - Image(systemName: "list.dash") - L10n.chapters.text - } - } - } - - // MARK: Jump Button Lengths - - if viewModel.shouldShowJumpButtonsInOverlayMenu { - Menu { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { forwardLength in - Button { - viewModel.jumpForwardLength = forwardLength - } label: { - if forwardLength == viewModel.jumpForwardLength { - Label(forwardLength.shortLabel, systemImage: "checkmark") - } else { - Text(forwardLength.shortLabel) - } - } - } - } label: { - HStack { - Image(systemName: "goforward") - L10n.jumpForwardLength.text - } - } - - Menu { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { backwardLength in - Button { - viewModel.jumpBackwardLength = backwardLength - } label: { - if backwardLength == viewModel.jumpBackwardLength { - Label(backwardLength.shortLabel, systemImage: "checkmark") - } else { - Text(backwardLength.shortLabel) - } - } - } - } label: { - HStack { - Image(systemName: "gobackward") - L10n.jumpBackwardLength.text - } - } - } - } label: { - Image(systemName: "ellipsis.circle") - } - } - } - .font(.system(size: 24)) - .frame(height: 50) - - if let seriesTitle = viewModel.subtitle { - Text(seriesTitle) - .font(.subheadline) - .foregroundColor(Color.gray) - .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in - context[.leading] - } - .offset(y: -18) - } - } - .padding(.horizontal, UIDevice.current.userInterfaceIdiom == .pad ? 30 : 0) - } - - // MARK: Center - - Spacer() - - if viewModel.overlayType == .normal { - HStack(spacing: 80) { - Button { - viewModel.playerOverlayDelegate?.didSelectBackward() - } label: { - Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel) - } - - Button { - viewModel.playerOverlayDelegate?.didSelectMain() - } label: { - mainButtonView - } - .frame(width: 200) - - Button { - viewModel.playerOverlayDelegate?.didSelectForward() - } label: { - Image(systemName: viewModel.jumpForwardLength.forwardImageLabel) - } - } - .font(.system(size: 48)) - .opacity(viewModel.isHiddenCenterViews ? 0 : 1) - } - - Spacer() - - // MARK: Bottom Bar - - ZStack(alignment: .center) { - if viewModel.overlayType == .compact { - LinearGradient( - gradient: Gradient(colors: [.clear, .black.opacity(0.8)]), - startPoint: .top, - endPoint: .bottom - ) - .ignoresSafeArea() - .frame(height: 70) - } - - VStack(alignment: .leading, spacing: 0) { - if viewModel.shouldShowChaptersInfoInBottomOverlay, - let currentChapter = viewModel.currentChapter - { - Button { - viewModel.playerOverlayDelegate?.didSelectChapters() - } label: { - HStack { - Text(currentChapter.name ?? .emptyDash) - Image(systemName: "chevron.right") - } - .font(.system(size: 16, weight: .semibold, design: .default)) - } - } - - HStack { - if viewModel.overlayType == .compact { - HStack { - Button { - viewModel.playerOverlayDelegate?.didSelectBackward() - } label: { - Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel) - .padding(.horizontal, 5) - } - - Button { - viewModel.playerOverlayDelegate?.didSelectMain() - } label: { - mainButtonView - .frame(minWidth: 30, maxWidth: 30) - .padding(.horizontal, 10) - } - - Button { - viewModel.playerOverlayDelegate?.didSelectForward() - } label: { - Image(systemName: viewModel.jumpForwardLength.forwardImageLabel) - .padding(.horizontal, 5) - } - } - .font(.system(size: 24, weight: .semibold, design: .default)) - } - - Text(viewModel.leftLabelText) - .font(.system(size: 18, weight: .semibold, design: .default)) - .frame(minWidth: 70, maxWidth: 70) - .accessibilityLabel(L10n.currentPosition) - .accessibilityValue(viewModel.leftLabelText) - - ValueSlider(value: $viewModel.sliderPercentage, onEditingChanged: { editing in - viewModel.sliderIsScrubbing = editing - }) - .valueSliderStyle(HorizontalValueSliderStyle( - track: - GeometryReader { proxy in - ZStack(alignment: .leading) { - HorizontalValueTrack( - view: - Capsule().foregroundColor(.purple) - ) - .background(Capsule().foregroundColor(Color.gray.opacity(0.75))) - - if viewModel.shouldShowChaptersInfoInBottomOverlay { - // Chapters seek masks - ForEach(viewModel.chapters, id: \.startPositionTicks) { chapter in - let ticksRatio = CGFloat(chapter.startPositionTicks ?? 0) / - CGFloat(viewModel.item.runTimeTicks ?? 0) - let x = proxy.size.width * ticksRatio - if x != 0 { - Rectangle() - .blendMode(.destinationOut) - .offset(x: x - 1.5) - .frame(width: 3) - } - } - } - } - .compositingGroup() - } - .frame(height: 4), - thumb: Circle().foregroundColor(.purple), - thumbSize: CGSize.Circle(radius: viewModel.sliderIsScrubbing ? 20 : 15), - thumbInteractiveSize: CGSize.Circle(radius: 40), - options: .defaultOptions - )) - .frame(maxHeight: 50) - - Text(viewModel.rightLabelText) - .font(.system(size: 18, weight: .semibold, design: .default)) - .frame(minWidth: 70, maxWidth: 70) - .accessibilityLabel(L10n.remainingTime) - .accessibilityValue(viewModel.rightLabelText) - } - } - .padding(.horizontal, UIDevice.current.userInterfaceIdiom == .pad ? 30 : 0) - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 10 : 0) - } - } - .tint(Color.white) - .foregroundColor(Color.white) - } - - @ViewBuilder - var contents: some View { - if viewModel.overlayType == .normal { - mainBody - .contentShape(Rectangle()) - .background { - Color(uiColor: .black.withAlphaComponent(0.5)) - .ignoresSafeArea() - } - } else { - mainBody - .contentShape(Rectangle()) - } - } - - var body: some View { - contents - .onLongPressGesture { - guard viewModel.playerGesturesLockGestureEnabled else { return } - viewModel.playerOverlayDelegate?.didGenerallyTap(point: nil) - viewModel.playerOverlayDelegate?.didLongPress() - } - .gesture( - DragGesture(minimumDistance: 0) - .onEnded { value in - viewModel.playerOverlayDelegate?.didGenerallyTap(point: value.location) - } - ) - .opacity(viewModel.isHiddenOverlay ? 0 : 1) - } -} - -struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { - static let videoPlayerViewModel = VideoPlayerViewModel( - item: BaseItemDto(), - title: "Glorious Purpose", - subtitle: "Loki - S1E1", - directStreamURL: URL(string: "www.apple.com")!, - transcodedStreamURL: nil, - hlsStreamURL: URL(string: "www.apple.com")!, - streamType: .direct, - response: PlaybackInfoResponse(), - videoStream: MediaStream(), - audioStreams: [MediaStream(displayTitle: "English", index: -1)], - subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], - chapters: [], - selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1, - subtitlesEnabled: true, - autoplayEnabled: false, - overlayType: .compact, - shouldShowPlayPreviousItem: true, - shouldShowPlayNextItem: true, - shouldShowAutoPlay: true, - container: "", - filename: nil, - versionName: nil - ) - - static var previews: some View { - ZStack { - Color.red - .ignoresSafeArea() - - VLCPlayerOverlayView(viewModel: videoPlayerViewModel) - } - .previewInterfaceOrientation(.landscapeLeft) - } -} - -// MARK: TitleSubtitleAlignment - -extension HorizontalAlignment { - private struct TitleSubtitleAlignment: AlignmentID { - static func defaultValue(in context: ViewDimensions) -> CGFloat { - context[HorizontalAlignment.leading] - } - } - - static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(TitleSubtitleAlignment.self) -} diff --git a/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift b/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift deleted file mode 100644 index b2f99170..00000000 --- a/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.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 Foundation -import JellyfinAPI -import UIKit - -protocol PlayerOverlayDelegate { - - func didSelectClose() - func didSelectMenu() - func didDeselectMenu() - - func didSelectBackward() - func didSelectForward() - func didSelectMain() - - func didGenerallyTap(point: CGPoint?) - func didLongPress() - - func didBeginScrubbing() - func didEndScrubbing() - - func didSelectAudioStream(index: Int) - func didSelectSubtitleStream(index: Int) - - func didSelectPlayPreviousItem() - func didSelectPlayNextItem() - - func didSelectChapters() - func didSelectChapter(_ chapter: ChapterInfo) - - func didSelectScreenFill() - func getScreenFilled() -> Bool - // Returns whether the aspect ratio of the video - // is greater than the aspect ratio of the screen - func isVideoAspectRatioGreater() -> Bool -} diff --git a/Swiftfin/Views/VideoPlayer/VLCPlayerView.swift b/Swiftfin/Views/VideoPlayer/VLCPlayerView.swift deleted file mode 100644 index 7d3a2cd1..00000000 --- a/Swiftfin/Views/VideoPlayer/VLCPlayerView.swift +++ /dev/null @@ -1,38 +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 -import UIKit - -struct NativePlayerView: UIViewControllerRepresentable { - - let viewModel: VideoPlayerViewModel - - typealias UIViewControllerType = NativePlayerViewController - - func makeUIViewController(context: Context) -> NativePlayerViewController { - - NativePlayerViewController(viewModel: viewModel) - } - - func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {} -} - -struct VLCPlayerView: UIViewControllerRepresentable { - - let viewModel: VideoPlayerViewModel - - typealias UIViewControllerType = VLCPlayerViewController - - func makeUIViewController(context: Context) -> VLCPlayerViewController { - - VLCPlayerViewController(viewModel: viewModel) - } - - func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) {} -} diff --git a/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift b/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift deleted file mode 100644 index 8721f522..00000000 --- a/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift +++ /dev/null @@ -1,1264 +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 AVFoundation -import AVKit -import Combine -import Defaults -import Factory -import JellyfinAPI -import MediaPlayer -import MobileVLCKit -import SwiftUI -import UIKit - -// TODO: Look at making the VLC player layer a view - -class VLCPlayerViewController: UIViewController { - - @Injected(LogManager.service) - private var logger - - // MARK: variables - - private var viewModel: VideoPlayerViewModel - private var vlcMediaPlayer: VLCMediaPlayer - private var lastPlayerTicks: Int64 = 0 - private var lastProgressReportTicks: Int64 = 0 - private var viewModelListeners = Set() - private var overlayDismissTimer: Timer? - private var isScreenFilled: Bool = false - private var isGesturesLocked = false - private var pinchScale: CGFloat = 1 - - private var currentPlayerTicks: Int64 { - Int64(vlcMediaPlayer.time.intValue) * 100_000 - } - - private var displayingOverlay: Bool { - currentOverlayHostingController?.view.alpha ?? 0 > 0 - } - - private var displayingChapterOverlay: Bool { - currentChapterOverlayHostingController?.view.alpha ?? 0 > 0 - } - - private var panBeganBrightness = CGFloat.zero - private var panBeganVolumeValue = Float.zero - private var panBeganSliderPercentage: Double = 0 - private var panBeganPoint = CGPoint.zero - private var tapLocationStack = [CGPoint]() - private var isJumping = false - private var jumpingCompletionWork: DispatchWorkItem? - private var isTapWhenJumping = false - - private lazy var videoContentView = makeVideoContentView() - private lazy var mainGestureView = makeMainGestureView() - private lazy var systemControlOverlayLabel = makeSystemControlOverlayLabel() - private lazy var lockedOverlayView = makeGestureLockedOverlayView() - private var currentOverlayHostingController: UIHostingController? - private var currentChapterOverlayHostingController: UIHostingController? - private var currentJumpBackwardOverlayView: UIImageView? - private var currentJumpForwardOverlayView: UIImageView? - private var volumeView = MPVolumeView() - - override var keyCommands: [UIKeyCommand]? { - var commands = [ - UIKeyCommand(title: L10n.playAndPause, action: #selector(didSelectMain), input: " "), - UIKeyCommand(title: L10n.jumpForward, action: #selector(didSelectForward), input: UIKeyCommand.inputRightArrow), - UIKeyCommand(title: L10n.jumpBackward, action: #selector(didSelectBackward), input: UIKeyCommand.inputLeftArrow), - UIKeyCommand( - title: L10n.nextItem, - action: #selector(didSelectPlayNextItem), - input: UIKeyCommand.inputRightArrow, - modifierFlags: .command - ), - UIKeyCommand( - title: L10n.previousItem, - action: #selector(didSelectPlayPreviousItem), - input: UIKeyCommand.inputLeftArrow, - modifierFlags: .command - ), - UIKeyCommand(title: L10n.close, action: #selector(didSelectClose), input: UIKeyCommand.inputEscape), - ] - if let previous = viewModel.playbackSpeed.previous { - commands.append(.init( - title: "\(L10n.playbackSpeed) \(previous.displayTitle)", - action: #selector(didSelectPreviousPlaybackSpeed), - input: "[", - modifierFlags: .command - )) - } - if let next = viewModel.playbackSpeed.next { - commands.append(.init( - title: "\(L10n.playbackSpeed) \(next.displayTitle)", - action: #selector(didSelectNextPlaybackSpeed), - input: "]", - modifierFlags: .command - )) - } - if viewModel.playbackSpeed != .one { - commands.append(.init( - title: "\(L10n.playbackSpeed) \(PlaybackSpeed.one.displayTitle)", - action: #selector(didSelectNormalPlaybackSpeed), - input: "\\", - modifierFlags: .command - )) - } - commands.forEach { $0.wantsPriorityOverSystemBehavior = true } - return commands - } - - // MARK: init - - init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel - self.vlcMediaPlayer = VLCMediaPlayer() - - super.init(nibName: nil, bundle: nil) - - viewModel.playerOverlayDelegate = self - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupSubviews() { - view.addSubview(videoContentView) - view.addSubview(mainGestureView) - view.addSubview(systemControlOverlayLabel) - view.addSubview(lockedOverlayView) - } - - private func setupConstraints() { - NSLayoutConstraint.activate([ - videoContentView.topAnchor.constraint(equalTo: view.topAnchor), - videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), - videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor), - ]) - NSLayoutConstraint.activate([ - mainGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), - mainGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - mainGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - mainGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - NSLayoutConstraint.activate([ - systemControlOverlayLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), - systemControlOverlayLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - NSLayoutConstraint.activate([ - lockedOverlayView.topAnchor.constraint(equalTo: view.topAnchor), - lockedOverlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - lockedOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor), - lockedOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor), - ]) - } - - // MARK: viewWillDisappear - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - NotificationCenter.default.removeObserver(self) - } - - // MARK: viewDidLoad - - override func viewDidLoad() { - super.viewDidLoad() - - setupSubviews() - setupConstraints() - - view.backgroundColor = .black - view.accessibilityIgnoresInvertColors = true - - setupMediaPlayer(newViewModel: viewModel) - - refreshJumpBackwardOverlayView(with: viewModel.jumpBackwardLength) - refreshJumpForwardOverlayView(with: viewModel.jumpForwardLength) - - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.addObserver( - self, - selector: #selector(appWillTerminate), - name: UIApplication.willTerminateNotification, - object: nil - ) - defaultNotificationCenter.addObserver( - self, - selector: #selector(appWillResignActive), - name: UIApplication.willResignActiveNotification, - object: nil - ) - defaultNotificationCenter.addObserver( - self, - selector: #selector(appWillResignActive), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - } - - @objc - private func appWillTerminate() { - viewModel.sendStopReport() - } - - @objc - private func appWillResignActive() { - hideChaptersOverlay() - - showOverlay() - - stopOverlayDismissTimer() - - vlcMediaPlayer.pause() - - viewModel.sendPauseReport(paused: true) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - startPlayback() - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - if isScreenFilled { - fillScreen(screenSize: size) - } - super.viewWillTransition(to: size, with: coordinator) - } - - // MARK: VideoContentView - - private func makeVideoContentView() -> UIView { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .black - - return view - } - - // MARK: MainGestureView - - private func makeMainGestureView() -> UIView { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - - let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) - - let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(didPinch(_:))) - - let verticalGesture = PanDirectionGestureRecognizer(direction: .vertical, target: self, action: #selector(didVerticalPan(_:))) - let horizontalGesture = PanDirectionGestureRecognizer(direction: .horizontal, target: self, action: #selector(didHorizontalPan(_:))) - - let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(didLongPress)) - - view.addGestureRecognizer(singleTapGesture) - view.addGestureRecognizer(pinchGesture) - - if viewModel.playerGesturesLockGestureEnabled { - view.addGestureRecognizer(longPressGesture) - } - - if viewModel.systemControlGesturesEnabled { - view.addGestureRecognizer(verticalGesture) - } - - if viewModel.seekSlideGestureEnabled { - view.addGestureRecognizer(horizontalGesture) - } - - return view - } - - // MARK: SystemControlOverlayLabel - - private func makeSystemControlOverlayLabel() -> UILabel { - let label = UILabel() - label.alpha = 0 - label.translatesAutoresizingMaskIntoConstraints = false - label.font = .systemFont(ofSize: 48) - label.layer.zPosition = 1 - return label - } - - // MARK: GestureLockedOverlayView - - private func makeGestureLockedOverlayView() -> UIView { - let backgroundView = UIView() - backgroundView.layer.zPosition = 1 - backgroundView.alpha = 0 - backgroundView.translatesAutoresizingMaskIntoConstraints = false - let button = UIButton(type: .custom, primaryAction: UIAction(handler: { [weak self] _ in - self?.isGesturesLocked = false - self?.hideLockedOverlay() - self?.didGenerallyTap() - })) - button.translatesAutoresizingMaskIntoConstraints = false - button.setImage( - UIImage(systemName: "lock.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? - .withTintColor(.white), - for: .normal - ) - backgroundView.addSubview(button) - - NSLayoutConstraint.activate([ - button.centerXAnchor.constraint(equalTo: backgroundView.centerXAnchor), - button.centerYAnchor.constraint(equalTo: backgroundView.centerYAnchor), - ]) - - let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) - backgroundView.addGestureRecognizer(singleTapGesture) - - return backgroundView - } - - @objc - private func didTap(_ gestureRecognizer: UITapGestureRecognizer) { - didGenerallyTap(point: gestureRecognizer.location(in: mainGestureView)) - } - - @objc - func didLongPress() { - guard !isGesturesLocked else { return } - isGesturesLocked = true - didGenerallyTap() - } - - @objc - private func didPinch(_ gestureRecognizer: UIPinchGestureRecognizer) { - guard !isGesturesLocked else { return } - if gestureRecognizer.state == .began || gestureRecognizer.state == .changed { - pinchScale = gestureRecognizer.scale - } else { - if pinchScale > 1, !isScreenFilled { - isScreenFilled.toggle() - fillScreen() - } else if pinchScale < 1, isScreenFilled { - isScreenFilled.toggle() - shrinkScreen() - } - } - } - - @objc - private func didVerticalPan(_ gestureRecognizer: UIPanGestureRecognizer) { - guard !isGesturesLocked else { return } - switch gestureRecognizer.state { - case .began: - panBeganBrightness = UIScreen.main.brightness - if let view = volumeView.subviews.first as? UISlider { - panBeganVolumeValue = view.value - } - panBeganPoint = gestureRecognizer.location(in: mainGestureView) - case .changed: - let mainGestureViewHalfWidth = mainGestureView.frame.width * 0.5 - let mainGestureViewHalfHeight = mainGestureView.frame.height * 0.5 - - let pos = gestureRecognizer.location(in: mainGestureView) - let moveDelta = pos.y - panBeganPoint.y - let changedValue = moveDelta / mainGestureViewHalfHeight - - if panBeganPoint.x < mainGestureViewHalfWidth { - UIScreen.main.brightness = panBeganBrightness - changedValue - showBrightnessOverlay() - } else if let view = volumeView.subviews.first as? UISlider { - view.value = panBeganVolumeValue - Float(changedValue) - showVolumeOverlay() - } - default: - hideSystemControlOverlay() - } - } - - @objc - private func didHorizontalPan(_ gestureRecognizer: UIPanGestureRecognizer) { - guard !isGesturesLocked else { return } - switch gestureRecognizer.state { - case .began: - exchangeOverlayView(isBringToFrontThanGestureView: false) - panBeganPoint = gestureRecognizer.location(in: mainGestureView) - panBeganSliderPercentage = viewModel.sliderPercentage - viewModel.sliderIsScrubbing = true - case .changed: - let pos = gestureRecognizer.location(in: mainGestureView) - let moveDelta = panBeganPoint.x - pos.x - let changedValue = (moveDelta / mainGestureView.frame.width) - - viewModel.sliderPercentage = min(max(0, panBeganSliderPercentage - changedValue), 1) - showSliderOverlay() - showOverlay() - default: - viewModel.sliderIsScrubbing = false - hideOverlay() - hideSystemControlOverlay() - } - } - - // MARK: setupOverlayHostingController - - private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { - // TODO: Look at injecting viewModel into the environment so it updates the current overlay - if let currentOverlayHostingController = currentOverlayHostingController { - // UX fade-out - UIView.animate(withDuration: 0.5) { - currentOverlayHostingController.view.alpha = 0 - } completion: { _ in - currentOverlayHostingController.view.isHidden = true - - currentOverlayHostingController.view.removeFromSuperview() - currentOverlayHostingController.removeFromParent() - } - } - - let newOverlayView = VLCPlayerOverlayView(viewModel: viewModel) - let newOverlayHostingController = UIHostingController(rootView: newOverlayView) - - newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newOverlayHostingController.view.backgroundColor = UIColor.clear - - // UX fade-in - newOverlayHostingController.view.alpha = 0 - - addChild(newOverlayHostingController) - view.addSubview(newOverlayHostingController.view) - newOverlayHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - // UX fade-in - UIView.animate(withDuration: 0.5) { - newOverlayHostingController.view.alpha = 1 - } - - currentOverlayHostingController = newOverlayHostingController - - if let currentChapterOverlayHostingController = currentChapterOverlayHostingController { - UIView.animate(withDuration: 0.5) { - currentChapterOverlayHostingController.view.alpha = 0 - } completion: { _ in - currentChapterOverlayHostingController.view.isHidden = true - - currentChapterOverlayHostingController.view.removeFromSuperview() - currentChapterOverlayHostingController.removeFromParent() - } - } - - let newChapterOverlayView = VLCPlayerChapterOverlayView(viewModel: viewModel) - let newChapterOverlayHostingController = UIHostingController(rootView: newChapterOverlayView) - - newChapterOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newChapterOverlayHostingController.view.backgroundColor = UIColor.clear - - newChapterOverlayHostingController.view.alpha = 0 - - addChild(newChapterOverlayHostingController) - view.addSubview(newChapterOverlayHostingController.view) - newChapterOverlayHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newChapterOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newChapterOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newChapterOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newChapterOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - currentChapterOverlayHostingController = newChapterOverlayHostingController - - // There is a weird behavior when after setting the new overlays that the navigation bar pops up, re-hide it - navigationController?.isNavigationBarHidden = true - } - - private func refreshJumpBackwardOverlayView(with jumpBackwardLength: VideoPlayerJumpLength) { - if let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView { - currentJumpBackwardOverlayView.removeFromSuperview() - } - - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) - let backwardSymbolImage = UIImage(systemName: jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) - let newJumpBackwardImageView = UIImageView(image: backwardSymbolImage) - - newJumpBackwardImageView.translatesAutoresizingMaskIntoConstraints = false - newJumpBackwardImageView.tintColor = .white - - newJumpBackwardImageView.alpha = 0 - - view.addSubview(newJumpBackwardImageView) - - NSLayoutConstraint.activate([ - newJumpBackwardImageView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 150), - newJumpBackwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - - currentJumpBackwardOverlayView = newJumpBackwardImageView - } - - private func refreshJumpForwardOverlayView(with jumpForwardLength: VideoPlayerJumpLength) { - if let currentJumpForwardOverlayView = currentJumpForwardOverlayView { - currentJumpForwardOverlayView.removeFromSuperview() - } - - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) - let forwardSymbolImage = UIImage(systemName: jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) - let newJumpForwardImageView = UIImageView(image: forwardSymbolImage) - - newJumpForwardImageView.translatesAutoresizingMaskIntoConstraints = false - newJumpForwardImageView.tintColor = .white - - newJumpForwardImageView.alpha = 0 - - view.addSubview(newJumpForwardImageView) - - NSLayoutConstraint.activate([ - newJumpForwardImageView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -150), - newJumpForwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - - currentJumpForwardOverlayView = newJumpForwardImageView - } - - private var isOverlayViewBringToFrontThanGestureView = true - private func exchangeOverlayView(isBringToFrontThanGestureView: Bool) { - guard isBringToFrontThanGestureView != isOverlayViewBringToFrontThanGestureView, - let currentOverlayView = currentOverlayHostingController?.view, - let mainGestureViewIndex = view.subviews.firstIndex(of: mainGestureView), - let currentOVerlayViewIndex = view.subviews.firstIndex(of: currentOverlayView) else { return } - isOverlayViewBringToFrontThanGestureView = isBringToFrontThanGestureView - view.exchangeSubview( - at: mainGestureViewIndex, - withSubviewAt: currentOVerlayViewIndex - ) - } -} - -// MARK: setupMediaPlayer - -extension VLCPlayerViewController { - /// Main function that handles setting up the media player with the current VideoPlayerViewModel - /// and also takes the role of setting the 'viewModel' property with the given viewModel - /// - /// Use case for this is setting new media within the same VLCPlayerViewController - func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { - // remove old player - - if vlcMediaPlayer.media != nil { - viewModelListeners.forEach { $0.cancel() } - - vlcMediaPlayer.stop() - viewModel.sendStopReport() - viewModel.playerOverlayDelegate = nil - } - - vlcMediaPlayer = VLCMediaPlayer() - - // setup with new player and view model - - vlcMediaPlayer = VLCMediaPlayer() - - vlcMediaPlayer.delegate = self - vlcMediaPlayer.drawable = videoContentView - - vlcMediaPlayer.setSubtitleFont(fontName: Defaults[.subtitleFontName]) - vlcMediaPlayer.setSubtitleSize(Defaults[.subtitleSize]) - - stopOverlayDismissTimer() - - lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - - let media: VLCMedia - - if let transcodedURL = newViewModel.transcodedStreamURL, - !Defaults[.Experimental.forceDirectPlay] - { - media = VLCMedia(url: transcodedURL) - } else { - media = VLCMedia(url: newViewModel.directStreamURL) - } - - media.addOption("--prefetch-buffer-size=1048576") - media.addOption("--network-caching=5000") - - vlcMediaPlayer.media = media - - setupOverlayHostingController(viewModel: newViewModel) - setupViewModelListeners(viewModel: newViewModel) - - newViewModel.getAdjacentEpisodes() - newViewModel.playerOverlayDelegate = self - - let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 - - if startPercentage > 0 { - if viewModel.resumeOffset { - let runTimeTicks = viewModel.item.runTimeTicks ?? 0 - let videoDurationSeconds = Double(runTimeTicks / 10_000_000) - var startSeconds = round((startPercentage / 100) * videoDurationSeconds) - startSeconds = startSeconds.subtract(5, floor: 0) - let newStartPercentage = startSeconds / videoDurationSeconds - newViewModel.sliderPercentage = newStartPercentage - } else { - newViewModel.sliderPercentage = startPercentage / 100 - } - } - - viewModel = newViewModel - - if viewModel.streamType == .direct { - logger.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? .emptyDash)") - } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { - logger.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? .emptyDash)") - } else { - logger.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? .emptyDash)") - } - } - - // MARK: startPlayback - - func startPlayback() { - vlcMediaPlayer.play() - - // Setup external subtitles - for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { - if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { - vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) - } - } - - setMediaPlayerTimeAtCurrentSlider() - - viewModel.sendPlayReport() - - restartOverlayDismissTimer() - } - - // MARK: setupViewModelListeners - - private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { - viewModel.$playbackSpeed.sink { newSpeed in - self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) - }.store(in: &viewModelListeners) - - viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in - if sliderIsScrubbing { - self.didBeginScrubbing() - } else { - self.didEndScrubbing() - } - }.store(in: &viewModelListeners) - - viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in - self.didSelectAudioStream(index: newAudioStreamIndex) - }.store(in: &viewModelListeners) - - viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in - self.didSelectSubtitleStream(index: newSubtitleStreamIndex) - }.store(in: &viewModelListeners) - - viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in - self.didToggleSubtitles(newValue: newSubtitlesEnabled) - }.store(in: &viewModelListeners) - - viewModel.$jumpBackwardLength.sink { newJumpBackwardLength in - self.refreshJumpBackwardOverlayView(with: newJumpBackwardLength) - }.store(in: &viewModelListeners) - - viewModel.$jumpForwardLength.sink { newJumpForwardLength in - self.refreshJumpForwardOverlayView(with: newJumpForwardLength) - }.store(in: &viewModelListeners) - } - - func setMediaPlayerTimeAtCurrentSlider() { - // Necessary math as VLCMediaPlayer doesn't work well - // by just setting the position - let runTimeTicks = viewModel.item.runTimeTicks ?? 0 - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let videoDuration = Double(runTimeTicks / 10_000_000) - let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) - let newPositionOffset = secondsScrubbedTo - videoPosition - - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } - } -} - -// MARK: Show/Hide Overlay - -extension VLCPlayerViewController { - private func showOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 1 else { return } - overlayHostingController.view.alpha = 1 - - withAnimation(.easeInOut(duration: 0.2)) { [weak self] in - self?.viewModel.isHiddenOverlay = false - } - } - - private func hideOverlay() { - guard !UIAccessibility.isVoiceOverRunning else { return } - - guard let overlayHostingController = currentOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 0 else { return } - - // for gestures UX - exchangeOverlayView(isBringToFrontThanGestureView: false) - UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) { - overlayHostingController.view.alpha = 0 - } completion: { [weak self] _ in - guard let self = self else { return } - self.exchangeOverlayView(isBringToFrontThanGestureView: true) - self.viewModel.isHiddenOverlay = true - } - } - - private func toggleOverlay() { - if viewModel.isHiddenOverlay { - showOverlay() - } else { - hideOverlay() - } - } -} - -// MARK: Show/Hide Locked Overlay - -extension VLCPlayerViewController { - private func showLockedOverlay() { - guard lockedOverlayView.alpha != 1 else { return } - - UIView.animate(withDuration: 0.2) { - self.lockedOverlayView.alpha = 1 - } - } - - private func hideLockedOverlay() { - guard !UIAccessibility.isVoiceOverRunning else { return } - - guard lockedOverlayView.alpha != 0 else { return } - - UIView.animate(withDuration: 0.2) { - self.lockedOverlayView.alpha = 0 - } - } - - private func toggleLockedOverlay() { - if lockedOverlayView.alpha < 1 { - showLockedOverlay() - } else { - hideLockedOverlay() - } - } -} - -// MARK: Show/Hide System Control - -extension VLCPlayerViewController { - private func showBrightnessOverlay() { - guard !displayingOverlay else { return } - - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(systemName: "sun.max", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? - .withTintColor(.white) - - let attributedString = NSMutableAttributedString() - attributedString.append(.init(attachment: imageAttachment)) - attributedString.append(.init(string: " \(String(format: "%.0f", UIScreen.main.brightness * 100))%")) - systemControlOverlayLabel.attributedText = attributedString - systemControlOverlayLabel.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - self.systemControlOverlayLabel.alpha = 1 - } - } - - private func showVolumeOverlay() { - guard !displayingOverlay, - let value = (volumeView.subviews.first as? UISlider)?.value else { return } - - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(systemName: "speaker.wave.2", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? - .withTintColor(.white) - - let attributedString = NSMutableAttributedString() - attributedString.append(.init(attachment: imageAttachment)) - attributedString.append(.init(string: " \(String(format: "%.0f", value * 100))%")) - systemControlOverlayLabel.attributedText = attributedString - systemControlOverlayLabel.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - self.systemControlOverlayLabel.alpha = 1 - } - } - - private func showSliderOverlay() { - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage( - systemName: "clock.arrow.circlepath", - withConfiguration: UIImage.SymbolConfiguration(pointSize: 48) - )? - .withTintColor(.white) - - let attributedString = NSMutableAttributedString() - attributedString.append(.init(attachment: imageAttachment)) - attributedString.append(.init(string: " \(viewModel.scrubbingTimeLabelText) (\(viewModel.leftLabelText))")) - systemControlOverlayLabel.attributedText = attributedString - systemControlOverlayLabel.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - self.systemControlOverlayLabel.alpha = 1 - } - } - - private func hideSystemControlOverlay() { - UIView.animate(withDuration: 0.75) { - self.systemControlOverlayLabel.alpha = 0 - } - } -} - -// MARK: Show/Hide Jump - -extension VLCPlayerViewController { - private func flashJumpBackwardOverlay() { - guard !displayingOverlay, let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } - - currentJumpBackwardOverlayView.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - currentJumpBackwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpBackwardOverlay() - } - } - - private func hideJumpBackwardOverlay() { - guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } - - UIView.animate(withDuration: 0.3) { - currentJumpBackwardOverlayView.alpha = 0 - } - } - - private func flashJumpFowardOverlay() { - guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } - - currentJumpForwardOverlayView.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - currentJumpForwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpForwardOverlay() - } - } - - private func hideJumpForwardOverlay() { - guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } - - UIView.animate(withDuration: 0.3) { - currentJumpForwardOverlayView.alpha = 0 - } - } -} - -// MARK: Hide/Show Chapters - -extension VLCPlayerViewController { - private func showChaptersOverlay() { - guard let overlayHostingController = currentChapterOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 1 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 1 - } - } - - private func hideChaptersOverlay() { - guard let overlayHostingController = currentChapterOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 0 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 0 - } - } -} - -// MARK: OverlayTimer - -extension VLCPlayerViewController { - private func restartOverlayDismissTimer(interval: Double = 3) { - overlayDismissTimer?.invalidate() - overlayDismissTimer = Timer.scheduledTimer( - timeInterval: interval, - target: self, - selector: #selector(dismissTimerFired), - userInfo: nil, - repeats: false - ) - } - - @objc - private func dismissTimerFired() { - hideOverlay() - hideLockedOverlay() - } - - private func stopOverlayDismissTimer() { - overlayDismissTimer?.invalidate() - } -} - -// MARK: VLCMediaPlayerDelegate - -extension VLCPlayerViewController: VLCMediaPlayerDelegate { - // MARK: mediaPlayerStateChanged - - func mediaPlayerStateChanged(_ aNotification: Notification) { - // Don't show buffering if paused, usually here while scrubbing - if vlcMediaPlayer.state == .buffering, viewModel.playerState == .paused { - return - } - - viewModel.playerState = vlcMediaPlayer.state - - if vlcMediaPlayer.state == VLCMediaPlayerState.ended { - if viewModel.autoplayEnabled, viewModel.nextItemVideoPlayerViewModel != nil { - didSelectPlayNextItem() - } else { - didSelectClose() - } - } - } - - // MARK: mediaPlayerTimeChanged - - func mediaPlayerTimeChanged(_ aNotification: Notification) { - if !viewModel.sliderIsScrubbing { - viewModel.sliderPercentage = Double(vlcMediaPlayer.position) - } - - // Have to manually set playing because VLCMediaPlayer doesn't - // properly set it itself - if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { - viewModel.playerState = VLCMediaPlayerState.playing - } - - // If needing to fix subtitle streams during playback - if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.videoSubtitleStreamIndex(of: viewModel.selectedSubtitleStreamIndex), - viewModel.subtitlesEnabled - { - didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) - } - - // If needing to fix audio stream during playback - if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { - didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) - } - - lastPlayerTicks = currentPlayerTicks - - // Send progress report every 5 seconds - if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - } -} - -// MARK: PlayerOverlayDelegate and more - -extension VLCPlayerViewController: PlayerOverlayDelegate { - func didSelectAudioStream(index: Int) { - vlcMediaPlayer.currentAudioTrackIndex = Int32(index) - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - /// Do not call when setting to index -1 - func didSelectSubtitleStream(index: Int) { - viewModel.subtitlesEnabled = true - vlcMediaPlayer.currentVideoSubTitleIndex = viewModel.videoSubtitleStreamIndex(of: index) - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - @objc - func didSelectClose() { - vlcMediaPlayer.stop() - - viewModel.sendStopReport() - - dismiss(animated: true, completion: nil) - } - - func didToggleSubtitles(newValue: Bool) { - if newValue { - vlcMediaPlayer.currentVideoSubTitleIndex = viewModel.videoSubtitleStreamIndex(of: viewModel.selectedSubtitleStreamIndex) - } else { - vlcMediaPlayer.currentVideoSubTitleIndex = -1 - } - } - - // TODO: Implement properly in overlays - func didSelectMenu() { - stopOverlayDismissTimer() - } - - // TODO: Implement properly in overlays - func didDeselectMenu() { - restartOverlayDismissTimer() - } - - @objc - func didSelectBackward() { - flashJumpBackwardOverlay() - - vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) - - if displayingOverlay { - restartOverlayDismissTimer() - } - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - @objc - func didSelectForward() { - flashJumpFowardOverlay() - - vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) - - if displayingOverlay { - restartOverlayDismissTimer() - } - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - @objc - func didSelectMain() { - switch viewModel.playerState { - case .buffering: - vlcMediaPlayer.play() - restartOverlayDismissTimer() - case .playing: - viewModel.sendPauseReport(paused: true) - vlcMediaPlayer.pause() - restartOverlayDismissTimer(interval: 5) - case .paused: - viewModel.sendPauseReport(paused: false) - vlcMediaPlayer.play() - restartOverlayDismissTimer() - default: () - } - } - - func didGenerallyTap(point: CGPoint? = nil) { - if isGesturesLocked { - toggleLockedOverlay() - } else { - if viewModel.jumpGesturesEnabled, - let point = point - { - let tempStack = tapLocationStack - tapLocationStack.append(point) - - if isSameLocationWithLast(point: point, in: tempStack) { - isTapWhenJumping = false - isJumping = true - tapLocationStack.removeAll() - jumpingCompletionWork?.cancel() - jumpingCompletionWork = DispatchWorkItem(block: { [weak self] in - guard let self = self else { return } - self.isJumping = false - guard self.isTapWhenJumping else { return } - self.isTapWhenJumping = false - self.toggleOverlay() - }) - DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: jumpingCompletionWork!) - - hideOverlay() - if point.x > (mainGestureView.frame.width / 2) { - didSelectForward() - } else { - didSelectBackward() - } - return - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) { [weak self] in - guard let self = self else { return } - guard !self.tapLocationStack.isEmpty else { return } - self.tapLocationStack.removeFirst() - } - } - } - guard !isJumping else { - isTapWhenJumping = true - return - } - - toggleOverlay() - } - - restartOverlayDismissTimer(interval: 5) - } - - private func isSameLocationWithLast(point: CGPoint, in stack: [CGPoint]) -> Bool { - guard let last = stack.last else { return false } - if last.x > (mainGestureView.frame.width / 2) { - return point.x > (mainGestureView.frame.width / 2) - } else { - return point.x <= (mainGestureView.frame.width / 2) - } - } - - func didBeginScrubbing() { - stopOverlayDismissTimer() - } - - func didEndScrubbing() { - setMediaPlayerTimeAtCurrentSlider() - - restartOverlayDismissTimer() - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - @objc - func didSelectPlayPreviousItem() { - if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) - startPlayback() - } - } - - @objc - func didSelectPlayNextItem() { - if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) - startPlayback() - } - } - - @objc - func didSelectPreviousPlaybackSpeed() { - if let previousPlaybackSpeed = viewModel.playbackSpeed.previous { - viewModel.playbackSpeed = previousPlaybackSpeed - } - } - - @objc - func didSelectNextPlaybackSpeed() { - if let nextPlaybackSpeed = viewModel.playbackSpeed.next { - viewModel.playbackSpeed = nextPlaybackSpeed - } - } - - @objc - func didSelectNormalPlaybackSpeed() { - viewModel.playbackSpeed = .one - } - - func didSelectChapters() { - if displayingChapterOverlay { - hideChaptersOverlay() - } else { - hideOverlay() - showChaptersOverlay() - } - } - - func didSelectChapter(_ chapter: ChapterInfo) { - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let chapterSeconds = Double((chapter.startPositionTicks ?? 0) / 10_000_000) - let newPositionOffset = chapterSeconds - videoPosition - - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } - - viewModel.sendProgressReport() - } - - func didSelectScreenFill() { - isScreenFilled.toggle() - - if isScreenFilled { - fillScreen() - } else { - shrinkScreen() - } - } - - private func fillScreen(screenSize: CGSize = UIScreen.main.bounds.size) { - let videoSize = vlcMediaPlayer.videoSize - let fillSize = CGSize.aspectFill(aspectRatio: videoSize, minimumSize: screenSize) - - let scale: CGFloat - - if fillSize.height > screenSize.height { - scale = fillSize.height / screenSize.height - } else { - scale = fillSize.width / screenSize.width - } - - UIView.animate(withDuration: 0.2) { - self.videoContentView.transform = CGAffineTransform(scaleX: scale, y: scale) - } - } - - private func shrinkScreen() { - UIView.animate(withDuration: 0.2) { - self.videoContentView.transform = .identity - } - } - - func getScreenFilled() -> Bool { - isScreenFilled - } - - func isVideoAspectRatioGreater() -> Bool { - let screenSize = UIScreen.main.bounds.size - let videoSize = vlcMediaPlayer.videoSize - - let screenAspectRatio = screenSize.width / screenSize.height - let videoAspectRatio = videoSize.width / videoSize.height - - return videoAspectRatio > screenAspectRatio - } -} diff --git a/Swiftfin/Views/VideoPlayer/VideoPlayer+Actions.swift b/Swiftfin/Views/VideoPlayer/VideoPlayer+Actions.swift new file mode 100644 index 00000000..98c5f9df --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/VideoPlayer+Actions.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 + +extension VideoPlayer { + + enum Action { + + // MARK: Aspect Fill + + func aspectFill( + state: UIGestureRecognizer.State, + unitPoint: UnitPoint, + scale: CGFloat + ) {} + } +} diff --git a/Swiftfin/Views/VideoPlayer/VideoPlayer+KeyCommands.swift b/Swiftfin/Views/VideoPlayer/VideoPlayer+KeyCommands.swift new file mode 100644 index 00000000..97a30b4d --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/VideoPlayer+KeyCommands.swift @@ -0,0 +1,235 @@ +// +// 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 View { + + func videoPlayerKeyCommands( + gestureStateHandler: VideoPlayer.GestureStateHandler, + videoPlayerManager: VideoPlayerManager, + updateViewProxy: UpdateViewProxy + ) -> some View { + self + .addingKeyCommand( + title: L10n.playAndPause, + input: " " + ) { + if videoPlayerManager.state == .playing { + videoPlayerManager.proxy.pause() + updateViewProxy.present(systemName: "pause.fill", title: "Pause") + } else { + videoPlayerManager.proxy.play() + updateViewProxy.present(systemName: "play.fill", title: "Play") + } + } + .addingKeyCommand( + title: L10n.jumpForward, + input: UIKeyCommand.inputRightArrow + ) { + if gestureStateHandler.jumpForwardKeyPressActive { + gestureStateHandler.jumpForwardKeyPressAmount += 1 + gestureStateHandler.jumpForwardKeyPressWorkItem?.cancel() + + let task = DispatchWorkItem { + gestureStateHandler.jumpForwardKeyPressActive = false + gestureStateHandler.jumpForwardKeyPressAmount = 0 + } + + gestureStateHandler.jumpForwardKeyPressWorkItem = task + + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) + } else { + gestureStateHandler.jumpForwardKeyPressActive = true + gestureStateHandler.jumpForwardKeyPressAmount += 1 + + let task = DispatchWorkItem { + gestureStateHandler.jumpForwardKeyPressActive = false + gestureStateHandler.jumpForwardKeyPressAmount = 0 + } + + gestureStateHandler.jumpForwardKeyPressWorkItem = task + + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) + } + +// jumpAction(unitPoint: .init(x: 1, y: 0), amount: gestureStateHandler.jumpForwardKeyPressAmount) + } + .addingKeyCommand( + title: L10n.jumpBackward, + input: UIKeyCommand.inputLeftArrow + ) { + if gestureStateHandler.jumpBackwardKeyPressActive { + gestureStateHandler.jumpBackwardKeyPressAmount += 1 + gestureStateHandler.jumpBackwardKeyPressWorkItem?.cancel() + + let task = DispatchWorkItem { + gestureStateHandler.jumpBackwardKeyPressActive = false + gestureStateHandler.jumpBackwardKeyPressAmount = 0 + } + + gestureStateHandler.jumpBackwardKeyPressWorkItem = task + + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) + } else { + gestureStateHandler.jumpBackwardKeyPressActive = true + gestureStateHandler.jumpBackwardKeyPressAmount += 1 + + let task = DispatchWorkItem { + gestureStateHandler.jumpBackwardKeyPressActive = false + gestureStateHandler.jumpBackwardKeyPressAmount = 0 + } + + gestureStateHandler.jumpBackwardKeyPressWorkItem = task + + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) + } + +// jumpAction(unitPoint: .init(x: 0, y: 0), amount: gestureStateHandler.jumpBackwardKeyPressAmount) + } + +// self.keyCommands([ +// .init( +// title: L10n.playAndPause, +// input: " ", +// action: { +// if videoPlayerManager.state == .playing { +// videoPlayerManager.proxy.pause() +// updateViewProxy.present(systemName: "pause.fill", title: "Pause") +// } else { +// videoPlayerManager.proxy.play() +// updateViewProxy.present(systemName: "play.fill", title: "Play") +// } +// } +// ), +// .init( +// title: L10n.jumpForward, +// input: UIKeyCommand.inputRightArrow, +// action: { +// if gestureStateHandler.jumpForwardKeyPressActive { +// gestureStateHandler.jumpForwardKeyPressAmount += 1 +// gestureStateHandler.jumpForwardKeyPressWorkItem?.cancel() +// +// let task = DispatchWorkItem { +// gestureStateHandler.jumpForwardKeyPressActive = false +// gestureStateHandler.jumpForwardKeyPressAmount = 0 +// } +// +// gestureStateHandler.jumpForwardKeyPressWorkItem = task +// +// DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) +// } else { +// gestureStateHandler.jumpForwardKeyPressActive = true +// gestureStateHandler.jumpForwardKeyPressAmount += 1 +// +// let task = DispatchWorkItem { +// gestureStateHandler.jumpForwardKeyPressActive = false +// gestureStateHandler.jumpForwardKeyPressAmount = 0 +// } +// +// gestureStateHandler.jumpForwardKeyPressWorkItem = task +// +// DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) +// } +// + //// jumpAction(unitPoint: .init(x: 1, y: 0), amount: gestureStateHandler.jumpForwardKeyPressAmount) +// } +// ), +// .init( +// title: L10n.jumpBackward, +// input: UIKeyCommand.inputLeftArrow, +// action: { +// if gestureStateHandler.jumpBackwardKeyPressActive { +// gestureStateHandler.jumpBackwardKeyPressAmount += 1 +// gestureStateHandler.jumpBackwardKeyPressWorkItem?.cancel() +// +// let task = DispatchWorkItem { +// gestureStateHandler.jumpBackwardKeyPressActive = false +// gestureStateHandler.jumpBackwardKeyPressAmount = 0 +// } +// +// gestureStateHandler.jumpBackwardKeyPressWorkItem = task +// +// DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) +// } else { +// gestureStateHandler.jumpBackwardKeyPressActive = true +// gestureStateHandler.jumpBackwardKeyPressAmount += 1 +// +// let task = DispatchWorkItem { +// gestureStateHandler.jumpBackwardKeyPressActive = false +// gestureStateHandler.jumpBackwardKeyPressAmount = 0 +// } +// +// gestureStateHandler.jumpBackwardKeyPressWorkItem = task +// +// DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) +// } +// + //// jumpAction(unitPoint: .init(x: 0, y: 0), amount: gestureStateHandler.jumpBackwardKeyPressAmount) +// } +// ), +// .init( +// title: "Decrease Playback Speed", +// input: "[", +// modifierFlags: .command, +// action: { +// let clampedPlaybackSpeed = clamp( +// videoPlayerManager.playbackSpeed - 0.25, +// min: 0.25, +// max: 5.0 +// ) +// +// updateViewProxy.present(systemName: "speedometer", title: clampedPlaybackSpeed.rateLabel) +// videoPlayerManager.proxy.setRate(.absolute(clampedPlaybackSpeed)) +// } +// ), +// .init( +// title: "Increase Playback Speed", +// input: "]", +// modifierFlags: .command, +// action: { +// let clampedPlaybackSpeed = clamp( +// videoPlayerManager.playbackSpeed + 0.25, +// min: 0.25, +// max: 5.0 +// ) +// +// updateViewProxy.present(systemName: "speedometer", title: clampedPlaybackSpeed.rateLabel) +// videoPlayerManager.proxy.setRate(.absolute(clampedPlaybackSpeed)) +// } +// ), +// .init( +// title: "Reset Playback Speed", +// input: "\\", +// modifierFlags: .command, +// action: { +// let clampedPlaybackSpeed: Float = 1 +// +// updateViewProxy.present(systemName: "speedometer", title: clampedPlaybackSpeed.rateLabel) +// videoPlayerManager.proxy.setRate(.absolute(clampedPlaybackSpeed)) +// } +// ), +// .init( +// title: L10n.nextItem, +// input: UIKeyCommand.inputRightArrow, +// modifierFlags: .command, +// action: { +// videoPlayerManager.selectNextViewModel() +// } +// ), +// .init( +// title: L10n.nextItem, +// input: UIKeyCommand.inputLeftArrow, +// modifierFlags: .command, +// action: { +// videoPlayerManager.selectPreviousViewModel() +// } +// ), +// ]) + } +} diff --git a/Swiftfin/Views/VideoPlayer/VideoPlayer.swift b/Swiftfin/Views/VideoPlayer/VideoPlayer.swift new file mode 100644 index 00000000..f6036fc1 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/VideoPlayer.swift @@ -0,0 +1,572 @@ +// +// 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 JellyfinAPI +import MediaPlayer +import Stinsen +import SwiftUI +import VLCUI + +// TODO: organize +// TODO: localization necessary for toast text? +// TODO: entire gesture layer should be separate + +struct VideoPlayer: View { + + enum OverlayType { + case main + case chapters + } + + class GestureStateHandler { + + var beganPanWithOverlay: Bool = false + var beginningPanProgress: CGFloat = 0 + var beginningHorizontalPanUnit: CGFloat = 0 + + var beginningAudioOffset: Int = 0 + var beginningBrightnessValue: CGFloat = 0 + var beginningPlaybackSpeed: Float = 0 + var beginningSubtitleOffset: Int = 0 + var beginningVolumeValue: Float = 0 + + var jumpBackwardKeyPressActive: Bool = false + var jumpBackwardKeyPressWorkItem: DispatchWorkItem? + var jumpBackwardKeyPressAmount: Int = 0 + + var jumpForwardKeyPressActive: Bool = false + var jumpForwardKeyPressWorkItem: DispatchWorkItem? + var jumpForwardKeyPressAmount: Int = 0 + } + + @Default(.VideoPlayer.jumpBackwardLength) + private var jumpBackwardLength + @Default(.VideoPlayer.jumpForwardLength) + private var jumpForwardLength + + @Default(.VideoPlayer.Gesture.horizontalPanGesture) + private var horizontalPanGesture + @Default(.VideoPlayer.Gesture.horizontalSwipeGesture) + private var horizontalSwipeGesture + @Default(.VideoPlayer.Gesture.longPressGesture) + private var longPressGesture + @Default(.VideoPlayer.Gesture.multiTapGesture) + private var multiTapGesture + @Default(.VideoPlayer.Gesture.doubleTouchGesture) + private var doubleTouchGesture + @Default(.VideoPlayer.Gesture.pinchGesture) + private var pinchGesture + @Default(.VideoPlayer.Gesture.verticalPanGestureLeft) + private var verticalGestureLeft + @Default(.VideoPlayer.Gesture.verticalPanGestureRight) + private var verticalGestureRight + + @Default(.VideoPlayer.Subtitle.subtitleColor) + private var subtitleColor + @Default(.VideoPlayer.Subtitle.subtitleFontName) + private var subtitleFontName + @Default(.VideoPlayer.Subtitle.subtitleSize) + private var subtitleSize + + @EnvironmentObject + private var router: VideoPlayerCoordinator.Router + + @ObservedObject + private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler + @StateObject + private var splitContentViewProxy: SplitContentViewProxy = .init() + @ObservedObject + private var videoPlayerManager: VideoPlayerManager + + @State + private var audioOffset: Int = 0 + @State + private var isAspectFilled: Bool = false + @State + private var isGestureLocked: Bool = false + @State + private var isPresentingOverlay: Bool = false + @State + private var isScrubbing: Bool = false + @State + private var playbackSpeed: Float = 1 + @State + private var subtitleOffset: Int = 0 + + private let gestureStateHandler: GestureStateHandler = .init() + private let updateViewProxy: UpdateViewProxy = .init() + private var overlay: () -> any View + + @ViewBuilder + private var playerView: some View { + SplitContentView(splitContentWidth: 400) + .proxy(splitContentViewProxy) + .content { + ZStack { + VLCVideoPlayer(configuration: videoPlayerManager.currentViewModel.vlcVideoPlayerConfiguration) + .proxy(videoPlayerManager.proxy) + .onTicksUpdated { ticks, _ in + + let newSeconds = ticks / 1000 + let newProgress = CGFloat(newSeconds) / CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) + currentProgressHandler.progress = newProgress + currentProgressHandler.seconds = newSeconds + + guard !isScrubbing else { return } + currentProgressHandler.scrubbedProgress = newProgress + } + .onStateUpdated { state, _ in + + videoPlayerManager.onStateUpdated(newState: state) + + if state == .ended { + if let _ = videoPlayerManager.nextViewModel, + Defaults[.VideoPlayer.autoPlayEnabled] + { + videoPlayerManager.selectNextViewModel() + } else { + router.dismissCoordinator { + AppDelegate.changeOrientation(.portrait) + } + } + } + } + + GestureView() + .onHorizontalPan { + handlePan(action: horizontalPanGesture, state: $0, point: $1.x, velocity: $2, translation: $3) + } + .onHorizontalSwipe(translation: 100, velocity: 1500, sameSwipeDirectionTimeout: 1, handleHorizontalSwipe) + .onLongPress(minimumDuration: 2, handleLongPress) + .onPinch(handlePinchGesture) + .onTap(samePointPadding: 10, samePointTimeout: 0.7, handleTapGesture) + .onDoubleTouch(handleDoubleTouchGesture) + .onVerticalPan { + if $1.x <= 0.5 { + handlePan(action: verticalGestureLeft, state: $0, point: -$1.y, velocity: $2, translation: $3) + } else { + handlePan(action: verticalGestureRight, state: $0, point: -$1.y, velocity: $2, translation: $3) + } + } + + Group { + overlay() + .eraseToAnyView() + } + .environmentObject(splitContentViewProxy) + .environmentObject(videoPlayerManager) + .environmentObject(videoPlayerManager.currentProgressHandler) + .environmentObject(videoPlayerManager.currentViewModel!) + .environmentObject(videoPlayerManager.proxy) + .environment(\.aspectFilled, $isAspectFilled) + .environment(\.isPresentingOverlay, $isPresentingOverlay) + .environment(\.isScrubbing, $isScrubbing) + .environment(\.playbackSpeed, $playbackSpeed) + } + } + .splitContent { + // Wrapped due to navigation controller popping due to published changes + WrappedView { + NavigationViewCoordinator(PlaybackSettingsCoordinator()).view() + } + .cornerRadius(20, corners: [.topLeft, .bottomLeft]) + .environmentObject(splitContentViewProxy) + .environmentObject(videoPlayerManager) + .environmentObject(videoPlayerManager.currentViewModel) + .environment(\.audioOffset, $audioOffset) + .environment(\.subtitleOffset, $subtitleOffset) + } + .onChange(of: videoPlayerManager.currentProgressHandler.scrubbedProgress) { newValue in + DispatchQueue.main.async { + videoPlayerManager.currentProgressHandler + .scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue) + } + } + .overlay(alignment: .top) { + UpdateView(proxy: updateViewProxy) + .padding(.top) + } + .videoPlayerKeyCommands( + gestureStateHandler: gestureStateHandler, + videoPlayerManager: videoPlayerManager, + updateViewProxy: updateViewProxy + ) + } + + var body: some View { + Group { + if let _ = videoPlayerManager.currentViewModel { + playerView + } else { + LoadingView() + } + } + .navigationBarHidden(true) + .statusBar(hidden: true) + .ignoresSafeArea() + .onChange(of: audioOffset) { newValue in + videoPlayerManager.proxy.setAudioDelay(.ticks(newValue)) + } + .onChange(of: isGestureLocked) { newValue in + if newValue { + updateViewProxy.present(systemName: "lock.fill", title: "Gestures Locked") + } else { + updateViewProxy.present(systemName: "lock.open.fill", title: "Gestures Unlocked") + } + } + .onChange(of: isScrubbing) { newValue in + guard !newValue else { return } + videoPlayerManager.proxy.setTime(.seconds(currentProgressHandler.scrubbedSeconds)) + } + .onChange(of: subtitleColor) { newValue in + videoPlayerManager.proxy.setSubtitleColor(.absolute(newValue.uiColor)) + } + .onChange(of: subtitleFontName) { newValue in + videoPlayerManager.proxy.setSubtitleFont(newValue) + } + .onChange(of: subtitleOffset) { newValue in + videoPlayerManager.proxy.setSubtitleDelay(.ticks(newValue)) + } + .onChange(of: subtitleSize) { newValue in + videoPlayerManager.proxy.setSubtitleSize(.absolute(24 - newValue)) + } + .onChange(of: videoPlayerManager.currentViewModel) { newViewModel in + guard let newViewModel else { return } + + videoPlayerManager.proxy.playNewMedia(newViewModel.vlcVideoPlayerConfiguration) + + isAspectFilled = false + audioOffset = 0 + subtitleOffset = 0 + } + } +} + +extension VideoPlayer { + + init(manager: VideoPlayerManager) { + self.init( + currentProgressHandler: manager.currentProgressHandler, + videoPlayerManager: manager, + overlay: { EmptyView() } + ) + } + + func overlay(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.overlay, with: content) + } +} + +// MARK: Gestures + +// TODO: refactor to be split into other files +// TODO: refactor so that actions are separate from the gesture calculations, so that actions are more general + +extension VideoPlayer { + + private func handlePan( + action: PanAction, + state: UIGestureRecognizer.State, + point: CGFloat, + velocity: CGFloat, + translation: CGFloat + ) { + guard !isGestureLocked else { return } + + switch action { + case .none: + return + case .audioffset: + audioOffsetAction(state: state, point: point, velocity: velocity, translation: translation) + case .brightness: + brightnessAction(state: state, point: point, velocity: velocity, translation: translation) + case .playbackSpeed: + playbackSpeedAction(state: state, point: point, velocity: velocity, translation: translation) + case .scrub: + scrubAction(state: state, point: point, velocity: velocity, translation: translation, rate: 1) + case .slowScrub: + scrubAction(state: state, point: point, velocity: velocity, translation: translation, rate: 0.1) + case .subtitleOffset: + subtitleOffsetAction(state: state, point: point, velocity: velocity, translation: translation) + case .volume: + volumeAction(state: state, point: point, velocity: velocity, translation: translation) + } + } + + private func handleHorizontalSwipe( + unitPoint: UnitPoint, + direction: Bool, + amount: Int + ) { + guard !isGestureLocked else { return } + + switch horizontalSwipeGesture { + case .none: + return + case .jump: + jumpAction(unitPoint: .init(x: direction ? 1 : 0, y: 0), amount: amount) + } + } + + private func handleLongPress(point: UnitPoint) { + switch longPressGesture { + case .none: + return + case .gestureLock: + guard !isPresentingOverlay else { return } + isGestureLocked.toggle() + } + } + + private func handlePinchGesture(state: UIGestureRecognizer.State, unitPoint: UnitPoint, scale: CGFloat) { + guard !isGestureLocked else { return } + + switch pinchGesture { + case .none: + return + case .aspectFill: + aspectFillAction(state: state, unitPoint: unitPoint, scale: scale) + } + } + + private func handleTapGesture(unitPoint: UnitPoint, taps: Int) { + guard !isGestureLocked else { + updateViewProxy.present(systemName: "lock.fill", title: "Gestures Locked") + return + } + + if taps > 1 && multiTapGesture != .none { + + withAnimation(.linear(duration: 0.1)) { + isPresentingOverlay = false + } + + switch multiTapGesture { + case .none: + return + case .jump: + jumpAction(unitPoint: unitPoint, amount: taps - 1) + } + } else { + withAnimation(.linear(duration: 0.1)) { + isPresentingOverlay = !isPresentingOverlay + } + } + } + + private func handleDoubleTouchGesture(unitPoint: UnitPoint, taps: Int) { + guard !isGestureLocked else { + updateViewProxy.present(systemName: "lock.fill", title: "Gestures Locked") + return + } + + switch doubleTouchGesture { + case .none: + return + case .aspectFill: () +// aspectFillAction(state: state, unitPoint: unitPoint, scale: <#T##CGFloat#>) + case .gestureLock: + guard !isPresentingOverlay else { return } + isGestureLocked.toggle() + case .pausePlay: () + } + } +} + +// MARK: Actions + +extension VideoPlayer { + + private func aspectFillAction(state: UIGestureRecognizer.State, unitPoint: UnitPoint, scale: CGFloat) { + guard state == .began || state == .changed else { return } + if scale > 1, !isAspectFilled { + isAspectFilled = true + UIView.animate(withDuration: 0.2) { + videoPlayerManager.proxy.aspectFill(1) + } + } else if scale < 1, isAspectFilled { + isAspectFilled = false + UIView.animate(withDuration: 0.2) { + videoPlayerManager.proxy.aspectFill(0) + } + } + } + + private func audioOffsetAction( + state: UIGestureRecognizer.State, + point: CGFloat, + velocity: CGFloat, + translation: CGFloat + ) { + if state == .began { + gestureStateHandler.beginningPanProgress = currentProgressHandler.progress + gestureStateHandler.beginningHorizontalPanUnit = point + gestureStateHandler.beginningAudioOffset = audioOffset + } else if state == .ended { + return + } + + let newOffset = gestureStateHandler.beginningAudioOffset - round( + Int((gestureStateHandler.beginningHorizontalPanUnit - point) * 2000), + toNearest: 100 + ) + + updateViewProxy.present(systemName: "speaker.wave.2.fill", title: newOffset.millisecondFormat) + audioOffset = clamp(newOffset, min: -30000, max: 30000) + } + + private func brightnessAction( + state: UIGestureRecognizer.State, + point: CGFloat, + velocity: CGFloat, + translation: CGFloat + ) { + if state == .began { + gestureStateHandler.beginningPanProgress = currentProgressHandler.progress + gestureStateHandler.beginningHorizontalPanUnit = point + gestureStateHandler.beginningBrightnessValue = UIScreen.main.brightness + } else if state == .ended { + return + } + + let newBrightness = gestureStateHandler.beginningBrightnessValue - (gestureStateHandler.beginningHorizontalPanUnit - point) + let clampedBrightness = clamp(newBrightness, min: 0, max: 1.0) + let flashPercentage = Int(clampedBrightness * 100) + + if flashPercentage >= 67 { + updateViewProxy.present(systemName: "sun.max.fill", title: "\(flashPercentage)%", iconSize: .init(width: 30, height: 30)) + } else if flashPercentage >= 33 { + updateViewProxy.present(systemName: "sun.max.fill", title: "\(flashPercentage)%") + } else { + updateViewProxy.present(systemName: "sun.min.fill", title: "\(flashPercentage)%", iconSize: .init(width: 20, height: 20)) + } + + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.01) { + UIScreen.main.brightness = clampedBrightness + } + } + + // TODO: decide on overlay behavior? + private func jumpAction( + unitPoint: UnitPoint, + amount: Int + ) { + if unitPoint.x <= 0.5 { + videoPlayerManager.proxy.jumpBackward(Int(jumpBackwardLength.rawValue)) + + updateViewProxy.present(systemName: "gobackward", title: "\(amount * Int(jumpBackwardLength.rawValue))s") + } else { + videoPlayerManager.proxy.jumpForward(Int(jumpForwardLength.rawValue)) + + updateViewProxy.present(systemName: "goforward", title: "\(amount * Int(jumpForwardLength.rawValue))s") + } + } + + private func playbackSpeedAction( + state: UIGestureRecognizer.State, + point: CGFloat, + velocity: CGFloat, + translation: CGFloat + ) { + if state == .began { + gestureStateHandler.beginningPanProgress = currentProgressHandler.progress + gestureStateHandler.beginningHorizontalPanUnit = point + gestureStateHandler.beginningPlaybackSpeed = playbackSpeed + } else if state == .ended { + return + } + + let newPlaybackSpeed = round( + gestureStateHandler.beginningPlaybackSpeed - Float(gestureStateHandler.beginningHorizontalPanUnit - point) * 2, + toNearest: 0.25 + ) + let clampedPlaybackSpeed = clamp(newPlaybackSpeed, min: 0.25, max: 5.0) + + updateViewProxy.present(systemName: "speedometer", title: clampedPlaybackSpeed.rateLabel) + + playbackSpeed = clampedPlaybackSpeed + videoPlayerManager.proxy.setRate(.absolute(clampedPlaybackSpeed)) + } + + private func scrubAction( + state: UIGestureRecognizer.State, + point: CGFloat, + velocity: CGFloat, + translation: CGFloat, + rate: CGFloat + ) { + if state == .began { + isScrubbing = true + + gestureStateHandler.beginningPanProgress = currentProgressHandler.progress + gestureStateHandler.beginningHorizontalPanUnit = point + gestureStateHandler.beganPanWithOverlay = isPresentingOverlay + } else if state == .ended { + if !gestureStateHandler.beganPanWithOverlay { + isPresentingOverlay = false + } + + isScrubbing = false + + return + } + + let newProgress = gestureStateHandler.beginningPanProgress - (gestureStateHandler.beginningHorizontalPanUnit - point) * rate + currentProgressHandler.scrubbedProgress = clamp(newProgress, min: 0, max: 1) + } + + private func subtitleOffsetAction( + state: UIGestureRecognizer.State, + point: CGFloat, + velocity: CGFloat, + translation: CGFloat + ) { + if state == .began { + gestureStateHandler.beginningPanProgress = currentProgressHandler.progress + gestureStateHandler.beginningHorizontalPanUnit = point + gestureStateHandler.beginningSubtitleOffset = subtitleOffset + } else if state == .ended { + return + } + + let newOffset = gestureStateHandler.beginningSubtitleOffset - round( + Int((gestureStateHandler.beginningHorizontalPanUnit - point) * 2000), + toNearest: 100 + ) + let clampedOffset = clamp(newOffset, min: -30000, max: 30000) + + updateViewProxy.present(systemName: "captions.bubble.fill", title: clampedOffset.millisecondFormat) + + subtitleOffset = clampedOffset + } + + private func volumeAction( + state: UIGestureRecognizer.State, + point: CGFloat, + velocity: CGFloat, + translation: CGFloat + ) { + let volumeView = MPVolumeView() + guard let slider = volumeView.subviews.first(where: { $0 is UISlider }) as? UISlider else { return } + + if state == .began { + gestureStateHandler.beginningPanProgress = currentProgressHandler.progress + gestureStateHandler.beginningHorizontalPanUnit = point + gestureStateHandler.beginningVolumeValue = AVAudioSession.sharedInstance().outputVolume + } else if state == .ended { + return + } + + let newVolume = gestureStateHandler.beginningVolumeValue - Float(gestureStateHandler.beginningHorizontalPanUnit - point) + + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.01) { + slider.value = newVolume + } + } +} diff --git a/TESTFLIGHT.md b/TESTFLIGHT.md new file mode 100644 index 00000000..ad582088 --- /dev/null +++ b/TESTFLIGHT.md @@ -0,0 +1,7 @@ +## ⚡️ Installation + +The TestFlight instance of Swiftfin + +**For Apple TV (without an iOS/iPadOS device)** + +If you have an Apple TV and do not own an iOS device, please use this [Google Form](https://forms.gle/U5CczbfQzm8MbpJX9) to get an invitation code. \ No newline at end of file diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index b44ed459..4fc65f5c 100644 Binary files a/Translations/en.lproj/Localizable.strings and b/Translations/en.lproj/Localizable.strings differ diff --git a/contributing.md b/contributing.md index dab114c5..417c36ed 100644 --- a/contributing.md +++ b/contributing.md @@ -6,7 +6,7 @@ ## Setup -Fork the Swiftfin repo and install the necessary dependencies with Xcode 13: +Fork the Swiftfin repo and install the necessary dependencies with Xcode 14: ```bash # install Carthage, SwiftFormat, and SwiftGen with homebrew @@ -16,55 +16,60 @@ $ brew install carthage swiftformat swiftgen $ carthage update --use-xcframeworks ``` -If you run into a build error with Swift Packages because some have not finished installing, you may need to close and reopen Xcode to finish installing the last packages. +In the event that all of the Swift Packages cannot be installed, clean the Swift Packages cache or close and reopen Xcode to restart the process. ## Git Flow Swiftfin follows the same Pull Request Guidelines as outlined in the [Jellyfin Pull Request Guidelines](https://jellyfin.org/docs/general/contributing/development.html#pull-request-guidelines). -If your Pull Request relates to an Issue, mention the issue correctly in your PR description. +If a Pull Request relates to an Issue, mention the issue correctly in the PR description. -[SwiftFormat](https://github.com/nicklockwood/SwiftFormat) is our linter. You can run `swiftformat .` in the project directory or install SwiftFormat's Xcode extension. +[SwiftFormat](https://github.com/nicklockwood/SwiftFormat) is our linter. `swiftformat .` can be run in the project directory or install SwiftFormat's Xcode extension. The following must pass in order for a PR to be merged: - automated `iOS` and `tvOS` builds must succeed - developer account cannot be attached -- SwiftFormat linting check must pass. If this does not pass after you have linted, you may need to update your local `swiftformat` +- SwiftFormat linting check must pass. If this does not pass, you may need to update your version of `swiftformat` - new strings that are not part of an experimental feature must be localized -- correct label(s) are attached, if applicable +- label(s) are attached, if applicable -Labeling PRs with `enhancement`, `bug`, or `crash` will allow the PR to be tracked in GitHub's [automatically generated release notes](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes). Small fixes (like minor UI adjustments) or non-user facing issues (like developer project clean up) can also have the `ignore-for-release` label because they may not be important to include in the release notes. If you think that no labels are required, that is acceptable. +Labeling PRs with `enhancement`, `bug`, or `crash` will allow the PR to be tracked in GitHub's [automatically generated release notes](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes). Small fixes (like minor UI adjustments) or non-user facing issues (like developer project clean up) can also have the `ignore-for-release` label because they may not be important to include in the release notes. ### Documentation Documentation for advanced or complex features and other implementation reasoning is encouraged so that future developers may have insights and a better understand of the application. `// MARK:` comments are encouraged for organization, maintainability, and ease of navigation in Xcode's Minimap. ## Architecture -Swiftfin is developed using SwiftUI with some UIKit components where deemed necessary, as SwiftUI is still in relatively early development and limiting. Swiftfin consists of both the iOS and tvOS Jellyfin clients with a shared underlying structure where each client has their own respective views. Due to this architecture, working on both clients at once may be necessary. +Swiftfin is developed using SwiftUI. The iOS and tvOS Jellyfin clients share the same backend with each client containing their respective views. Due to this architecture, working on both clients at once may be necessary. -Playback is done with [VLCKit](https://code.videolan.org/videolan/VLCKit) for its great codec support. Becoming familiar with VLCKit will be necessary for video playback development or relating features. +Playback is done with [VLCKit](https://code.videolan.org/videolan/VLCKit) for its great codec support. Becoming familiar with VLCKit will be necessary for video playback development and debugging. ## Design -While there are no design guidelines for UI/UX features, Swiftfin has the goal to use native SwiftUI/UIKit components while adhering to a Jellyfin theme. If your feature creates new UI/UX components, you are welcome to introduce a general design that may receive feedback during the PR process or may be re-designed later on. +While there are no design guidelines for UI/UX features, Swiftfin has the goal to use native SwiftUI/UIKit components while adhering to a Jellyfin theme. If a feature creates new UI/UX components, it may receive feedback during the PR process or may be re-designed later on. User customizable UI/UX features are welcome and intended, however not all customization may be accepted for code maintainability and to also establish a distinct Swiftfin design. Taking inspiration, but not always copying, from other applications is encouraged. +## App Icons + +Ideas for new icons and minor tweaks to existing icons can be presented however may not be accepted. Overall, app icons must follow these rules: + +- Must feature the Jellyfin logo. +- Must be for general usage (i.e: holiday, hacker theme). Ideas for individual preferences or logos will not be accepted. +- Must be unique. (i.e: cannot have two blue icons just with different gradients) + ## New Features -If you would like to develop a new feature or `Developer` issue, create an issue with a description of the feature so that a discussion can be made for its possibility, whether it belongs in Swiftfin, and finally its general implementation. Leave a comment when you start work on an approved feature so that duplicate work among developers doesn't conflict. +If you would like to develop a new feature, create a Feature Request to discuss the feature's possibility and implementation. Leave a comment when you start working to prevent conflicts. If the implementation of a feature is large or complex, creating a Draft PR is acceptable to surface progress and to receive feedback. ## Other Code Work -Other code work like bug fixes, issues with `Developer` tags, localizations, and accessibility efforts are welcome to be picked up at any time. Just leave a comment when you start work on a bug fix or `Developer` issue. +Other code work like bug fixes, issues with `Developer` tags, localizations, and accessibility efforts are welcome to be picked up at any time. -If you notice undesirable behavior or would like to make a UI/UX tweak, create an issue or ask in the iOS Matrix/Discord channel and a discussion will be made. - -If you have a question about any existing implementations, ask the iOS Matrix/Discord channel for developer insights. +If you notice undesirable behavior, would like to make a UI/UX tweak, or have a question about implementations, create an issue or ask in the iOS Matrix/Discord channel for insights. ## Intended Behaviors Due to Technical Limitations The following behaviors are intended due to current technical limitations with VLCKit: -- Pausing playback when app is backgrounded as VLCKit pauses video output at the same time - Audio delay when starting playback and un-pausing, may be fixed in VLCKit v4