Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent d94a50f8c3
commit 100df744d9
1043 changed files with 163886 additions and 68471 deletions

View 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()
}
}

View 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.

View 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)
}

View 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
}
}
}

View 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)
}
}
}

View 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)
}

View 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
}
}

View 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
}
}

File diff suppressed because it is too large Load Diff

View 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"))
}

View 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)
}
}
}
}

View 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 }
}
}

View 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