Files
yattee/Yattee/Views/Home/ContinueWatchingView.swift
Arkadiusz Fal 7db03f2392 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.
2026-09-19 23:43:00 +02:00

342 lines
12 KiB
Swift

//
// ContinueWatchingView.swift
// Yattee
//
// Full-screen view of all videos in progress.
//
import SwiftUI
struct ContinueWatchingView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Namespace private var sheetTransition
#if os(tvOS)
@FocusState private var focusedVideoID: String?
@State private var lastFocusedVideoID: String?
#endif
@State private var watchHistory: [WatchEntry] = []
// View options (persisted)
@AppStorage("continueWatching.layout") private var layout: VideoListLayout = .grid
@AppStorage("continueWatching.rowStyle") private var rowStyle: VideoRowStyle = .regular
@AppStorage("continueWatching.gridColumns") private var gridColumns = 2
/// List style from centralized settings.
private var listStyle: VideoListStyle {
appEnvironment?.settingsManager.listStyle ?? .inset
}
// UI state
@State private var showViewOptions = false
@State private var viewWidth: CGFloat = 0
private var dataManager: DataManager? { appEnvironment?.dataManager }
/// Filtered to only show in-progress videos.
private var inProgressEntries: [WatchEntry] {
watchHistory.filter { !$0.isFinished && !$0.isLive && $0.watchedSeconds > 10 }
}
// Grid layout configuration
private var gridConfig: GridLayoutConfiguration {
GridLayoutConfiguration(viewWidth: viewWidth, gridColumns: gridColumns)
}
private var viewOptionsSheetContent: some View {
ViewOptionsSheet(
layout: $layout,
rowStyle: $rowStyle,
gridColumns: $gridColumns,
maxGridColumns: gridConfig.maxColumns
)
}
/// View options button lives on the leading edge on macOS, trailing elsewhere.
private var viewOptionsPlacement: ToolbarItemPlacement {
#if os(macOS)
.navigation
#else
.primaryAction
#endif
}
var body: some View {
GeometryReader { geometry in
#if os(tvOS)
VStack(spacing: 0) {
if !inProgressEntries.isEmpty {
HStack(spacing: 24) {
Text(String(localized: "home.continueWatching.title"))
.font(.title2)
.fontWeight(.semibold)
Spacer()
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
Menu {
Button(role: .destructive) {
clearAllProgress()
} label: {
Label(String(localized: "continueWatching.clearAll"), systemImage: "trash")
}
} label: {
Image(systemName: "trash")
}
}
.focusSection()
.padding(.horizontal, 48)
.padding(.top, 40)
.padding(.bottom, 40)
}
Group {
if inProgressEntries.isEmpty {
emptyState
} else {
switch layout {
case .list:
listContent
case .grid:
gridContent
}
}
}
.focusSection()
}
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
#else
Group {
if inProgressEntries.isEmpty {
emptyState
} else {
switch layout {
case .list:
listContent
case .grid:
gridContent
}
}
}
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
#endif
}
#if !os(tvOS)
.navigationTitle(String(localized: "home.continueWatching.title"))
.toolbarTitleDisplayMode(.inlineLarge)
.toolbar {
if !inProgressEntries.isEmpty {
ToolbarItem(placement: viewOptionsPlacement) {
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
.liquidGlassTransitionSource(id: "continueWatchingViewOptions", in: sheetTransition)
#if os(macOS)
.popover(isPresented: $showViewOptions, arrowEdge: .bottom) {
viewOptionsSheetContent
}
#endif
}
ToolbarItem(placement: .primaryAction) {
Menu {
Button(role: .destructive) {
clearAllProgress()
} label: {
Label(String(localized: "continueWatching.clearAll"), systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
#endif
#if !os(macOS)
.sheet(isPresented: $showViewOptions) {
viewOptionsSheetContent
.liquidGlassSheetContent(sourceID: "continueWatchingViewOptions", in: sheetTransition)
}
#endif
.onAppear {
loadHistory()
}
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
loadHistory()
}
#if os(tvOS)
.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.
// 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
}
// MARK: - Content
private var emptyState: some View {
ContentUnavailableView {
Label(String(localized: "home.continueWatching.title"), systemImage: "play.circle")
} description: {
Text(String(localized: "home.continueWatching.empty"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var listContent: some View {
VideoListContainer(listStyle: listStyle, rowStyle: rowStyle) {
// Header spacer for top padding
Spacer()
.frame(height: 16)
} content: {
ForEach(Array(inProgressEntries.enumerated()), id: \.element.videoID) { index, entry in
VideoListRow(
isLast: index == inProgressEntries.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
entryRowView(entry: entry, index: index)
}
#if os(tvOS)
.focused($focusedVideoID, equals: entry.videoID)
#else
.videoSwipeActions(
video: entry.toVideo(),
fixedActions: [
SwipeAction(
symbolImage: "xmark.circle.fill",
tint: .white,
background: .red
) { reset in
removeEntry(entry)
reset()
}
]
)
#endif
}
}
}
/// Reusable row view for a watch entry.
@ViewBuilder
private func entryRowView(entry: WatchEntry, index: Int) -> some View {
VideoRowView(
video: entry.toVideo(),
style: rowStyle,
watchProgress: entry.progress,
customMetadata: entry.isFinished || entry.isLive ? nil : String(localized: "home.history.remaining \(entry.remainingTime)")
)
.tappableVideo(
entry.toVideo(),
startTime: entry.watchedSeconds,
queueSource: .manual,
sourceLabel: String(localized: "queue.source.continueWatching"),
videoList: inProgressEntries.map { $0.toVideo() },
videoIndex: index,
loadMoreVideos: loadMoreCallback
)
.videoContextMenu(
video: entry.toVideo(),
customActions: [
VideoContextAction(
String(localized: "continueWatching.remove"),
systemImage: "xmark.circle",
role: .destructive,
action: { removeEntry(entry) }
)
],
context: .continueWatching,
startTime: entry.watchedSeconds
)
}
private var gridContent: some View {
ScrollView {
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(inProgressEntries, id: \.videoID) { entry in
TappableContinueWatchingGridCard(entry: entry, onRemove: {
removeEntry(entry)
})
#if os(tvOS)
.focused($focusedVideoID, equals: entry.videoID)
#endif
}
}
}
#if os(tvOS)
.scrollClipDisabled()
#endif
}
// MARK: - Actions
private func loadHistory() {
watchHistory = dataManager?.watchHistory(limit: 100) ?? []
}
private func removeEntry(_ entry: WatchEntry) {
dataManager?.removeFromHistory(videoID: entry.videoID)
loadHistory()
}
private func clearAllProgress() {
dataManager?.clearInProgressHistory()
}
/// Stub callback for video queue continuation.
@Sendable
private func loadMoreCallback() async throws -> ([Video], String?) {
return ([], nil) // No pagination
}
}
// MARK: - Preview
#Preview {
NavigationStack {
ContinueWatchingView()
}
.appEnvironment(.preview)
}