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.
This commit is contained in:
Arkadiusz Fal
2026-09-19 23:43:00 +02:00
parent 7c71c2d7b9
commit 7db03f2392

View File

@@ -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
}