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:
291
Yattee/Views/Home/BookmarksListView.swift
Normal file
291
Yattee/Views/Home/BookmarksListView.swift
Normal file
@@ -0,0 +1,291 @@
|
||||
//
|
||||
// BookmarksListView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Full page view for bookmarked videos.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct BookmarksListView: View {
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
@Namespace private var sheetTransition
|
||||
@State private var bookmarks: [Bookmark] = []
|
||||
|
||||
// View options (persisted)
|
||||
@AppStorage("bookmarks.layout") private var layout: VideoListLayout = .list
|
||||
@AppStorage("bookmarks.rowStyle") private var rowStyle: VideoRowStyle = .regular
|
||||
@AppStorage("bookmarks.gridColumns") private var gridColumns = 2
|
||||
@AppStorage("bookmarks.hideWatched") private var hideWatched = false
|
||||
|
||||
/// 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
|
||||
@State private var watchEntriesMap: [String: WatchEntry] = [:]
|
||||
@State private var searchText = ""
|
||||
|
||||
// Grid layout configuration
|
||||
private var gridConfig: GridLayoutConfiguration {
|
||||
GridLayoutConfiguration(viewWidth: viewWidth, gridColumns: gridColumns)
|
||||
}
|
||||
|
||||
private var dataManager: DataManager? { appEnvironment?.dataManager }
|
||||
|
||||
/// Bookmarks filtered by search and watch status.
|
||||
private var filteredBookmarks: [Bookmark] {
|
||||
var result = bookmarks
|
||||
|
||||
// Apply search filter
|
||||
if !searchText.isEmpty {
|
||||
let query = searchText.lowercased()
|
||||
result = result.filter { bookmark in
|
||||
bookmark.title.lowercased().contains(query) ||
|
||||
bookmark.authorName.lowercased().contains(query) ||
|
||||
bookmark.videoID.lowercased().contains(query) ||
|
||||
bookmark.tags.contains { $0.lowercased().contains(query) } ||
|
||||
(bookmark.note?.lowercased().contains(query) ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply watch status filter
|
||||
if hideWatched {
|
||||
result = result.filter { bookmark in
|
||||
guard let entry = watchEntriesMap[bookmark.videoID] else { return true }
|
||||
return !entry.isFinished
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Gets the watch progress (0.0-1.0) for a bookmark, or nil if not watched/finished.
|
||||
private func watchProgress(for bookmark: Bookmark) -> Double? {
|
||||
guard let entry = watchEntriesMap[bookmark.videoID] else { return nil }
|
||||
let progress = entry.progress
|
||||
// Only show progress bar for partially watched videos
|
||||
return progress > 0 && progress < 1 ? progress : nil
|
||||
}
|
||||
|
||||
/// Queue source for bookmarks.
|
||||
private var bookmarksQueueSource: QueueSource {
|
||||
.manual
|
||||
}
|
||||
|
||||
/// Stub callback for video queue continuation.
|
||||
@Sendable
|
||||
private func loadMoreBookmarksCallback() async throws -> ([Video], String?) {
|
||||
return ([], nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
#if os(tvOS)
|
||||
VStack(spacing: 0) {
|
||||
// tvOS: Inline search field and action button for better focus navigation
|
||||
HStack(spacing: 24) {
|
||||
TextField("Search bookmarks", text: $searchText)
|
||||
.textFieldStyle(.plain)
|
||||
|
||||
Button {
|
||||
showViewOptions = true
|
||||
} label: {
|
||||
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
||||
}
|
||||
}
|
||||
.focusSection()
|
||||
.padding(.horizontal, 48)
|
||||
.padding(.top, 20)
|
||||
|
||||
// Content
|
||||
Group {
|
||||
if filteredBookmarks.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
switch layout {
|
||||
case .list:
|
||||
listContent
|
||||
case .grid:
|
||||
gridContent
|
||||
}
|
||||
}
|
||||
}
|
||||
.focusSection()
|
||||
}
|
||||
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
|
||||
viewWidth = newWidth
|
||||
}
|
||||
#else
|
||||
Group {
|
||||
if filteredBookmarks.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
switch layout {
|
||||
case .list:
|
||||
listContent
|
||||
case .grid:
|
||||
gridContent
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
|
||||
viewWidth = newWidth
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.navigationTitle(String(localized: "home.bookmarks.title"))
|
||||
#if !os(tvOS)
|
||||
.toolbarTitleDisplayMode(.inlineLarge)
|
||||
.searchable(text: $searchText, prompt: Text("Search bookmarks"))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showViewOptions = true
|
||||
} label: {
|
||||
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
||||
}
|
||||
.liquidGlassTransitionSource(id: "bookmarksViewOptions", in: sheetTransition)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.sheet(isPresented: $showViewOptions) {
|
||||
ViewOptionsSheet(
|
||||
layout: $layout,
|
||||
rowStyle: $rowStyle,
|
||||
gridColumns: $gridColumns,
|
||||
hideWatched: $hideWatched,
|
||||
maxGridColumns: gridConfig.maxColumns
|
||||
)
|
||||
.liquidGlassSheetContent(sourceID: "bookmarksViewOptions", in: sheetTransition)
|
||||
}
|
||||
.onAppear {
|
||||
loadBookmarks()
|
||||
loadWatchEntries()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .bookmarksDidChange)) { _ in
|
||||
loadBookmarks()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
|
||||
loadWatchEntries()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty View
|
||||
|
||||
@ViewBuilder
|
||||
private var emptyView: some View {
|
||||
if !searchText.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
} else {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "home.bookmarks.title"), systemImage: "bookmark")
|
||||
} description: {
|
||||
Text(String(localized: "home.bookmarks.empty"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List Layout
|
||||
|
||||
private var listContent: some View {
|
||||
VideoListContainer(listStyle: listStyle, rowStyle: rowStyle) {
|
||||
Spacer()
|
||||
.frame(height: 16)
|
||||
} content: {
|
||||
ForEach(Array(filteredBookmarks.enumerated()), id: \.element.videoID) { index, bookmark in
|
||||
VideoListRow(
|
||||
isLast: index == filteredBookmarks.count - 1,
|
||||
rowStyle: rowStyle,
|
||||
listStyle: listStyle
|
||||
) {
|
||||
bookmarkRow(bookmark: bookmark, index: index)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.videoSwipeActions(
|
||||
video: bookmark.toVideo(),
|
||||
fixedActions: [
|
||||
SwipeAction(
|
||||
symbolImage: "trash.fill",
|
||||
tint: .white,
|
||||
background: .red
|
||||
) { reset in
|
||||
removeBookmark(bookmark)
|
||||
reset()
|
||||
}
|
||||
]
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grid Layout
|
||||
|
||||
private var gridContent: some View {
|
||||
ScrollView {
|
||||
VideoGridContent(columns: gridConfig.effectiveColumns) {
|
||||
ForEach(Array(filteredBookmarks.enumerated()), id: \.element.videoID) { index, bookmark in
|
||||
BookmarkCardView(
|
||||
bookmark: bookmark,
|
||||
watchProgress: watchProgress(for: bookmark),
|
||||
isCompact: gridConfig.isCompactCards
|
||||
)
|
||||
.tappableVideo(
|
||||
bookmark.toVideo(),
|
||||
queueSource: bookmarksQueueSource,
|
||||
sourceLabel: String(localized: "queue.source.bookmarks"),
|
||||
videoList: filteredBookmarks.map { $0.toVideo() },
|
||||
videoIndex: index,
|
||||
loadMoreVideos: loadMoreBookmarksCallback
|
||||
)
|
||||
.videoContextMenu(
|
||||
video: bookmark.toVideo(),
|
||||
customActions: [
|
||||
VideoContextAction(
|
||||
String(localized: "home.bookmarks.remove"),
|
||||
systemImage: "trash",
|
||||
role: .destructive,
|
||||
action: { removeBookmark(bookmark) }
|
||||
)
|
||||
],
|
||||
context: .bookmarks
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Views
|
||||
|
||||
@ViewBuilder
|
||||
private func bookmarkRow(bookmark: Bookmark, index: Int) -> some View {
|
||||
BookmarkRowView(
|
||||
bookmark: bookmark,
|
||||
style: rowStyle,
|
||||
watchProgress: watchProgress(for: bookmark),
|
||||
onRemove: { removeBookmark(bookmark) },
|
||||
queueSource: bookmarksQueueSource,
|
||||
sourceLabel: String(localized: "queue.source.bookmarks"),
|
||||
videoList: filteredBookmarks.map { $0.toVideo() },
|
||||
videoIndex: index,
|
||||
loadMoreVideos: loadMoreBookmarksCallback
|
||||
)
|
||||
}
|
||||
|
||||
private func loadBookmarks() {
|
||||
bookmarks = dataManager?.bookmarks(limit: 10000) ?? []
|
||||
}
|
||||
|
||||
private func loadWatchEntries() {
|
||||
watchEntriesMap = dataManager?.watchEntriesMap() ?? [:]
|
||||
}
|
||||
|
||||
private func removeBookmark(_ bookmark: Bookmark) {
|
||||
dataManager?.removeBookmark(for: bookmark.videoID)
|
||||
loadBookmarks()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user