mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 17:59:45 +00:00
292 lines
10 KiB
Swift
292 lines
10 KiB
Swift
//
|
|
// 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()
|
|
}
|
|
}
|