mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
220
Yattee/Views/Home/ContinueWatchingView.swift
Normal file
220
Yattee/Views/Home/ContinueWatchingView.swift
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user