Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,220 @@
//
// 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
@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.watchedSeconds > 10 }
}
// Grid layout configuration
private var gridConfig: GridLayoutConfiguration {
GridLayoutConfiguration(viewWidth: viewWidth, gridColumns: gridColumns)
}
var body: some View {
GeometryReader { geometry in
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
}
}
.navigationTitle(String(localized: "home.continueWatching.title"))
#if !os(tvOS)
.toolbarTitleDisplayMode(.inlineLarge)
#endif
.toolbar {
if !inProgressEntries.isEmpty {
ToolbarItem(placement: .primaryAction) {
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
.liquidGlassTransitionSource(id: "continueWatchingViewOptions", in: sheetTransition)
}
ToolbarItem(placement: .primaryAction) {
Menu {
Button(role: .destructive) {
clearAllProgress()
} label: {
Label(String(localized: "continueWatching.clearAll"), systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
.sheet(isPresented: $showViewOptions) {
ViewOptionsSheet(
layout: $layout,
rowStyle: $rowStyle,
gridColumns: $gridColumns,
maxGridColumns: gridConfig.maxColumns
)
.liquidGlassSheetContent(sourceID: "continueWatchingViewOptions", in: sheetTransition)
}
.onAppear {
loadHistory()
}
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
loadHistory()
}
}
// MARK: - Content
private var emptyState: some View {
ContentUnavailableView {
Label(String(localized: "home.continueWatching.title"), systemImage: "play.circle")
} description: {
Text(String(localized: "home.continueWatching.empty"))
}
}
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)
.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 ? 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)
})
}
}
}
}
// 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)
}