mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
221 lines
7.2 KiB
Swift
221 lines
7.2 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
|
|
@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)
|
|
}
|