diff --git a/Shared/Components/TruncatedText.swift b/Shared/Components/TruncatedText.swift index bd3150f3..f71ab2d0 100644 --- a/Shared/Components/TruncatedText.swift +++ b/Shared/Components/TruncatedText.swift @@ -10,6 +10,7 @@ import Defaults import SwiftUI // TODO: only allow `view` selection when truncated? +// TODO: fix when also using `lineLimit(reserveSpace > 1)` struct TruncatedText: View { diff --git a/Shared/Extensions/CGSize.swift b/Shared/Extensions/CGSize.swift index a45531ee..fa2e9a3a 100644 --- a/Shared/Extensions/CGSize.swift +++ b/Shared/Extensions/CGSize.swift @@ -13,4 +13,12 @@ extension CGSize { static func Square(length: CGFloat) -> CGSize { CGSize(width: length, height: length) } + + var isLandscape: Bool { + width >= height + } + + var isPortrait: Bool { + height >= width + } } diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift index 628ec701..d3cca7c3 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift @@ -259,4 +259,21 @@ extension BaseItemDto { var alternateTitle: String? { originalTitle != displayTitle ? originalTitle : nil } + + var playButtonLabel: String { + + if isUnaired { + return L10n.unaired + } + + if isMissing { + return L10n.missing + } + + if let progressLabel { + return progressLabel + } + + return L10n.play + } } diff --git a/Shared/Extensions/JellyfinAPI/JellyfinAPIError.swift b/Shared/Extensions/JellyfinAPI/JellyfinAPIError.swift index 37cb3ea7..0e8eb714 100644 --- a/Shared/Extensions/JellyfinAPI/JellyfinAPIError.swift +++ b/Shared/Extensions/JellyfinAPI/JellyfinAPIError.swift @@ -14,7 +14,7 @@ import Foundation /// /// - Important: Only really use for debugging. For practical errors, /// statically define errors for each domain/context. -struct JellyfinAPIError: LocalizedError, Equatable { +struct JellyfinAPIError: LocalizedError, Hashable { private let message: String diff --git a/Shared/Extensions/String.swift b/Shared/Extensions/String.swift index cc11dc6b..09131724 100644 --- a/Shared/Extensions/String.swift +++ b/Shared/Extensions/String.swift @@ -6,6 +6,7 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import Algorithms import Foundation import SwiftUI @@ -19,6 +20,8 @@ extension String: Displayable { extension String { + static let alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + static func + (lhs: String, rhs: Character) -> String { lhs.appending(rhs) } @@ -84,6 +87,16 @@ extension String { (split(separator: "/").last?.description ?? self) .replacingOccurrences(of: ".swift", with: "") } + + static func random(count: Int) -> String { + let characters = Self.alphanumeric.randomSample(count: count) + return String(characters) + } + + static func random(count range: Range) -> String { + let characters = Self.alphanumeric.randomSample(count: Int.random(in: range)) + return String(characters) + } } extension CharacterSet { diff --git a/Shared/Extensions/ViewExtensions/Modifiers/BackgroundParallaxHeaderModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/BackgroundParallaxHeaderModifier.swift index 44e778af..b589e889 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/BackgroundParallaxHeaderModifier.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/BackgroundParallaxHeaderModifier.swift @@ -11,11 +11,14 @@ import SwiftUI struct BackgroundParallaxHeaderModifier: ViewModifier { @Binding - var scrollViewOffset: CGFloat + private var scrollViewOffset: CGFloat - let height: CGFloat - let multiplier: CGFloat - let header: () -> Header + @State + private var contentSize: CGSize = .zero + + private let height: CGFloat + private let multiplier: CGFloat + private let header: () -> Header init( _ scrollViewOffset: Binding, @@ -30,15 +33,18 @@ struct BackgroundParallaxHeaderModifier: ViewModifier { } func body(content: Content) -> some View { - content.background(alignment: .top) { - 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() - } + content + .size($contentSize) + .background(alignment: .top) { + header() + .offset(y: scrollViewOffset > 0 ? -scrollViewOffset * multiplier : 0) + .scaleEffect(scrollViewOffset < 0 ? (height - scrollViewOffset) / height : 1, anchor: .top) + .frame(width: contentSize.width) + .mask(alignment: .top) { + Color.black + .frame(height: max(0, height - scrollViewOffset)) + } + .ignoresSafeArea() + } } } diff --git a/Shared/Extensions/ViewExtensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions/ViewExtensions.swift index 35f3277a..5e78f9fe 100644 --- a/Shared/Extensions/ViewExtensions/ViewExtensions.swift +++ b/Shared/Extensions/ViewExtensions/ViewExtensions.swift @@ -91,15 +91,15 @@ extension View { /// Applies the aspect ratio and corner radius for the given `PosterType` @ViewBuilder - func posterStyle(_ type: PosterType) -> some View { + func posterStyle(_ type: PosterType, contentMode: ContentMode = .fill) -> some View { switch type { case .portrait: - aspectRatio(2 / 3, contentMode: .fill) + aspectRatio(2 / 3, contentMode: contentMode) #if !os(tvOS) .cornerRadius(ratio: 0.0375, of: \.width) #endif case .landscape: - aspectRatio(1.77, contentMode: .fill) + aspectRatio(1.77, contentMode: contentMode) #if !os(tvOS) .cornerRadius(ratio: 1 / 30, of: \.width) #endif @@ -152,6 +152,7 @@ extension View { .onPreferenceChange(FramePreferenceKey.self, perform: onChange) } + // TODO: probably rename since this doesn't set the frame but tracks it func frame(_ binding: Binding) -> some View { onFrameChanged { newFrame in binding.wrappedValue = newFrame @@ -173,6 +174,7 @@ extension View { .onPreferenceChange(LocationPreferenceKey.self, perform: onChange) } + // TODO: probably rename since this doesn't set the location but tracks it func location(_ binding: Binding) -> some View { onLocationChanged { newLocation in binding.wrappedValue = newLocation @@ -191,6 +193,7 @@ extension View { .onPreferenceChange(SizePreferenceKey.self, perform: onChange) } + // TODO: probably rename since this doesn't set the size but tracks it func size(_ binding: Binding) -> some View { onSizeChanged { newSize in binding.wrappedValue = newSize diff --git a/Shared/Objects/LibraryParent/LibraryParent.swift b/Shared/Objects/LibraryParent/LibraryParent.swift index 8621ae01..2e56805a 100644 --- a/Shared/Objects/LibraryParent/LibraryParent.swift +++ b/Shared/Objects/LibraryParent/LibraryParent.swift @@ -9,7 +9,7 @@ import Foundation import JellyfinAPI -protocol LibraryParent: Displayable, Identifiable { +protocol LibraryParent: Displayable, Hashable, Identifiable { // Only called `libraryType` because `BaseItemPerson` has // a different `type` property. However, people should have diff --git a/Shared/Objects/Stateful.swift b/Shared/Objects/Stateful.swift index fcb5ebfd..e1315158 100644 --- a/Shared/Objects/Stateful.swift +++ b/Shared/Objects/Stateful.swift @@ -7,24 +7,38 @@ // import Foundation +import OrderedCollections // TODO: documentation +// TODO: find a better way to handle backgroundStates on action/state transitions +// so that conformers don't have to manually insert/remove them +// TODO: better/official way for subclasses of conformers to perform actions during +// parent class actions +// TODO: official way for a cleaner `respond` method so it doesn't have all Task +// construction and get bloated protocol Stateful: AnyObject { - associatedtype Action - associatedtype State: Equatable + associatedtype Action: Equatable + associatedtype BackgroundState: Hashable = Never + associatedtype State: Hashable + /// Background states that the conformer can be in. + /// Usually used to indicate background events that shouldn't + /// set the conformer to a primary state. + var backgroundStates: OrderedSet { get set } + + var lastAction: Action? { get set } var state: State { get set } + /// Respond to a sent action and return the new state + @MainActor + func respond(to action: Action) -> State + /// Send an action to the `Stateful` object, which will /// `respond` to the action and set the new state. @MainActor func send(_ action: Action) - - /// Respond to a sent action and return the new state - @MainActor - func respond(to action: Action) -> State } extension Stateful { @@ -32,5 +46,17 @@ extension Stateful { @MainActor func send(_ action: Action) { state = respond(to: action) + lastAction = action + } +} + +extension Stateful where BackgroundState == Never { + + var backgroundStates: OrderedSet { + get { + assertionFailure("Attempted to access `backgroundStates` when there are none") + return [] + } + set { assertionFailure("Attempted to set `backgroundStates` when there are none") } } } diff --git a/Shared/Services/SwiftfinNotifications.swift b/Shared/Services/SwiftfinNotifications.swift index fc3ff4ec..6a2ecec4 100644 --- a/Shared/Services/SwiftfinNotifications.swift +++ b/Shared/Services/SwiftfinNotifications.swift @@ -14,22 +14,26 @@ class SwiftfinNotification { @Injected(Notifications.service) private var notificationService - private let notificationName: Notification.Name + private let name: Notification.Name fileprivate init(_ notificationName: Notification.Name) { - self.notificationName = notificationName + self.name = notificationName } func post(object: Any? = nil) { - notificationService.post(name: notificationName, object: object) + notificationService.post(name: name, object: object) } func subscribe(_ observer: Any, selector: Selector) { - notificationService.addObserver(observer, selector: selector, name: notificationName, object: nil) + notificationService.addObserver(observer, selector: selector, name: name, object: nil) } func unsubscribe(_ observer: Any) { - notificationService.removeObserver(self, name: notificationName, object: nil) + notificationService.removeObserver(self, name: name, object: nil) + } + + var publisher: NotificationCenter.Publisher { + notificationService.publisher(for: name) } } @@ -37,7 +41,16 @@ enum Notifications { static let service = Factory(scope: .singleton) { NotificationCenter() } - final class Key { + struct Key: Hashable { + + static func == (lhs: Notifications.Key, rhs: Notifications.Key) -> Bool { + lhs.key == rhs.key + } + + func hash(into hasher: inout Hasher) { + hasher.combine(key) + } + typealias NotificationKey = Notifications.Key let key: String @@ -63,4 +76,6 @@ extension Notifications.Key { static let didChangeServerCurrentURI = NotificationKey("didChangeCurrentLoginURI") static let didSendStopReport = NotificationKey("didSendStopReport") static let didRequestGlobalRefresh = NotificationKey("didRequestGlobalRefresh") + + static let itemMetadataDidChange = NotificationKey("itemMetadataDidChange") } diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index 55d035c3..31a11c9f 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -9,6 +9,7 @@ import Combine import CoreStore import Factory +import Get import JellyfinAPI import OrderedCollections @@ -16,14 +17,22 @@ final class HomeViewModel: ViewModel, Stateful { // MARK: Action - enum Action { + enum Action: Equatable { + case backgroundRefresh case error(JellyfinAPIError) + case setIsPlayed(Bool, BaseItemDto) + case refresh + } + + // MARK: BackgroundState + + enum BackgroundState: Hashable { case refresh } // MARK: State - enum State: Equatable { + enum State: Hashable { case content case error(JellyfinAPIError) case initial @@ -31,26 +40,93 @@ final class HomeViewModel: ViewModel, Stateful { } @Published - var libraries: [LatestInLibraryViewModel] = [] + private(set) var libraries: [LatestInLibraryViewModel] = [] @Published var resumeItems: OrderedSet = [] + @Published + var backgroundStates: OrderedSet = [] + @Published + var lastAction: Action? = nil @Published var state: State = .initial - private(set) var nextUpViewModel: NextUpLibraryViewModel = .init() - private(set) var recentlyAddedViewModel: RecentlyAddedLibraryViewModel = .init() + // TODO: replace with views checking what notifications were + // posted since last disappear + @Published + var notificationsReceived: Set = [] + private var backgroundRefreshTask: AnyCancellable? private var refreshTask: AnyCancellable? + var nextUpViewModel: NextUpLibraryViewModel = .init() + var recentlyAddedViewModel: RecentlyAddedLibraryViewModel = .init() + + override init() { + super.init() + + Notifications[.itemMetadataDidChange].publisher + .sink { _ in + // Necessary because when this notification is posted, even with asyncAfter, + // the view will cause layout issues since it will redraw while in landscape. + // TODO: look for better solution + DispatchQueue.main.async { + self.notificationsReceived.insert(Notifications.Key.itemMetadataDidChange) + } + } + .store(in: &cancellables) + } + func respond(to action: Action) -> State { switch action { + case .backgroundRefresh: + + backgroundRefreshTask?.cancel() + backgroundStates.append(.refresh) + + backgroundRefreshTask = Task { [weak self] in + guard let self else { return } + do { + + nextUpViewModel.send(.refresh) + recentlyAddedViewModel.send(.refresh) + + let resumeItems = try await getResumeItems() + + guard !Task.isCancelled else { return } + + await MainActor.run { + self.resumeItems.elements = resumeItems + self.backgroundStates.remove(.refresh) + } + } catch { + guard !Task.isCancelled else { return } + + await MainActor.run { + self.backgroundStates.remove(.refresh) + self.send(.error(.init(error.localizedDescription))) + } + } + } + .asAnyCancellable() + + return state case let .error(error): return .error(error) - case .refresh: - cancellables.removeAll() + case let .setIsPlayed(isPlayed, item): () + Task { + try await setIsPlayed(isPlayed, for: item) - Task { [weak self] in + self.send(.backgroundRefresh) + } + .store(in: &cancellables) + + return state + case .refresh: + backgroundRefreshTask?.cancel() + refreshTask?.cancel() + + refreshTask = Task { [weak self] in guard let self else { return } do { @@ -69,7 +145,7 @@ final class HomeViewModel: ViewModel, Stateful { } } } - .store(in: &cancellables) + .asAnyCancellable() return .refreshing } @@ -77,13 +153,8 @@ final class HomeViewModel: ViewModel, Stateful { private func refresh() async throws { - Task { - await nextUpViewModel.send(.refresh) - } - - Task { - await recentlyAddedViewModel.send(.refresh) - } + await nextUpViewModel.send(.refresh) + await recentlyAddedViewModel.send(.refresh) let resumeItems = try await getResumeItems() let libraries = try await getLibraries() @@ -124,8 +195,7 @@ final class HomeViewModel: ViewModel, Stateful { .map { LatestInLibraryViewModel(parent: $0) } } - // TODO: eventually a more robust user/server information retrieval system - // will be in place. Replace with using the data from the remove user + // TODO: use the more updated server/user data when implemented private func getExcludedLibraries() async throws -> [String] { let currentUserPath = Paths.getCurrentUser let response = try await userSession.client.send(currentUserPath) @@ -133,38 +203,21 @@ final class HomeViewModel: ViewModel, Stateful { return response.value.configuration?.latestItemsExcludes ?? [] } - // TODO: fix - func markItemUnplayed(_ item: BaseItemDto) { -// guard resumeItems.contains(where: { $0.id == item.id! }) else { return } -// -// Task { -// let request = Paths.markUnplayedItem( -// userID: userSession.user.id, -// itemID: item.id! -// ) -// let _ = try await userSession.client.send(request) -// - //// refreshResumeItems() -// -// try await nextUpViewModel.refresh() -// try await recentlyAddedViewModel.refresh() -// } - } + private func setIsPlayed(_ isPlayed: Bool, for item: BaseItemDto) async throws { + let request: Request - // TODO: fix - func markItemPlayed(_ item: BaseItemDto) { -// guard resumeItems.contains(where: { $0.id == item.id! }) else { return } -// -// Task { -// let request = Paths.markPlayedItem( -// userID: userSession.user.id, -// itemID: item.id! -// ) -// let _ = try await userSession.client.send(request) -// - //// refreshResumeItems() -// try await nextUpViewModel.refresh() -// try await recentlyAddedViewModel.refresh() -// } + if isPlayed { + request = Paths.markPlayedItem( + userID: userSession.user.id, + itemID: item.id! + ) + } else { + request = Paths.markUnplayedItem( + userID: userSession.user.id, + itemID: item.id! + ) + } + + let _ = try await userSession.client.send(request) } } diff --git a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift index 26c2d822..368af421 100644 --- a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift @@ -13,27 +13,28 @@ import JellyfinAPI final class CollectionItemViewModel: ItemViewModel { @Published - var collectionItems: [BaseItemDto] = [] + private(set) var collectionItems: [BaseItemDto] = [] - override init(item: BaseItemDto) { - super.init(item: item) + override func onRefresh() async throws { + let collectionItems = try await self.getCollectionItems() - getCollectionItems() - } - - private func getCollectionItems() { - 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 ?? [] - } + await MainActor.run { + self.collectionItems = collectionItems } } + + private func getCollectionItems() async throws -> [BaseItemDto] { + + var parameters = Paths.GetItemsByUserIDParameters() + parameters.fields = .MinimumFields + parameters.parentID = item.id + + let request = Paths.getItemsByUserID( + userID: userSession.user.id, + parameters: parameters + ) + let response = try await userSession.client.send(request) + + return response.value.items ?? [] + } } diff --git a/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift b/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift index 80c98f6c..39454f15 100644 --- a/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift @@ -14,32 +14,51 @@ import Stinsen final class EpisodeItemViewModel: ItemViewModel { @Published - var seriesItem: BaseItemDto? + private(set) var seriesItem: BaseItemDto? + + private var seriesItemTask: AnyCancellable? override init(item: BaseItemDto) { super.init(item: item) - getSeriesItem() + $lastAction + .sink { [weak self] action in + guard let self else { return } + + if action == .refresh { + seriesItemTask?.cancel() + + seriesItemTask = Task { + let seriesItem = try await self.getSeriesItem() + + await MainActor.run { + self.seriesItem = seriesItem + } + } + .asAnyCancellable() + } + } + .store(in: &cancellables) } - override func updateItem() {} + private func getSeriesItem() async throws -> BaseItemDto { - private func getSeriesItem() { - 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) + guard let seriesID = item.seriesID else { throw JellyfinAPIError("Expected series ID missing") } - await MainActor.run { - seriesItem = response.value.items?.first - } - } + var parameters = Paths.GetItemsByUserIDParameters() + parameters.enableUserData = true + parameters.fields = .MinimumFields + parameters.ids = [seriesID] + parameters.limit = 1 + + let request = Paths.getItemsByUserID( + userID: userSession.user.id, + parameters: parameters + ) + let response = try await userSession.client.send(request) + + guard let seriesItem = response.value.items?.first else { throw JellyfinAPIError("Expected series item missing") } + + return seriesItem } } diff --git a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift index 4cee7865..2291f664 100644 --- a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift @@ -9,14 +9,40 @@ import Combine import Factory import Foundation +import Get import JellyfinAPI +import OrderedCollections import UIKit -// TODO: transition to `Stateful` -class ItemViewModel: ViewModel { +class ItemViewModel: ViewModel, Stateful { + + // MARK: Action + + enum Action: Equatable { + case backgroundRefresh + case error(JellyfinAPIError) + case refresh + case toggleIsFavorite + case toggleIsPlayed + } + + // MARK: BackgroundState + + enum BackgroundState: Hashable { + case refresh + } + + // MARK: State + + enum State: Hashable { + case content + case error(JellyfinAPIError) + case initial + case refreshing + } @Published - var item: BaseItemDto { + private(set) var item: BaseItemDto { willSet { switch item.type { case .episode, .movie: @@ -37,169 +63,273 @@ class ItemViewModel: ViewModel { } @Published - var isFavorited = false + private(set) var selectedMediaSource: MediaSourceInfo? @Published - var isPlayed = false + private(set) var similarItems: [BaseItemDto] = [] @Published - var selectedMediaSource: MediaSourceInfo? + private(set) var specialFeatures: [BaseItemDto] = [] + @Published - var similarItems: [BaseItemDto] = [] + final var backgroundStates: OrderedSet = [] @Published - var specialFeatures: [BaseItemDto] = [] + final var lastAction: Action? = nil + @Published + final var state: State = .initial + + // tasks + + private var toggleIsFavoriteTask: AnyCancellable? + private var toggleIsPlayedTask: AnyCancellable? + private var refreshTask: AnyCancellable? + + // MARK: init init(item: BaseItemDto) { self.item = item super.init() - getFullItem() + // TODO: should replace with a more robust "PlaybackManager" + Notifications[.itemMetadataDidChange].publisher + .sink { [weak self] notification in - isFavorited = item.userData?.isFavorite ?? false - isPlayed = item.userData?.isPlayed ?? false + guard let userInfo = notification.object as? [String: String] else { return } - getSimilarItems() - getSpecialFeatures() - - Notifications[.didSendStopReport].subscribe(self, selector: #selector(receivedStopReport(_:))) - } - - private func getFullItem() { - Task { - - await MainActor.run { - isLoading = true + if let itemID = userInfo["itemID"], itemID == item.id { + Task { [weak self] in + await self?.send(.backgroundRefresh) + } + } else if let seriesID = userInfo["seriesID"], seriesID == item.id { + Task { [weak self] in + await self?.send(.backgroundRefresh) + } + } } + .store(in: &cancellables) + } - let parameters = Paths.GetItemsParameters( - userID: userSession.user.id, - fields: ItemFields.allCases, - enableUserData: true, - ids: [item.id!] - ) + // MARK: respond - let request = Paths.getItems(parameters: parameters) - let response = try await userSession.client.send(request) + func respond(to action: Action) -> State { + switch action { + case .backgroundRefresh: - guard let fullItem = response.value.items?.first else { return } + backgroundStates.append(.refresh) - await MainActor.run { - self.item = fullItem - isLoading = false + Task { [weak self] in + guard let self else { return } + do { + async let fullItem = getFullItem() + async let similarItems = getSimilarItems() + async let specialFeatures = getSpecialFeatures() + + let results = try await ( + fullItem: fullItem, + similarItems: similarItems, + specialFeatures: specialFeatures + ) + + guard !Task.isCancelled else { return } + + try await onRefresh() + + guard !Task.isCancelled else { return } + + await MainActor.run { + self.backgroundStates.remove(.refresh) + self.item = results.fullItem + self.similarItems = results.similarItems + self.specialFeatures = results.specialFeatures + } + } catch { + guard !Task.isCancelled else { return } + + await MainActor.run { + self.backgroundStates.remove(.refresh) + self.send(.error(.init(error.localizedDescription))) + } + } } - } - } + .store(in: &cancellables) - @objc - private func receivedStopReport(_ notification: NSNotification) { - guard let itemID = notification.object as? String else { return } + return state + case let .error(error): + return .error(error) + case .refresh: - if itemID == item.id { - updateItem() - } else { - // Remove if necessary. Note that this cannot be in deinit as - // holding as an observer won't allow the object to be deinit-ed - Notifications[.didSendStopReport].unsubscribe(self) - } - } + refreshTask?.cancel() - // TODO: remove and have views handle - func playButtonText() -> String { + refreshTask = Task { [weak self] in + guard let self else { return } + do { + async let fullItem = getFullItem() + async let similarItems = getSimilarItems() + async let specialFeatures = getSpecialFeatures() - if item.isUnaired { - return L10n.unaired - } + let results = try await ( + fullItem: fullItem, + similarItems: similarItems, + specialFeatures: specialFeatures + ) - if item.isMissing { - return L10n.missing - } + guard !Task.isCancelled else { return } - if let itemProgressString = item.progressLabel { - return itemProgressString - } + try await onRefresh() - return L10n.play - } + guard !Task.isCancelled else { return } - func getSimilarItems() { - Task { - let parameters = Paths.GetSimilarItemsParameters( - userID: userSession.user.id, - limit: 20, - fields: .MinimumFields - ) - let request = Paths.getSimilarItems( - itemID: item.id!, - parameters: parameters - ) - let response = try await userSession.client.send(request) + await MainActor.run { + self.item = results.fullItem + self.similarItems = results.similarItems + self.specialFeatures = results.specialFeatures - await MainActor.run { - similarItems = response.value.items ?? [] + self.state = .content + } + } catch { + guard !Task.isCancelled else { return } + + await MainActor.run { + self.send(.error(.init(error.localizedDescription))) + } + } } + .asAnyCancellable() + + return .refreshing + case .toggleIsFavorite: + + toggleIsFavoriteTask?.cancel() + + toggleIsFavoriteTask = Task { + + let beforeIsFavorite = item.userData?.isFavorite ?? false + + await MainActor.run { + item.userData?.isFavorite?.toggle() + } + + do { + try await setIsFavorite(!beforeIsFavorite) + } catch { + await MainActor.run { + item.userData?.isFavorite = beforeIsFavorite + // emit event that toggle unsuccessful + } + } + } + .asAnyCancellable() + + return state + case .toggleIsPlayed: + + toggleIsPlayedTask?.cancel() + + toggleIsPlayedTask = Task { + + let beforeIsPlayed = item.userData?.isPlayed ?? false + + await MainActor.run { + item.userData?.isPlayed?.toggle() + } + + do { + try await setIsPlayed(!beforeIsPlayed) + } catch { + await MainActor.run { + item.userData?.isPlayed = beforeIsPlayed + // emit event that toggle unsuccessful + } + } + } + .asAnyCancellable() + + return state } } - func getSpecialFeatures() { - Task { - let request = Paths.getSpecialFeatures( + func onRefresh() async throws {} + + private func getFullItem() async throws -> BaseItemDto { + + var parameters = Paths.GetItemsByUserIDParameters() + parameters.enableUserData = true + parameters.fields = ItemFields.allCases + parameters.ids = [item.id!] + + let request = Paths.getItemsByUserID(userID: userSession.user.id, parameters: parameters) + let response = try await userSession.client.send(request) + + guard let fullItem = response.value.items?.first else { throw JellyfinAPIError("Full item not in response") } + + return fullItem + } + + private func getSimilarItems() async -> [BaseItemDto] { + + var parameters = Paths.GetSimilarItemsParameters() + parameters.fields = .MinimumFields + parameters.limit = 20 + parameters.userID = userSession.user.id + + let request = Paths.getSimilarItems( + itemID: item.id!, + parameters: parameters + ) + + let response = try? await userSession.client.send(request) + + return response?.value.items ?? [] + } + + private func getSpecialFeatures() async -> [BaseItemDto] { + + let request = Paths.getSpecialFeatures( + userID: userSession.user.id, + itemID: item.id! + ) + let response = try? await userSession.client.send(request) + + return (response?.value ?? []) + .filter { $0.extraType?.isVideo ?? false } + } + + private func setIsPlayed(_ isPlayed: Bool) async throws { + + let request: Request + + if isPlayed { + request = Paths.markPlayedItem( + userID: userSession.user.id, + itemID: item.id! + ) + } else { + request = Paths.markUnplayedItem( 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 } - } } + + let _ = try await userSession.client.send(request) + + let ids = ["itemID": item.id] + Notifications[.itemMetadataDidChange].post(object: ids) } - func toggleWatchState() { -// let current = isPlayed -// isPlayed.toggle() -// let request: AnyPublisher + private func setIsFavorite(_ isFavorite: Bool) async throws { -// if current { -// request = PlaystateAPI.markUnplayedItem(userId: "123abc", itemId: item.id!) -// } else { -// request = PlaystateAPI.markPlayedItem(userId: "123abc", itemId: item.id!) -// } + let request: Request -// 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) + if isFavorite { + request = Paths.markFavoriteItem( + userID: userSession.user.id, + itemID: item.id! + ) + } else { + request = Paths.unmarkFavoriteItem( + userID: userSession.user.id, + itemID: item.id! + ) + } + + let _ = try await userSession.client.send(request) } - - func toggleFavoriteState() { -// let current = isFavorited -// isFavorited.toggle() -// let request: AnyPublisher - -// 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) - } - - // Overridden by subclasses - func updateItem() {} } diff --git a/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift b/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift index 61b231c3..4c8f340a 100644 --- a/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift @@ -10,7 +10,4 @@ import Combine import Foundation import JellyfinAPI -final class MovieItemViewModel: ItemViewModel { - - override func updateItem() {} -} +final class MovieItemViewModel: ItemViewModel {} diff --git a/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift new file mode 100644 index 00000000..8fdac71c --- /dev/null +++ b/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift @@ -0,0 +1,56 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Foundation +import JellyfinAPI + +// Since we don't view care to view seasons directly, this doesn't subclass from `ItemViewModel`. +// If we ever care for viewing seasons directly, subclass from that and have the library view model +// as a property. +final class SeasonItemViewModel: PagingLibraryViewModel { + + let season: BaseItemDto + + init(season: BaseItemDto) { + self.season = season + super.init(parent: season) + } + + override func get(page: Int) async throws -> [BaseItemDto] { + + var parameters = Paths.GetEpisodesParameters() + parameters.enableUserData = true + parameters.fields = .MinimumFields + parameters.isMissing = Defaults[.Customization.shouldShowMissingEpisodes] ? nil : false + parameters.seasonID = parent!.id + parameters.userID = userSession.user.id + +// parameters.startIndex = page * pageSize +// parameters.limit = pageSize + + let request = Paths.getEpisodes( + seriesID: parent!.id!, + parameters: parameters + ) + let response = try await userSession.client.send(request) + + return response.value.items ?? [] + } +} + +extension SeasonItemViewModel: Hashable { + + static func == (lhs: SeasonItemViewModel, rhs: SeasonItemViewModel) -> Bool { + lhs.parent as! BaseItemDto == rhs.parent as! BaseItemDto + } + + func hash(into hasher: inout Hasher) { + hasher.combine((parent as! BaseItemDto).hashValue) + } +} diff --git a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift index 9d18517e..ae428806 100644 --- a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift @@ -13,170 +13,121 @@ import Foundation import JellyfinAPI import OrderedCollections -// TODO: use OrderedDictionary - +// TODO: care for one long episodes list? +// - after SeasonItemViewModel is bidirectional +// - would have to see if server returns right amount of episodes/season final class SeriesItemViewModel: ItemViewModel { @Published - var menuSelection: BaseItemDto? - @Published - var currentItems: OrderedSet = [] + var seasons: OrderedSet = [] - var menuSections: [BaseItemDto: OrderedSet] - var menuSectionSort: (BaseItemDto, BaseItemDto) -> Bool + override func onRefresh() async throws { - override init(item: BaseItemDto) { - self.menuSections = [:] - self.menuSectionSort = { i, j in i.indexNumber ?? -1 < j.indexNumber ?? -1 } - - super.init(item: item) - - getSeasons() - - // The server won't have both a next up item - // and a resume item at the same time, so they - // control the button first. Also fetch first available - // item, which may be overwritten by next up or resume. - getNextUp() - getResumeItem() - getFirstAvailableItem() - } - - override func playButtonText() -> String { - - if item.isUnaired { - return L10n.unaired + await MainActor.run { + self.seasons.removeAll() } - if item.isMissing { - return L10n.missing + async let nextUp = getNextUp() + async let resume = getResumeItem() + async let firstAvailable = getFirstAvailableItem() + async let seasons = getSeasons() + + let newSeasons = try await seasons + .sorted { ($0.indexNumber ?? -1) < ($1.indexNumber ?? -1) } // sort just in case + .map(SeasonItemViewModel.init) + + await MainActor.run { + self.seasons.append(contentsOf: newSeasons) } - guard let playButtonItem = playButtonItem, - let episodeLocator = playButtonItem.seasonEpisodeLabel else { return L10n.play } - - return episodeLocator - } - - private func getNextUp() { - Task { - let parameters = Paths.GetNextUpParameters( - userID: userSession.user.id, - fields: .MinimumFields, - seriesID: item.id, - enableUserData: true - ) - let request = Paths.getNextUp(parameters: parameters) - let response = try await userSession.client.send(request) - - 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: .MinimumFields - ) - 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 - } - } - } - } - - private func getFirstAvailableItem() { - Task { - let parameters = Paths.GetItemsParameters( - userID: userSession.user.id, - limit: 1, - isRecursive: true, - sortOrder: [.ascending], - parentID: item.id, - fields: .MinimumFields, - includeItemTypes: [.episode] - ) - let request = Paths.getItems(parameters: parameters) - let response = try await userSession.client.send(request) - - if let item = response.value.items?.first { - if self.playButtonItem == nil { - await MainActor.run { - self.playButtonItem = item - } - } - } - } - } - - func select(section: BaseItemDto) { - self.menuSelection = section - - if let episodes = menuSections[section] { - if episodes.isEmpty { - getEpisodesForSeason(section) - } else { - self.currentItems = episodes - } - } - } - - 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 } - + if let episodeItem = try await [nextUp, resume].compacted().first { await MainActor.run { - for season in seasons { - self.menuSections[season] = [] - } + self.playButtonItem = episodeItem } - - if let firstSeason = seasons.first { - self.getEpisodesForSeason(firstSeason) - await MainActor.run { - self.menuSelection = firstSeason - } + } else if let firstAvailable = try await firstAvailable { + await MainActor.run { + self.playButtonItem = firstAvailable } } } - // TODO: implement lazy loading - private func getEpisodesForSeason(_ season: BaseItemDto) { - Task { - let parameters = Paths.GetEpisodesParameters( - userID: userSession.user.id, - fields: .MinimumFields, - 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) +// override func playButtonText() -> String { +// +// if item.isUnaired { +// return L10n.unaired +// } +// +// if item.isMissing { +// return L10n.missing +// } +// +// guard let playButtonItem = playButtonItem, +// let episodeLocator = playButtonItem.seasonEpisodeLabel else { return L10n.play } +// +// return episodeLocator +// } - await MainActor.run { - if let items = response.value.items { - let newItems = OrderedSet(items) - self.menuSections[season] = newItems - self.currentItems = newItems - } - } + private func getNextUp() async throws -> BaseItemDto? { + + var parameters = Paths.GetNextUpParameters() + parameters.fields = .MinimumFields + parameters.seriesID = item.id + parameters.userID = userSession.user.id + + let request = Paths.getNextUp(parameters: parameters) + let response = try await userSession.client.send(request) + + guard let item = response.value.items?.first, !item.isMissing else { + return nil } + + return item + } + + private func getResumeItem() async throws -> BaseItemDto? { + + var parameters = Paths.GetResumeItemsParameters() + parameters.fields = .MinimumFields + parameters.limit = 1 + parameters.parentID = item.id + + let request = Paths.getResumeItems(userID: userSession.user.id, parameters: parameters) + let response = try await userSession.client.send(request) + + return response.value.items?.first + } + + private func getFirstAvailableItem() async throws -> BaseItemDto? { + + var parameters = Paths.GetItemsByUserIDParameters() + parameters.fields = .MinimumFields + parameters.includeItemTypes = [.episode] + parameters.isRecursive = true + parameters.limit = 1 + parameters.parentID = item.id + parameters.sortOrder = [.ascending] + + let request = Paths.getItemsByUserID( + userID: userSession.user.id, + parameters: parameters + ) + let response = try await userSession.client.send(request) + + return response.value.items?.first + } + + private func getSeasons() async throws -> [BaseItemDto] { + + var parameters = Paths.GetSeasonsParameters() + parameters.isMissing = Defaults[.Customization.shouldShowMissingSeasons] ? nil : false + parameters.userID = userSession.user.id + + let request = Paths.getSeasons( + seriesID: item.id!, + parameters: parameters + ) + let response = try await userSession.client.send(request) + + return response.value.items ?? [] } } diff --git a/Shared/ViewModels/LibraryViewModel/NextUpLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/NextUpLibraryViewModel.swift index 20553ef0..dc2dac4a 100644 --- a/Shared/ViewModels/LibraryViewModel/NextUpLibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel/NextUpLibraryViewModel.swift @@ -36,18 +36,4 @@ final class NextUpLibraryViewModel: PagingLibraryViewModel { return parameters } - - // TODO: fix - func markPlayed(item: BaseItemDto) { -// Task { -// -// let request = Paths.markPlayedItem( -// userID: userSession.user.id, -// itemID: item.id! -// ) -// let _ = try await userSession.client.send(request) -// -// try await refresh() -// } - } } diff --git a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift index 22067b3f..4a9d3eea 100644 --- a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift @@ -20,11 +20,13 @@ private let DefaultPageSize = 50 // and I don't want additional views for it. Is there a way we can transform a // `BaseItemPerson` into a `BaseItemDto` and just use the concrete type? -// TODO: how to indicate that this is performing some kind of background action (ie: RandomItem) -// *without* being in an explicit state? // TODO: fix how `hasNextPage` is determined // - some subclasses might not have "paging" and only have one call. This can be solved with // a check if elements were actually appended to the set but that requires a redundant get +// TODO: this doesn't allow "scrolling" to an item if index > pageSize +// on refresh. Should make bidirectional/offset index start? +// - use startIndex/index ranges instead of pages +// - source of data doesn't guarantee that all items in 0 ..< startIndex exist class PagingLibraryViewModel: ViewModel, Eventful, Stateful { // MARK: Event @@ -36,42 +38,35 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { // MARK: Action enum Action: Equatable { - case error(LibraryError) + case error(JellyfinAPIError) case refresh case getNextPage case getRandomItem } + // MARK: BackgroundState + + enum BackgroundState: Hashable { + case gettingNextPage + } + // MARK: State - enum State: Equatable { + enum State: Hashable { case content - case error(LibraryError) - case gettingNextPage + case error(JellyfinAPIError) case initial case refreshing } - // TODO: wrap Get HTTP and NSURL errors either here - // or in a general implementation - enum LibraryError: LocalizedError { - case unableToGetPage - case unableToGetRandomItem - - var errorDescription: String? { - switch self { - case .unableToGetPage: - "Unable to get page" - case .unableToGetRandomItem: - "Unable to get random item" - } - } - } - + @Published + final var backgroundStates: OrderedSet = [] @Published final var elements: OrderedSet @Published final var state: State = .initial + @Published + final var lastAction: Action? = nil final let filterViewModel: FilterViewModel? final let parent: (any LibraryParent)? @@ -97,19 +92,27 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { // MARK: init + // static init( _ data: some Collection, - parent: (any LibraryParent)? = nil, - pageSize: Int = DefaultPageSize + parent: (any LibraryParent)? = nil ) { self.filterViewModel = nil self.elements = OrderedSet(data) self.isStatic = true self.hasNextPage = false - self.pageSize = pageSize + self.pageSize = DefaultPageSize self.parent = parent } + convenience init( + title: String, + _ data: some Collection + ) { + self.init(data, parent: TitledLibraryParent(displayTitle: title)) + } + + // paging init( parent: (any LibraryParent)? = nil, filters: ItemFilterCollection? = nil, @@ -152,7 +155,11 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { filters: ItemFilterCollection = .default, pageSize: Int = DefaultPageSize ) { - self.init(parent: TitledLibraryParent(displayTitle: title), filters: filters, pageSize: pageSize) + self.init( + parent: TitledLibraryParent(displayTitle: title), + filters: filters, + pageSize: pageSize + ) } // MARK: respond @@ -198,7 +205,7 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { guard !Task.isCancelled else { return } await MainActor.run { - self.send(.error(.unableToGetPage)) + self.send(.error(.init(error.localizedDescription))) } } } @@ -209,6 +216,8 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { guard hasNextPage else { return state } + backgroundStates.append(.gettingNextPage) + pagingTask = Task { [weak self] in do { try await self?.getNextPage() @@ -216,19 +225,21 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { guard !Task.isCancelled else { return } await MainActor.run { + self?.backgroundStates.remove(.gettingNextPage) self?.state = .content } } catch { guard !Task.isCancelled else { return } await MainActor.run { - self?.state = .error(.unableToGetPage) + self?.backgroundStates.remove(.gettingNextPage) + self?.state = .error(.init(error.localizedDescription)) } } } .asAnyCancellable() - return .gettingNextPage + return .content case .getRandomItem: randomItemTask = Task { [weak self] in diff --git a/Shared/ViewModels/MediaViewModel/MediaViewModel.swift b/Shared/ViewModels/MediaViewModel/MediaViewModel.swift index edea5df0..09ad56e1 100644 --- a/Shared/ViewModels/MediaViewModel/MediaViewModel.swift +++ b/Shared/ViewModels/MediaViewModel/MediaViewModel.swift @@ -18,14 +18,14 @@ final class MediaViewModel: ViewModel, Stateful { // MARK: Action - enum Action { + enum Action: Equatable { case error(JellyfinAPIError) case refresh } // MARK: State - enum State: Equatable { + enum State: Hashable { case content case error(JellyfinAPIError) case initial @@ -36,7 +36,9 @@ final class MediaViewModel: ViewModel, Stateful { var mediaItems: OrderedSet = [] @Published - var state: State = .initial + final var state: State = .initial + @Published + final var lastAction: Action? = nil func respond(to action: Action) -> State { switch action { diff --git a/Shared/ViewModels/SearchViewModel.swift b/Shared/ViewModels/SearchViewModel.swift index 27c5de65..eb19c7ac 100644 --- a/Shared/ViewModels/SearchViewModel.swift +++ b/Shared/ViewModels/SearchViewModel.swift @@ -9,13 +9,14 @@ import Combine import Foundation import JellyfinAPI +import OrderedCollections import SwiftUI final class SearchViewModel: ViewModel, Stateful { // MARK: Action - enum Action { + enum Action: Equatable { case error(JellyfinAPIError) case getSuggestions case search(query: String) @@ -23,7 +24,7 @@ final class SearchViewModel: ViewModel, Stateful { // MARK: State - enum State: Equatable { + enum State: Hashable { case content case error(JellyfinAPIError) case initial @@ -31,20 +32,22 @@ final class SearchViewModel: ViewModel, Stateful { } @Published - var collections: [BaseItemDto] = [] + private(set) var collections: [BaseItemDto] = [] @Published - var episodes: [BaseItemDto] = [] + private(set) var episodes: [BaseItemDto] = [] @Published - var movies: [BaseItemDto] = [] + private(set) var movies: [BaseItemDto] = [] @Published - var people: [BaseItemDto] = [] + private(set) var people: [BaseItemDto] = [] @Published - var series: [BaseItemDto] = [] + private(set) var series: [BaseItemDto] = [] @Published - var suggestions: [BaseItemDto] = [] + private(set) var suggestions: [BaseItemDto] = [] @Published - var state: State = .initial + final var state: State = .initial + @Published + final var lastAction: Action? = nil private var searchTask: AnyCancellable? private var searchQuery: CurrentValueSubject = .init("") @@ -179,9 +182,7 @@ final class SearchViewModel: ViewModel, Stateful { } } catch { - guard !Task.isCancelled else { print("search was cancelled") - return - } + guard !Task.isCancelled else { return } await MainActor.run { self.send(.error(.init(error.localizedDescription))) diff --git a/Shared/ViewModels/VideoPlayerManager/VideoPlayerManager.swift b/Shared/ViewModels/VideoPlayerManager/VideoPlayerManager.swift index f7fbd64f..00ad685d 100644 --- a/Shared/ViewModels/VideoPlayerManager/VideoPlayerManager.swift +++ b/Shared/ViewModels/VideoPlayerManager/VideoPlayerManager.swift @@ -202,6 +202,9 @@ class VideoPlayerManager: ViewModel { func sendStopReport() { + let ids = ["itemID": currentViewModel.item.id, "seriesID": currentViewModel.item.parentID] + Notifications[.itemMetadataDidChange].post(object: ids) + #if DEBUG guard Defaults[.sendProgressReports] else { return } #endif diff --git a/Swiftfin tvOS/Components/PagingLibraryView.swift b/Swiftfin tvOS/Components/PagingLibraryView.swift index 5f3f8dfc..9251de99 100644 --- a/Swiftfin tvOS/Components/PagingLibraryView.swift +++ b/Swiftfin tvOS/Components/PagingLibraryView.swift @@ -174,7 +174,7 @@ struct PagingLibraryView: View { Text(error.localizedDescription) case .initial, .refreshing: ProgressView() - case .gettingNextPage, .content: + case .content: if viewModel.elements.isEmpty { L10n.noResults.text } else { diff --git a/Swiftfin tvOS/Views/HomeView/HomeView.swift b/Swiftfin tvOS/Views/HomeView/HomeView.swift index 293d5cb5..9ba67ff2 100644 --- a/Swiftfin tvOS/Views/HomeView/HomeView.swift +++ b/Swiftfin tvOS/Views/HomeView/HomeView.swift @@ -61,5 +61,11 @@ struct HomeView: View { viewModel.send(.refresh) } .ignoresSafeArea() + .afterLastDisappear { interval in + if interval > 60 || viewModel.notificationsReceived.contains(.itemMetadataDidChange) { + viewModel.send(.backgroundRefresh) + viewModel.notificationsReceived.remove(.itemMetadataDidChange) + } + } } } diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift index 3699cd87..f33c2069 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift @@ -18,10 +18,10 @@ extension ItemView { var body: some View { HStack { Button { - viewModel.toggleWatchState() + viewModel.send(.toggleIsPlayed) } label: { Group { - if viewModel.isPlayed { + if viewModel.item.userData?.isPlayed ?? false { Image(systemName: "checkmark.circle.fill") .paletteOverlayRendering(color: .white) } else { @@ -35,10 +35,10 @@ extension ItemView { .buttonStyle(.plain) Button { - viewModel.toggleFavoriteState() + viewModel.send(.toggleIsFavorite) } label: { Group { - if viewModel.isFavorited { + if viewModel.item.userData?.isFavorite ?? false { Image(systemName: "heart.circle.fill") .symbolRenderingMode(.palette) .foregroundStyle(.white, .pink) diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift new file mode 100644 index 00000000..f8d78749 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift @@ -0,0 +1,66 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Factory +import JellyfinAPI +import SwiftUI + +extension SeriesEpisodeSelector { + + struct EpisodeCard: View { + + @EnvironmentObject + private var router: ItemCoordinator.Router + + let episode: BaseItemDto + + var body: some View { + PosterButton( + item: episode, + type: .landscape, + singleImage: true + ) + .content { + let content: String = if episode.isUnaired { + episode.airDateLabel ?? L10n.noOverviewAvailable + } else { + episode.overview ?? L10n.noOverviewAvailable + } + + SeriesEpisodeSelector.EpisodeContent( + subHeader: episode.episodeLocator ?? .emptyDash, + header: episode.displayTitle, + content: content + ) + .onSelect { + router.route(to: \.item, episode) + } + } + .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 { return } + router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: episode, mediaSource: mediaSource)) + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift new file mode 100644 index 00000000..17a62dbb --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +extension SeriesEpisodeSelector { + + struct EpisodeContent: View { + + @Default(.accentColor) + private var accentColor + + private var onSelect: () -> Void + + let subHeader: String + let header: String + let content: String + + @ViewBuilder + private var subHeaderView: some View { + Text(subHeader) + .font(.caption) + .foregroundColor(.secondary) + } + + @ViewBuilder + private var headerView: some View { + Text(header) + .font(.footnote) + .foregroundColor(.primary) + .lineLimit(1) + .multilineTextAlignment(.leading) + .padding(.bottom, 1) + } + + @ViewBuilder + private var contentView: some View { + Text(content) + .font(.caption.weight(.light)) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + .backport + .lineLimit(3, reservesSpace: true) + } + + var body: some View { + Button { + onSelect() + } label: { + VStack(alignment: .leading) { + subHeaderView + + headerView + + contentView + + L10n.seeMore.text + .font(.caption.weight(.light)) + .foregroundStyle(accentColor) + } + .padding(5) + } + .buttonStyle(.card) + } + } +} + +extension SeriesEpisodeSelector.EpisodeContent { + + init( + subHeader: String, + header: String, + content: String + ) { + self.subHeader = subHeader + self.header = header + self.content = content + self.onSelect = {} + } + + func onSelect(perform action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift new file mode 100644 index 00000000..efd02955 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.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) 2024 Jellyfin & Jellyfin Contributors +// + +import CollectionHStack +import Foundation +import JellyfinAPI +import SwiftUI + +extension SeriesEpisodeSelector { + + struct EpisodeHStack: View { + + @EnvironmentObject + private var focusGuide: FocusGuide + + @FocusState + private var focusedEpisodeID: String? + + @ObservedObject + var viewModel: SeasonItemViewModel + + @State + private var didScrollToPlayButtonItem = false + @State + private var lastFocusedEpisodeID: String? + + @StateObject + private var proxy = CollectionHStackProxy() + + let playButtonItem: BaseItemDto? + + private func contentView(viewModel: SeasonItemViewModel) -> some View { + CollectionHStack( + $viewModel.elements, + columns: 3.5 + ) { episode in + SeriesEpisodeSelector.EpisodeCard(episode: episode) + .focused($focusedEpisodeID, equals: episode.id) + } + .scrollBehavior(.continuousLeadingEdge) + .insets(horizontal: EdgeInsets.defaultEdgePadding) + .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + .proxy(proxy) + .onFirstAppear { + guard !didScrollToPlayButtonItem else { return } + didScrollToPlayButtonItem = true + + lastFocusedEpisodeID = playButtonItem?.id + + // good enough? + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard let playButtonItem else { return } + proxy.scrollTo(element: playButtonItem, animated: false) + } + } + } + + var body: some View { + WrappedView { + switch viewModel.state { + case .content: + contentView(viewModel: viewModel) + case let .error(error): + ErrorHStack(viewModel: viewModel, error: error) + case .initial, .refreshing: + LoadingHStack() + } + } + .focusSection() + .focusGuide( + focusGuide, + tag: "episodes", + onContentFocus: { focusedEpisodeID = lastFocusedEpisodeID }, + top: "seasons" + ) + .onChange(of: viewModel) { newValue in + lastFocusedEpisodeID = newValue.elements.first?.id + } + .onChange(of: focusedEpisodeID) { newValue in + guard let newValue else { return } + lastFocusedEpisodeID = newValue + } + } + } + + struct ErrorHStack: View { + + @ObservedObject + var viewModel: SeasonItemViewModel + + let error: JellyfinAPIError + + var body: some View { + CollectionHStack( + 0 ..< 1, + columns: 3.5 + ) { _ in + SeriesEpisodeSelector.ErrorCard(error: error) + .onSelect { + viewModel.send(.refresh) + } + } + .allowScrolling(false) + .insets(horizontal: EdgeInsets.defaultEdgePadding) + .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + } + } + + struct LoadingHStack: View { + + var body: some View { + CollectionHStack( + 0 ..< Int.random(in: 2 ..< 5), + columns: 3.5 + ) { _ in + SeriesEpisodeSelector.LoadingCard() + } + .allowScrolling(false) + .insets(horizontal: EdgeInsets.defaultEdgePadding) + .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift new file mode 100644 index 00000000..4b308386 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.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) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension SeriesEpisodeSelector { + + struct ErrorCard: View { + + let error: JellyfinAPIError + private var onSelect: () -> Void + + init(error: JellyfinAPIError) { + self.error = error + self.onSelect = {} + } + + func onSelect(perform action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } + + var body: some View { + Button { + onSelect() + } label: { + VStack(alignment: .leading) { + Color.secondarySystemFill + .opacity(0.75) + .posterStyle(.landscape) + .overlay { + Image(systemName: "arrow.clockwise.circle.fill") + .font(.system(size: 40)) + } + + SeriesEpisodeSelector.EpisodeContent( + subHeader: .emptyDash, + header: L10n.error, + content: error.localizedDescription + ) + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift new file mode 100644 index 00000000..a9be264d --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.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) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI +import SwiftUI + +extension SeriesEpisodeSelector { + + struct LoadingCard: View { + + var body: some View { + VStack(alignment: .leading) { + Color.secondarySystemFill + .opacity(0.75) + .posterStyle(.landscape) + + SeriesEpisodeSelector.EpisodeContent( + subHeader: String.random(count: 7 ..< 12), + header: String.random(count: 10 ..< 20), + content: String.random(count: 20 ..< 80) + ) + .redacted(reason: .placeholder) + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodeSelector.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift similarity index 51% rename from Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodeSelector.swift rename to Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift index bbbd13f2..0b11e5b6 100644 --- a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodeSelector.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift @@ -19,13 +19,40 @@ struct SeriesEpisodeSelector: View { @EnvironmentObject private var parentFocusGuide: FocusGuide + @State + private var didSelectPlayButtonSeason = false + @State + private var selection: SeasonItemViewModel? + var body: some View { VStack(spacing: 0) { - SeasonsHStack(viewModel: viewModel) + SeasonsHStack(viewModel: viewModel, selection: $selection) .environmentObject(parentFocusGuide) - EpisodesHStack(viewModel: viewModel) - .environmentObject(parentFocusGuide) + if let selection { + EpisodeHStack(viewModel: selection, playButtonItem: viewModel.playButtonItem) + .environmentObject(parentFocusGuide) + } else { + LoadingHStack() + } + } + .onReceive(viewModel.playButtonItem.publisher) { newValue in + + guard !didSelectPlayButtonSeason else { return } + didSelectPlayButtonSeason = true + + if let season = viewModel.seasons.first(where: { $0.season.id == newValue.seasonID }) { + selection = season + } else { + selection = viewModel.seasons.first + } + } + .onChange(of: selection) { newValue in + guard let newValue else { return } + + if newValue.state == .initial { + newValue.send(.refresh) + } } } } @@ -36,39 +63,41 @@ extension SeriesEpisodeSelector { struct SeasonsHStack: View { - @ObservedObject - var viewModel: SeriesItemViewModel - @EnvironmentObject private var focusGuide: FocusGuide @FocusState - private var focusedSeason: BaseItemDto? + private var focusedSeason: SeasonItemViewModel? + + @ObservedObject + var viewModel: SeriesItemViewModel + + var selection: Binding var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack { - ForEach(viewModel.menuSections.keys.sorted(by: { viewModel.menuSectionSort($0, $1) }), id: \.self) { season in + ForEach(viewModel.seasons, id: \.season.id) { seasonViewModel in Button { - Text(season.displayTitle) + Text(seasonViewModel.season.displayTitle) .font(.headline) .fontWeight(.semibold) .padding(.vertical, 10) .padding(.horizontal, 20) - .if(viewModel.menuSelection == season) { text in + .if(selection.wrappedValue == seasonViewModel) { text in text .background(Color.white) .foregroundColor(.black) } } .buttonStyle(.card) - .focused($focusedSeason, equals: season) + .focused($focusedSeason, equals: seasonViewModel) } } .focusGuide( focusGuide, tag: "seasons", - onContentFocus: { focusedSeason = viewModel.menuSelection }, + onContentFocus: { focusedSeason = selection.wrappedValue }, top: "top", bottom: "episodes" ) @@ -77,41 +106,6 @@ extension SeriesEpisodeSelector { .padding(.top) .padding(.bottom, 45) } - .onChange(of: focusedSeason) { season in - guard let season = season else { return } - viewModel.select(section: season) - } - } - } -} - -extension SeriesEpisodeSelector { - - // MARK: EpisodesHStack - - struct EpisodesHStack: View { - - @ObservedObject - var viewModel: SeriesItemViewModel - - @EnvironmentObject - private var focusGuide: FocusGuide - @FocusState - private var focusedEpisodeID: String? - @State - private var lastFocusedEpisodeID: String? - @State - private var wrappedScrollView: UIScrollView? - - var contentView: some View { - CollectionHStack( - $viewModel.currentItems, - columns: 3.5 - ) { item in - EpisodeCard(episode: item) - .focused($focusedEpisodeID, equals: item.id) - } - .insets(vertical: 20) .mask { VStack(spacing: 0) { Color.white @@ -127,31 +121,9 @@ extension SeriesEpisodeSelector { .frame(height: 20) } } - .transition(.opacity) - .focusSection() - .focusGuide( - focusGuide, - tag: "episodes", - onContentFocus: { focusedEpisodeID = lastFocusedEpisodeID }, - top: "seasons" - ) - .onChange(of: viewModel.menuSelection) { _ in - lastFocusedEpisodeID = viewModel.currentItems.first?.id - } - .onChange(of: focusedEpisodeID) { episodeIndex in - guard let episodeIndex = episodeIndex else { return } - lastFocusedEpisodeID = episodeIndex - } - .onChange(of: viewModel.currentItems) { _ in - lastFocusedEpisodeID = viewModel.currentItems.first?.id - } - } - - var body: some View { - if viewModel.currentItems.isEmpty { - EmptyView() - } else { - contentView + .onChange(of: focusedSeason) { newValue in + guard let newValue else { return } + selection.wrappedValue = newValue } } } diff --git a/Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift b/Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift index a75b995d..a82f598c 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift @@ -25,6 +25,14 @@ extension ItemView { @FocusState private var isFocused: Bool + private var title: String { + if let seriesViewModel = viewModel as? SeriesItemViewModel { + return seriesViewModel.playButtonItem?.seasonEpisodeLabel ?? L10n.play + } else { + return viewModel.playButtonItem?.playButtonLabel ?? L10n.play + } + } + var body: some View { Button { if let playButtonItem = viewModel.playButtonItem, let selectedMediaSource = viewModel.selectedMediaSource { @@ -37,7 +45,8 @@ extension ItemView { Image(systemName: "play.fill") .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) .font(.title3) - Text(viewModel.playButtonText()) + + Text(title) .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) .fontWeight(.semibold) } diff --git a/Swiftfin tvOS/Views/ItemView/ItemView.swift b/Swiftfin tvOS/Views/ItemView/ItemView.swift index cf5e4e41..7bce0851 100644 --- a/Swiftfin tvOS/Views/ItemView/ItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/ItemView.swift @@ -13,24 +13,59 @@ import SwiftUI struct ItemView: View { - private let item: BaseItemDto + @StateObject + private var viewModel: ItemViewModel + + private static func typeViewModel(for item: BaseItemDto) -> ItemViewModel { + switch item.type { + case .boxSet: + return CollectionItemViewModel(item: item) + case .episode: + return EpisodeItemViewModel(item: item) + case .movie: + return MovieItemViewModel(item: item) + case .series: + return SeriesItemViewModel(item: item) + default: + assertionFailure("Unsupported item") + return ItemViewModel(item: item) + } + } init(item: BaseItemDto) { - self.item = item + self._viewModel = StateObject(wrappedValue: Self.typeViewModel(for: item)) + } + + @ViewBuilder + private var contentView: some View { + switch viewModel.item.type { + case .boxSet: + CollectionItemView(viewModel: viewModel as! CollectionItemViewModel) + case .episode: + EpisodeItemView(viewModel: viewModel as! EpisodeItemViewModel) + case .movie: + MovieItemView(viewModel: viewModel as! MovieItemViewModel) + case .series: + SeriesItemView(viewModel: viewModel as! SeriesItemViewModel) + default: + Text(L10n.notImplementedYetWithType(viewModel.item.type ?? "--")) + } } var body: some View { - switch item.type { - case .movie: - MovieItemView(viewModel: .init(item: item)) - case .episode: - EpisodeItemView(viewModel: .init(item: item)) - case .series: - SeriesItemView(viewModel: .init(item: item)) - case .boxSet: - CollectionItemView(viewModel: .init(item: item)) - default: - Text(L10n.notImplementedYetWithType(item.type ?? "--")) + WrappedView { + switch viewModel.state { + case .content: + contentView + case let .error(error): + Text(error.localizedDescription) + case .initial, .refreshing: + ProgressView() + } + } + .transition(.opacity.animation(.linear(duration: 0.2))) + .onFirstAppear { + viewModel.send(.refresh) } } } diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift deleted file mode 100644 index 5fd8e999..00000000 --- a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import Defaults -import 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 - - let episode: BaseItemDto - - var body: some View { - PosterButton( - item: episode, - type: .landscape, - singleImage: true - ) - .content { - Button { - router.route(to: \.item, episode) - } label: { - VStack(alignment: .leading) { - - VStack(alignment: .leading, spacing: 0) { - Color.clear - .frame(height: 0.01) - .frame(maxWidth: .infinity) - - Text(episode.episodeLocator ?? L10n.unknown) - .font(.caption) - .foregroundColor(.secondary) - } - - Text(episode.displayTitle) - .font(.footnote) - .padding(.bottom, 1) - - if episode.isUnaired { - Text(episode.airDateLabel ?? L10n.noOverviewAvailable) - .font(.caption) - .lineLimit(1) - } else { - Text(episode.overview ?? L10n.noOverviewAvailable) - .font(.caption) - .lineLimit(3) - } - - Spacer(minLength: 0) - - L10n.seeMore.text - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.jellyfinPurple) - } - .aspectRatio(510 / 220, contentMode: .fill) - .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/SeriesItemContentView.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift index beccadbb..8a31864d 100644 --- a/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift +++ b/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift @@ -28,8 +28,10 @@ extension SeriesItemView { .frame(height: UIScreen.main.bounds.height - 150) .padding(.bottom, 50) - SeriesEpisodeSelector(viewModel: viewModel) - .environmentObject(focusGuide) + if viewModel.seasons.isNotEmpty { + SeriesEpisodeSelector(viewModel: viewModel) + .environmentObject(focusGuide) + } if let castAndCrew = viewModel.item.people, castAndCrew.isNotEmpty { ItemView.CastAndCrewHStack(people: castAndCrew) diff --git a/Swiftfin tvOS/Views/SearchView.swift b/Swiftfin tvOS/Views/SearchView.swift index 18a790b6..7642b4dc 100644 --- a/Swiftfin tvOS/Views/SearchView.swift +++ b/Swiftfin tvOS/Views/SearchView.swift @@ -74,7 +74,7 @@ struct SearchView: View { @ViewBuilder private func itemsSection( title: String, - keyPath: ReferenceWritableKeyPath, + keyPath: KeyPath, posterType: PosterType ) -> some View { PosterHStack( diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 72d794e0..e5e0ccd5 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -178,8 +178,6 @@ E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; }; E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */; }; E104C873296E0D0A00C1C3F9 /* IndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104C872296E0D0A00C1C3F9 /* IndicatorSettingsView.swift */; }; - E104DC902B9D8995008F506D /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E104DC8F2B9D8995008F506D /* CollectionVGrid */; }; - E104DC942B9D89A2008F506D /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E104DC932B9D89A2008F506D /* CollectionVGrid */; }; E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104DC952B9E7E29008F506D /* AssertionFailureView.swift */; }; E104DC972B9E7E29008F506D /* AssertionFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104DC952B9E7E29008F506D /* AssertionFailureView.swift */; }; E10706102942F57D00646DAF /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = E107060F2942F57D00646DAF /* Pulse */; }; @@ -212,6 +210,17 @@ E113A2A72B5A178D009CAAAA /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E113A2A62B5A178D009CAAAA /* CollectionHStack */; }; E113A2AA2B5A179A009CAAAA /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E113A2A92B5A179A009CAAAA /* CollectionVGrid */; }; E114DB332B1944FA00B75FB3 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E114DB322B1944FA00B75FB3 /* CollectionVGrid */; }; + E1153D942BBA3D3000424D36 /* EpisodeContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1153D932BBA3D3000424D36 /* EpisodeContent.swift */; }; + E1153D962BBA3E2F00424D36 /* EpisodeHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1153D952BBA3E2F00424D36 /* EpisodeHStack.swift */; }; + E1153D9A2BBA3E9800424D36 /* ErrorCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1153D992BBA3E9800424D36 /* ErrorCard.swift */; }; + E1153D9C2BBA3E9D00424D36 /* LoadingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1153D9B2BBA3E9D00424D36 /* LoadingCard.swift */; }; + E1153DA42BBA614F00424D36 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DA32BBA614F00424D36 /* CollectionVGrid */; }; + E1153DA72BBA641000424D36 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DA62BBA641000424D36 /* CollectionVGrid */; }; + E1153DA92BBA642A00424D36 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DA82BBA642A00424D36 /* CollectionVGrid */; }; + E1153DAC2BBA6AD200424D36 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DAB2BBA6AD200424D36 /* CollectionHStack */; }; + E1153DAF2BBA734200424D36 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DAE2BBA734200424D36 /* CollectionHStack */; }; + E1153DB12BBA734C00424D36 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DB02BBA734C00424D36 /* CollectionHStack */; }; + E1153DB42BBA80FB00424D36 /* EmptyCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1153DB22BBA80B400424D36 /* EmptyCard.swift */; }; E1171A1928A2212600FA1AF5 /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */; }; E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E118959C289312020042947B /* BaseItemPerson+Poster.swift */; }; E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E118959C289312020042947B /* BaseItemPerson+Poster.swift */; }; @@ -285,8 +294,6 @@ E1388A43293F0AAD009721B1 /* PreferenceUIHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1388A41293F0AAD009721B1 /* PreferenceUIHostingController.swift */; }; E1388A46293F0ABA009721B1 /* SwizzleSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E1388A45293F0ABA009721B1 /* SwizzleSwift */; }; E1392FED2BA218A80034110D /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = E1392FEC2BA218A80034110D /* SwiftUIIntrospect */; }; - E1392FF22BA21B360034110D /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1392FF12BA21B360034110D /* CollectionHStack */; }; - E1392FF42BA21B470034110D /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1392FF32BA21B470034110D /* CollectionHStack */; }; 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 */; }; @@ -446,6 +453,9 @@ 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 */; }; + E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E172D3AC2BAC9DF8007B4647 /* SeasonItemViewModel.swift */; }; + E172D3AE2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E172D3AC2BAC9DF8007B4647 /* SeasonItemViewModel.swift */; }; + E172D3B22BACA569007B4647 /* EpisodeContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E172D3B12BACA569007B4647 /* EpisodeContent.swift */; }; E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */; }; E173DA5226D04AAF00CC4EB7 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* Color.swift */; }; E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; }; @@ -496,6 +506,7 @@ E18CE0B228A229E70092E7F1 /* UserDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B128A229E70092E7F1 /* UserDto.swift */; }; E18CE0B428A22EDA0092E7F1 /* RepeatingTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */; }; E18CE0B928A2322D0092E7F1 /* QuickConnectCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B828A2322D0092E7F1 /* QuickConnectCoordinator.swift */; }; + E18D6AA62BAA96F000A0D167 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E18D6AA52BAA96F000A0D167 /* CollectionHStack */; }; E18E01AB288746AF0022598C /* PillHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A5288746AF0022598C /* PillHStack.swift */; }; E18E01AD288746AF0022598C /* DotHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A7288746AF0022598C /* DotHStack.swift */; }; E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01B6288747230022598C /* iPadOSEpisodeContentView.swift */; }; @@ -574,6 +585,12 @@ E1A1529128FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528F28FD23D600600579 /* PlaybackSettingsCoordinator.swift */; }; E1A16C9D2889AF1E00EA4679 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A16C9C2889AF1E00EA4679 /* AboutView.swift */; }; E1A2C154279A7D5A005EC829 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C153279A7D5A005EC829 /* UIApplication.swift */; }; + E1A3E4C72BB74E50005C59F8 /* EpisodeCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A3E4C62BB74E50005C59F8 /* EpisodeCard.swift */; }; + E1A3E4C92BB74EA3005C59F8 /* LoadingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A3E4C82BB74EA3005C59F8 /* LoadingCard.swift */; }; + E1A3E4CB2BB74EFD005C59F8 /* EpisodeHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A3E4CA2BB74EFD005C59F8 /* EpisodeHStack.swift */; }; + E1A3E4CD2BB7D8C8005C59F8 /* iOSLabelExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A3E4CC2BB7D8C8005C59F8 /* iOSLabelExtensions.swift */; }; + E1A3E4CF2BB7E02B005C59F8 /* DelayedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A3E4CE2BB7E02B005C59F8 /* DelayedProgressView.swift */; }; + E1A3E4D12BB7F5BF005C59F8 /* ErrorCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A3E4D02BB7F5BF005C59F8 /* ErrorCard.swift */; }; E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */; }; E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */; }; E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */; }; @@ -626,7 +643,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 /* SeriesEpisodeSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926072887565C002A7A66 /* SeriesEpisodeSelector.swift */; }; + E1C926132887565C002A7A66 /* EpisodeSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926072887565C002A7A66 /* EpisodeSelector.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 */; }; @@ -674,7 +691,7 @@ 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 */; }; - E1DA656F28E78C9900592A73 /* SeriesEpisodeSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA656E28E78C9900592A73 /* SeriesEpisodeSelector.swift */; }; + E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA656E28E78C9900592A73 /* EpisodeSelector.swift */; }; E1DABAFA2A270E62008AC34A /* OverviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DABAF92A270E62008AC34A /* OverviewCard.swift */; }; E1DABAFC2A270EE7008AC34A /* MediaSourcesCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DABAFB2A270EE7008AC34A /* MediaSourcesCard.swift */; }; E1DABAFE2A27B982008AC34A /* RatingsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DABAFD2A27B982008AC34A /* RatingsCard.swift */; }; @@ -951,6 +968,11 @@ E113133528BE98AA00930F75 /* FilterDrawerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterDrawerButton.swift; sourceTree = ""; }; E113133728BEADBA00930F75 /* LibraryParent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryParent.swift; sourceTree = ""; }; E113133928BEB71D00930F75 /* FilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterViewModel.swift; sourceTree = ""; }; + E1153D932BBA3D3000424D36 /* EpisodeContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeContent.swift; sourceTree = ""; }; + E1153D952BBA3E2F00424D36 /* EpisodeHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeHStack.swift; sourceTree = ""; }; + E1153D992BBA3E9800424D36 /* ErrorCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorCard.swift; sourceTree = ""; }; + E1153D9B2BBA3E9D00424D36 /* LoadingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCard.swift; sourceTree = ""; }; + E1153DB22BBA80B400424D36 /* EmptyCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyCard.swift; sourceTree = ""; }; E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectView.swift; sourceTree = ""; }; E118959C289312020042947B /* BaseItemPerson+Poster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemPerson+Poster.swift"; sourceTree = ""; }; E11895A8289383BC0042947B /* ScrollViewOffsetModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewOffsetModifier.swift; sourceTree = ""; }; @@ -1069,6 +1091,8 @@ 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 = ""; }; + E172D3AC2BAC9DF8007B4647 /* SeasonItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemViewModel.swift; sourceTree = ""; }; + E172D3B12BACA569007B4647 /* EpisodeContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeContent.swift; sourceTree = ""; }; E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.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 = ""; }; @@ -1155,6 +1179,12 @@ E1A1528F28FD23D600600579 /* PlaybackSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSettingsCoordinator.swift; sourceTree = ""; }; E1A16C9C2889AF1E00EA4679 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; E1A2C153279A7D5A005EC829 /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; + E1A3E4C62BB74E50005C59F8 /* EpisodeCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeCard.swift; sourceTree = ""; }; + E1A3E4C82BB74EA3005C59F8 /* LoadingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCard.swift; sourceTree = ""; }; + E1A3E4CA2BB74EFD005C59F8 /* EpisodeHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeHStack.swift; sourceTree = ""; }; + E1A3E4CC2BB7D8C8005C59F8 /* iOSLabelExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSLabelExtensions.swift; sourceTree = ""; }; + E1A3E4CE2BB7E02B005C59F8 /* DelayedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelayedProgressView.swift; sourceTree = ""; }; + E1A3E4D02BB7F5BF005C59F8 /* ErrorCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorCard.swift; sourceTree = ""; }; E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemSelector.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 = ""; }; @@ -1196,7 +1226,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 /* SeriesEpisodeSelector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesEpisodeSelector.swift; sourceTree = ""; }; + E1C926072887565C002A7A66 /* EpisodeSelector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeSelector.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 = ""; }; @@ -1234,7 +1264,7 @@ 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 = ""; }; - E1DA656E28E78C9900592A73 /* SeriesEpisodeSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesEpisodeSelector.swift; sourceTree = ""; }; + E1DA656E28E78C9900592A73 /* EpisodeSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeSelector.swift; sourceTree = ""; }; E1DABAF92A270E62008AC34A /* OverviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewCard.swift; sourceTree = ""; }; E1DABAFB2A270EE7008AC34A /* MediaSourcesCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourcesCard.swift; sourceTree = ""; }; E1DABAFD2A27B982008AC34A /* RatingsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingsCard.swift; sourceTree = ""; }; @@ -1330,10 +1360,10 @@ 62666E1F27E501DF00EC0ECD /* CoreText.framework in Frameworks */, E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */, E1A7B1652B9A9F7800152546 /* PreferencesView in Frameworks */, - E104DC942B9D89A2008F506D /* CollectionVGrid in Frameworks */, + E1153DA92BBA642A00424D36 /* CollectionVGrid in Frameworks */, 62666E1527E501C800EC0ECD /* AVFoundation.framework in Frameworks */, E13AF3BC28A0C59E009093AB /* BlurHashKit in Frameworks */, - E1392FF42BA21B470034110D /* CollectionHStack in Frameworks */, + E1153DB12BBA734C00424D36 /* CollectionHStack in Frameworks */, 62666E1327E501C300EC0ECD /* AudioToolbox.framework in Frameworks */, E13AF3B628A0C598009093AB /* Nuke in Frameworks */, E12186DE2718F1C50010884C /* Defaults in Frameworks */, @@ -1353,8 +1383,8 @@ E1002B682793CFBA00E47059 /* Algorithms in Frameworks */, E113A2AA2B5A179A009CAAAA /* CollectionVGrid in Frameworks */, 62666E1127E501B900EC0ECD /* UIKit.framework in Frameworks */, - E104DC902B9D8995008F506D /* CollectionVGrid in Frameworks */, E15210582946DF1B00375CC2 /* PulseUI in Frameworks */, + E1153DA42BBA614F00424D36 /* CollectionVGrid in Frameworks */, 62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */, 62666E0327E5017100EC0ECD /* CoreMedia.framework in Frameworks */, E10706122942F57D00646DAF /* PulseLogHandler in Frameworks */, @@ -1372,16 +1402,18 @@ 62666E0C27E501A500EC0ECD /* OpenGLES.framework in Frameworks */, E19E6E0A28A0BEFF005C10C8 /* BlurHashKit in Frameworks */, E1FAD1C62A0375BA007F5521 /* UDPBroadcast in Frameworks */, + E18D6AA62BAA96F000A0D167 /* CollectionHStack in Frameworks */, 62666E0127E5016900EC0ECD /* CoreFoundation.framework in Frameworks */, E14CB6862A9FF62A001586C6 /* JellyfinAPI in Frameworks */, 62666E2427E501F300EC0ECD /* Foundation.framework in Frameworks */, E18A8E7A28D5FEDF00333B9A /* VLCUI in Frameworks */, + E1153DA72BBA641000424D36 /* CollectionVGrid in Frameworks */, E114DB332B1944FA00B75FB3 /* CollectionVGrid in Frameworks */, 53352571265EA0A0006CCA86 /* Introspect in Frameworks */, E15210562946DF1B00375CC2 /* PulseLogHandler in Frameworks */, + E1153DAF2BBA734200424D36 /* CollectionHStack in Frameworks */, 62666E0427E5017500EC0ECD /* CoreText.framework in Frameworks */, E13DD3C62716499E009D4DAF /* CoreStore in Frameworks */, - E1392FF22BA21B360034110D /* CollectionHStack in Frameworks */, 62666E0E27E501AF00EC0ECD /* Security.framework in Frameworks */, E1DC9814296DC06200982F06 /* PulseLogHandler in Frameworks */, E15EFA842BA167350080E926 /* CollectionHStack in Frameworks */, @@ -1389,6 +1421,7 @@ 62666DFE27E5015700EC0ECD /* AVFoundation.framework in Frameworks */, 62666DFD27E5014F00EC0ECD /* AudioToolbox.framework in Frameworks */, E19E6E0528A0B958005C10C8 /* Nuke in Frameworks */, + E1153DAC2BBA6AD200424D36 /* CollectionHStack in Frameworks */, 62666E0D27E501AA00EC0ECD /* QuartzCore.framework in Frameworks */, E15D4F052B1B0C3C00442DB8 /* PreferencesView in Frameworks */, E19E6E0728A0B958005C10C8 /* NukeUI in Frameworks */, @@ -1801,6 +1834,7 @@ E1D8429429346C6400D1041A /* BasicStepper.swift */, E1A1528728FD229500600579 /* ChevronButton.swift */, E133328C2953AE4B00EE76AB /* CircularProgressView.swift */, + E1A3E4CE2BB7E02B005C59F8 /* DelayedProgressView.swift */, E18E01A7288746AF0022598C /* DotHStack.swift */, E1DE2B492B97ECB900F6715F /* ErrorView.swift */, E1921B7528E63306003A5238 /* GestureView.swift */, @@ -1935,6 +1969,7 @@ 62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */, 62E632F2267D54030063E547 /* ItemViewModel.swift */, 62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */, + E172D3AC2BAC9DF8007B4647 /* SeasonItemViewModel.swift */, 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */, ); path = ItemViewModel; @@ -1983,6 +2018,27 @@ path = NavBarDrawerButtons; sourceTree = ""; }; + E1153D972BBA3E5300424D36 /* Components */ = { + isa = PBXGroup; + children = ( + E1C926092887565C002A7A66 /* EpisodeCard.swift */, + E1153D932BBA3D3000424D36 /* EpisodeContent.swift */, + E1153D952BBA3E2F00424D36 /* EpisodeHStack.swift */, + E1153D992BBA3E9800424D36 /* ErrorCard.swift */, + E1153D9B2BBA3E9D00424D36 /* LoadingCard.swift */, + ); + path = Components; + sourceTree = ""; + }; + E1153D982BBA3E6100424D36 /* EpisodeSelector */ = { + isa = PBXGroup; + children = ( + E1153D972BBA3E5300424D36 /* Components */, + E1C926072887565C002A7A66 /* EpisodeSelector.swift */, + ); + path = EpisodeSelector; + sourceTree = ""; + }; E1171A1A28A2215800FA1AF5 /* UserSignInView */ = { isa = PBXGroup; children = ( @@ -2015,6 +2071,7 @@ E11CEB85289984F5003E74C7 /* Extensions */ = { isa = PBXGroup; children = ( + E1A3E4CC2BB7D8C8005C59F8 /* iOSLabelExtensions.swift */, E11CEB8828998522003E74C7 /* View */, ); path = Extensions; @@ -2275,6 +2332,28 @@ path = PlaybackButtons; sourceTree = ""; }; + E172D3AF2BACA54A007B4647 /* EpisodeSelector */ = { + isa = PBXGroup; + children = ( + E172D3B02BACA560007B4647 /* Components */, + E1DA656E28E78C9900592A73 /* EpisodeSelector.swift */, + ); + path = EpisodeSelector; + sourceTree = ""; + }; + E172D3B02BACA560007B4647 /* Components */ = { + isa = PBXGroup; + children = ( + E1153DB22BBA80B400424D36 /* EmptyCard.swift */, + E1A3E4C62BB74E50005C59F8 /* EpisodeCard.swift */, + E172D3B12BACA569007B4647 /* EpisodeContent.swift */, + E1A3E4CA2BB74EFD005C59F8 /* EpisodeHStack.swift */, + E1A3E4D02BB7F5BF005C59F8 /* ErrorCard.swift */, + E1A3E4C82BB74EA3005C59F8 /* LoadingCard.swift */, + ); + path = Components; + sourceTree = ""; + }; E178859C2780F5300094FBCF /* tvOSSLider */ = { isa = PBXGroup; children = ( @@ -2466,10 +2545,10 @@ E18E01D7288747230022598C /* AttributeHStack.swift */, E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */, E17AC9722955007A003D2BC2 /* DownloadTaskButton.swift */, + E172D3AF2BACA54A007B4647 /* EpisodeSelector */, E17FB55A28C1266400311DFE /* GenresHStack.swift */, E1D8424E2932F7C400D1041A /* OverviewView.swift */, E18E01D8288747230022598C /* PlayButton.swift */, - E1DA656E28E78C9900592A73 /* SeriesEpisodeSelector.swift */, E17FB55428C1250B00311DFE /* SimilarItemsHStack.swift */, E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */, E17FB55828C125E900311DFE /* StudiosHStack.swift */, @@ -2635,6 +2714,7 @@ E1C926032887565C002A7A66 /* ActionButtonHStack.swift */, E1C926012887565C002A7A66 /* AttributeHStack.swift */, E185920528CDAA6400326F80 /* CastAndCrewHStack.swift */, + E1153D982BBA3E6100424D36 /* EpisodeSelector */, E1C926022887565C002A7A66 /* PlayButton.swift */, E185920728CDAAA200326F80 /* SimilarItemsHStack.swift */, E169C7B7296D2E8200AE25F9 /* SpecialFeaturesHStack.swift */, @@ -2645,22 +2725,12 @@ E1C926042887565C002A7A66 /* SeriesItemView */ = { isa = PBXGroup; children = ( - E1C926062887565C002A7A66 /* Components */, E1C926052887565C002A7A66 /* SeriesItemContentView.swift */, E1C9260A2887565C002A7A66 /* SeriesItemView.swift */, ); path = SeriesItemView; sourceTree = ""; }; - E1C926062887565C002A7A66 /* Components */ = { - isa = PBXGroup; - children = ( - E1C926092887565C002A7A66 /* EpisodeCard.swift */, - E1C926072887565C002A7A66 /* SeriesEpisodeSelector.swift */, - ); - path = Components; - sourceTree = ""; - }; E1CAF65C2BA345830087D991 /* MediaViewModel */ = { isa = PBXGroup; children = ( @@ -2917,9 +2987,9 @@ E18443CA2A037773002DDDC8 /* UDPBroadcast */, E14CB6872A9FF71F001586C6 /* JellyfinAPI */, E1A7B1642B9A9F7800152546 /* PreferencesView */, - E104DC932B9D89A2008F506D /* CollectionVGrid */, E1392FEC2BA218A80034110D /* SwiftUIIntrospect */, - E1392FF32BA21B470034110D /* CollectionHStack */, + E1153DA82BBA642A00424D36 /* CollectionVGrid */, + E1153DB02BBA734C00424D36 /* CollectionHStack */, ); productName = "JellyfinPlayer tvOS"; productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */; @@ -2966,10 +3036,13 @@ E15D4F042B1B0C3C00442DB8 /* PreferencesView */, E113A2A62B5A178D009CAAAA /* CollectionHStack */, E113A2A92B5A179A009CAAAA /* CollectionVGrid */, - E104DC8F2B9D8995008F506D /* CollectionVGrid */, E15EFA832BA167350080E926 /* CollectionHStack */, E15EFA852BA1685F0080E926 /* SwiftUIIntrospect */, - E1392FF12BA21B360034110D /* CollectionHStack */, + E18D6AA52BAA96F000A0D167 /* CollectionHStack */, + E1153DA32BBA614F00424D36 /* CollectionVGrid */, + E1153DA62BBA641000424D36 /* CollectionVGrid */, + E1153DAB2BBA6AD200424D36 /* CollectionHStack */, + E1153DAE2BBA734200424D36 /* CollectionHStack */, ); productName = JellyfinPlayer; productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */; @@ -3039,8 +3112,8 @@ E1FAD1C42A0375BA007F5521 /* XCRemoteSwiftPackageReference "UDPBroadcastConnection" */, E14CB6842A9FF62A001586C6 /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, E15D4F032B1B0C3C00442DB8 /* XCLocalSwiftPackageReference "PreferencesView" */, - E104DC8E2B9D8995008F506D /* XCRemoteSwiftPackageReference "CollectionVGrid" */, - E1392FF02BA21B360034110D /* XCRemoteSwiftPackageReference "CollectionHStack" */, + E1153DA52BBA641000424D36 /* XCRemoteSwiftPackageReference "CollectionVGrid" */, + E1153DAD2BBA734200424D36 /* XCRemoteSwiftPackageReference "CollectionHStack" */, ); productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; projectDirPath = ""; @@ -3249,11 +3322,13 @@ E1575E85293E7A00001665B1 /* DarkAppIcon.swift in Sources */, E178859E2780F53B0094FBCF /* SliderView.swift in Sources */, E1575E95293E7B1E001665B1 /* Font.swift in Sources */, + E172D3AE2BAC9DF8007B4647 /* SeasonItemViewModel.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 */, + E1153D9A2BBA3E9800424D36 /* ErrorCard.swift in Sources */, E1ED91162B95897500802036 /* LatestInLibraryViewModel.swift in Sources */, E12376B32A33DFAC001F5B44 /* ItemOverviewView.swift in Sources */, E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */, @@ -3391,7 +3466,7 @@ E133328929538D8D00EE76AB /* Files.swift in Sources */, E154967A296CB4B000C4EF88 /* VideoPlayerSettingsView.swift in Sources */, E1575EA0293E7B1E001665B1 /* CGPoint.swift in Sources */, - E1C926132887565C002A7A66 /* SeriesEpisodeSelector.swift in Sources */, + E1C926132887565C002A7A66 /* EpisodeSelector.swift in Sources */, E12CC1CD28D135C700678D5D /* NextUpView.swift in Sources */, E18E02232887492B0022598C /* ImageView.swift in Sources */, E1575E7F293E77B5001665B1 /* AppAppearance.swift in Sources */, @@ -3402,6 +3477,7 @@ E1E1643A28BAC2EF00323B0A /* SearchView.swift in Sources */, E1388A43293F0AAD009721B1 /* PreferenceUIHostingController.swift in Sources */, E12CC1C728D12FD600678D5D /* CinematicRecentlyAddedView.swift in Sources */, + E1153D942BBA3D3000424D36 /* EpisodeContent.swift in Sources */, E11BDF982B865F550045C54A /* ItemTag.swift in Sources */, E1DC9848296DEFF500982F06 /* FavoriteIndicator.swift in Sources */, E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */, @@ -3414,6 +3490,7 @@ E185920A28CEF23A00326F80 /* FocusGuide.swift in Sources */, E1D37F532B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */, C4BE07722725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift in Sources */, + E1153D9C2BBA3E9D00424D36 /* LoadingCard.swift in Sources */, 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */, E1575E68293E77B5001665B1 /* LibraryParent.swift in Sources */, E1C9260D2887565C002A7A66 /* CinematicScrollView.swift in Sources */, @@ -3426,6 +3503,7 @@ E1E9EFEA28C6B96500CC1F8B /* ServerButton.swift in Sources */, E1575E65293E77B5001665B1 /* VideoPlayerJumpLength.swift in Sources */, E169C7B8296D2E8200AE25F9 /* SpecialFeaturesHStack.swift in Sources */, + E1153D962BBA3E2F00424D36 /* EpisodeHStack.swift in Sources */, E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */, E1B5861329E32EEF00E45D6E /* Sequence.swift in Sources */, C4E5081B2703F82A0045C9AB /* MediaView.swift in Sources */, @@ -3510,6 +3588,7 @@ E1BDF2E92951490400CC0294 /* ActionButtonSelectorView.swift in Sources */, E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */, E148128828C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */, + E1A3E4D12BB7F5BF005C59F8 /* ErrorCard.swift in Sources */, C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */, E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */, 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, @@ -3541,6 +3620,7 @@ 6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, E18A8E8528D60D0000333B9A /* VideoPlayerCoordinator.swift in Sources */, E19E551F2897326C003CE330 /* BottomEdgeGradientModifier.swift in Sources */, + E1A3E4CD2BB7D8C8005C59F8 /* iOSLabelExtensions.swift in Sources */, E13DD3EC27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, E12CC1BE28D11F4500678D5D /* RecentlyAddedView.swift in Sources */, E17AC96A2954D00E003D2BC2 /* URLResponse.swift in Sources */, @@ -3559,6 +3639,7 @@ E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */, E1B5861229E32EEF00E45D6E /* Sequence.swift in Sources */, E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, + E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */, E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */, @@ -3590,6 +3671,7 @@ E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */, E1C8CE5B28FE512400DF5D7B /* CGPoint.swift in Sources */, E18ACA922A15A32F00BB4F35 /* (null) in Sources */, + E1A3E4C92BB74EA3005C59F8 /* LoadingCard.swift in Sources */, E1E1E24D28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */, E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */, E15756322935642A00976E1F /* Double.swift in Sources */, @@ -3654,6 +3736,7 @@ E1D3044428D1991900587289 /* LibraryViewTypeToggle.swift in Sources */, E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */, E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, + E1A3E4CB2BB74EFD005C59F8 /* EpisodeHStack.swift in Sources */, E1E0BEB729EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */, E1FA891E289A305D00176FEB /* iPadOSCollectionItemContentView.swift in Sources */, E18CE0B928A2322D0092E7F1 /* QuickConnectCoordinator.swift in Sources */, @@ -3661,7 +3744,7 @@ E1549666296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */, E1A1528528FD191A00600579 /* TextPair.swift in Sources */, 6334175D287DE0D0000603CE /* QuickConnectSettingsViewModel.swift in Sources */, - E1DA656F28E78C9900592A73 /* SeriesEpisodeSelector.swift in Sources */, + E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */, E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */, E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */, E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, @@ -3689,6 +3772,7 @@ E1D37F4E2B9CEDC400343D2B /* DeviceProfile.swift in Sources */, E1EF4C412911B783008CC695 /* StreamType.swift in Sources */, 6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */, + E1A3E4CF2BB7E02B005C59F8 /* DelayedProgressView.swift in Sources */, E1921B7628E63306003A5238 /* GestureView.swift in Sources */, E18A8E8028D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */, E18E01DC288747230022598C /* iPadOSCinematicScrollView.swift in Sources */, @@ -3811,7 +3895,10 @@ 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */, E148128528C15472003B8787 /* SortOrder.swift in Sources */, E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */, + E1A3E4C72BB74E50005C59F8 /* EpisodeCard.swift in Sources */, + E1153DB42BBA80FB00424D36 /* EmptyCard.swift in Sources */, E1D3043528D1763100587289 /* SeeAllButton.swift in Sources */, + E172D3B22BACA569007B4647 /* EpisodeContent.swift in Sources */, E13F05EC28BC9000003499D2 /* LibraryViewType.swift in Sources */, E1356E0329A730B200382563 /* SeparatorHStack.swift in Sources */, 5377CBF5263B596A003A4E83 /* SwiftfinApp.swift in Sources */, @@ -4287,14 +4374,6 @@ minimumVersion = 1.0.0; }; }; - E104DC8E2B9D8995008F506D /* XCRemoteSwiftPackageReference "CollectionVGrid" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/LePips/CollectionVGrid"; - requirement = { - branch = main; - kind = branch; - }; - }; E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kean/Pulse"; @@ -4303,7 +4382,15 @@ minimumVersion = 2.0.0; }; }; - E1392FF02BA21B360034110D /* XCRemoteSwiftPackageReference "CollectionHStack" */ = { + E1153DA52BBA641000424D36 /* XCRemoteSwiftPackageReference "CollectionVGrid" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/LePips/CollectionVGrid"; + requirement = { + branch = main; + kind = branch; + }; + }; + E1153DAD2BBA734200424D36 /* XCRemoteSwiftPackageReference "CollectionHStack" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/LePips/CollectionHStack"; requirement = { @@ -4453,16 +4540,6 @@ package = E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */; productName = Algorithms; }; - E104DC8F2B9D8995008F506D /* CollectionVGrid */ = { - isa = XCSwiftPackageProductDependency; - package = E104DC8E2B9D8995008F506D /* XCRemoteSwiftPackageReference "CollectionVGrid" */; - productName = CollectionVGrid; - }; - E104DC932B9D89A2008F506D /* CollectionVGrid */ = { - isa = XCSwiftPackageProductDependency; - package = E104DC8E2B9D8995008F506D /* XCRemoteSwiftPackageReference "CollectionVGrid" */; - productName = CollectionVGrid; - }; E107060F2942F57D00646DAF /* Pulse */ = { isa = XCSwiftPackageProductDependency; package = E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */; @@ -4490,6 +4567,34 @@ isa = XCSwiftPackageProductDependency; productName = CollectionVGrid; }; + E1153DA32BBA614F00424D36 /* CollectionVGrid */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionVGrid; + }; + E1153DA62BBA641000424D36 /* CollectionVGrid */ = { + isa = XCSwiftPackageProductDependency; + package = E1153DA52BBA641000424D36 /* XCRemoteSwiftPackageReference "CollectionVGrid" */; + productName = CollectionVGrid; + }; + E1153DA82BBA642A00424D36 /* CollectionVGrid */ = { + isa = XCSwiftPackageProductDependency; + package = E1153DA52BBA641000424D36 /* XCRemoteSwiftPackageReference "CollectionVGrid" */; + productName = CollectionVGrid; + }; + E1153DAB2BBA6AD200424D36 /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionHStack; + }; + E1153DAE2BBA734200424D36 /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + package = E1153DAD2BBA734200424D36 /* XCRemoteSwiftPackageReference "CollectionHStack" */; + productName = CollectionHStack; + }; + E1153DB02BBA734C00424D36 /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + package = E1153DAD2BBA734200424D36 /* XCRemoteSwiftPackageReference "CollectionHStack" */; + productName = CollectionHStack; + }; E12186DD2718F1C50010884C /* Defaults */ = { isa = XCSwiftPackageProductDependency; package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */; @@ -4505,16 +4610,6 @@ package = 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; productName = SwiftUIIntrospect; }; - E1392FF12BA21B360034110D /* CollectionHStack */ = { - isa = XCSwiftPackageProductDependency; - package = E1392FF02BA21B360034110D /* XCRemoteSwiftPackageReference "CollectionHStack" */; - productName = CollectionHStack; - }; - E1392FF32BA21B470034110D /* CollectionHStack */ = { - isa = XCSwiftPackageProductDependency; - package = E1392FF02BA21B360034110D /* XCRemoteSwiftPackageReference "CollectionHStack" */; - productName = CollectionHStack; - }; E13AF3B528A0C598009093AB /* Nuke */ = { isa = XCSwiftPackageProductDependency; package = E19E6E0328A0B958005C10C8 /* XCRemoteSwiftPackageReference "Nuke" */; @@ -4617,6 +4712,10 @@ package = E18A8E7828D5FEDF00333B9A /* XCRemoteSwiftPackageReference "VLCUI" */; productName = VLCUI; }; + E18D6AA52BAA96F000A0D167 /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionHStack; + }; E192608228D2D0DB002314B4 /* Factory */ = { isa = XCSwiftPackageProductDependency; package = E192608128D2D0DB002314B4 /* XCRemoteSwiftPackageReference "Factory" */; diff --git a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f6ab9b41..179cd155 100644 --- a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,7 +15,7 @@ "location" : "https://github.com/LePips/CollectionHStack", "state" : { "branch" : "main", - "revision" : "e192023a2f2ce9351cbe7fb6f01c47043de209a8" + "revision" : "894b595185bbfce007d60b219ee3e4013884131c" } }, { @@ -24,7 +24,7 @@ "location" : "https://github.com/LePips/CollectionVGrid", "state" : { "branch" : "main", - "revision" : "2be0988304df1ab59a3340e41c07f94eee480e66" + "revision" : "91513692e56cc564f1bcbd476289ae060eb7e877" } }, { diff --git a/Swiftfin/Components/DelayedProgressView.swift b/Swiftfin/Components/DelayedProgressView.swift new file mode 100644 index 00000000..1314ec65 --- /dev/null +++ b/Swiftfin/Components/DelayedProgressView.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Combine +import SwiftUI + +// TODO: retry button and/or loading text after a few more seconds +struct DelayedProgressView: View { + + @State + private var interval = 0 + + private let timer: Publishers.Autoconnect + + init(interval: Double = 0.5) { + self.timer = Timer.publish(every: interval, on: .main, in: .common).autoconnect() + } + + var body: some View { + VStack { + if interval > 0 { + ProgressView() + } + } + .onReceive(timer) { _ in + withAnimation { + interval += 1 + } + } + } +} diff --git a/Swiftfin/Extensions/iOSLabelExtensions.swift b/Swiftfin/Extensions/iOSLabelExtensions.swift new file mode 100644 index 00000000..bdd77978 --- /dev/null +++ b/Swiftfin/Extensions/iOSLabelExtensions.swift @@ -0,0 +1,55 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension LabelStyle where Self == EpisodeSelectorLabelStyle { + + static var episodeSelector: EpisodeSelectorLabelStyle { + EpisodeSelectorLabelStyle() + } +} + +extension LabelStyle where Self == TrailingIconLabelStyle { + + static var trailingIcon: TrailingIconLabelStyle { + TrailingIconLabelStyle() + } +} + +struct EpisodeSelectorLabelStyle: LabelStyle { + + func makeBody(configuration: Configuration) -> some View { + HStack { + configuration.title + + configuration.icon + } + .font(.headline) + .padding(.vertical, 5) + .padding(.horizontal, 10) + .background { + Color.tertiarySystemFill + .cornerRadius(10) + } + .compositingGroup() + .shadow(radius: 1) + .font(.caption) + } +} + +struct TrailingIconLabelStyle: LabelStyle { + + func makeBody(configuration: Configuration) -> some View { + HStack { + configuration.title + + configuration.icon + } + } +} diff --git a/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift b/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift index 837c5e1a..753f9a4b 100644 --- a/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift +++ b/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift @@ -40,13 +40,13 @@ extension HomeView { PosterButton(item: item, type: .landscape) .contextMenu { Button { - viewModel.markItemPlayed(item) + viewModel.send(.setIsPlayed(true, item)) } label: { Label(L10n.played, systemImage: "checkmark.circle") } Button(role: .destructive) { - viewModel.markItemUnplayed(item) + viewModel.send(.setIsPlayed(false, item)) } label: { Label(L10n.unplayed, systemImage: "minus.circle") } diff --git a/Swiftfin/Views/HomeView/Components/NextUpView.swift b/Swiftfin/Views/HomeView/Components/NextUpView.swift index a1451a2c..cccdf69d 100644 --- a/Swiftfin/Views/HomeView/Components/NextUpView.swift +++ b/Swiftfin/Views/HomeView/Components/NextUpView.swift @@ -22,24 +22,24 @@ extension HomeView { private var router: HomeCoordinator.Router @ObservedObject - var viewModel: NextUpLibraryViewModel + var homeViewModel: HomeViewModel var body: some View { - if viewModel.elements.isNotEmpty { + if homeViewModel.nextUpViewModel.elements.isNotEmpty { PosterHStack( title: L10n.nextUp, type: nextUpPosterType, - items: $viewModel.elements + items: $homeViewModel.nextUpViewModel.elements ) .trailing { SeeAllButton() .onSelect { - router.route(to: \.library, viewModel) + router.route(to: \.library, homeViewModel.nextUpViewModel) } } .contextMenu { item in Button { - viewModel.markPlayed(item: item) + homeViewModel.send(.setIsPlayed(true, item)) } label: { Label(L10n.played, systemImage: "checkmark.circle") } diff --git a/Swiftfin/Views/HomeView/HomeView.swift b/Swiftfin/Views/HomeView/HomeView.swift index 88a9a643..29528121 100644 --- a/Swiftfin/Views/HomeView/HomeView.swift +++ b/Swiftfin/Views/HomeView/HomeView.swift @@ -12,6 +12,7 @@ import SwiftUI // TODO: seems to redraw view when popped to sometimes? // - similar to MediaView TODO bug? +// - indicated by snapping to the top struct HomeView: View { @Default(.Customization.nextUpPosterType) @@ -31,7 +32,7 @@ struct HomeView: View { ContinueWatchingView(viewModel: viewModel) - NextUpView(viewModel: viewModel.nextUpViewModel) + NextUpView(homeViewModel: viewModel) RecentlyAddedView(viewModel: viewModel.recentlyAddedViewModel) @@ -41,6 +42,9 @@ struct HomeView: View { } .edgePadding(.vertical) } + .refreshable { + viewModel.send(.refresh) + } } private func errorView(with error: some Error) -> some View { @@ -52,30 +56,37 @@ struct HomeView: View { var body: some View { WrappedView { - Group { - switch viewModel.state { - case .content: - contentView - case let .error(error): - errorView(with: error) - case .initial, .refreshing: - ProgressView() - } + switch viewModel.state { + case .content: + contentView + case let .error(error): + errorView(with: error) + case .initial, .refreshing: + DelayedProgressView() } - .transition(.opacity.animation(.linear(duration: 0.1))) } + .transition(.opacity.animation(.linear(duration: 0.2))) .onFirstAppear { viewModel.send(.refresh) } .navigationTitle(L10n.home) - .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - Button { - router.route(to: \.settings) - } label: { - Image(systemName: "gearshape.fill") - .accessibilityLabel(L10n.settings) - } + .topBarTrailing { + + if viewModel.backgroundStates.contains(.refresh) { + ProgressView() + } + + Button { + router.route(to: \.settings) + } label: { + Image(systemName: "gearshape.fill") + .accessibilityLabel(L10n.settings) + } + } + .afterLastDisappear { interval in + if interval > 60 || viewModel.notificationsReceived.contains(.itemMetadataDidChange) { + viewModel.send(.backgroundRefresh) + viewModel.notificationsReceived.remove(.itemMetadataDidChange) } } } diff --git a/Swiftfin/Views/ItemOverviewView.swift b/Swiftfin/Views/ItemOverviewView.swift index 5401da53..2094ab56 100644 --- a/Swiftfin/Views/ItemOverviewView.swift +++ b/Swiftfin/Views/ItemOverviewView.swift @@ -10,6 +10,7 @@ import JellyfinAPI import SwiftUI // TODO: fix with shorter text +// - seems to center align struct ItemOverviewView: View { diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift index e31e102f..bc1169d9 100644 --- a/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift +++ b/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift @@ -15,11 +15,12 @@ extension ItemView { struct ActionButtonHStack: View { + @Injected(Container.downloadManager) + private var downloadManager: DownloadManager + @EnvironmentObject private var router: ItemCoordinator.Router - @ObservedObject - private var downloadManager: DownloadManager @ObservedObject private var viewModel: ItemViewModel @@ -28,17 +29,15 @@ extension ItemView { init(viewModel: ItemViewModel, equalSpacing: Bool = true) { self.viewModel = viewModel self.equalSpacing = equalSpacing - - self.downloadManager = Container.downloadManager() } var body: some View { HStack(alignment: .center, spacing: 15) { Button { UIDevice.impact(.light) - viewModel.toggleWatchState() + viewModel.send(.toggleIsPlayed) } label: { - if viewModel.isPlayed { + if viewModel.item.userData?.isPlayed ?? false { Image(systemName: "checkmark.circle.fill") .symbolRenderingMode(.palette) .foregroundStyle( @@ -56,9 +55,9 @@ extension ItemView { Button { UIDevice.impact(.light) - viewModel.toggleFavoriteState() + viewModel.send(.toggleIsFavorite) } label: { - if viewModel.isFavorited { + if viewModel.item.userData?.isFavorite ?? false { Image(systemName: "heart.fill") .symbolRenderingMode(.palette) .foregroundStyle(Color.red) @@ -78,7 +77,7 @@ extension ItemView { Menu { ForEach(mediaSources, id: \.hashValue) { mediaSource in Button { - viewModel.selectedMediaSource = mediaSource +// viewModel.selectedMediaSource = mediaSource } label: { if let selectedMediaSource = viewModel.selectedMediaSource, selectedMediaSource == mediaSource { Label(selectedMediaSource.displayTitle, systemImage: "checkmark") diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift new file mode 100644 index 00000000..9db21ebd --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.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) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI +import SwiftUI + +extension SeriesEpisodeSelector { + + struct EmptyCard: View { + + var body: some View { + VStack(alignment: .leading) { + Color.secondarySystemFill + .opacity(0.75) + .posterStyle(.landscape) + + SeriesEpisodeSelector.EpisodeContent( + subHeader: .emptyDash, + header: L10n.noResults, + content: L10n.noEpisodesAvailable + ) + .disabled(true) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift new file mode 100644 index 00000000..f78ecc1e --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.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) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI +import SwiftUI + +extension SeriesEpisodeSelector { + + struct EpisodeCard: View { + + @EnvironmentObject + private var mainRouter: MainCoordinator.Router + @EnvironmentObject + private var router: ItemCoordinator.Router + + let episode: BaseItemDto + + @ViewBuilder + private var overlayView: some View { + 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) + .paletteOverlayRendering(color: .white) + .padding() + } + } + } + + var body: some View { + PosterButton( + item: episode, + type: .landscape, + singleImage: true + ) + .content { + let content: String = if episode.isUnaired { + episode.airDateLabel ?? L10n.noOverviewAvailable + } else { + episode.overview ?? L10n.noOverviewAvailable + } + + SeriesEpisodeSelector.EpisodeContent( + subHeader: episode.episodeLocator ?? .emptyDash, + header: episode.displayTitle, + content: content + ) + .onSelect { + router.route(to: \.item, episode) + } + } + .imageOverlay { + overlayView + } + .onSelect { + guard let mediaSource = episode.mediaSources?.first else { return } + mainRouter.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: episode, mediaSource: mediaSource)) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift new file mode 100644 index 00000000..b1c7625e --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift @@ -0,0 +1,90 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +extension SeriesEpisodeSelector { + + struct EpisodeContent: View { + + @Default(.accentColor) + private var accentColor + + private var onSelect: () -> Void + + let subHeader: String + let header: String + let content: String + + @ViewBuilder + private var subHeaderView: some View { + Text(subHeader) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(1) + } + + @ViewBuilder + private var headerView: some View { + Text(header) + .font(.body) + .foregroundColor(.primary) + .lineLimit(1) + .multilineTextAlignment(.leading) + .padding(.bottom, 1) + } + + @ViewBuilder + private var contentView: some View { + Text(content) + .font(.caption.weight(.light)) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + .backport + .lineLimit(3, reservesSpace: true) + } + + var body: some View { + Button { + onSelect() + } label: { + VStack(alignment: .leading) { + subHeaderView + + headerView + + contentView + + L10n.seeMore.text + .font(.caption.weight(.light)) + .foregroundStyle(accentColor) + } + } + } + } +} + +extension SeriesEpisodeSelector.EpisodeContent { + + init( + subHeader: String, + header: String, + content: String + ) { + self.subHeader = subHeader + self.header = header + self.content = content + self.onSelect = {} + } + + func onSelect(perform action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift new file mode 100644 index 00000000..43aa6b6e --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift @@ -0,0 +1,123 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import CollectionHStack +import Foundation +import JellyfinAPI +import SwiftUI + +// TODO: The content/loading/error states are implemented as different CollectionHStacks because it was just easy. +// A theoretically better implementation would be a single CollectionHStack with cards that represent the state instead. +extension SeriesEpisodeSelector { + + struct EpisodeHStack: View { + + @ObservedObject + var viewModel: SeasonItemViewModel + + @State + private var didScrollToPlayButtonItem = false + + @StateObject + private var proxy = CollectionHStackProxy() + + let playButtonItem: BaseItemDto? + + private func contentView(viewModel: SeasonItemViewModel) -> some View { + CollectionHStack( + $viewModel.elements, + columns: UIDevice.isPhone ? 1.5 : 3.5 + ) { episode in + SeriesEpisodeSelector.EpisodeCard(episode: episode) + } + .scrollBehavior(.continuousLeadingEdge) + .insets(horizontal: EdgeInsets.defaultEdgePadding) + .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + .proxy(proxy) + .onFirstAppear { + guard !didScrollToPlayButtonItem else { return } + didScrollToPlayButtonItem = true + + // good enough? + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard let playButtonItem else { return } + proxy.scrollTo(element: playButtonItem, animated: false) + } + } + } + + var body: some View { + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + EmptyHStack() + } else { + contentView(viewModel: viewModel) + } + case let .error(error): + ErrorHStack(viewModel: viewModel, error: error) + case .initial, .refreshing: + LoadingHStack() + } + } + } + + struct EmptyHStack: View { + + var body: some View { + CollectionHStack( + 0 ..< 1, + columns: UIDevice.isPhone ? 1.5 : 3.5 + ) { _ in + SeriesEpisodeSelector.EmptyCard() + } + .allowScrolling(false) + .insets(horizontal: EdgeInsets.defaultEdgePadding) + .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + } + } + + // TODO: better refresh design + struct ErrorHStack: View { + + @ObservedObject + var viewModel: SeasonItemViewModel + + let error: JellyfinAPIError + + var body: some View { + CollectionHStack( + 0 ..< 1, + columns: UIDevice.isPhone ? 1.5 : 3.5 + ) { _ in + SeriesEpisodeSelector.ErrorCard(error: error) + .onSelect { + viewModel.send(.refresh) + } + } + .allowScrolling(false) + .insets(horizontal: EdgeInsets.defaultEdgePadding) + .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + } + } + + struct LoadingHStack: View { + + var body: some View { + CollectionHStack( + 0 ..< Int.random(in: 2 ..< 5), + columns: UIDevice.isPhone ? 1.5 : 3.5 + ) { _ in + SeriesEpisodeSelector.LoadingCard() + } + .allowScrolling(false) + .insets(horizontal: EdgeInsets.defaultEdgePadding) + .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift new file mode 100644 index 00000000..4b308386 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.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) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension SeriesEpisodeSelector { + + struct ErrorCard: View { + + let error: JellyfinAPIError + private var onSelect: () -> Void + + init(error: JellyfinAPIError) { + self.error = error + self.onSelect = {} + } + + func onSelect(perform action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } + + var body: some View { + Button { + onSelect() + } label: { + VStack(alignment: .leading) { + Color.secondarySystemFill + .opacity(0.75) + .posterStyle(.landscape) + .overlay { + Image(systemName: "arrow.clockwise.circle.fill") + .font(.system(size: 40)) + } + + SeriesEpisodeSelector.EpisodeContent( + subHeader: .emptyDash, + header: L10n.error, + content: error.localizedDescription + ) + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift new file mode 100644 index 00000000..a9be264d --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.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) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI +import SwiftUI + +extension SeriesEpisodeSelector { + + struct LoadingCard: View { + + var body: some View { + VStack(alignment: .leading) { + Color.secondarySystemFill + .opacity(0.75) + .posterStyle(.landscape) + + SeriesEpisodeSelector.EpisodeContent( + subHeader: String.random(count: 7 ..< 12), + header: String.random(count: 10 ..< 20), + content: String.random(count: 20 ..< 80) + ) + .redacted(reason: .placeholder) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift new file mode 100644 index 00000000..28ab0971 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift @@ -0,0 +1,83 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import CollectionHStack +import Defaults +import JellyfinAPI +import OrderedCollections +import SwiftUI + +struct SeriesEpisodeSelector: View { + + @ObservedObject + var viewModel: SeriesItemViewModel + + @State + private var didSelectPlayButtonSeason = false + @State + private var selection: SeasonItemViewModel? + + @ViewBuilder + private var seasonSelectorMenu: some View { + Menu { + ForEach(viewModel.seasons, id: \.season.id) { seasonViewModel in + Button { + selection = seasonViewModel + } label: { + if seasonViewModel == selection { + Label(seasonViewModel.season.displayTitle, systemImage: "checkmark") + } else { + Text(seasonViewModel.season.displayTitle) + } + } + } + } label: { + Label( + selection?.season.displayTitle ?? .emptyDash, + systemImage: "chevron.down" + ) + .labelStyle(.episodeSelector) + } + .fixedSize() + } + + var body: some View { + VStack(alignment: .leading) { + + seasonSelectorMenu + .edgePadding([.bottom, .horizontal]) + + Group { + if let selection { + EpisodeHStack(viewModel: selection, playButtonItem: viewModel.playButtonItem) + } else { + LoadingHStack() + } + } + .transition(.opacity.animation(.linear(duration: 0.1))) + } + .onReceive(viewModel.playButtonItem.publisher) { newValue in + + guard !didSelectPlayButtonSeason else { return } + didSelectPlayButtonSeason = true + + if let season = viewModel.seasons.first(where: { $0.season.id == newValue.seasonID }) { + selection = season + } else { + selection = viewModel.seasons.first + } + } + .onChange(of: selection) { newValue in + guard let newValue else { return } + + if newValue.state == .initial { + newValue.send(.refresh) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/PlayButton.swift b/Swiftfin/Views/ItemView/Components/PlayButton.swift index b0c9fcbd..ed740d68 100644 --- a/Swiftfin/Views/ItemView/Components/PlayButton.swift +++ b/Swiftfin/Views/ItemView/Components/PlayButton.swift @@ -26,6 +26,14 @@ extension ItemView { @ObservedObject var viewModel: ItemViewModel + private var title: String { + if let seriesViewModel = viewModel as? SeriesItemViewModel { + return seriesViewModel.playButtonItem?.seasonEpisodeLabel ?? L10n.play + } else { + return viewModel.playButtonItem?.playButtonLabel ?? L10n.play + } + } + var body: some View { Button { if let playButtonItem = viewModel.playButtonItem, let selectedMediaSource = viewModel.selectedMediaSource { @@ -43,13 +51,14 @@ extension ItemView { Image(systemName: "play.fill") .font(.system(size: 20)) - Text(viewModel.playButtonText()) + Text(title) .font(.callout) .fontWeight(.semibold) } .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : accentColor.overlayColor) } } + .disabled(viewModel.playButtonItem == nil) // .contextMenu { // if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { // Button { diff --git a/Swiftfin/Views/ItemView/Components/SeriesEpisodeSelector.swift b/Swiftfin/Views/ItemView/Components/SeriesEpisodeSelector.swift deleted file mode 100644 index a620f1da..00000000 --- a/Swiftfin/Views/ItemView/Components/SeriesEpisodeSelector.swift +++ /dev/null @@ -1,181 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import CollectionHStack -import Defaults -import JellyfinAPI -import SwiftUI - -struct SeriesEpisodeSelector: View { - - @EnvironmentObject - private var mainRouter: MainCoordinator.Router - - @ObservedObject - var viewModel: SeriesItemViewModel - - @ViewBuilder - private var selectorMenu: some View { - Menu { - ForEach(viewModel.menuSections.keys.sorted(by: { viewModel.menuSectionSort($0, $1) }), id: \.displayTitle) { section in - Button { - viewModel.select(section: section) - } label: { - if section == viewModel.menuSelection { - Label(section.displayTitle, systemImage: "checkmark") - } else { - Text(section.displayTitle) - } - } - } - } label: { - HStack(spacing: 5) { - Group { - Text(viewModel.menuSelection?.displayTitle ?? L10n.unknown) - .fixedSize() - Image(systemName: "chevron.down") - } - .font(.title3.weight(.semibold)) - } - } - .padding(.bottom) - .fixedSize() - } - - var body: some View { - VStack(alignment: .leading) { - selectorMenu - .edgePadding(.horizontal) - - if viewModel.currentItems.isEmpty { - EmptyView() - } else { - CollectionHStack( - $viewModel.currentItems, - columns: UIDevice.isPhone ? 1.5 : 3.5 - ) { item in - PosterButton( - item: item, - type: .landscape, - singleImage: true - ) - .content { - EpisodeContent(episode: item) - } - .imageOverlay { - EpisodeOverlay(episode: item) - } - .onSelect { - guard let mediaSource = item.mediaSources?.first else { return } - mainRouter.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: item, mediaSource: mediaSource)) - } - } - .scrollBehavior(.continuousLeadingEdge) - .insets(horizontal: EdgeInsets.defaultEdgePadding) - .itemSpacing(8) - } - } - } -} - -extension SeriesEpisodeSelector { - - struct EpisodeOverlay: View { - - let episode: BaseItemDto - - var body: some View { - 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) - .paletteOverlayRendering(color: .white) - .padding() - } - } - } - } - - struct EpisodeContent: View { - - @Default(.accentColor) - private var accentColor - - @EnvironmentObject - private var router: ItemCoordinator.Router - @ScaledMetric - private var staticOverviewHeight: CGFloat = 50 - - let episode: BaseItemDto - - @ViewBuilder - private var subHeader: some View { - Text(episode.episodeLocator ?? L10n.unknown) - .font(.footnote) - .foregroundColor(.secondary) - } - - @ViewBuilder - private var header: some View { - Text(episode.displayTitle) - .font(.body) - .foregroundColor(.primary) - .padding(.bottom, 1) - .lineLimit(2) - .multilineTextAlignment(.leading) - } - - // TODO: why the static overview height? - @ViewBuilder - private var content: some View { - Group { - 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(3) - .multilineTextAlignment(.leading) - } - - var body: some View { - Button { - router.route(to: \.item, episode) - } label: { - VStack(alignment: .leading) { - subHeader - - header - - content - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/ItemView.swift b/Swiftfin/Views/ItemView/ItemView.swift index 33f2884f..e232f4bc 100644 --- a/Swiftfin/Views/ItemView/ItemView.swift +++ b/Swiftfin/Views/ItemView/ItemView.swift @@ -13,40 +13,91 @@ import WidgetKit struct ItemView: View { - let item: BaseItemDto + @StateObject + private var viewModel: ItemViewModel + + private static func typeViewModel(for item: BaseItemDto) -> ItemViewModel { + switch item.type { + case .boxSet: + return CollectionItemViewModel(item: item) + case .episode: + return EpisodeItemViewModel(item: item) + case .movie: + return MovieItemViewModel(item: item) + case .series: + return SeriesItemViewModel(item: item) + default: + assertionFailure("Unsupported item") + return ItemViewModel(item: item) + } + } + + init(item: BaseItemDto) { + self._viewModel = StateObject(wrappedValue: Self.typeViewModel(for: item)) + } + + @ViewBuilder + private var padView: some View { + switch viewModel.item.type { + case .boxSet: + iPadOSCollectionItemView(viewModel: viewModel as! CollectionItemViewModel) + case .episode: + iPadOSEpisodeItemView(viewModel: viewModel as! EpisodeItemViewModel) + case .movie: + iPadOSMovieItemView(viewModel: viewModel as! MovieItemViewModel) + case .series: + iPadOSSeriesItemView(viewModel: viewModel as! SeriesItemViewModel) + default: + Text(L10n.notImplementedYetWithType(viewModel.item.type ?? "--")) + } + } + + @ViewBuilder + private var phoneView: some View { + switch viewModel.item.type { + case .boxSet: + CollectionItemView(viewModel: viewModel as! CollectionItemViewModel) + case .episode: + EpisodeItemView(viewModel: viewModel as! EpisodeItemViewModel) + case .movie: + MovieItemView(viewModel: viewModel as! MovieItemViewModel) + case .series: + SeriesItemView(viewModel: viewModel as! SeriesItemViewModel) + default: + Text(L10n.notImplementedYetWithType(viewModel.item.type ?? "--")) + } + } + + @ViewBuilder + private var contentView: some View { + if UIDevice.isPad { + padView + } else { + phoneView + } + } var body: some View { - Group { - switch item.type { - case .movie: - if UIDevice.isPad { - iPadOSMovieItemView(viewModel: .init(item: item)) - } else { - MovieItemView(viewModel: .init(item: item)) - } - case .series: - if UIDevice.isPad { - iPadOSSeriesItemView(viewModel: .init(item: item)) - } else { - SeriesItemView(viewModel: .init(item: item)) - } - case .episode: - if UIDevice.isPad { - iPadOSEpisodeItemView(viewModel: .init(item: item)) - } else { - EpisodeItemView(viewModel: .init(item: item)) - } - case .boxSet: - if UIDevice.isPad { - iPadOSCollectionItemView(viewModel: .init(item: item)) - } else { - CollectionItemView(viewModel: .init(item: item)) - } - default: - Text(L10n.notImplementedYetWithType(item.type ?? "--")) + WrappedView { + switch viewModel.state { + case .content: + contentView + .navigationTitle(viewModel.item.displayTitle) + case let .error(error): + ErrorView(error: error) + case .initial, .refreshing: + DelayedProgressView() } } + .transition(.opacity.animation(.linear(duration: 0.2))) .navigationBarTitleDisplayMode(.inline) - .navigationTitle(item.displayTitle) + .onFirstAppear { + viewModel.send(.refresh) + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.refresh) { + ProgressView() + } + } } } diff --git a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift index 28599609..e818e58d 100644 --- a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift @@ -46,6 +46,16 @@ extension CollectionItemView { type: .portrait, items: viewModel.collectionItems ) + .trailing { + SeeAllButton() + .onSelect { + let viewModel = ItemLibraryViewModel( + title: viewModel.item.displayTitle, + viewModel.collectionItems + ) + router.route(to: \.library, viewModel) + } + } .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 4068ced7..9325d018 100644 --- a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.swift +++ b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.swift @@ -7,6 +7,7 @@ // import Defaults +import JellyfinAPI import SwiftUI struct CollectionItemView: View { diff --git a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift index 26b8283f..d094eb54 100644 --- a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift @@ -114,8 +114,8 @@ extension EpisodeItemView.ContentView { .padding(.horizontal) DotHStack { - if let episodeLocation = viewModel.item.episodeLocator { - Text(episodeLocation) + if let seasonEpisodeLabel = viewModel.item.seasonEpisodeLabel { + Text(seasonEpisodeLabel) } if let productionYear = viewModel.item.premiereDateYear { diff --git a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift index 6092f52a..ae931fe2 100644 --- a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift +++ b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift @@ -23,19 +23,13 @@ struct EpisodeItemView: View { var body: some View { ScrollView(showsIndicators: false) { ContentView(viewModel: viewModel) + .edgePadding(.bottom) } .scrollViewOffset($scrollViewOffset) .navigationBarOffset( $scrollViewOffset, start: 0, - end: 30 + end: 10 ) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - if viewModel.isLoading { - ProgressView() - } - } - } } } diff --git a/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift b/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift index 6e28758e..8234dee2 100644 --- a/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift @@ -64,6 +64,7 @@ extension MovieItemView { ItemView.AboutView(viewModel: viewModel) } + .animation(.linear(duration: 0.2), value: viewModel.item) } } } diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift index 063110c6..578ad84c 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift @@ -44,6 +44,7 @@ extension ItemView { cinematicItemViewTypeUsePrimaryImage ? .primary : .backdrop, maxWidth: UIScreen.main.bounds.width )) + .aspectRatio(contentMode: .fill) .frame(height: UIScreen.main.bounds.height * 0.6) .bottomEdgeGradient(bottomColor: blurHashBottomEdgeColor) .onAppear { @@ -108,11 +109,9 @@ extension ItemView { ) { headerView } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - if viewModel.isLoading { - ProgressView() - } + .topBarTrailing { + if viewModel.state == .refreshing { + ProgressView() } } } @@ -137,20 +136,19 @@ extension ItemView.CinematicScrollView { VStack(alignment: .center, spacing: 10) { if !cinematicItemViewTypeUsePrimaryImage { ImageView(viewModel.item.imageURL(.logo, maxWidth: UIScreen.main.bounds.width)) -// .resizingMode(.aspectFit) - .placeholder { - EmptyView() - } - .failure { - MaxHeightText(text: viewModel.item.displayTitle, maxHeight: 100) - .font(.largeTitle.weight(.semibold)) - .lineLimit(2) - .multilineTextAlignment(.center) - .foregroundColor(.white) - } - .aspectRatio(contentMode: .fit) - .frame(height: 100) - .frame(maxWidth: .infinity) + .placeholder { + EmptyView() + } + .failure { + MaxHeightText(text: viewModel.item.displayTitle, maxHeight: 100) + .font(.largeTitle.weight(.semibold)) + .lineLimit(2) + .multilineTextAlignment(.center) + .foregroundColor(.white) + } + .aspectRatio(contentMode: .fit) + .frame(height: 100) + .frame(maxWidth: .infinity) } else { Spacer() .frame(height: 50) diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift index 6452631f..4a2239c6 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift @@ -85,11 +85,10 @@ extension ItemView { ItemView.OverviewView(item: viewModel.item) .overviewLineLimit(4) .taglineLineLimit(2) - .padding(.top) - .padding(.horizontal) + .edgePadding() content() - .padding(.vertical) + .edgePadding(.bottom) } } .edgesIgnoringSafeArea(.top) @@ -106,13 +105,6 @@ extension ItemView { ) { headerView } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - if viewModel.isLoading { - ProgressView() - } - } - } } } } diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift index afe7aba4..f33345cd 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift @@ -37,8 +37,19 @@ extension ItemView { @ViewBuilder private var headerView: some View { ImageView(viewModel.item.imageSource(.backdrop, maxWidth: UIScreen.main.bounds.width)) + .aspectRatio(contentMode: .fill) .frame(height: UIScreen.main.bounds.height * 0.35) .bottomEdgeGradient(bottomColor: blurHashBottomEdgeColor) + .onAppear { + if let backdropBlurHash = viewModel.item.blurHash(.backdrop) { + let bottomRGB = BlurHash(string: backdropBlurHash)!.averageLinearRGB + blurHashBottomEdgeColor = Color( + red: Double(bottomRGB.0), + green: Double(bottomRGB.1), + blue: Double(bottomRGB.2) + ) + } + } } var body: some View { @@ -96,21 +107,9 @@ extension ItemView { ) { headerView } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - if viewModel.isLoading { - ProgressView() - } - } - } - .onAppear { - if let backdropBlurHash = viewModel.item.blurHash(.backdrop) { - let bottomRGB = BlurHash(string: backdropBlurHash)!.averageLinearRGB - blurHashBottomEdgeColor = Color( - red: Double(bottomRGB.0), - green: Double(bottomRGB.1), - blue: Double(bottomRGB.2) - ) + .topBarTrailing { + if viewModel.state == .refreshing { + ProgressView() } } } @@ -173,7 +172,10 @@ extension ItemView.CompactPosterScrollView { // MARK: Portrait Image ImageView(viewModel.item.imageSource(.primary, maxWidth: 130)) - .aspectRatio(2 / 3, contentMode: .fit) + .failure { + SystemImageContentView(systemName: viewModel.item.typeSystemImage) + } + .posterStyle(.portrait, contentMode: .fit) .frame(width: 130) .accessibilityIgnoresInvertColors() diff --git a/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift b/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift index 5947064a..3def39a0 100644 --- a/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift @@ -22,7 +22,9 @@ extension SeriesItemView { // MARK: Episodes - SeriesEpisodeSelector(viewModel: viewModel) + if viewModel.seasons.isNotEmpty { + SeriesEpisodeSelector(viewModel: viewModel) + } // MARK: Genres diff --git a/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemView.swift b/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemView.swift index f4e118da..9d316e90 100644 --- a/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemView.swift @@ -6,6 +6,7 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import JellyfinAPI import SwiftUI struct iPadOSCollectionItemView: View { diff --git a/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeItemView.swift b/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeItemView.swift index 04142415..bb9914ab 100644 --- a/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeItemView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeItemView.swift @@ -11,9 +11,6 @@ import SwiftUI struct iPadOSEpisodeItemView: View { - @EnvironmentObject - private var router: ItemCoordinator.Router - @ObservedObject var viewModel: EpisodeItemViewModel diff --git a/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift b/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift index 46013c52..c182a7fb 100644 --- a/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift @@ -18,14 +18,16 @@ extension ItemView { @ObservedObject var viewModel: ItemViewModel + @State + private var globalSize: CGSize = .zero @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 start = globalSize.isLandscape ? globalSize.height * 0.45 : globalSize.height * 0.25 + let end = globalSize.isLandscape ? globalSize.height * 0.65 : globalSize.height * 0.30 let diff = end - start let opacity = clamp((scrollViewOffset - start) / diff, min: 0, max: 1) return opacity @@ -40,7 +42,8 @@ extension ItemView { ImageView(viewModel.item.imageSource(.backdrop, maxWidth: 1920)) } } - .frame(height: UIScreen.main.bounds.height * 0.8) + .aspectRatio(contentMode: .fill) + .frame(height: globalSize.isLandscape ? globalSize.height * 0.8 : globalSize.height * 0.4) } var body: some View { @@ -50,10 +53,9 @@ extension ItemView { Spacer() OverlayView(viewModel: viewModel) - .padding2(.horizontal) - .padding2(.bottom) + .edgePadding() } - .frame(height: UIScreen.main.bounds.height * 0.8) + .frame(height: globalSize.isLandscape ? globalSize.height * 0.8 : globalSize.height * 0.4) .background { BlurView(style: .systemThinMaterialDark) .mask { @@ -82,23 +84,17 @@ extension ItemView { .scrollViewOffset($scrollViewOffset) .navigationBarOffset( $scrollViewOffset, - start: UIScreen.main.bounds.height * 0.65, - end: UIScreen.main.bounds.height * 0.65 + 50 + start: globalSize.isLandscape ? globalSize.height * 0.65 : globalSize.height * 0.30, + end: globalSize.isLandscape ? globalSize.height * 0.65 + 50 : globalSize.height * 0.30 + 50 ) .backgroundParallaxHeader( $scrollViewOffset, - height: UIScreen.main.bounds.height * 0.8, + height: globalSize.isLandscape ? globalSize.height * 0.8 : globalSize.height * 0.4, multiplier: 0.3 ) { headerView } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - if viewModel.isLoading { - ProgressView() - } - } - } + .size($globalSize) } } } diff --git a/Swiftfin/Views/MediaView.swift b/Swiftfin/Views/MediaView.swift index 5bb0c4de..64d61482 100644 --- a/Swiftfin/Views/MediaView.swift +++ b/Swiftfin/Views/MediaView.swift @@ -16,6 +16,7 @@ import SwiftUI // TODO: seems to redraw view when popped to sometimes? // - similar to HomeView TODO bug? // TODO: list view +// TODO: `afterLastDisappear` with `backgroundRefresh` struct MediaView: View { @EnvironmentObject @@ -59,6 +60,9 @@ struct MediaView: View { } } } + .refreshable { + viewModel.send(.refresh) + } } private func errorView(with error: some Error) -> some View { @@ -70,18 +74,16 @@ struct MediaView: View { var body: some View { WrappedView { - Group { - switch viewModel.state { - case .content: - contentView - case let .error(error): - errorView(with: error) - case .initial, .refreshing: - ProgressView() - } + switch viewModel.state { + case .content: + contentView + case let .error(error): + errorView(with: error) + case .initial, .refreshing: + DelayedProgressView() } - .transition(.opacity.animation(.linear(duration: 0.1))) } + .transition(.opacity.animation(.linear(duration: 0.2))) .ignoresSafeArea() .navigationTitle(L10n.allMedia) .topBarTrailing { @@ -101,6 +103,7 @@ extension MediaView { // - differentiate between what media types are Swiftfin only // which would allow some cleanup // - allow server or random view per library? + // TODO: if local label on image, also needs to be in blurhash placeholder struct MediaItem: View { @Default(.Customization.Library.randomImage) diff --git a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift index 2741ab37..3320522c 100644 --- a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift +++ b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift @@ -16,6 +16,8 @@ import SwiftUI // other items that don't have a subtitle which requires the entire library to implement // subtitle content but that doesn't look appealing. Until a solution arrives grid posters // will not have subtitle content. +// There should be a solution since there are contexts where subtitles are desirable and/or +// we can have subtitle content for other items. struct PagingLibraryView: View { @@ -80,6 +82,9 @@ struct PagingLibraryView: View { case .collectionFolder, .folder: let viewModel = ItemLibraryViewModel(parent: item, filters: .default) router.route(to: \.library, viewModel) + case .person: + let viewModel = ItemLibraryViewModel(parent: item) + router.route(to: \.library, viewModel) default: router.route(to: \.item, item) } @@ -179,32 +184,33 @@ struct PagingLibraryView: View { listItemView(item: item) } } - .onReachedBottomEdge(offset: 300) { + .onReachedTopEdge(offset: .offset(300)) { viewModel.send(.getNextPage) } .proxy(collectionVGridProxy) + .refreshable { + viewModel.send(.refresh) + } } // MARK: body var body: some View { WrappedView { - Group { - switch viewModel.state { - case let .error(error): - errorView(with: error) - case .initial, .refreshing: - ProgressView() - case .gettingNextPage, .content: - if viewModel.elements.isEmpty { - L10n.noResults.text - } else { - contentView - } + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + L10n.noResults.text + } else { + contentView } + case let .error(error): + errorView(with: error) + case .initial, .refreshing: + DelayedProgressView() } - .transition(.opacity.animation(.linear(duration: 0.2))) } + .transition(.opacity.animation(.linear(duration: 0.2))) .ignoresSafeArea() .navigationTitle(viewModel.parent?.displayTitle ?? "") .navigationBarTitleDisplayMode(.inline) @@ -282,7 +288,7 @@ struct PagingLibraryView: View { } .topBarTrailing { - if viewModel.state == .gettingNextPage { + if viewModel.backgroundStates.contains(.gettingNextPage) { ProgressView() } diff --git a/Swiftfin/Views/SearchView.swift b/Swiftfin/Views/SearchView.swift index dd202561..fea6e836 100644 --- a/Swiftfin/Views/SearchView.swift +++ b/Swiftfin/Views/SearchView.swift @@ -85,7 +85,7 @@ struct SearchView: View { @ViewBuilder private func itemsSection( title: String, - keyPath: ReferenceWritableKeyPath, + keyPath: KeyPath, posterType: PosterType ) -> some View { PosterHStack( @@ -96,7 +96,8 @@ struct SearchView: View { .trailing { SeeAllButton() .onSelect { - router.route(to: \.library, .init(viewModel[keyPath: keyPath])) + let viewModel = PagingLibraryViewModel(title: title, viewModel[keyPath: keyPath]) + router.route(to: \.library, viewModel) } } .onSelect(select) diff --git a/Swiftfin/Views/VideoPlayer/Overlays/ChapterOverlay.swift b/Swiftfin/Views/VideoPlayer/Overlays/ChapterOverlay.swift index 5927d1ea..446d7963 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/ChapterOverlay.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/ChapterOverlay.swift @@ -12,6 +12,8 @@ import JellyfinAPI import SwiftUI import VLCUI +// TODO: figure out why `continuousLeadingEdge` scroll behavior has different +// insets than default continuous extension VideoPlayer.Overlay { struct ChapterOverlay: View { @@ -56,8 +58,7 @@ extension VideoPlayer.Overlay { Button { if let currentChapter = viewModel.chapter(from: currentProgressHandler.seconds) { - let index = viewModel.chapters.firstIndex(of: currentChapter)! - collectionHStackProxy.scrollTo(index: index) + collectionHStackProxy.scrollTo(element: currentChapter, animated: true) } } label: { Text(L10n.current) @@ -80,8 +81,7 @@ extension VideoPlayer.Overlay { guard newValue == .chapters else { return } if let currentChapter = viewModel.chapter(from: currentProgressHandler.seconds) { - let index = viewModel.chapters.firstIndex(of: currentChapter)! - collectionHStackProxy.scrollTo(index: index, animated: false) + collectionHStackProxy.scrollTo(element: currentChapter, animated: false) } } }