From 7db03f2392607ea635d0279822d23f1d4a6f9e0a Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 19 Sep 2026 23:43:00 +0200 Subject: [PATCH] Restore tvOS focus after removing a Continue Watching entry Marking a video as watched (or removing it) from the context menu left nothing focused once the entry disappeared. Track the last focused video and move focus to the entry that took its place (or the previous one if it was last). The context menu dismissal restores focus to the removed row after the first attempt, so the target is re-asserted a few times until the focus engine takes it. --- Yattee/Views/Home/ContinueWatchingView.swift | 32 ++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/Yattee/Views/Home/ContinueWatchingView.swift b/Yattee/Views/Home/ContinueWatchingView.swift index 7cb449e5..457d371a 100644 --- a/Yattee/Views/Home/ContinueWatchingView.swift +++ b/Yattee/Views/Home/ContinueWatchingView.swift @@ -12,6 +12,7 @@ struct ContinueWatchingView: View { @Namespace private var sheetTransition #if os(tvOS) @FocusState private var focusedVideoID: String? + @State private var lastFocusedVideoID: String? #endif @State private var watchHistory: [WatchEntry] = [] @@ -173,14 +174,41 @@ struct ContinueWatchingView: View { loadHistory() } #if os(tvOS) - .onChange(of: inProgressEntries.first?.videoID, initial: true) { _, newValue in + .onChange(of: inProgressEntries.first?.videoID, initial: true) { oldValue, newValue in // Work around tvOS ScrollView + prefersDefaultFocus bug: set initial focus // to the first video after LazyVGrid has time to materialize cells. - guard let newValue else { return } + // Only on first population — later list changes are handled below. + guard let newValue, oldValue == nil else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { focusedVideoID = newValue } } + .onChange(of: focusedVideoID) { _, newValue in + if let newValue { + lastFocusedVideoID = newValue + } + } + .onChange(of: inProgressEntries.map(\.videoID)) { oldIDs, newIDs in + // When the focused video is removed (marked watched / removed from + // history), move focus to the entry that took its place, or the + // previous one if it was last — otherwise focus is lost entirely. + guard let removed = lastFocusedVideoID, + !newIDs.contains(removed), + !newIDs.isEmpty else { return } + let oldIndex = oldIDs.firstIndex(of: removed) ?? 0 + let target = newIDs[min(oldIndex, newIDs.count - 1)] + LoggingService.shared.info("Continue Watching: focused entry \(removed) removed, refocusing \(target)") + // The context menu's dismissal restores focus to the (now removed) + // source view after our first assert lands, so re-assert until the + // focus engine actually takes the new target. + for delay in [0.2, 0.7, 1.4] { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + if focusedVideoID == nil { + focusedVideoID = target + } + } + } + } #endif }