mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
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.
342 lines
12 KiB
Swift
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)
|
|
}
|