From 80e8f193daa6f90a81f1fa46785cf2997da08d91 Mon Sep 17 00:00:00 2001 From: 3aa49ec6bfc910647fa1c5a013e48eef <3aa49ec6bfc910647fa1c5a013e48eef> Date: Mon, 17 Nov 2025 20:05:44 +0800 Subject: [PATCH 1/3] Fix tvOS episode focus (#1640) --- Shared/Extensions/UIDevice.swift | 32 +++ .../Components/HStacks/EpisodeHStack.swift | 253 ++++++++++++++---- 2 files changed, 240 insertions(+), 45 deletions(-) diff --git a/Shared/Extensions/UIDevice.swift b/Shared/Extensions/UIDevice.swift index 202d114269..c637c2568a 100644 --- a/Shared/Extensions/UIDevice.swift +++ b/Shared/Extensions/UIDevice.swift @@ -6,6 +6,7 @@ // Copyright (c) 2025 Jellyfin & Jellyfin Contributors // +import Darwin import UIKit extension UIDevice { @@ -70,6 +71,37 @@ extension UIDevice { #endif } +#if os(tvOS) +extension UIDevice { + + static var platformGeneration: Int { + let identifier = hardwareIdentifier() + + switch identifier { + case "AppleTV14,1", "AppleTV14,2": + return 3 + case "AppleTV11,1": + return 2 + case "AppleTV6,2", "AppleTV5,3": + return 1 + default: + return 3 + } + } + + private static func hardwareIdentifier() -> String { + var systemInfo = utsname() + uname(&systemInfo) + + return withUnsafePointer(to: &systemInfo.machine.0) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: 1) { + String(cString: $0) + } + } + } +} +#endif + #if os(tvOS) enum UINotificationFeedbackGenerator { enum FeedbackType { diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift index dd76c327d0..533c71a6ed 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift @@ -9,12 +9,73 @@ import CollectionHStack import Foundation import JellyfinAPI +import Logging import SwiftUI +import UIKit extension SeriesEpisodeSelector { struct EpisodeHStack: View { + private enum Constants { + static let emptyCardID = "emptyCard" + static let errorCardID = "errorCard" + static let loadingCardID = "loadingCard" + + static var playButtonScrollDelay: TimeInterval { + UIDevice.platformGeneration < 3 ? 0.35 : 0.1 + } + } + + private enum FocusUpdateReason: String { + case initial + case focusGuide + case pending + case seasonChange + case stateChange + } + + private struct EpisodeFocusCoordinator { + var pendingEpisodeID: String? + var lastCommittedEpisodeID: String? + var isApplyingFocus = false + + mutating func queuePendingEpisode(_ id: String?) { + pendingEpisodeID = id + } + + mutating func registerCommit(for id: String) { + lastCommittedEpisodeID = id + if pendingEpisodeID == id { + pendingEpisodeID = nil + } + } + + mutating func dropPending(ifMatching id: String) { + if pendingEpisodeID == id { + pendingEpisodeID = nil + } + } + + mutating func reset() { + pendingEpisodeID = nil + lastCommittedEpisodeID = nil + isApplyingFocus = false + } + + mutating func invalidate(using visibleIDs: Set) { + if let last = lastCommittedEpisodeID, !visibleIDs.contains(last) { + lastCommittedEpisodeID = nil + } + + if let pending = pendingEpisodeID, !visibleIDs.contains(pending) { + pendingEpisodeID = nil + } + } + } + + private static let logger = Logger(label: "org.jellyfin.swiftfin.seriesEpisodeFocus") + @EnvironmentObject private var focusGuide: FocusGuide @@ -27,7 +88,7 @@ extension SeriesEpisodeSelector { @State private var didScrollToPlayButtonItem = false @State - private var lastFocusedEpisodeID: String? + private var focusCoordinator = EpisodeFocusCoordinator() @StateObject private var proxy = CollectionHStackProxy() @@ -51,43 +112,7 @@ extension SeriesEpisodeSelector { .itemSpacing(EdgeInsets.edgePadding / 2) .proxy(proxy) .onFirstAppear { - guard !didScrollToPlayButtonItem else { return } - didScrollToPlayButtonItem = true - - lastFocusedEpisodeID = playButtonItem?.id - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - guard let playButtonItem else { return } - proxy.scrollTo(id: playButtonItem.unwrappedIDHashOrZero, animated: false) - } - } - } - - // MARK: - Determine Which Episode should be Focused - - private func getContentFocus() { - switch viewModel.state { - case .content: - if viewModel.elements.isEmpty { - /// Focus the EmptyCard if the Season has no elements - focusedEpisodeID = "emptyCard" - } else { - if let lastFocusedEpisodeID, - viewModel.elements.contains(where: { $0.id == lastFocusedEpisodeID }) - { - /// Return focus to the Last Focused Episode if it exists in the current Season - focusedEpisodeID = lastFocusedEpisodeID - } else { - /// Focus the First Episode in the season as a last resort - focusedEpisodeID = viewModel.elements.first?.id - } - } - case .error: - /// Focus the ErrorCard if the Season failed to load - focusedEpisodeID = "errorCard" - case .initial, .refreshing: - /// Focus the LoadingCard if the Season is currently loading - focusedEpisodeID = "loadingCard" + attemptScrollToPendingEpisodeIfNeeded() } } @@ -118,22 +143,160 @@ extension SeriesEpisodeSelector { focusGuide, tag: "episodes", onContentFocus: { - getContentFocus() + applyFocusIfNeeded(reason: .focusGuide) }, top: "belowHeader" ) - .onChange(of: viewModel.id) { - lastFocusedEpisodeID = viewModel.elements.first?.id + .onAppear { + configurePendingFocusIfNeeded() + applyFocusIfNeeded(reason: .initial) } - .onChange(of: focusedEpisodeID) { _, newValue in - guard let newValue else { return } - lastFocusedEpisodeID = newValue + .onChange(of: viewModel.id) { _, _ in + didScrollToPlayButtonItem = false + focusCoordinator.reset() + configurePendingFocusIfNeeded(force: true) + applyFocusIfNeeded(reason: .seasonChange) } .onChange(of: viewModel.state) { _, newValue in if newValue == .content { - lastFocusedEpisodeID = viewModel.elements.first?.id + configurePendingFocusIfNeeded() + attemptScrollToPendingEpisodeIfNeeded() } + applyFocusIfNeeded(reason: .stateChange) + } + .onChange(of: focusedEpisodeID) { _, newValue in + guard let newValue else { return } + handleFocusedEpisodeChange(newValue) + } + } + } + + private extension SeriesEpisodeSelector.EpisodeHStack { + + var episodeIDs: Set { + Set(viewModel.elements.compactMap(\.id)) + } + + var defaultEpisodeFocusID: String? { + if playButtonIsInSeason { + return playButtonItem?.id } + + return viewModel.elements.first?.id + } + + var playButtonIsInSeason: Bool { + guard let playButtonItem else { return false } + return playButtonItem.seasonID == viewModel.id + } + + func configurePendingFocusIfNeeded(force: Bool = false) { + if force { + let pendingID = playButtonIsInSeason ? playButtonItem?.id : nil + focusCoordinator.queuePendingEpisode(pendingID) + return + } + + if focusCoordinator.pendingEpisodeID == nil, + focusCoordinator.lastCommittedEpisodeID == nil + { + focusCoordinator.queuePendingEpisode(defaultEpisodeFocusID) + } + } + + func handleFocusedEpisodeChange(_ newValue: String) { + if isPlaceholder(newValue) { + focusCoordinator.dropPending(ifMatching: newValue) + return + } + + if episodeIDs.contains(newValue) { + focusCoordinator.lastCommittedEpisodeID = newValue + } + } + + func attemptScrollToPendingEpisodeIfNeeded() { + guard !didScrollToPlayButtonItem, + playButtonIsInSeason, + let pendingID = focusCoordinator.pendingEpisodeID, + let playButtonItem, + pendingID == playButtonItem.id + else { + return + } + + didScrollToPlayButtonItem = true + + DispatchQueue.main.asyncAfter(deadline: .now() + Constants.playButtonScrollDelay) { + proxy.scrollTo(id: playButtonItem.unwrappedIDHashOrZero, animated: false) + applyFocusIfNeeded(reason: .pending) + } + } + + func preferredFocusID() -> String? { + switch viewModel.state { + case .content: + guard !viewModel.elements.isEmpty else { + focusCoordinator.lastCommittedEpisodeID = nil + return Constants.emptyCardID + } + + if let pending = focusCoordinator.pendingEpisodeID, + episodeIDs.contains(pending) + { + return pending + } + + if let last = focusCoordinator.lastCommittedEpisodeID, + episodeIDs.contains(last) + { + return last + } + + return viewModel.elements.first?.id ?? Constants.emptyCardID + case .error: + return Constants.errorCardID + case .initial, .refreshing: + return Constants.loadingCardID + } + } + + func applyFocusIfNeeded(reason: FocusUpdateReason) { + focusCoordinator.invalidate(using: episodeIDs) + + guard let targetID = preferredFocusID() else { + return + } + + guard !focusCoordinator.isApplyingFocus else { + return + } + + focusCoordinator.isApplyingFocus = true + + var transaction = Transaction(animation: .none) + transaction.disablesAnimations = true + + withTransaction(transaction) { + DispatchQueue.main.async { + focusedEpisodeID = targetID + + if isPlaceholder(targetID) { + focusCoordinator.dropPending(ifMatching: targetID) + } else { + focusCoordinator.registerCommit(for: targetID) + } + + focusCoordinator.isApplyingFocus = false + Self.logger.debug("Focused \(targetID) [reason: \(reason.rawValue)]") + } + } + } + + func isPlaceholder(_ id: String) -> Bool { + id == Constants.emptyCardID || + id == Constants.errorCardID || + id == Constants.loadingCardID } } From 3e100f01c8d4a0d4031c03c6ac75d3dead82dd15 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 24 Nov 2025 20:43:36 -0700 Subject: [PATCH 2/3] WIP --- Shared/Extensions/UIDevice.swift | 32 --- .../Components/HStacks/EpisodeHStack.swift | 252 +++--------------- 2 files changed, 44 insertions(+), 240 deletions(-) diff --git a/Shared/Extensions/UIDevice.swift b/Shared/Extensions/UIDevice.swift index c637c2568a..202d114269 100644 --- a/Shared/Extensions/UIDevice.swift +++ b/Shared/Extensions/UIDevice.swift @@ -6,7 +6,6 @@ // Copyright (c) 2025 Jellyfin & Jellyfin Contributors // -import Darwin import UIKit extension UIDevice { @@ -71,37 +70,6 @@ extension UIDevice { #endif } -#if os(tvOS) -extension UIDevice { - - static var platformGeneration: Int { - let identifier = hardwareIdentifier() - - switch identifier { - case "AppleTV14,1", "AppleTV14,2": - return 3 - case "AppleTV11,1": - return 2 - case "AppleTV6,2", "AppleTV5,3": - return 1 - default: - return 3 - } - } - - private static func hardwareIdentifier() -> String { - var systemInfo = utsname() - uname(&systemInfo) - - return withUnsafePointer(to: &systemInfo.machine.0) { ptr in - ptr.withMemoryRebound(to: CChar.self, capacity: 1) { - String(cString: $0) - } - } - } -} -#endif - #if os(tvOS) enum UINotificationFeedbackGenerator { enum FeedbackType { diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift index 533c71a6ed..015c79a35e 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift @@ -9,73 +9,12 @@ import CollectionHStack import Foundation import JellyfinAPI -import Logging import SwiftUI -import UIKit extension SeriesEpisodeSelector { struct EpisodeHStack: View { - private enum Constants { - static let emptyCardID = "emptyCard" - static let errorCardID = "errorCard" - static let loadingCardID = "loadingCard" - - static var playButtonScrollDelay: TimeInterval { - UIDevice.platformGeneration < 3 ? 0.35 : 0.1 - } - } - - private enum FocusUpdateReason: String { - case initial - case focusGuide - case pending - case seasonChange - case stateChange - } - - private struct EpisodeFocusCoordinator { - var pendingEpisodeID: String? - var lastCommittedEpisodeID: String? - var isApplyingFocus = false - - mutating func queuePendingEpisode(_ id: String?) { - pendingEpisodeID = id - } - - mutating func registerCommit(for id: String) { - lastCommittedEpisodeID = id - if pendingEpisodeID == id { - pendingEpisodeID = nil - } - } - - mutating func dropPending(ifMatching id: String) { - if pendingEpisodeID == id { - pendingEpisodeID = nil - } - } - - mutating func reset() { - pendingEpisodeID = nil - lastCommittedEpisodeID = nil - isApplyingFocus = false - } - - mutating func invalidate(using visibleIDs: Set) { - if let last = lastCommittedEpisodeID, !visibleIDs.contains(last) { - lastCommittedEpisodeID = nil - } - - if let pending = pendingEpisodeID, !visibleIDs.contains(pending) { - pendingEpisodeID = nil - } - } - } - - private static let logger = Logger(label: "org.jellyfin.swiftfin.seriesEpisodeFocus") - @EnvironmentObject private var focusGuide: FocusGuide @@ -88,7 +27,7 @@ extension SeriesEpisodeSelector { @State private var didScrollToPlayButtonItem = false @State - private var focusCoordinator = EpisodeFocusCoordinator() + private var lastFocusedEpisodeID: String? @StateObject private var proxy = CollectionHStackProxy() @@ -106,13 +45,47 @@ extension SeriesEpisodeSelector { SeriesEpisodeSelector.EpisodeCard(episode: episode) .focused($focusedEpisodeID, equals: episode.id) .padding(.horizontal, 4) + /// Wait to scroll until we see a `EpisodeCard` in the HStack + .onFirstAppear { + guard let playButtonItem, + !didScrollToPlayButtonItem, + viewModel.state == .content + else { return } + + lastFocusedEpisodeID = playButtonItem.id + proxy.scrollTo(id: playButtonItem.unwrappedIDHashOrZero, animated: false) + didScrollToPlayButtonItem = true + } } .scrollBehavior(.continuousLeadingEdge) .insets(horizontal: EdgeInsets.edgePadding) .itemSpacing(EdgeInsets.edgePadding / 2) .proxy(proxy) - .onFirstAppear { - attemptScrollToPendingEpisodeIfNeeded() + } + + private func getContentFocus() { + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + /// Focus the EmptyCard if the Season has no elements + focusedEpisodeID = "emptyCard" + } else { + if let lastFocusedEpisodeID, + viewModel.elements.contains(where: { $0.id == lastFocusedEpisodeID }) + { + /// Return focus to the Last Focused Episode if it exists in the current Season + focusedEpisodeID = lastFocusedEpisodeID + } else { + /// Focus the First Episode in the season as a last resort + focusedEpisodeID = viewModel.elements.first?.id + } + } + case .error: + /// Focus the ErrorCard if the Season failed to load + focusedEpisodeID = "errorCard" + case .initial, .refreshing: + /// Focus the LoadingCard if the Season is currently loading + focusedEpisodeID = "loadingCard" } } @@ -143,161 +116,24 @@ extension SeriesEpisodeSelector { focusGuide, tag: "episodes", onContentFocus: { - applyFocusIfNeeded(reason: .focusGuide) + getContentFocus() }, top: "belowHeader" ) - .onAppear { - configurePendingFocusIfNeeded() - applyFocusIfNeeded(reason: .initial) - } - .onChange(of: viewModel.id) { _, _ in + .onChange(of: viewModel.id) { + lastFocusedEpisodeID = viewModel.elements.first?.id didScrollToPlayButtonItem = false - focusCoordinator.reset() - configurePendingFocusIfNeeded(force: true) - applyFocusIfNeeded(reason: .seasonChange) - } - .onChange(of: viewModel.state) { _, newValue in - if newValue == .content { - configurePendingFocusIfNeeded() - attemptScrollToPendingEpisodeIfNeeded() - } - applyFocusIfNeeded(reason: .stateChange) } .onChange(of: focusedEpisodeID) { _, newValue in guard let newValue else { return } - handleFocusedEpisodeChange(newValue) - } - } - } - - private extension SeriesEpisodeSelector.EpisodeHStack { - - var episodeIDs: Set { - Set(viewModel.elements.compactMap(\.id)) - } - - var defaultEpisodeFocusID: String? { - if playButtonIsInSeason { - return playButtonItem?.id + lastFocusedEpisodeID = newValue } - - return viewModel.elements.first?.id - } - - var playButtonIsInSeason: Bool { - guard let playButtonItem else { return false } - return playButtonItem.seasonID == viewModel.id - } - - func configurePendingFocusIfNeeded(force: Bool = false) { - if force { - let pendingID = playButtonIsInSeason ? playButtonItem?.id : nil - focusCoordinator.queuePendingEpisode(pendingID) - return - } - - if focusCoordinator.pendingEpisodeID == nil, - focusCoordinator.lastCommittedEpisodeID == nil - { - focusCoordinator.queuePendingEpisode(defaultEpisodeFocusID) - } - } - - func handleFocusedEpisodeChange(_ newValue: String) { - if isPlaceholder(newValue) { - focusCoordinator.dropPending(ifMatching: newValue) - return - } - - if episodeIDs.contains(newValue) { - focusCoordinator.lastCommittedEpisodeID = newValue - } - } - - func attemptScrollToPendingEpisodeIfNeeded() { - guard !didScrollToPlayButtonItem, - playButtonIsInSeason, - let pendingID = focusCoordinator.pendingEpisodeID, - let playButtonItem, - pendingID == playButtonItem.id - else { - return - } - - didScrollToPlayButtonItem = true - - DispatchQueue.main.asyncAfter(deadline: .now() + Constants.playButtonScrollDelay) { - proxy.scrollTo(id: playButtonItem.unwrappedIDHashOrZero, animated: false) - applyFocusIfNeeded(reason: .pending) - } - } - - func preferredFocusID() -> String? { - switch viewModel.state { - case .content: - guard !viewModel.elements.isEmpty else { - focusCoordinator.lastCommittedEpisodeID = nil - return Constants.emptyCardID - } - - if let pending = focusCoordinator.pendingEpisodeID, - episodeIDs.contains(pending) - { - return pending - } - - if let last = focusCoordinator.lastCommittedEpisodeID, - episodeIDs.contains(last) - { - return last - } - - return viewModel.elements.first?.id ?? Constants.emptyCardID - case .error: - return Constants.errorCardID - case .initial, .refreshing: - return Constants.loadingCardID - } - } - - func applyFocusIfNeeded(reason: FocusUpdateReason) { - focusCoordinator.invalidate(using: episodeIDs) - - guard let targetID = preferredFocusID() else { - return - } - - guard !focusCoordinator.isApplyingFocus else { - return - } - - focusCoordinator.isApplyingFocus = true - - var transaction = Transaction(animation: .none) - transaction.disablesAnimations = true - - withTransaction(transaction) { - DispatchQueue.main.async { - focusedEpisodeID = targetID - - if isPlaceholder(targetID) { - focusCoordinator.dropPending(ifMatching: targetID) - } else { - focusCoordinator.registerCommit(for: targetID) - } - - focusCoordinator.isApplyingFocus = false - Self.logger.debug("Focused \(targetID) [reason: \(reason.rawValue)]") + .onChange(of: viewModel.state) { _, newValue in + if newValue == .content { + lastFocusedEpisodeID = viewModel.elements.first?.id } } } - - func isPlaceholder(_ id: String) -> Bool { - id == Constants.emptyCardID || - id == Constants.errorCardID || - id == Constants.loadingCardID - } } // MARK: - Empty HStack From 334b7de129f483e68f05259140458029fc40a789 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 24 Nov 2025 20:50:19 -0700 Subject: [PATCH 3/3] Remove unscroll on season change. --- .../EpisodeSelector/Components/HStacks/EpisodeHStack.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift index 015c79a35e..654a3c446e 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift @@ -122,7 +122,6 @@ extension SeriesEpisodeSelector { ) .onChange(of: viewModel.id) { lastFocusedEpisodeID = viewModel.elements.first?.id - didScrollToPlayButtonItem = false } .onChange(of: focusedEpisodeID) { _, newValue in guard let newValue else { return }