Files
yattee/Yattee/Views/Home/BookmarksListView.swift
Arkadiusz Fal 8464464199 Fix locales
2026-02-09 00:13:46 +01:00

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(String(localized: "bookmarks.search.placeholder")))
.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()
}
}