mirror of
https://github.com/yattee/yattee.git
synced 2026-05-13 02:45:03 +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()
|
||||
}
|
||||
}
|
||||
62
Yattee/Views/Home/ContinueWatchingGridCard.swift
Normal file
62
Yattee/Views/Home/ContinueWatchingGridCard.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// ContinueWatchingGridCard.swift
|
||||
// Yattee
|
||||
//
|
||||
// Grid card view for continue watching items.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TappableContinueWatchingGridCard: View {
|
||||
let entry: WatchEntry
|
||||
let onRemove: () -> Void
|
||||
|
||||
private var video: Video {
|
||||
entry.toVideo()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ContinueWatchingGridCard(entry: entry)
|
||||
.tappableVideo(
|
||||
video,
|
||||
startTime: entry.watchedSeconds,
|
||||
queueSource: .manual,
|
||||
sourceLabel: String(localized: "queue.source.continueWatching")
|
||||
)
|
||||
.videoContextMenu(
|
||||
video: video,
|
||||
customActions: [
|
||||
VideoContextAction(
|
||||
String(localized: "continueWatching.remove"),
|
||||
systemImage: "xmark.circle",
|
||||
role: .destructive,
|
||||
action: onRemove
|
||||
)
|
||||
],
|
||||
context: .continueWatching,
|
||||
startTime: entry.watchedSeconds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid card for continue watching items with automatic DeArrow support.
|
||||
struct ContinueWatchingGridCard: View {
|
||||
let entry: WatchEntry
|
||||
|
||||
private var video: Video {
|
||||
entry.toVideo()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VideoCardView(
|
||||
video: video,
|
||||
watchProgress: entry.progress,
|
||||
customMetadata: entry.isFinished ? nil : String(localized: "home.history.remaining \(entry.remainingTime)"),
|
||||
customDuration: entry.remainingTime
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
// Note: Preview requires a WatchEntry object from SwiftData context.
|
||||
// See ContinueWatchingView.swift for usage examples.
|
||||
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)
|
||||
}
|
||||
411
Yattee/Views/Home/HistoryListView.swift
Normal file
411
Yattee/Views/Home/HistoryListView.swift
Normal file
@@ -0,0 +1,411 @@
|
||||
//
|
||||
// HistoryListView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Full page view for watch history.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct HistoryListView: View {
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
@Namespace private var sheetTransition
|
||||
@State private var history: [WatchEntry] = []
|
||||
@State private var videos: [Video] = [] // Cached Video conversions to avoid repeated toVideo() calls
|
||||
@State private var showingClearConfirmation = false
|
||||
@State private var selectedClearOption: ClearHistoryOption?
|
||||
|
||||
// View options (persisted)
|
||||
@AppStorage("history.layout") private var layout: VideoListLayout = .list
|
||||
@AppStorage("history.rowStyle") private var rowStyle: VideoRowStyle = .regular
|
||||
@AppStorage("history.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
|
||||
@State private var searchText = ""
|
||||
|
||||
// Grid layout configuration
|
||||
private var gridConfig: GridLayoutConfiguration {
|
||||
GridLayoutConfiguration(viewWidth: viewWidth, gridColumns: gridColumns)
|
||||
}
|
||||
|
||||
private var dataManager: DataManager? { appEnvironment?.dataManager }
|
||||
|
||||
/// History entries filtered by search text.
|
||||
private var filteredHistory: [WatchEntry] {
|
||||
guard !searchText.isEmpty else { return history }
|
||||
let query = searchText.lowercased()
|
||||
return history.filter { entry in
|
||||
entry.title.lowercased().contains(query) ||
|
||||
entry.authorName.lowercased().contains(query) ||
|
||||
entry.videoID.lowercased().contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-computed Video objects for filtered history entries.
|
||||
private var filteredVideos: [Video] {
|
||||
guard !searchText.isEmpty else { return videos }
|
||||
let filteredIDs = Set(filteredHistory.map { $0.videoID })
|
||||
return zip(history, videos)
|
||||
.filter { filteredIDs.contains($0.0.videoID) }
|
||||
.map { $0.1 }
|
||||
}
|
||||
|
||||
/// Gets the watch progress (0.0-1.0) for a watch entry, or nil if finished.
|
||||
private func watchProgress(for entry: WatchEntry) -> Double? {
|
||||
let progress = entry.progress
|
||||
// Only show progress bar for partially watched videos
|
||||
return progress > 0 && progress < 1 ? progress : nil
|
||||
}
|
||||
|
||||
/// Queue source for history.
|
||||
private var historyQueueSource: QueueSource {
|
||||
.manual
|
||||
}
|
||||
|
||||
/// Stub callback for video queue continuation.
|
||||
/// History doesn't support server-side pagination,
|
||||
/// so this returns empty to prevent errors.
|
||||
@Sendable
|
||||
private func loadMoreHistoryCallback() async throws -> ([Video], String?) {
|
||||
// History is fully loaded on initial fetch
|
||||
// No pagination support available
|
||||
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 history", 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 filteredHistory.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 filteredHistory.isEmpty {
|
||||
// Empty state
|
||||
emptyView
|
||||
} else {
|
||||
// Content based on layout
|
||||
switch layout {
|
||||
case .list:
|
||||
listContent
|
||||
case .grid:
|
||||
gridContent
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
|
||||
viewWidth = newWidth
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.navigationTitle(String(localized: "home.history.title"))
|
||||
#if !os(tvOS)
|
||||
.toolbarTitleDisplayMode(.inlineLarge)
|
||||
.searchable(text: $searchText, prompt: Text("Search history"))
|
||||
.toolbar {
|
||||
// View options button (always visible)
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showViewOptions = true
|
||||
} label: {
|
||||
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
||||
}
|
||||
.liquidGlassTransitionSource(id: "historyViewOptions", in: sheetTransition)
|
||||
}
|
||||
|
||||
// Clear history menu (only when not empty)
|
||||
if !history.isEmpty {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
Menu {
|
||||
ForEach(ClearHistoryOption.allCases, id: \.self) { option in
|
||||
Button(role: .destructive) {
|
||||
selectedClearOption = option
|
||||
showingClearConfirmation = true
|
||||
} label: {
|
||||
Label(option.localizedTitle, systemImage: option.systemImage)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.sheet(isPresented: $showViewOptions) {
|
||||
ViewOptionsSheet(
|
||||
layout: $layout,
|
||||
rowStyle: $rowStyle,
|
||||
gridColumns: $gridColumns,
|
||||
hideWatched: nil, // No hide watched for history view
|
||||
maxGridColumns: gridConfig.maxColumns
|
||||
)
|
||||
.liquidGlassSheetContent(sourceID: "historyViewOptions", in: sheetTransition)
|
||||
}
|
||||
.confirmationDialog(
|
||||
confirmationTitle,
|
||||
isPresented: $showingClearConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "home.history.clear"), role: .destructive) {
|
||||
clearHistory()
|
||||
}
|
||||
Button(String(localized: "common.cancel"), role: .cancel) {
|
||||
selectedClearOption = nil
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadHistory()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
|
||||
loadHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty View
|
||||
|
||||
@ViewBuilder
|
||||
private var emptyView: some View {
|
||||
if !searchText.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
} else {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "home.history.title"), systemImage: "clock")
|
||||
} description: {
|
||||
Text(String(localized: "home.history.empty"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List Layout
|
||||
|
||||
private var listContent: some View {
|
||||
VideoListContainer(listStyle: listStyle, rowStyle: rowStyle) {
|
||||
// Header spacer for top padding
|
||||
Spacer()
|
||||
.frame(height: 16)
|
||||
} content: {
|
||||
ForEach(Array(filteredHistory.enumerated()), id: \.element.videoID) { index, entry in
|
||||
let video = filteredVideos[index]
|
||||
VideoListRow(
|
||||
isLast: index == filteredHistory.count - 1,
|
||||
rowStyle: rowStyle,
|
||||
listStyle: listStyle
|
||||
) {
|
||||
entryRowView(entry: entry, index: index)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.videoSwipeActions(
|
||||
video: video,
|
||||
fixedActions: [
|
||||
SwipeAction(
|
||||
symbolImage: "trash.fill",
|
||||
tint: .white,
|
||||
background: .red
|
||||
) { reset in
|
||||
removeEntry(entry)
|
||||
reset()
|
||||
}
|
||||
]
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable row view for a history entry.
|
||||
@ViewBuilder
|
||||
private func entryRowView(entry: WatchEntry, index: Int) -> some View {
|
||||
let video = filteredVideos[index]
|
||||
VideoRowView(
|
||||
video: video,
|
||||
style: rowStyle,
|
||||
watchProgress: watchProgress(for: entry),
|
||||
customMetadata: entry.isFinished ? nil : String(localized: "home.history.remaining \(entry.remainingTime)")
|
||||
)
|
||||
.tappableVideo(
|
||||
video,
|
||||
startTime: entry.watchedSeconds,
|
||||
customActions: [
|
||||
VideoContextAction(
|
||||
String(localized: "home.history.remove"),
|
||||
systemImage: "trash",
|
||||
role: .destructive,
|
||||
action: { removeEntry(entry) }
|
||||
)
|
||||
],
|
||||
context: .history,
|
||||
queueSource: historyQueueSource,
|
||||
sourceLabel: String(localized: "queue.source.history"),
|
||||
videoList: filteredVideos,
|
||||
videoIndex: index,
|
||||
loadMoreVideos: loadMoreHistoryCallback
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Grid Layout
|
||||
|
||||
private var gridContent: some View {
|
||||
ScrollView {
|
||||
VideoGridContent(columns: gridConfig.effectiveColumns) {
|
||||
ForEach(Array(filteredHistory.enumerated()), id: \.element.videoID) { index, entry in
|
||||
let video = filteredVideos[index]
|
||||
VideoCardView(
|
||||
video: video,
|
||||
watchProgress: watchProgress(for: entry),
|
||||
isCompact: gridConfig.isCompactCards
|
||||
)
|
||||
.tappableVideo(
|
||||
video,
|
||||
startTime: entry.watchedSeconds,
|
||||
customActions: [
|
||||
VideoContextAction(
|
||||
String(localized: "home.history.remove"),
|
||||
systemImage: "trash",
|
||||
role: .destructive,
|
||||
action: { removeEntry(entry) }
|
||||
)
|
||||
],
|
||||
context: .history,
|
||||
queueSource: historyQueueSource,
|
||||
sourceLabel: String(localized: "queue.source.history"),
|
||||
videoList: filteredVideos,
|
||||
videoIndex: index,
|
||||
loadMoreVideos: loadMoreHistoryCallback
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var confirmationTitle: String {
|
||||
guard let option = selectedClearOption else {
|
||||
return String(localized: "home.history.clear.confirm")
|
||||
}
|
||||
|
||||
if option == .all {
|
||||
return String(localized: "home.history.clear.confirm")
|
||||
}
|
||||
|
||||
return String(localized: "home.history.clear.confirm.timeRange \(option.localizedTitle)")
|
||||
}
|
||||
|
||||
private func loadHistory() {
|
||||
history = dataManager?.watchHistory(limit: 10000) ?? []
|
||||
videos = history.map { $0.toVideo() } // Pre-compute all Video conversions once
|
||||
}
|
||||
|
||||
private func removeEntry(_ entry: WatchEntry) {
|
||||
dataManager?.removeFromHistory(videoID: entry.videoID)
|
||||
loadHistory()
|
||||
}
|
||||
|
||||
private func clearHistory() {
|
||||
guard let option = selectedClearOption else { return }
|
||||
|
||||
if option == .all {
|
||||
dataManager?.clearWatchHistory()
|
||||
} else if let date = option.cutoffDate {
|
||||
dataManager?.clearWatchHistory(since: date)
|
||||
}
|
||||
|
||||
selectedClearOption = nil
|
||||
loadHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Clear History Options
|
||||
|
||||
private enum ClearHistoryOption: CaseIterable {
|
||||
case lastHour
|
||||
case lastDay
|
||||
case lastWeek
|
||||
case lastMonth
|
||||
case all
|
||||
|
||||
var localizedTitle: String {
|
||||
switch self {
|
||||
case .lastHour:
|
||||
return String(localized: "home.history.clear.lastHour")
|
||||
case .lastDay:
|
||||
return String(localized: "home.history.clear.lastDay")
|
||||
case .lastWeek:
|
||||
return String(localized: "home.history.clear.lastWeek")
|
||||
case .lastMonth:
|
||||
return String(localized: "home.history.clear.lastMonth")
|
||||
case .all:
|
||||
return String(localized: "home.history.clear.all")
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .lastHour:
|
||||
return "clock"
|
||||
case .lastDay:
|
||||
return "sun.max"
|
||||
case .lastWeek:
|
||||
return "calendar"
|
||||
case .lastMonth:
|
||||
return "calendar.badge.clock"
|
||||
case .all:
|
||||
return "trash"
|
||||
}
|
||||
}
|
||||
|
||||
var cutoffDate: Date? {
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
|
||||
switch self {
|
||||
case .lastHour:
|
||||
return calendar.date(byAdding: .hour, value: -1, to: now)
|
||||
case .lastDay:
|
||||
return calendar.date(byAdding: .day, value: -1, to: now)
|
||||
case .lastWeek:
|
||||
return calendar.date(byAdding: .weekOfYear, value: -1, to: now)
|
||||
case .lastMonth:
|
||||
return calendar.date(byAdding: .month, value: -1, to: now)
|
||||
case .all:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
400
Yattee/Views/Home/HomeItem.swift
Normal file
400
Yattee/Views/Home/HomeItem.swift
Normal file
@@ -0,0 +1,400 @@
|
||||
//
|
||||
// HomeItem.swift
|
||||
// Yattee
|
||||
//
|
||||
// Models for configurable Home view items.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Home Shortcut Layout
|
||||
|
||||
/// Layout mode for the shortcuts section in the Home view.
|
||||
enum HomeShortcutLayout: String, CaseIterable, Sendable {
|
||||
case list
|
||||
case cards
|
||||
|
||||
var displayName: LocalizedStringKey {
|
||||
switch self {
|
||||
case .list: return "home.shortcuts.layout.list"
|
||||
case .cards: return "home.shortcuts.layout.cards"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .list: return "list.bullet"
|
||||
case .cards: return "square.grid.2x2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Instance Content Type
|
||||
|
||||
/// Content type for instance home items.
|
||||
enum InstanceContentType: String, Codable, Sendable {
|
||||
case feed
|
||||
case popular
|
||||
case trending
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .feed: return "person.crop.rectangle.stack"
|
||||
case .popular: return "flame"
|
||||
case .trending: return "chart.line.uptrend.xyaxis"
|
||||
}
|
||||
}
|
||||
|
||||
var localizedTitle: String {
|
||||
switch self {
|
||||
case .feed: return String(localized: "home.instanceContent.feed")
|
||||
case .popular: return String(localized: "home.instanceContent.popular")
|
||||
case .trending: return String(localized: "home.instanceContent.trending")
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts to InstanceBrowseView.BrowseTab for navigation
|
||||
func toBrowseTab() -> InstanceBrowseView.BrowseTab {
|
||||
switch self {
|
||||
case .feed: return .feed
|
||||
case .popular: return .popular
|
||||
case .trending: return .trending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Home Shortcut Item
|
||||
|
||||
/// Represents a shortcut item in the Home view dashboard.
|
||||
enum HomeShortcutItem: Codable, Hashable, Identifiable, Sendable {
|
||||
case openURL
|
||||
case remoteControl
|
||||
case playlists
|
||||
case bookmarks
|
||||
case continueWatching
|
||||
case history
|
||||
case downloads
|
||||
case channels
|
||||
case subscriptions
|
||||
case mediaSources
|
||||
case instanceContent(instanceID: UUID, contentType: InstanceContentType)
|
||||
case mediaSource(sourceID: UUID)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .openURL: return "openURL"
|
||||
case .remoteControl: return "remoteControl"
|
||||
case .playlists: return "playlists"
|
||||
case .bookmarks: return "bookmarks"
|
||||
case .continueWatching: return "continueWatching"
|
||||
case .history: return "history"
|
||||
case .downloads: return "downloads"
|
||||
case .channels: return "channels"
|
||||
case .subscriptions: return "subscriptions"
|
||||
case .mediaSources: return "mediaSources"
|
||||
case .instanceContent(let instanceID, let contentType):
|
||||
return "instance_\(instanceID.uuidString)_\(contentType.rawValue)"
|
||||
case .mediaSource(let sourceID):
|
||||
return "mediaSource_\(sourceID.uuidString)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Default order for shortcuts.
|
||||
static var defaultOrder: [HomeShortcutItem] {
|
||||
#if os(tvOS)
|
||||
[.openURL, .remoteControl, .playlists, .bookmarks, .continueWatching, .history, .channels, .subscriptions, .mediaSources]
|
||||
#else
|
||||
[.openURL, .remoteControl, .playlists, .bookmarks, .continueWatching, .history, .downloads, .channels, .subscriptions, .mediaSources]
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Default visibility for all shortcuts.
|
||||
static var defaultVisibility: [HomeShortcutItem: Bool] {
|
||||
#if os(tvOS)
|
||||
[.openURL: true, .remoteControl: true, .playlists: true, .bookmarks: true, .continueWatching: false, .history: true, .channels: true, .subscriptions: false, .mediaSources: false]
|
||||
#else
|
||||
[.openURL: true, .remoteControl: true, .playlists: false, .bookmarks: true, .continueWatching: false, .history: true, .downloads: false, .channels: false, .subscriptions: false, .mediaSources: false]
|
||||
#endif
|
||||
}
|
||||
|
||||
/// SF Symbol icon name.
|
||||
/// Note: For .mediaSource, returns a placeholder. Views should look up the actual source icon.
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .openURL: return "link"
|
||||
case .remoteControl: return "antenna.radiowaves.left.and.right"
|
||||
case .playlists: return "list.bullet.rectangle"
|
||||
case .bookmarks: return "bookmark.fill"
|
||||
case .continueWatching: return "play.circle"
|
||||
case .history: return "clock"
|
||||
case .downloads: return "arrow.down.circle"
|
||||
case .channels: return "person.2"
|
||||
case .subscriptions: return "play.rectangle.on.rectangle"
|
||||
case .mediaSources: return "externaldrive.connected.to.line.below"
|
||||
case .instanceContent(_, let contentType):
|
||||
return contentType.icon
|
||||
case .mediaSource:
|
||||
return "externaldrive.connected.to.line.below"
|
||||
}
|
||||
}
|
||||
|
||||
/// Localized display title.
|
||||
/// Note: For .mediaSource, returns a placeholder. Views should look up the actual source name.
|
||||
var localizedTitle: String {
|
||||
switch self {
|
||||
case .openURL: return String(localized: "home.shortcut.openURL")
|
||||
case .remoteControl: return String(localized: "home.shortcut.remoteControl")
|
||||
case .playlists: return String(localized: "home.shortcut.playlists")
|
||||
case .bookmarks: return String(localized: "home.shortcut.bookmarks")
|
||||
case .continueWatching: return String(localized: "home.shortcut.continueWatching")
|
||||
case .history: return String(localized: "home.shortcut.history")
|
||||
case .downloads: return String(localized: "home.shortcut.downloads")
|
||||
case .channels: return String(localized: "home.shortcut.channels")
|
||||
case .subscriptions: return String(localized: "home.shortcut.subscriptions")
|
||||
case .mediaSources: return "Media Sources"
|
||||
case .instanceContent(_, let contentType):
|
||||
return contentType.localizedTitle
|
||||
case .mediaSource:
|
||||
return "Media Source"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codable Implementation
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case instanceID
|
||||
case contentType
|
||||
case sourceID
|
||||
}
|
||||
|
||||
private enum CardType: String, Codable {
|
||||
case openURL
|
||||
case remoteControl
|
||||
case playlists
|
||||
case bookmarks
|
||||
case continueWatching
|
||||
case history
|
||||
case downloads
|
||||
case channels
|
||||
case subscriptions
|
||||
case mediaSources
|
||||
case instanceContent
|
||||
case mediaSource
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
switch self {
|
||||
case .openURL:
|
||||
try container.encode(CardType.openURL, forKey: .type)
|
||||
case .remoteControl:
|
||||
try container.encode(CardType.remoteControl, forKey: .type)
|
||||
case .playlists:
|
||||
try container.encode(CardType.playlists, forKey: .type)
|
||||
case .bookmarks:
|
||||
try container.encode(CardType.bookmarks, forKey: .type)
|
||||
case .continueWatching:
|
||||
try container.encode(CardType.continueWatching, forKey: .type)
|
||||
case .history:
|
||||
try container.encode(CardType.history, forKey: .type)
|
||||
case .downloads:
|
||||
try container.encode(CardType.downloads, forKey: .type)
|
||||
case .channels:
|
||||
try container.encode(CardType.channels, forKey: .type)
|
||||
case .subscriptions:
|
||||
try container.encode(CardType.subscriptions, forKey: .type)
|
||||
case .mediaSources:
|
||||
try container.encode(CardType.mediaSources, forKey: .type)
|
||||
case .instanceContent(let instanceID, let contentType):
|
||||
try container.encode(CardType.instanceContent, forKey: .type)
|
||||
try container.encode(instanceID, forKey: .instanceID)
|
||||
try container.encode(contentType, forKey: .contentType)
|
||||
case .mediaSource(let sourceID):
|
||||
try container.encode(CardType.mediaSource, forKey: .type)
|
||||
try container.encode(sourceID, forKey: .sourceID)
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(CardType.self, forKey: .type)
|
||||
|
||||
switch type {
|
||||
case .openURL:
|
||||
self = .openURL
|
||||
case .remoteControl:
|
||||
self = .remoteControl
|
||||
case .playlists:
|
||||
self = .playlists
|
||||
case .bookmarks:
|
||||
self = .bookmarks
|
||||
case .continueWatching:
|
||||
self = .continueWatching
|
||||
case .history:
|
||||
self = .history
|
||||
case .downloads:
|
||||
self = .downloads
|
||||
case .channels:
|
||||
self = .channels
|
||||
case .subscriptions:
|
||||
self = .subscriptions
|
||||
case .mediaSources:
|
||||
self = .mediaSources
|
||||
case .instanceContent:
|
||||
let instanceID = try container.decode(UUID.self, forKey: .instanceID)
|
||||
let contentType = try container.decode(InstanceContentType.self, forKey: .contentType)
|
||||
self = .instanceContent(instanceID: instanceID, contentType: contentType)
|
||||
case .mediaSource:
|
||||
let sourceID = try container.decode(UUID.self, forKey: .sourceID)
|
||||
self = .mediaSource(sourceID: sourceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Home Section Item
|
||||
|
||||
/// Represents a section item below the shortcuts in the Home view.
|
||||
enum HomeSectionItem: Codable, Hashable, Identifiable, Sendable {
|
||||
case continueWatching
|
||||
case feed
|
||||
case bookmarks
|
||||
case history
|
||||
case downloads
|
||||
case instanceContent(instanceID: UUID, contentType: InstanceContentType)
|
||||
case mediaSource(sourceID: UUID)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .continueWatching: return "continueWatching"
|
||||
case .feed: return "feed"
|
||||
case .bookmarks: return "bookmarks"
|
||||
case .history: return "history"
|
||||
case .downloads: return "downloads"
|
||||
case .instanceContent(let instanceID, let contentType):
|
||||
return "instance_\(instanceID.uuidString)_\(contentType.rawValue)"
|
||||
case .mediaSource(let sourceID):
|
||||
return "mediaSource_\(sourceID.uuidString)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Default order for sections.
|
||||
static var defaultOrder: [HomeSectionItem] {
|
||||
#if os(tvOS)
|
||||
[.continueWatching, .feed, .bookmarks, .history]
|
||||
#else
|
||||
[.continueWatching, .feed, .bookmarks, .history, .downloads]
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Default visibility for sections (only continue watching on by default).
|
||||
static var defaultVisibility: [HomeSectionItem: Bool] {
|
||||
#if os(tvOS)
|
||||
[.continueWatching: true, .feed: false, .bookmarks: false, .history: false]
|
||||
#else
|
||||
[.continueWatching: true, .feed: false, .bookmarks: false, .history: false, .downloads: false]
|
||||
#endif
|
||||
}
|
||||
|
||||
/// SF Symbol icon name.
|
||||
/// Note: For .mediaSource, returns a placeholder. Views should look up the actual source icon.
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .continueWatching: return "play.circle.fill"
|
||||
case .feed: return "play.rectangle.on.rectangle.fill"
|
||||
case .bookmarks: return "bookmark.fill"
|
||||
case .history: return "clock.arrow.circlepath"
|
||||
case .downloads: return "arrow.down.circle.fill"
|
||||
case .instanceContent(_, let contentType):
|
||||
return contentType.icon
|
||||
case .mediaSource:
|
||||
return "externaldrive.connected.to.line.below"
|
||||
}
|
||||
}
|
||||
|
||||
/// Localized display title.
|
||||
/// Note: For .mediaSource, returns a placeholder. Views should look up the actual source name.
|
||||
var localizedTitle: String {
|
||||
switch self {
|
||||
case .continueWatching: return String(localized: "home.section.continueWatching")
|
||||
case .feed: return String(localized: "home.section.feed")
|
||||
case .bookmarks: return String(localized: "home.section.bookmarks")
|
||||
case .history: return String(localized: "home.section.history")
|
||||
case .downloads: return String(localized: "home.section.downloads")
|
||||
case .instanceContent(_, let contentType):
|
||||
return contentType.localizedTitle
|
||||
case .mediaSource:
|
||||
return "Media Source"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codable Implementation
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case instanceID
|
||||
case contentType
|
||||
case sourceID
|
||||
}
|
||||
|
||||
private enum SectionType: String, Codable {
|
||||
case continueWatching
|
||||
case feed
|
||||
case bookmarks
|
||||
case history
|
||||
case downloads
|
||||
case instanceContent
|
||||
case mediaSource
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
switch self {
|
||||
case .continueWatching:
|
||||
try container.encode(SectionType.continueWatching, forKey: .type)
|
||||
case .feed:
|
||||
try container.encode(SectionType.feed, forKey: .type)
|
||||
case .bookmarks:
|
||||
try container.encode(SectionType.bookmarks, forKey: .type)
|
||||
case .history:
|
||||
try container.encode(SectionType.history, forKey: .type)
|
||||
case .downloads:
|
||||
try container.encode(SectionType.downloads, forKey: .type)
|
||||
case .instanceContent(let instanceID, let contentType):
|
||||
try container.encode(SectionType.instanceContent, forKey: .type)
|
||||
try container.encode(instanceID, forKey: .instanceID)
|
||||
try container.encode(contentType, forKey: .contentType)
|
||||
case .mediaSource(let sourceID):
|
||||
try container.encode(SectionType.mediaSource, forKey: .type)
|
||||
try container.encode(sourceID, forKey: .sourceID)
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(SectionType.self, forKey: .type)
|
||||
|
||||
switch type {
|
||||
case .continueWatching:
|
||||
self = .continueWatching
|
||||
case .feed:
|
||||
self = .feed
|
||||
case .bookmarks:
|
||||
self = .bookmarks
|
||||
case .history:
|
||||
self = .history
|
||||
case .downloads:
|
||||
self = .downloads
|
||||
case .instanceContent:
|
||||
let instanceID = try container.decode(UUID.self, forKey: .instanceID)
|
||||
let contentType = try container.decode(InstanceContentType.self, forKey: .contentType)
|
||||
self = .instanceContent(instanceID: instanceID, contentType: contentType)
|
||||
case .mediaSource:
|
||||
let sourceID = try container.decode(UUID.self, forKey: .sourceID)
|
||||
self = .mediaSource(sourceID: sourceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
820
Yattee/Views/Home/HomeSettingsView.swift
Normal file
820
Yattee/Views/Home/HomeSettingsView.swift
Normal file
@@ -0,0 +1,820 @@
|
||||
//
|
||||
// HomeSettingsView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Settings sheet for customizing the Home view layout.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct HomeSettingsView: View {
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
// Local state for editing (copied from settings on appear, saved on dismiss)
|
||||
@State private var shortcutLayout: HomeShortcutLayout = .cards
|
||||
@State private var shortcutOrder: [HomeShortcutItem] = []
|
||||
@State private var shortcutVisibility: [HomeShortcutItem: Bool] = [:]
|
||||
@State private var sectionOrder: [HomeSectionItem] = []
|
||||
@State private var sectionVisibility: [HomeSectionItem: Bool] = [:]
|
||||
@State private var sectionItemsLimit: Int = 5
|
||||
|
||||
// Available items (not yet added to Home)
|
||||
@State private var availableShortcutsByInstance: [(instance: Instance, cards: [HomeShortcutItem])] = []
|
||||
@State private var availableSectionsByInstance: [(instance: Instance, sections: [HomeSectionItem])] = []
|
||||
@State private var availableShortcutsByMediaSource: [(source: MediaSource, cards: [HomeShortcutItem])] = []
|
||||
@State private var availableSectionsByMediaSource: [(source: MediaSource, sections: [HomeSectionItem])] = []
|
||||
|
||||
// Edit mode for delete functionality
|
||||
@State private var isEditMode = false
|
||||
|
||||
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
shortcutsSection
|
||||
availableShortcutsSection
|
||||
sectionsSection
|
||||
availableSectionsSection
|
||||
itemsLimitSection
|
||||
}
|
||||
#if os(iOS)
|
||||
.environment(\.editMode, isEditMode ? .constant(.active) : .constant(.inactive))
|
||||
#endif
|
||||
.navigationTitle(String(localized: "home.settings.title"))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.onAppear {
|
||||
loadSettings()
|
||||
}
|
||||
.onDisappear {
|
||||
saveSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var shortcutsSection: some View {
|
||||
Section {
|
||||
#if !os(tvOS)
|
||||
// Layout picker (List vs Cards)
|
||||
Picker(String(localized: "home.settings.shortcuts.layout"), selection: $shortcutLayout) {
|
||||
ForEach(HomeShortcutLayout.allCases, id: \.self) { layout in
|
||||
Label(layout.displayName, systemImage: layout.systemImage)
|
||||
.tag(layout)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
ForEach(Array(shortcutOrder.enumerated()), id: \.element.id) { index, card in
|
||||
if card != .downloads {
|
||||
TVHomeItemRow(
|
||||
icon: card.icon,
|
||||
title: card.localizedTitle,
|
||||
isVisible: shortcutBinding(for: card),
|
||||
canMoveUp: index > 0 && shortcutOrder[index - 1] != .downloads,
|
||||
canMoveDown: index < shortcutOrder.count - 1,
|
||||
onMoveUp: { moveShortcut(at: index, direction: -1) },
|
||||
onMoveDown: { moveShortcut(at: index, direction: 1) },
|
||||
canDelete: canDelete(shortcut: card),
|
||||
onDelete: { removeShortcut(card) }
|
||||
)
|
||||
}
|
||||
}
|
||||
#else
|
||||
ForEach(shortcutOrder) { card in
|
||||
shortcutRowView(for: card)
|
||||
}
|
||||
.onMove { from, to in
|
||||
shortcutOrder.move(fromOffsets: from, toOffset: to)
|
||||
}
|
||||
#endif
|
||||
} header: {
|
||||
Text(String(localized: "home.settings.shortcuts.header"))
|
||||
}
|
||||
}
|
||||
|
||||
private var availableShortcutsSection: some View {
|
||||
Section {
|
||||
if availableShortcutsByInstance.isEmpty && availableShortcutsByMediaSource.isEmpty {
|
||||
Text(String(localized: "home.settings.availableShortcuts.empty"))
|
||||
.foregroundStyle(.secondary)
|
||||
.italic()
|
||||
} else {
|
||||
ForEach(availableShortcutsByInstance, id: \.instance.id) { item in
|
||||
ForEach(item.cards) { card in
|
||||
availableShortcutRow(for: card, instance: item.instance)
|
||||
}
|
||||
}
|
||||
ForEach(availableShortcutsByMediaSource, id: \.source.id) { item in
|
||||
ForEach(item.cards) { card in
|
||||
availableMediaSourceShortcutRow(for: card, source: item.source)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "home.settings.availableShortcuts.header"))
|
||||
} footer: {
|
||||
Text(String(localized: "home.settings.availableShortcuts.footer"))
|
||||
}
|
||||
}
|
||||
|
||||
private var sectionsSection: some View {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
ForEach(Array(sectionOrder.enumerated()), id: \.element.id) { index, section in
|
||||
if section != .downloads {
|
||||
TVHomeItemRow(
|
||||
icon: section.icon,
|
||||
title: section.localizedTitle,
|
||||
isVisible: sectionBinding(for: section),
|
||||
canMoveUp: index > 0 && sectionOrder[index - 1] != .downloads,
|
||||
canMoveDown: index < sectionOrder.count - 1,
|
||||
onMoveUp: { moveSection(at: index, direction: -1) },
|
||||
onMoveDown: { moveSection(at: index, direction: 1) },
|
||||
canDelete: canDelete(section: section),
|
||||
onDelete: { removeSection(section) }
|
||||
)
|
||||
}
|
||||
}
|
||||
#else
|
||||
ForEach(sectionOrder) { section in
|
||||
sectionRowView(for: section)
|
||||
}
|
||||
.onMove { from, to in
|
||||
sectionOrder.move(fromOffsets: from, toOffset: to)
|
||||
}
|
||||
#endif
|
||||
} header: {
|
||||
Text(String(localized: "home.settings.sections.header"))
|
||||
} footer: {
|
||||
Text(String(localized: "home.settings.sections.footer"))
|
||||
}
|
||||
}
|
||||
|
||||
private var availableSectionsSection: some View {
|
||||
Section {
|
||||
if availableSectionsByInstance.isEmpty && availableSectionsByMediaSource.isEmpty {
|
||||
Text(String(localized: "home.settings.availableSections.empty"))
|
||||
.foregroundStyle(.secondary)
|
||||
.italic()
|
||||
} else {
|
||||
ForEach(availableSectionsByInstance, id: \.instance.id) { item in
|
||||
ForEach(item.sections) { section in
|
||||
availableSectionRow(for: section, instance: item.instance)
|
||||
}
|
||||
}
|
||||
ForEach(availableSectionsByMediaSource, id: \.source.id) { item in
|
||||
ForEach(item.sections) { section in
|
||||
availableMediaSourceSectionRow(for: section, source: item.source)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "home.settings.availableSections.header"))
|
||||
} footer: {
|
||||
Text(String(localized: "home.settings.availableSections.footer"))
|
||||
}
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private func moveShortcut(at index: Int, direction: Int) {
|
||||
let newIndex = index + direction
|
||||
guard newIndex >= 0, newIndex < shortcutOrder.count else { return }
|
||||
shortcutOrder.swapAt(index, newIndex)
|
||||
}
|
||||
|
||||
private func moveSection(at index: Int, direction: Int) {
|
||||
let newIndex = index + direction
|
||||
guard newIndex >= 0, newIndex < sectionOrder.count else { return }
|
||||
sectionOrder.swapAt(index, newIndex)
|
||||
}
|
||||
#endif
|
||||
|
||||
private var itemsLimitSection: some View {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
HStack {
|
||||
Text(String(localized: "home.settings.itemsLimit"))
|
||||
Spacer()
|
||||
Button {
|
||||
if sectionItemsLimit > 1 { sectionItemsLimit -= 1 }
|
||||
} label: {
|
||||
Image(systemName: "minus.circle")
|
||||
}
|
||||
.buttonStyle(TVSettingsButtonStyle())
|
||||
Text("\(sectionItemsLimit)")
|
||||
.monospacedDigit()
|
||||
Button {
|
||||
if sectionItemsLimit < 20 { sectionItemsLimit += 1 }
|
||||
} label: {
|
||||
Image(systemName: "plus.circle")
|
||||
}
|
||||
.buttonStyle(TVSettingsButtonStyle())
|
||||
}
|
||||
#else
|
||||
Stepper(value: $sectionItemsLimit, in: 1...20) {
|
||||
HStack {
|
||||
Text(String(localized: "home.settings.itemsLimit"))
|
||||
Spacer()
|
||||
Text("\(sectionItemsLimit)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bindings
|
||||
|
||||
private func shortcutBinding(for card: HomeShortcutItem) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: { shortcutVisibility[card] ?? true },
|
||||
set: { shortcutVisibility[card] = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private func sectionBinding(for section: HomeSectionItem) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: { sectionVisibility[section] ?? false },
|
||||
set: { sectionVisibility[section] = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Data Management
|
||||
|
||||
private func loadSettings() {
|
||||
guard let settings = settingsManager,
|
||||
let env = appEnvironment else { return }
|
||||
|
||||
shortcutLayout = settings.homeShortcutLayout
|
||||
shortcutOrder = settings.homeShortcutOrder
|
||||
shortcutVisibility = settings.homeShortcutVisibility
|
||||
sectionOrder = settings.homeSectionOrder
|
||||
sectionVisibility = settings.homeSectionVisibility
|
||||
sectionItemsLimit = settings.homeSectionItemsLimit
|
||||
|
||||
// Load available items
|
||||
let instances = env.instancesManager.instances
|
||||
availableShortcutsByInstance = settings.allAvailableShortcuts(instances: instances)
|
||||
availableSectionsByInstance = settings.allAvailableSections(instances: instances)
|
||||
|
||||
let sources = env.mediaSourcesManager.sources
|
||||
availableShortcutsByMediaSource = settings.allAvailableMediaSourceShortcuts(sources: sources)
|
||||
availableSectionsByMediaSource = settings.allAvailableMediaSourceSections(sources: sources)
|
||||
}
|
||||
|
||||
private func saveSettings() {
|
||||
guard let settings = settingsManager else { return }
|
||||
settings.homeShortcutLayout = shortcutLayout
|
||||
settings.homeShortcutOrder = shortcutOrder
|
||||
settings.homeShortcutVisibility = shortcutVisibility
|
||||
settings.homeSectionOrder = sectionOrder
|
||||
settings.homeSectionVisibility = sectionVisibility
|
||||
settings.homeSectionItemsLimit = sectionItemsLimit
|
||||
}
|
||||
|
||||
// MARK: - Available Item Management
|
||||
|
||||
private func addShortcut(_ card: HomeShortcutItem) {
|
||||
// Add to local state
|
||||
if !shortcutOrder.contains(where: { $0.id == card.id }) {
|
||||
shortcutOrder.append(card)
|
||||
shortcutVisibility[card] = true // Visible by default
|
||||
}
|
||||
|
||||
// Persist to settings
|
||||
switch card {
|
||||
case .instanceContent(let instanceID, let contentType):
|
||||
settingsManager?.addToHome(instanceID: instanceID, contentType: contentType, asCard: true)
|
||||
case .mediaSource(let sourceID):
|
||||
settingsManager?.addToHome(sourceID: sourceID, asCard: true)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Reload available items
|
||||
loadSettings()
|
||||
}
|
||||
|
||||
private func addSection(_ section: HomeSectionItem) {
|
||||
// Add to local state
|
||||
if !sectionOrder.contains(where: { $0.id == section.id }) {
|
||||
sectionOrder.append(section)
|
||||
sectionVisibility[section] = true // Visible by default
|
||||
}
|
||||
|
||||
// Persist to settings
|
||||
switch section {
|
||||
case .instanceContent(let instanceID, let contentType):
|
||||
settingsManager?.addToHome(instanceID: instanceID, contentType: contentType, asCard: false)
|
||||
case .mediaSource(let sourceID):
|
||||
settingsManager?.addToHome(sourceID: sourceID, asCard: false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Reload available items
|
||||
loadSettings()
|
||||
}
|
||||
|
||||
private func removeShortcut(_ card: HomeShortcutItem) {
|
||||
// Remove from local state
|
||||
shortcutOrder.removeAll { $0.id == card.id }
|
||||
shortcutVisibility.removeValue(forKey: card)
|
||||
|
||||
// Persist to settings
|
||||
switch card {
|
||||
case .instanceContent(let instanceID, let contentType):
|
||||
settingsManager?.removeFromHome(instanceID: instanceID, contentType: contentType)
|
||||
case .mediaSource(let sourceID):
|
||||
settingsManager?.removeFromHome(sourceID: sourceID)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Reload available items
|
||||
loadSettings()
|
||||
}
|
||||
|
||||
private func removeSection(_ section: HomeSectionItem) {
|
||||
// Remove from local state
|
||||
sectionOrder.removeAll { $0.id == section.id }
|
||||
sectionVisibility.removeValue(forKey: section)
|
||||
|
||||
// Persist to settings
|
||||
switch section {
|
||||
case .instanceContent(let instanceID, let contentType):
|
||||
settingsManager?.removeFromHome(instanceID: instanceID, contentType: contentType)
|
||||
case .mediaSource(let sourceID):
|
||||
settingsManager?.removeFromHome(sourceID: sourceID)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Reload available items
|
||||
loadSettings()
|
||||
}
|
||||
|
||||
private func canDelete(shortcut: HomeShortcutItem) -> Bool {
|
||||
if case .instanceContent = shortcut {
|
||||
return true
|
||||
}
|
||||
if case .mediaSource = shortcut {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func canDelete(section: HomeSectionItem) -> Bool {
|
||||
if case .instanceContent = section {
|
||||
return true
|
||||
}
|
||||
if case .mediaSource = section {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Card and Section Row Views
|
||||
|
||||
@ViewBuilder
|
||||
private func shortcutRowView(for card: HomeShortcutItem) -> some View {
|
||||
switch card {
|
||||
case .instanceContent(let instanceID, let contentType):
|
||||
if let instance = instanceFromID(instanceID) {
|
||||
let isDisabled = !instance.isEnabled || (contentType == .feed && !isLoggedIn(instance))
|
||||
|
||||
HomeItemRow(
|
||||
icon: contentType.icon,
|
||||
title: "\(instance.displayName) - \(contentType.localizedTitle)",
|
||||
isVisible: shortcutBinding(for: card),
|
||||
isDisabled: isDisabled,
|
||||
disabledReason: disabledReason(instance: instance, contentType: contentType)
|
||||
)
|
||||
#if os(iOS)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
removeShortcut(card)
|
||||
} label: {
|
||||
Label(String(localized: "home.settings.remove"), systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
#endif
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
removeShortcut(card)
|
||||
} label: {
|
||||
Label(String(localized: "home.settings.remove"), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
case .mediaSource(let sourceID):
|
||||
if let source = appEnvironment?.mediaSourcesManager.sources.first(where: { $0.id == sourceID }) {
|
||||
let isDisabled = !source.isEnabled
|
||||
|
||||
HomeItemRow(
|
||||
icon: source.type.systemImage,
|
||||
title: "\(source.name) (\(source.type.displayName))",
|
||||
isVisible: shortcutBinding(for: card),
|
||||
isDisabled: isDisabled,
|
||||
disabledReason: isDisabled ? String(localized: "home.settings.sourceDisabled") : nil
|
||||
)
|
||||
#if os(iOS)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
removeShortcut(card)
|
||||
} label: {
|
||||
Label(String(localized: "home.settings.remove"), systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
#endif
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
removeShortcut(card)
|
||||
} label: {
|
||||
Label(String(localized: "home.settings.remove"), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
HomeItemRow(
|
||||
icon: card.icon,
|
||||
title: card.localizedTitle,
|
||||
isVisible: shortcutBinding(for: card)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionRowView(for section: HomeSectionItem) -> some View {
|
||||
switch section {
|
||||
case .instanceContent(let instanceID, let contentType):
|
||||
if let instance = instanceFromID(instanceID) {
|
||||
let isDisabled = !instance.isEnabled || (contentType == .feed && !isLoggedIn(instance))
|
||||
|
||||
HomeItemRow(
|
||||
icon: contentType.icon,
|
||||
title: "\(instance.displayName) - \(contentType.localizedTitle)",
|
||||
isVisible: sectionBinding(for: section),
|
||||
isDisabled: isDisabled,
|
||||
disabledReason: disabledReason(instance: instance, contentType: contentType)
|
||||
)
|
||||
#if os(iOS)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
removeSection(section)
|
||||
} label: {
|
||||
Label(String(localized: "home.settings.remove"), systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
#endif
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
removeSection(section)
|
||||
} label: {
|
||||
Label(String(localized: "home.settings.remove"), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
case .mediaSource(let sourceID):
|
||||
if let source = appEnvironment?.mediaSourcesManager.sources.first(where: { $0.id == sourceID }) {
|
||||
let isDisabled = !source.isEnabled
|
||||
|
||||
HomeItemRow(
|
||||
icon: source.type.systemImage,
|
||||
title: "\(source.name) (\(source.type.displayName))",
|
||||
isVisible: sectionBinding(for: section),
|
||||
isDisabled: isDisabled,
|
||||
disabledReason: isDisabled ? String(localized: "home.settings.sourceDisabled") : nil
|
||||
)
|
||||
#if os(iOS)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
removeSection(section)
|
||||
} label: {
|
||||
Label(String(localized: "home.settings.remove"), systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
#endif
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
removeSection(section)
|
||||
} label: {
|
||||
Label(String(localized: "home.settings.remove"), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
HomeItemRow(
|
||||
icon: section.icon,
|
||||
title: section.localizedTitle,
|
||||
isVisible: sectionBinding(for: section)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func availableShortcutRow(for card: HomeShortcutItem, instance: Instance) -> some View {
|
||||
if case .instanceContent(_, let contentType) = card {
|
||||
let isDisabled = !instance.isEnabled || (contentType == .feed && !isLoggedIn(instance))
|
||||
|
||||
HStack {
|
||||
Image(systemName: contentType.icon)
|
||||
.frame(width: 24)
|
||||
.foregroundStyle(isDisabled ? .tertiary : .secondary)
|
||||
|
||||
Text("\(instance.displayName) - \(contentType.localizedTitle)")
|
||||
.foregroundStyle(isDisabled ? .tertiary : .primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
addShortcut(card)
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.imageScale(.large)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isDisabled)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.help(isDisabled ? (disabledReason(instance: instance, contentType: contentType) ?? "") : "")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func availableSectionRow(for section: HomeSectionItem, instance: Instance) -> some View {
|
||||
if case .instanceContent(_, let contentType) = section {
|
||||
let isDisabled = !instance.isEnabled || (contentType == .feed && !isLoggedIn(instance))
|
||||
|
||||
HStack {
|
||||
Image(systemName: contentType.icon)
|
||||
.frame(width: 24)
|
||||
.foregroundStyle(isDisabled ? .tertiary : .secondary)
|
||||
|
||||
Text("\(instance.displayName) - \(contentType.localizedTitle)")
|
||||
.foregroundStyle(isDisabled ? .tertiary : .primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
addSection(section)
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.imageScale(.large)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isDisabled)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.help(isDisabled ? (disabledReason(instance: instance, contentType: contentType) ?? "") : "")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func instanceFromID(_ id: UUID) -> Instance? {
|
||||
appEnvironment?.instancesManager.instances.first(where: { $0.id == id })
|
||||
}
|
||||
|
||||
private func isLoggedIn(_ instance: Instance) -> Bool {
|
||||
guard instance.supportsFeed else { return false }
|
||||
return appEnvironment?.credentialsManager(for: instance)?.isLoggedIn(for: instance) ?? false
|
||||
}
|
||||
|
||||
private func disabledReason(instance: Instance, contentType: InstanceContentType) -> String? {
|
||||
if !instance.isEnabled {
|
||||
return String(localized: "home.settings.instanceDisabled")
|
||||
}
|
||||
if contentType == .feed && !isLoggedIn(instance) {
|
||||
return String(localized: "home.settings.feedRequiresLogin")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func availableMediaSourceShortcutRow(for card: HomeShortcutItem, source: MediaSource) -> some View {
|
||||
if case .mediaSource = card {
|
||||
let isDisabled = !source.isEnabled
|
||||
|
||||
HStack {
|
||||
Image(systemName: source.type.systemImage)
|
||||
.frame(width: 24)
|
||||
.foregroundStyle(isDisabled ? .tertiary : .secondary)
|
||||
|
||||
Text("\(source.name) (\(source.type.displayName))")
|
||||
.foregroundStyle(isDisabled ? .tertiary : .primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
addShortcut(card)
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.imageScale(.large)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isDisabled)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.help(isDisabled ? String(localized: "home.settings.sourceDisabled") : "")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func availableMediaSourceSectionRow(for section: HomeSectionItem, source: MediaSource) -> some View {
|
||||
if case .mediaSource = section {
|
||||
let isDisabled = !source.isEnabled
|
||||
|
||||
HStack {
|
||||
Image(systemName: source.type.systemImage)
|
||||
.frame(width: 24)
|
||||
.foregroundStyle(isDisabled ? .tertiary : .secondary)
|
||||
|
||||
Text("\(source.name) (\(source.type.displayName))")
|
||||
.foregroundStyle(isDisabled ? .tertiary : .primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
addSection(section)
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.imageScale(.large)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isDisabled)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.help(isDisabled ? String(localized: "home.settings.sourceDisabled") : "")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Home Item Row
|
||||
|
||||
#if os(tvOS)
|
||||
private struct TVHomeItemRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
@Binding var isVisible: Bool
|
||||
let canMoveUp: Bool
|
||||
let canMoveDown: Bool
|
||||
let onMoveUp: () -> Void
|
||||
let onMoveDown: () -> Void
|
||||
var canDelete: Bool = false
|
||||
var onDelete: (() -> Void)? = nil
|
||||
|
||||
@State private var showingDeleteConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Move buttons - compact style
|
||||
VStack(spacing: 4) {
|
||||
Button {
|
||||
onMoveUp()
|
||||
} label: {
|
||||
Image(systemName: "chevron.up")
|
||||
.font(.caption)
|
||||
.foregroundStyle(canMoveUp ? .primary : .tertiary)
|
||||
.frame(width: 30, height: 24)
|
||||
}
|
||||
.buttonStyle(TVCompactButtonStyle())
|
||||
.disabled(!canMoveUp)
|
||||
|
||||
Button {
|
||||
onMoveDown()
|
||||
} label: {
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption)
|
||||
.foregroundStyle(canMoveDown ? .primary : .tertiary)
|
||||
.frame(width: 30, height: 24)
|
||||
}
|
||||
.buttonStyle(TVCompactButtonStyle())
|
||||
.disabled(!canMoveDown)
|
||||
}
|
||||
|
||||
// Main content - toggle visibility
|
||||
Button {
|
||||
isVisible.toggle()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(title)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Image(systemName: isVisible ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundColor(isVisible ? .green : .secondary)
|
||||
.font(.title3)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TVFormRowButtonStyle())
|
||||
|
||||
// Delete button (only for instance content)
|
||||
if canDelete {
|
||||
Button {
|
||||
showingDeleteConfirmation = true
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.frame(width: 30, height: 24)
|
||||
}
|
||||
.buttonStyle(TVCompactButtonStyle())
|
||||
.alert("Remove from Home?", isPresented: $showingDeleteConfirmation) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Remove", role: .destructive) {
|
||||
onDelete?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact button style for small controls like up/down arrows
|
||||
private struct TVCompactButtonStyle: ButtonStyle {
|
||||
@Environment(\.isFocused) private var isFocused
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(isFocused ? Color.white.opacity(0.2) : Color.clear)
|
||||
)
|
||||
.scaleEffect(configuration.isPressed ? 0.9 : (isFocused ? 1.1 : 1.0))
|
||||
.animation(.easeInOut(duration: 0.1), value: isFocused)
|
||||
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private struct HomeItemRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
@Binding var isVisible: Bool
|
||||
var isDisabled: Bool = false
|
||||
var disabledReason: String? = nil
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
Button {
|
||||
if !isDisabled {
|
||||
isVisible.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24)
|
||||
.foregroundStyle(isDisabled ? .tertiary : .secondary)
|
||||
Text(title)
|
||||
.foregroundStyle(isDisabled ? .tertiary : .primary)
|
||||
Spacer()
|
||||
Image(systemName: isVisible ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundColor(isDisabled ? .secondary.opacity(0.5) : (isVisible ? .green : .secondary))
|
||||
.font(.title3)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(TVFormRowButtonStyle())
|
||||
.disabled(isDisabled)
|
||||
#else
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24)
|
||||
.foregroundStyle(isDisabled ? .tertiary : .secondary)
|
||||
Text(title)
|
||||
.foregroundStyle(isDisabled ? .tertiary : .primary)
|
||||
Spacer()
|
||||
Toggle("", isOn: $isVisible)
|
||||
.labelsHidden()
|
||||
.disabled(isDisabled)
|
||||
}
|
||||
.help(disabledReason ?? "")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
HomeSettingsView()
|
||||
.appEnvironment(.preview)
|
||||
}
|
||||
184
Yattee/Views/Home/HomeShortcutCardView.swift
Normal file
184
Yattee/Views/Home/HomeShortcutCardView.swift
Normal file
@@ -0,0 +1,184 @@
|
||||
//
|
||||
// HomeShortcutCardView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Card component for home shortcuts.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct HomeShortcutCardView<StatusIndicator: View>: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let count: Int
|
||||
let subtitle: String
|
||||
var statusIndicator: StatusIndicator?
|
||||
|
||||
init(
|
||||
icon: String,
|
||||
title: String,
|
||||
count: Int,
|
||||
subtitle: String,
|
||||
statusIndicator: StatusIndicator?
|
||||
) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self.count = count
|
||||
self.subtitle = subtitle
|
||||
self.statusIndicator = statusIndicator
|
||||
}
|
||||
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
#if os(tvOS)
|
||||
@Environment(\.isFocused) private var isFocused
|
||||
#endif
|
||||
|
||||
// Platform-specific styling
|
||||
#if os(tvOS)
|
||||
private let iconSize: CGFloat = 36
|
||||
private let titleFont: Font = .headline
|
||||
private let subtitleFont: Font = .subheadline.monospacedDigit()
|
||||
private let hPadding: CGFloat = 20
|
||||
private let vPadding: CGFloat = 20
|
||||
private let cornerRadius: CGFloat = 20
|
||||
#elseif os(macOS)
|
||||
private let iconSize: CGFloat = 28
|
||||
private let titleFont: Font = .body
|
||||
private let subtitleFont: Font = .subheadline.monospacedDigit()
|
||||
private let hPadding: CGFloat = 12
|
||||
private let vPadding: CGFloat = 12
|
||||
private let cornerRadius: CGFloat = 16
|
||||
#else
|
||||
private let iconSize: CGFloat = 28
|
||||
private let titleFont: Font = .subheadline
|
||||
private let subtitleFont: Font = .caption.monospacedDigit()
|
||||
private let hPadding: CGFloat = 12
|
||||
private let vPadding: CGFloat = 12
|
||||
private let cornerRadius: CGFloat = 16
|
||||
#endif
|
||||
|
||||
private var needsVerticalLayout: Bool {
|
||||
#if os(tvOS)
|
||||
return true // Always vertical on tvOS for better readability
|
||||
#else
|
||||
return dynamicTypeSize >= .xxxLarge
|
||||
#endif
|
||||
}
|
||||
|
||||
private var hasSubtitle: Bool {
|
||||
!subtitle.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if needsVerticalLayout {
|
||||
// Vertical layout for tvOS and accessibility sizes
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: iconSize)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(titleFont)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.primary)
|
||||
if let statusIndicator {
|
||||
statusIndicator.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
|
||||
if hasSubtitle {
|
||||
Text(subtitle)
|
||||
.font(subtitleFont)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: subtitleMinHeight, alignment: hasSubtitle ? .top : .center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
// Horizontal layout for standard sizes
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: iconSize)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(titleFont)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.primary)
|
||||
.allowsTightening(true)
|
||||
.lineLimit(1)
|
||||
if let statusIndicator {
|
||||
statusIndicator.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
|
||||
if hasSubtitle {
|
||||
Text(subtitle)
|
||||
.font(subtitleFont)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(minHeight: subtitleMinHeight)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, hPadding)
|
||||
.padding(.vertical, vPadding)
|
||||
.background(cardBackground)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(Color.accentColor.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
}
|
||||
|
||||
/// Minimum height for the text content area to ensure consistent card heights
|
||||
private var subtitleMinHeight: CGFloat {
|
||||
#if os(tvOS)
|
||||
// headline + subheadline + spacing
|
||||
44
|
||||
#elseif os(macOS)
|
||||
// body + subheadline + spacing
|
||||
38
|
||||
#else
|
||||
// subheadline + caption + spacing
|
||||
34
|
||||
#endif
|
||||
}
|
||||
|
||||
private var cardBackground: some ShapeStyle {
|
||||
#if os(tvOS)
|
||||
isFocused ? Color.white.opacity(0.2) : Color.gray.opacity(0.3)
|
||||
#else
|
||||
Color.accentColor.opacity(0.1)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Initializer (no status indicator)
|
||||
|
||||
extension HomeShortcutCardView where StatusIndicator == EmptyView {
|
||||
init(
|
||||
icon: String,
|
||||
title: String,
|
||||
count: Int,
|
||||
subtitle: String
|
||||
) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self.count = count
|
||||
self.subtitle = subtitle
|
||||
self.statusIndicator = nil
|
||||
}
|
||||
}
|
||||
77
Yattee/Views/Home/HomeShortcutRowView.swift
Normal file
77
Yattee/Views/Home/HomeShortcutRowView.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// HomeShortcutRowView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Row component for home shortcuts in list layout.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct HomeShortcutRowView<StatusIndicator: View>: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
var statusIndicator: StatusIndicator?
|
||||
|
||||
init(
|
||||
icon: String,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
statusIndicator: StatusIndicator?
|
||||
) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.statusIndicator = statusIndicator
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
if let statusIndicator {
|
||||
statusIndicator
|
||||
}
|
||||
}
|
||||
|
||||
if !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Initializer (no status indicator)
|
||||
|
||||
extension HomeShortcutRowView where StatusIndicator == EmptyView {
|
||||
init(
|
||||
icon: String,
|
||||
title: String,
|
||||
subtitle: String
|
||||
) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.statusIndicator = nil
|
||||
}
|
||||
}
|
||||
1305
Yattee/Views/Home/HomeView.swift
Normal file
1305
Yattee/Views/Home/HomeView.swift
Normal file
File diff suppressed because it is too large
Load Diff
854
Yattee/Views/Home/OpenLinkSheet.swift
Normal file
854
Yattee/Views/Home/OpenLinkSheet.swift
Normal file
@@ -0,0 +1,854 @@
|
||||
//
|
||||
// OpenLinkSheet.swift
|
||||
// Yattee
|
||||
//
|
||||
// Sheet for entering URLs to play or download via Yattee Server's yt-dlp extraction.
|
||||
// Supports multiple URLs (one per line) with batch processing.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
// MARK: - Extraction Item Model
|
||||
|
||||
/// Tracks extraction status for each URL in batch processing.
|
||||
private struct ExtractedItem: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
var displayHost: String { url.host ?? url.absoluteString }
|
||||
var status: ExtractionStatus = .pending
|
||||
var video: Video?
|
||||
var streams: [Stream] = []
|
||||
var captions: [Caption] = []
|
||||
var storyboards: [Storyboard] = []
|
||||
}
|
||||
|
||||
private enum ExtractionStatus {
|
||||
case pending
|
||||
case extracting
|
||||
case success
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
// MARK: - OpenLinkSheet
|
||||
|
||||
/// Sheet for entering URLs to play or download from external sites.
|
||||
/// Supports multiple URLs (one per line, max 20).
|
||||
struct OpenLinkSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
@State private var urlText: String
|
||||
@State private var clipboardURLs: [URL] = []
|
||||
@FocusState private var isTextEditorFocused: Bool
|
||||
|
||||
// Extraction state
|
||||
@State private var isExtracting = false
|
||||
@State private var extractedItems: [ExtractedItem] = []
|
||||
@State private var hasErrors = false
|
||||
|
||||
// Download flow states
|
||||
@State private var showingDownloadSheet = false
|
||||
@State private var pendingDownloadItems: [ExtractedItem] = []
|
||||
|
||||
/// Maximum number of URLs allowed.
|
||||
private static let maxURLs = 20
|
||||
|
||||
/// Initialize with optional pre-filled URL.
|
||||
init(prefilledURL: URL? = nil) {
|
||||
_urlText = State(initialValue: prefilledURL?.absoluteString ?? "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
urlInputSection
|
||||
extractionResultsSection
|
||||
actionButtonsSection
|
||||
yatteeServerWarningSection
|
||||
}
|
||||
.navigationTitle(String(localized: "openLink.title"))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "common.cancel")) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
checkClipboard()
|
||||
if urlText.isEmpty {
|
||||
isTextEditorFocused = true
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.sheet(isPresented: $showingDownloadSheet, onDismiss: {
|
||||
// Close OpenLinkSheet when download sheet is dismissed (if no errors)
|
||||
if !hasErrors {
|
||||
dismiss()
|
||||
}
|
||||
}) {
|
||||
BatchDownloadQualitySheet(videoCount: pendingDownloadItems.count) { quality, includeSubtitles in
|
||||
Task {
|
||||
await downloadPendingItems(quality: quality, includeSubtitles: includeSubtitles)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URL Input Section
|
||||
|
||||
@ViewBuilder
|
||||
private var urlInputSection: some View {
|
||||
Section {
|
||||
#if os(tvOS)
|
||||
// tvOS doesn't have TextEditor, use TextField for single URL
|
||||
TextField(String(localized: "openLink.urlPlaceholder"), text: $urlText)
|
||||
.textContentType(.URL)
|
||||
.focused($isTextEditorFocused)
|
||||
.disabled(isExtracting)
|
||||
#else
|
||||
TextEditor(text: $urlText)
|
||||
.frame(minHeight: 100, maxHeight: 200)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
#if os(iOS)
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
.focused($isTextEditorFocused)
|
||||
.disabled(isExtracting)
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
// URL count indicator (not shown on tvOS since it only supports single URL)
|
||||
HStack {
|
||||
if isTooManyURLs {
|
||||
Label(
|
||||
String(localized: "openLink.tooManyUrls \(Self.maxURLs)"),
|
||||
systemImage: "exclamationmark.triangle"
|
||||
)
|
||||
.foregroundStyle(.orange)
|
||||
.font(.caption)
|
||||
} else if urlCount > 0 {
|
||||
Text(String(localized: "openLink.urlCount \(urlCount)"))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Clipboard paste button (not available on tvOS)
|
||||
if !clipboardURLs.isEmpty, !isExtracting {
|
||||
let clipboardText = clipboardURLs.map(\.absoluteString).joined(separator: "\n")
|
||||
if clipboardText != urlText {
|
||||
Button {
|
||||
urlText = clipboardText
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
VStack(alignment: .leading) {
|
||||
if clipboardURLs.count > 1 {
|
||||
Text(String(localized: "openLink.pasteMultiple \(clipboardURLs.count)"))
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
Text(String(localized: "openLink.pasteClipboard"))
|
||||
.font(.subheadline)
|
||||
}
|
||||
Text(clipboardURLs.first?.host ?? "")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} footer: {
|
||||
Text(supportedSitesHint)
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamic hint text based on enabled backend instances.
|
||||
private var supportedSitesHint: String {
|
||||
guard let instancesManager = appEnvironment?.instancesManager else {
|
||||
return String(localized: "openLink.hint.noInstances")
|
||||
}
|
||||
|
||||
let hasEnabledYatteeServer = !instancesManager.enabledYatteeServerInstances.isEmpty
|
||||
let hasEnabledInvidiousPiped = instancesManager.enabledInstances.contains {
|
||||
$0.type == .invidious || $0.type == .piped
|
||||
}
|
||||
|
||||
if hasEnabledYatteeServer {
|
||||
return String(localized: "openLink.hint.yatteeServer")
|
||||
} else if hasEnabledInvidiousPiped {
|
||||
return String(localized: "openLink.hint.youtubeOnly")
|
||||
} else {
|
||||
return String(localized: "openLink.hint.noInstances")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extraction Results Section
|
||||
|
||||
@ViewBuilder
|
||||
private var extractionResultsSection: some View {
|
||||
if !extractedItems.isEmpty {
|
||||
Section {
|
||||
ForEach(extractedItems) { item in
|
||||
HStack(spacing: 12) {
|
||||
// Status indicator
|
||||
Group {
|
||||
switch item.status {
|
||||
case .pending:
|
||||
Image(systemName: "circle")
|
||||
.foregroundStyle(.secondary)
|
||||
case .extracting:
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
case .success:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
case .failed:
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.frame(width: 20)
|
||||
|
||||
// URL info
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let video = item.video {
|
||||
Text(video.title)
|
||||
.lineLimit(1)
|
||||
Text(item.displayHost)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
// No video extracted - show full URL (useful for failures)
|
||||
Text(item.url.absoluteString)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
if case .failed(let error) = item.status {
|
||||
Text(error)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
if isExtracting {
|
||||
let processed = extractedItems.filter { item in
|
||||
switch item.status {
|
||||
case .pending: return false
|
||||
default: return true
|
||||
}
|
||||
}.count
|
||||
Text(String(localized: "openLink.extractingProgress \(processed) \(extractedItems.count)"))
|
||||
} else {
|
||||
Text(String(localized: "openLink.results"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Action Buttons Section
|
||||
|
||||
@ViewBuilder
|
||||
private var actionButtonsSection: some View {
|
||||
Section {
|
||||
if isExtracting {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text(String(localized: "openLink.extracting"))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
// Open/Play button
|
||||
Button {
|
||||
isTextEditorFocused = false
|
||||
Task { await openAllURLs() }
|
||||
} label: {
|
||||
Label(
|
||||
isMultipleURLs
|
||||
? String(localized: "openLink.openAll")
|
||||
: String(localized: "openLink.open"),
|
||||
systemImage: "play.fill"
|
||||
)
|
||||
}
|
||||
.disabled(!isValidInput)
|
||||
|
||||
#if !os(tvOS)
|
||||
// Download button
|
||||
Button {
|
||||
isTextEditorFocused = false
|
||||
Task { await downloadAllURLs() }
|
||||
} label: {
|
||||
Label(
|
||||
isMultipleURLs
|
||||
? String(localized: "openLink.downloadAll")
|
||||
: String(localized: "openLink.download"),
|
||||
systemImage: "arrow.down.circle"
|
||||
)
|
||||
}
|
||||
.disabled(!isValidInput)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Yattee Server Warning Section
|
||||
|
||||
@ViewBuilder
|
||||
private var yatteeServerWarningSection: some View {
|
||||
if !hasYatteeServer && hasExternalURLs && !isExtracting {
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
Text(String(localized: "openLink.yatteeServerNotConfigured"))
|
||||
.font(.subheadline)
|
||||
}
|
||||
Text(String(localized: "openLink.yatteeServerMessage"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var hasYatteeServer: Bool {
|
||||
appEnvironment?.instancesManager.yatteeServerInstance != nil
|
||||
}
|
||||
|
||||
/// Parse URLs from input text, one per line.
|
||||
private var parsedURLs: [URL] {
|
||||
urlText
|
||||
.components(separatedBy: .newlines)
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
.compactMap { URL(string: $0) }
|
||||
.filter { url in
|
||||
guard let scheme = url.scheme?.lowercased() else { return false }
|
||||
return (scheme == "http" || scheme == "https") && url.host != nil
|
||||
}
|
||||
.prefix(Self.maxURLs)
|
||||
.map { $0 }
|
||||
}
|
||||
|
||||
private var urlCount: Int { parsedURLs.count }
|
||||
private var isValidInput: Bool { !parsedURLs.isEmpty }
|
||||
private var isMultipleURLs: Bool { urlCount > 1 }
|
||||
|
||||
private var isTooManyURLs: Bool {
|
||||
urlText
|
||||
.components(separatedBy: .newlines)
|
||||
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
.count > Self.maxURLs
|
||||
}
|
||||
|
||||
/// Whether any of the parsed URLs are external (non-YouTube/PeerTube).
|
||||
private var hasExternalURLs: Bool {
|
||||
let router = URLRouter()
|
||||
return parsedURLs.contains { url in
|
||||
if let destination = router.route(url) {
|
||||
if case .externalVideo = destination { return true }
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Clipboard
|
||||
|
||||
private func checkClipboard() {
|
||||
clipboardURLs = []
|
||||
|
||||
#if os(iOS)
|
||||
if let string = UIPasteboard.general.string {
|
||||
clipboardURLs = parseURLsFromString(string)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if let string = NSPasteboard.general.string(forType: .string) {
|
||||
clipboardURLs = parseURLsFromString(string)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func parseURLsFromString(_ string: String) -> [URL] {
|
||||
string
|
||||
.components(separatedBy: .newlines)
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
.compactMap { URL(string: $0) }
|
||||
.filter { url in
|
||||
guard let scheme = url.scheme?.lowercased() else { return false }
|
||||
return (scheme == "http" || scheme == "https") && url.host != nil
|
||||
}
|
||||
.prefix(Self.maxURLs)
|
||||
.map { $0 }
|
||||
}
|
||||
|
||||
// MARK: - Open (Play) Action
|
||||
|
||||
private func openAllURLs() async {
|
||||
let urls = parsedURLs
|
||||
guard !urls.isEmpty, let appEnvironment else { return }
|
||||
|
||||
isExtracting = true
|
||||
hasErrors = false
|
||||
extractedItems = urls.map { ExtractedItem(url: $0) }
|
||||
|
||||
var successCount = 0
|
||||
var failedCount = 0
|
||||
var firstVideoPlayed = false
|
||||
|
||||
for (index, url) in urls.enumerated() {
|
||||
extractedItems[index].status = .extracting
|
||||
|
||||
do {
|
||||
let (video, streams) = try await extractVideo(from: url, appEnvironment: appEnvironment)
|
||||
extractedItems[index].status = .success
|
||||
extractedItems[index].video = video
|
||||
extractedItems[index].streams = streams
|
||||
successCount += 1
|
||||
|
||||
if !firstVideoPlayed {
|
||||
// Play first video - this expands player
|
||||
playVideo(video, appEnvironment: appEnvironment)
|
||||
firstVideoPlayed = true
|
||||
} else {
|
||||
// Add to queue
|
||||
appEnvironment.queueManager.addToQueue(video, queueSource: .manual)
|
||||
}
|
||||
} catch {
|
||||
extractedItems[index].status = .failed(error.localizedDescription)
|
||||
failedCount += 1
|
||||
hasErrors = true
|
||||
}
|
||||
}
|
||||
|
||||
isExtracting = false
|
||||
|
||||
// Show completion toast and handle dismissal
|
||||
if failedCount == 0 {
|
||||
if successCount > 1 {
|
||||
appEnvironment.toastManager.showSuccess(
|
||||
String(localized: "openLink.queuedSuccess.title"),
|
||||
subtitle: String(localized: "openLink.queuedSuccess.subtitle \(successCount)")
|
||||
)
|
||||
}
|
||||
dismiss()
|
||||
} else if successCount > 0 {
|
||||
appEnvironment.toastManager.show(
|
||||
category: .error,
|
||||
title: String(localized: "openLink.queuedPartial.title"),
|
||||
subtitle: String(localized: "openLink.queuedPartial.subtitle \(successCount) \(failedCount)")
|
||||
)
|
||||
// Keep sheet open so user can see errors
|
||||
} else {
|
||||
appEnvironment.toastManager.show(
|
||||
category: .error,
|
||||
title: String(localized: "openLink.allFailed.title"),
|
||||
subtitle: String(localized: "openLink.allFailed.subtitle \(failedCount)")
|
||||
)
|
||||
// Keep sheet open
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts video from URL, routing through appropriate API.
|
||||
private func extractVideo(
|
||||
from url: URL,
|
||||
appEnvironment: AppEnvironment
|
||||
) async throws -> (Video, [Stream]) {
|
||||
let router = URLRouter()
|
||||
let destination = router.route(url)
|
||||
|
||||
switch destination {
|
||||
case .video(let source, _):
|
||||
guard case .id(let videoID) = source else {
|
||||
throw OpenLinkError.notAVideo
|
||||
}
|
||||
// YouTube/PeerTube - use content-aware instance selection
|
||||
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
|
||||
throw OpenLinkError.noInstanceAvailable
|
||||
}
|
||||
let (video, streams, _, _) = try await appEnvironment.contentService
|
||||
.videoWithProxyStreamsAndCaptionsAndStoryboards(
|
||||
id: videoID.videoID,
|
||||
instance: instance
|
||||
)
|
||||
return (video, streams)
|
||||
|
||||
case .directMedia(let mediaURL):
|
||||
// Direct media URL - no extraction needed
|
||||
let video = DirectMediaHelper.createVideo(from: mediaURL)
|
||||
let stream = DirectMediaHelper.createStream(from: mediaURL)
|
||||
return (video, [stream])
|
||||
|
||||
case .externalVideo, nil:
|
||||
// External URL - use Yattee Server
|
||||
guard let instance = appEnvironment.instancesManager.yatteeServerInstance else {
|
||||
throw OpenLinkError.noYatteeServer
|
||||
}
|
||||
let (video, streams, _) = try await appEnvironment.contentService
|
||||
.extractURL(url, instance: instance)
|
||||
return (video, streams)
|
||||
|
||||
default:
|
||||
throw OpenLinkError.notAVideo
|
||||
}
|
||||
}
|
||||
|
||||
private func playVideo(_ video: Video, appEnvironment: AppEnvironment) {
|
||||
// Don't pass a specific stream - let the player's selectStreamAndBackend
|
||||
// choose the best video+audio combination. Using streams.first would
|
||||
// incorrectly select audio-only streams for sites like Bilibili.
|
||||
appEnvironment.playerService.openVideo(video)
|
||||
}
|
||||
|
||||
// MARK: - Download Action
|
||||
|
||||
#if !os(tvOS)
|
||||
private func downloadAllURLs() async {
|
||||
let urls = parsedURLs
|
||||
guard !urls.isEmpty, let appEnvironment else { return }
|
||||
|
||||
isExtracting = true
|
||||
hasErrors = false
|
||||
extractedItems = urls.map { ExtractedItem(url: $0) }
|
||||
|
||||
let downloadSettings = appEnvironment.downloadSettings
|
||||
var successCount = 0
|
||||
var failedCount = 0
|
||||
|
||||
for (index, url) in urls.enumerated() {
|
||||
extractedItems[index].status = .extracting
|
||||
|
||||
do {
|
||||
let (video, streams, captions, storyboards) = try await extractVideoFull(from: url, appEnvironment: appEnvironment)
|
||||
extractedItems[index].status = .success
|
||||
extractedItems[index].video = video
|
||||
extractedItems[index].streams = streams
|
||||
extractedItems[index].captions = captions
|
||||
extractedItems[index].storyboards = storyboards
|
||||
successCount += 1
|
||||
|
||||
// If auto-download is configured, enqueue immediately
|
||||
if downloadSettings.preferredDownloadQuality != .ask {
|
||||
try await enqueueDownload(
|
||||
video: video,
|
||||
streams: streams,
|
||||
captions: captions,
|
||||
storyboards: storyboards,
|
||||
quality: downloadSettings.preferredDownloadQuality,
|
||||
includeSubtitles: downloadSettings.includeSubtitlesInAutoDownload,
|
||||
appEnvironment: appEnvironment
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
extractedItems[index].status = .failed(error.localizedDescription)
|
||||
failedCount += 1
|
||||
hasErrors = true
|
||||
}
|
||||
}
|
||||
|
||||
isExtracting = false
|
||||
|
||||
// Handle completion based on download mode
|
||||
if downloadSettings.preferredDownloadQuality == .ask {
|
||||
// Show quality picker for all extracted videos
|
||||
pendingDownloadItems = extractedItems.filter { $0.video != nil }
|
||||
if !pendingDownloadItems.isEmpty {
|
||||
showingDownloadSheet = true
|
||||
} else if failedCount > 0 {
|
||||
appEnvironment.toastManager.show(
|
||||
category: .error,
|
||||
title: String(localized: "openLink.allFailed.title"),
|
||||
subtitle: String(localized: "openLink.allFailed.subtitle \(failedCount)")
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Auto-download mode - show completion toast
|
||||
if failedCount == 0 {
|
||||
if successCount >= 2 {
|
||||
appEnvironment.toastManager.showSuccess(
|
||||
String(localized: "openLink.downloadQueued.title"),
|
||||
subtitle: String(localized: "openLink.downloadQueued.subtitle \(successCount)")
|
||||
)
|
||||
}
|
||||
dismiss()
|
||||
} else if successCount > 0 {
|
||||
appEnvironment.toastManager.show(
|
||||
category: .error,
|
||||
title: String(localized: "openLink.downloadPartial.title"),
|
||||
subtitle: String(localized: "openLink.downloadPartial.subtitle \(successCount) \(failedCount)")
|
||||
)
|
||||
// Keep sheet open
|
||||
} else {
|
||||
appEnvironment.toastManager.show(
|
||||
category: .error,
|
||||
title: String(localized: "openLink.allFailed.title"),
|
||||
subtitle: String(localized: "openLink.allFailed.subtitle \(failedCount)")
|
||||
)
|
||||
// Keep sheet open
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts video with full details (including captions and storyboards) for download.
|
||||
private func extractVideoFull(
|
||||
from url: URL,
|
||||
appEnvironment: AppEnvironment
|
||||
) async throws -> (Video, [Stream], [Caption], [Storyboard]) {
|
||||
let router = URLRouter()
|
||||
let destination = router.route(url)
|
||||
|
||||
switch destination {
|
||||
case .video(let source, _):
|
||||
guard case .id(let videoID) = source else {
|
||||
throw OpenLinkError.notAVideo
|
||||
}
|
||||
// YouTube/PeerTube - use content-aware instance selection
|
||||
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
|
||||
throw OpenLinkError.noInstanceAvailable
|
||||
}
|
||||
let (video, streams, captions, storyboards) = try await appEnvironment.contentService
|
||||
.videoWithProxyStreamsAndCaptionsAndStoryboards(
|
||||
id: videoID.videoID,
|
||||
instance: instance
|
||||
)
|
||||
return (video, streams, captions, storyboards)
|
||||
|
||||
case .directMedia(let mediaURL):
|
||||
// Direct media URL - no extraction needed, no captions/storyboards
|
||||
let video = DirectMediaHelper.createVideo(from: mediaURL)
|
||||
let stream = DirectMediaHelper.createStream(from: mediaURL)
|
||||
return (video, [stream], [], [])
|
||||
|
||||
case .externalVideo, nil:
|
||||
// External URL - use Yattee Server (doesn't support storyboards)
|
||||
guard let instance = appEnvironment.instancesManager.yatteeServerInstance else {
|
||||
throw OpenLinkError.noYatteeServer
|
||||
}
|
||||
let (video, streams, captions) = try await appEnvironment.contentService
|
||||
.extractURL(url, instance: instance)
|
||||
return (video, streams, captions, [])
|
||||
|
||||
default:
|
||||
throw OpenLinkError.notAVideo
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads pending items after user selects quality.
|
||||
private func downloadPendingItems(quality: DownloadQuality, includeSubtitles: Bool) async {
|
||||
guard let appEnvironment else { return }
|
||||
|
||||
var successCount = 0
|
||||
var failedCount = 0
|
||||
|
||||
for item in pendingDownloadItems {
|
||||
guard let video = item.video else { continue }
|
||||
|
||||
do {
|
||||
try await enqueueDownload(
|
||||
video: video,
|
||||
streams: item.streams,
|
||||
captions: item.captions,
|
||||
storyboards: item.storyboards,
|
||||
quality: quality,
|
||||
includeSubtitles: includeSubtitles,
|
||||
appEnvironment: appEnvironment
|
||||
)
|
||||
successCount += 1
|
||||
} catch {
|
||||
failedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
pendingDownloadItems = []
|
||||
|
||||
// Show completion toast
|
||||
if failedCount == 0 {
|
||||
if successCount >= 2 {
|
||||
appEnvironment.toastManager.showSuccess(
|
||||
String(localized: "openLink.downloadQueued.title"),
|
||||
subtitle: String(localized: "openLink.downloadQueued.subtitle \(successCount)")
|
||||
)
|
||||
}
|
||||
if !hasErrors {
|
||||
dismiss()
|
||||
}
|
||||
} else {
|
||||
appEnvironment.toastManager.show(
|
||||
category: .error,
|
||||
title: String(localized: "openLink.downloadPartial.title"),
|
||||
subtitle: String(localized: "openLink.downloadPartial.subtitle \(successCount) \(failedCount)")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueues a single video for download with already-fetched streams.
|
||||
private func enqueueDownload(
|
||||
video: Video,
|
||||
streams: [Stream],
|
||||
captions: [Caption],
|
||||
storyboards: [Storyboard],
|
||||
quality: DownloadQuality,
|
||||
includeSubtitles: Bool,
|
||||
appEnvironment: AppEnvironment
|
||||
) async throws {
|
||||
// Select best video stream
|
||||
let videoStream = selectBestVideoStream(from: streams, maxQuality: quality)
|
||||
guard let videoStream else {
|
||||
throw DownloadError.noStreamAvailable
|
||||
}
|
||||
|
||||
// Select audio stream if needed
|
||||
var audioStream: Stream?
|
||||
if videoStream.isVideoOnly {
|
||||
audioStream = selectBestAudioStream(
|
||||
from: streams,
|
||||
preferredLanguage: appEnvironment.settingsManager.preferredAudioLanguage
|
||||
)
|
||||
}
|
||||
|
||||
// Select caption if enabled
|
||||
var caption: Caption?
|
||||
if includeSubtitles, let preferredLang = appEnvironment.settingsManager.preferredSubtitlesLanguage {
|
||||
caption = selectBestCaption(from: captions, preferredLanguage: preferredLang)
|
||||
}
|
||||
|
||||
let audioCodec = videoStream.isMuxed ? videoStream.audioCodec : audioStream?.audioCodec
|
||||
let audioBitrate = videoStream.isMuxed ? nil : audioStream?.bitrate
|
||||
|
||||
try await appEnvironment.downloadManager.enqueue(
|
||||
video,
|
||||
quality: videoStream.qualityLabel,
|
||||
formatID: videoStream.format,
|
||||
streamURL: videoStream.url,
|
||||
audioStreamURL: videoStream.isVideoOnly ? audioStream?.url : nil,
|
||||
captionURL: caption?.url,
|
||||
audioLanguage: audioStream?.audioLanguage,
|
||||
captionLanguage: caption?.languageCode,
|
||||
httpHeaders: videoStream.httpHeaders,
|
||||
storyboard: storyboards.highest(),
|
||||
dislikeCount: nil,
|
||||
videoCodec: videoStream.videoCodec,
|
||||
audioCodec: audioCodec,
|
||||
videoBitrate: videoStream.bitrate,
|
||||
audioBitrate: audioBitrate
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Stream Selection Helpers
|
||||
|
||||
private func selectBestVideoStream(from streams: [Stream], maxQuality: DownloadQuality) -> Stream? {
|
||||
let maxRes = maxQuality.maxResolution
|
||||
|
||||
let videoStreams = streams
|
||||
.filter { !$0.isAudioOnly && $0.resolution != nil }
|
||||
.filter {
|
||||
let format = StreamFormat.detect(from: $0)
|
||||
return format != .hls && format != .dash
|
||||
}
|
||||
.sorted { s1, s2 in
|
||||
let res1 = s1.resolution ?? .p360
|
||||
let res2 = s2.resolution ?? .p360
|
||||
if res1 != res2 { return res1 > res2 }
|
||||
if s1.isMuxed != s2.isMuxed { return s1.isMuxed }
|
||||
return HardwareCapabilities.shared.codecPriority(for: s1.videoCodec) >
|
||||
HardwareCapabilities.shared.codecPriority(for: s2.videoCodec)
|
||||
}
|
||||
|
||||
guard let maxRes else {
|
||||
return videoStreams.first
|
||||
}
|
||||
|
||||
if let stream = videoStreams.first(where: { ($0.resolution ?? .p360) <= maxRes }) {
|
||||
return stream
|
||||
}
|
||||
|
||||
return videoStreams.last
|
||||
}
|
||||
|
||||
private func selectBestAudioStream(from streams: [Stream], preferredLanguage: String?) -> Stream? {
|
||||
let audioStreams = streams.filter { $0.isAudioOnly }
|
||||
|
||||
if let preferred = preferredLanguage {
|
||||
if let match = audioStreams.first(where: { ($0.audioLanguage ?? "").hasPrefix(preferred) }) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
if let original = audioStreams.first(where: { $0.isOriginalAudio }) {
|
||||
return original
|
||||
}
|
||||
|
||||
return audioStreams.first
|
||||
}
|
||||
|
||||
private func selectBestCaption(from captions: [Caption], preferredLanguage: String) -> Caption? {
|
||||
if let exact = captions.first(where: { $0.languageCode == preferredLanguage }) {
|
||||
return exact
|
||||
}
|
||||
if let prefix = captions.first(where: {
|
||||
$0.languageCode.hasPrefix(preferredLanguage) || $0.baseLanguageCode == preferredLanguage
|
||||
}) {
|
||||
return prefix
|
||||
}
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
private enum OpenLinkError: LocalizedError {
|
||||
case noInstanceAvailable
|
||||
case noYatteeServer
|
||||
case notAVideo
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noInstanceAvailable:
|
||||
return String(localized: "openLink.noInstance")
|
||||
case .noYatteeServer:
|
||||
return String(localized: "openLink.noYatteeServer")
|
||||
case .notAVideo:
|
||||
return String(localized: "openLink.notAVideo")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview {
|
||||
OpenLinkSheet()
|
||||
}
|
||||
|
||||
#Preview("With URL") {
|
||||
OpenLinkSheet(prefilledURL: URL(string: "https://vimeo.com/123456789"))
|
||||
}
|
||||
|
||||
#Preview("Multiple URLs") {
|
||||
OpenLinkSheet(prefilledURL: URL(string: "https://youtube.com/watch?v=abc123"))
|
||||
}
|
||||
46
Yattee/Views/Home/PlaylistRowView.swift
Normal file
46
Yattee/Views/Home/PlaylistRowView.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// PlaylistRowView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Row view for displaying a playlist in lists.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import NukeUI
|
||||
|
||||
struct PlaylistRowView: View {
|
||||
let playlist: LocalPlaylist
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Thumbnail
|
||||
LazyImage(url: playlist.thumbnailURL) { state in
|
||||
if let image = state.image {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(.quaternary)
|
||||
.overlay {
|
||||
Image(systemName: "music.note.list")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 80, height: 45)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
|
||||
// Info
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(playlist.title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
|
||||
Text("\(playlist.videoCount) videos • \(playlist.formattedTotalDuration)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
136
Yattee/Views/Home/PlaylistsListView.swift
Normal file
136
Yattee/Views/Home/PlaylistsListView.swift
Normal file
@@ -0,0 +1,136 @@
|
||||
//
|
||||
// PlaylistsListView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Full page view for listing all playlists.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PlaylistsListView: View {
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
@State private var playlists: [LocalPlaylist] = []
|
||||
@State private var showingNewPlaylist = false
|
||||
@State private var playlistToEdit: LocalPlaylist?
|
||||
|
||||
private var dataManager: DataManager? { appEnvironment?.dataManager }
|
||||
|
||||
/// List style from centralized settings.
|
||||
private var listStyle: VideoListStyle {
|
||||
appEnvironment?.settingsManager.listStyle ?? .inset
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if playlists.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
listContent
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "home.playlists.title"))
|
||||
#if !os(tvOS)
|
||||
.toolbarTitleDisplayMode(.inlineLarge)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showingNewPlaylist = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingNewPlaylist) {
|
||||
PlaylistFormSheet(mode: .create) { title, description in
|
||||
_ = dataManager?.createPlaylist(title: title, description: description)
|
||||
loadPlaylists()
|
||||
}
|
||||
}
|
||||
.sheet(item: $playlistToEdit) { playlist in
|
||||
PlaylistFormSheet(mode: .edit(playlist)) { newTitle, newDescription in
|
||||
dataManager?.updatePlaylist(playlist, title: newTitle, description: newDescription)
|
||||
loadPlaylists()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadPlaylists()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .playlistsDidChange)) { _ in
|
||||
loadPlaylists()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty View
|
||||
|
||||
private var emptyView: some View {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "home.playlists.title"), systemImage: "list.bullet.rectangle")
|
||||
} description: {
|
||||
Text(String(localized: "home.empty.description"))
|
||||
} actions: {
|
||||
Button {
|
||||
showingNewPlaylist = true
|
||||
} label: {
|
||||
Label(String(localized: "home.playlists.new"), systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List Content
|
||||
|
||||
private var listContent: some View {
|
||||
VideoListContainer(listStyle: listStyle, rowStyle: .regular) {
|
||||
Spacer()
|
||||
.frame(height: 16)
|
||||
} content: {
|
||||
ForEach(Array(playlists.enumerated()), id: \.element.id) { index, playlist in
|
||||
VideoListRow(
|
||||
isLast: index == playlists.count - 1,
|
||||
rowStyle: .regular,
|
||||
listStyle: listStyle,
|
||||
contentWidth: 80 // PlaylistRowView thumbnail width
|
||||
) {
|
||||
playlistRow(playlist: playlist)
|
||||
}
|
||||
.swipeActions {
|
||||
SwipeAction(
|
||||
symbolImage: "pencil",
|
||||
tint: .white,
|
||||
background: .orange
|
||||
) { reset in
|
||||
playlistToEdit = playlist
|
||||
reset()
|
||||
}
|
||||
SwipeAction(
|
||||
symbolImage: "trash.fill",
|
||||
tint: .white,
|
||||
background: .red
|
||||
) { reset in
|
||||
dataManager?.deletePlaylist(playlist)
|
||||
loadPlaylists()
|
||||
reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Views
|
||||
|
||||
@ViewBuilder
|
||||
private func playlistRow(playlist: LocalPlaylist) -> some View {
|
||||
PlaylistRowView(playlist: playlist)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
appEnvironment?.navigationCoordinator.navigate(to: .playlist(.local(playlist.id, title: playlist.title)))
|
||||
}
|
||||
.zoomTransitionSource(id: playlist.id)
|
||||
}
|
||||
|
||||
private func loadPlaylists() {
|
||||
playlists = (dataManager?.playlists() ?? []).sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
40
Yattee/Views/Home/TVHomeButtonStyles.swift
Normal file
40
Yattee/Views/Home/TVHomeButtonStyles.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// TVHomeButtonStyles.swift
|
||||
// Yattee
|
||||
//
|
||||
// Button styles for tvOS Home view.
|
||||
//
|
||||
|
||||
#if os(tvOS)
|
||||
import SwiftUI
|
||||
|
||||
/// Button style for Home cards with scale + opacity focus effect.
|
||||
struct TVHomeCardButtonStyle: ButtonStyle {
|
||||
@Environment(\.isFocused) private var isFocused
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.95 : (isFocused ? 1.05 : 1.0))
|
||||
.animation(.easeInOut(duration: 0.15), value: isFocused)
|
||||
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Button style for list rows with card background + focus effect.
|
||||
struct TVHomeRowButtonStyle: ButtonStyle {
|
||||
@Environment(\.isFocused) private var isFocused
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isFocused ? .white.opacity(0.15) : .white.opacity(0.05))
|
||||
)
|
||||
.scaleEffect(configuration.isPressed ? 0.98 : (isFocused ? 1.02 : 1.0))
|
||||
.animation(.easeInOut(duration: 0.15), value: isFocused)
|
||||
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user