Files
yattee/Yattee/Views/Home/HomeView.swift
2026-02-08 18:33:56 +01:00

1306 lines
47 KiB
Swift

//
// HomeView.swift
// Yattee
//
// Home tab with shortcuts dashboard for Playlists, History, and Downloads.
//
import SwiftUI
struct HomeView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Namespace private var sheetTransition
@State private var playlists: [LocalPlaylist] = []
@State private var bookmarksCount: Int = 0
@State private var recentBookmarks: [Bookmark] = []
@State private var continueWatchingCount: Int = 0
@State private var recentContinueWatching: [WatchEntry] = []
@State private var historyCount: Int = 0
@State private var recentHistory: [WatchEntry] = []
@State private var showingSettings = false
@State private var showingOpenLink = false
@State private var showingRemoteControl = false
@State private var showingCustomizeHome = false
@State private var channelsCount: Int = 0
@State private var discoveredDevicesCount: Int = 0
@State private var feedCache = SubscriptionFeedCache.shared
private var dataManager: DataManager? { appEnvironment?.dataManager }
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
#if !os(tvOS)
private var downloadManager: DownloadManager? { appEnvironment?.downloadManager }
#endif
private var columns: [GridItem] {
#if os(tvOS)
[GridItem(.adaptive(minimum: 280, maximum: 380), spacing: 32)]
#else
[GridItem(.adaptive(minimum: 150), spacing: 16)]
#endif
}
private var sectionItemsLimit: Int {
settingsManager?.homeSectionItemsLimit ?? SettingsManager.defaultHomeSectionItemsLimit
}
/// Check if any shortcuts are visible
private var hasVisibleShortcuts: Bool {
guard let settings = settingsManager else { return true }
return !settings.visibleShortcuts().isEmpty
}
/// The current layout for shortcuts (list or cards)
private var shortcutLayout: HomeShortcutLayout {
settingsManager?.homeShortcutLayout ?? .cards
}
/// List style from centralized settings.
private var listStyle: VideoListStyle {
appEnvironment?.settingsManager.listStyle ?? .inset
}
var body: some View {
mainContent
.navigationTitle(String(localized: "tabs.home"))
.navigationSubtitleIfAvailable(
settingsManager?.incognitoModeEnabled == true
? String(localized: "home.incognitoMode.subtitle")
: nil
)
#if !os(tvOS)
.toolbarTitleDisplayMode(.inlineLarge)
#endif
.toolbar {
#if !os(tvOS)
ToolbarItem(placement: .primaryAction) {
Button {
showingSettings = true
} label: {
Image(systemName: "gear")
}
.accessibilityIdentifier("home.settingsButton")
.accessibilityLabel("Settings")
.liquidGlassTransitionSource(id: "homeSettings", in: sheetTransition)
}
#endif
}
#if !os(tvOS)
.sheet(isPresented: $showingSettings) {
SettingsView()
.liquidGlassSheetContent(sourceID: "homeSettings", in: sheetTransition)
}
.onChange(of: appEnvironment?.navigationCoordinator.dismissSettingsTrigger) {
showingSettings = false
}
#endif
.sheet(isPresented: $showingOpenLink) {
OpenLinkSheet()
}
.sheet(isPresented: $showingRemoteControl) {
RemoteDevicesSheet()
}
#if !os(tvOS)
.sheet(isPresented: $showingCustomizeHome) {
NavigationStack {
HomeSettingsView()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "common.done")) {
showingCustomizeHome = false
}
}
}
}
}
#endif
.onAppear {
loadData()
}
.task {
await feedCache.loadFromDiskIfNeeded()
await appEnvironment?.homeInstanceCache.loadFromDiskIfNeeded()
await refreshHomeInstanceContent()
}
.onChange(of: appEnvironment?.navigationCoordinator.isPlayerExpanded) { _, isExpanded in
// Refresh when player is collapsed
if isExpanded == false {
loadData()
}
}
.onReceive(NotificationCenter.default.publisher(for: .bookmarksDidChange)) { _ in
loadBookmarksData()
}
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
loadHistoryData()
loadContinueWatchingData()
}
.onReceive(NotificationCenter.default.publisher(for: .playlistsDidChange)) { _ in
loadPlaylistsData()
}
.onReceive(NotificationCenter.default.publisher(for: .subscriptionsDidChange)) { _ in
loadChannelsData()
}
}
// MARK: - Main Content
@ViewBuilder
private var mainContent: some View {
#if os(tvOS)
ScrollView {
LazyVStack(spacing: 0) {
homeContent
}
}
#else
let backgroundStyle: ListBackgroundStyle = listStyle == .inset ? .grouped : .plain
backgroundStyle.color
.ignoresSafeArea()
.overlay(
ScrollView {
LazyVStack(spacing: 0) {
Spacer().frame(height: 16)
homeContent
}
}
)
#endif
}
// MARK: - Home Content
@ViewBuilder
private var homeContent: some View {
if hasVisibleShortcuts {
shortcutsSection
}
ForEach(settingsManager?.visibleSections() ?? HomeSectionItem.defaultOrder.filter { HomeSectionItem.defaultVisibility[$0] == true }) { section in
sectionView(for: section)
}
#if !os(tvOS)
customizeButton
#endif
}
// MARK: - Section Header Helper
private func sectionHeader(title: LocalizedStringKey, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 4) {
Text(title)
.fontWeight(.semibold)
Image(systemName: "chevron.right")
.font(.caption)
}
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
.padding(.horizontal, listStyle == .inset ? 32 : 16)
.padding(.top, 16)
.padding(.bottom, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Customize Button
#if !os(tvOS)
private var customizeButton: some View {
Button {
showingCustomizeHome = true
} label: {
HStack(spacing: 6) {
Spacer()
Image(systemName: "gear")
Text(String(localized: "home.customize"))
.fontWeight(.semibold)
Spacer()
}
}
.foregroundStyle(.secondary)
.padding(.top, 16)
.padding(.bottom, 32)
}
#endif
// MARK: - Shortcuts Section
private var shortcutsSection: some View {
VStack(alignment: .leading, spacing: 0) {
if shortcutLayout == .list {
VideoListContent(listStyle: listStyle) {
shortcutsList
}
} else {
shortcutsCardContent
}
}
}
@ViewBuilder
private var shortcutsCardContent: some View {
if listStyle == .inset {
VStack(spacing: 0) {
shortcutsGrid
.padding(.vertical, 12)
.padding(.horizontal, 12)
}
.background(ListBackgroundStyle.card.color)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
.padding(.bottom, 16)
} else {
shortcutsGrid
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
}
private var shortcutsGrid: some View {
#if os(tvOS)
let gridSpacing: CGFloat = 32
#else
let gridSpacing: CGFloat = 16
#endif
return LazyVGrid(columns: columns, spacing: gridSpacing) {
ForEach(settingsManager?.visibleShortcuts() ?? HomeShortcutItem.defaultOrder) { shortcut in
shortcutCardView(for: shortcut)
}
}
#if os(iOS)
// WORKAROUND: Prevent UICollectionView layout oscillation during iPad Stage Manager window resize.
// The LazyVGrid inside a List section can cause 1pt height differences between layout passes,
// triggering a feedback loop that crashes the app. Using geometryGroup() isolates the grid's
// geometry calculations from the parent collection view's layout system.
.geometryGroup()
#endif
}
private var shortcutsList: some View {
let shortcuts = settingsManager?.visibleShortcuts() ?? HomeShortcutItem.defaultOrder
return ForEach(Array(shortcuts.enumerated()), id: \.element) { index, shortcut in
VideoListRow(
isLast: index == shortcuts.count - 1,
rowStyle: .regular,
listStyle: listStyle,
contentWidth: 28
) {
shortcutRowView(for: shortcut)
}
}
}
@ViewBuilder
private func shortcutCardView(for shortcut: HomeShortcutItem) -> some View {
switch shortcut {
case .openURL:
openURLShortcutCard
case .remoteControl:
remoteControlShortcutCard
case .playlists:
playlistsShortcutCard
case .bookmarks:
bookmarksShortcutCard
case .continueWatching:
continueWatchingShortcutCard
case .history:
historyShortcutCard
case .downloads:
#if !os(tvOS)
downloadsShortcutCard
#else
EmptyView()
#endif
case .channels:
channelsShortcutCard
case .subscriptions:
subscriptionsShortcutCard
case .mediaSources:
mediaSourcesShortcutCard
case .instanceContent(let instanceID, let contentType):
instanceContentShortcutCard(instanceID: instanceID, contentType: contentType)
case .mediaSource(let sourceID):
mediaSourceShortcutCard(sourceID: sourceID)
}
}
@ViewBuilder
private func shortcutRowView(for shortcut: HomeShortcutItem) -> some View {
switch shortcut {
case .openURL:
openURLShortcutRow
case .remoteControl:
remoteControlShortcutRow
case .playlists:
playlistsShortcutRow
case .bookmarks:
bookmarksShortcutRow
case .continueWatching:
continueWatchingShortcutRow
case .history:
historyShortcutRow
case .downloads:
#if !os(tvOS)
downloadsShortcutRow
#else
EmptyView()
#endif
case .channels:
channelsShortcutRow
case .subscriptions:
subscriptionsShortcutRow
case .mediaSources:
mediaSourcesShortcutRow
case .instanceContent(let instanceID, let contentType):
instanceContentShortcutRow(instanceID: instanceID, contentType: contentType)
case .mediaSource(let sourceID):
mediaSourceShortcutRow(sourceID: sourceID)
}
}
// MARK: - Shortcut Card Views
private var openURLShortcutCard: some View {
Button {
showingOpenLink = true
} label: {
HomeShortcutCardView(
icon: "link",
title: String(localized: "home.shortcut.openURL"),
count: 0,
subtitle: ""
)
}
#if os(tvOS)
.buttonStyle(TVHomeCardButtonStyle())
#else
.buttonStyle(.plain)
#endif
.accessibilityIdentifier("home.shortcut.openURL")
}
private var remoteControlShortcutCard: some View {
let isHosting = appEnvironment?.localNetworkService.isHosting ?? false
return Button {
showingRemoteControl = true
} label: {
HomeShortcutCardView(
icon: "antenna.radiowaves.left.and.right",
title: String(localized: "home.shortcut.remoteControl"),
count: discoveredDevicesCount,
subtitle: "",
statusIndicator: Circle()
.fill(isHosting ? Color.green : Color.red)
.frame(width: 8, height: 8)
)
}
#if os(tvOS)
.buttonStyle(TVHomeCardButtonStyle())
#else
.buttonStyle(.plain)
#endif
.accessibilityIdentifier("home.shortcut.remoteControl")
}
private var playlistsShortcutCard: some View {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .playlists)
} label: {
HomeShortcutCardView(
icon: "list.bullet.rectangle",
title: String(localized: "home.playlists.title"),
count: playlists.count,
subtitle: formatCount(playlists.count, singular: "home.count.playlist", plural: "home.count.playlists")
)
}
#if os(tvOS)
.buttonStyle(TVHomeCardButtonStyle())
#else
.buttonStyle(.plain)
#endif
.accessibilityIdentifier("home.shortcut.playlists")
}
private var bookmarksShortcutCard: some View {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .bookmarks)
} label: {
HomeShortcutCardView(
icon: "bookmark",
title: String(localized: "home.bookmarks.title"),
count: bookmarksCount,
subtitle: formatCount(bookmarksCount, singular: "home.count.bookmark", plural: "home.count.bookmarks")
)
}
#if os(tvOS)
.buttonStyle(TVHomeCardButtonStyle())
#else
.buttonStyle(.plain)
#endif
.accessibilityIdentifier("home.shortcut.bookmarks")
}
private var continueWatchingShortcutCard: some View {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .continueWatching)
} label: {
HomeShortcutCardView(
icon: "play.circle",
title: String(localized: "home.shortcut.continueWatching"),
count: continueWatchingCount,
subtitle: formatCount(continueWatchingCount, singular: "home.count.video", plural: "home.count.videos")
)
}
#if os(tvOS)
.buttonStyle(TVHomeCardButtonStyle())
#else
.buttonStyle(.plain)
#endif
.accessibilityIdentifier("home.shortcut.continueWatching")
}
private var historyShortcutCard: some View {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .history)
} label: {
HomeShortcutCardView(
icon: "clock",
title: String(localized: "home.history.title"),
count: historyCount,
subtitle: formatCount(historyCount, singular: "home.count.video", plural: "home.count.videos")
)
}
#if os(tvOS)
.buttonStyle(TVHomeCardButtonStyle())
#else
.buttonStyle(.plain)
#endif
.accessibilityIdentifier("home.shortcut.history")
}
#if !os(tvOS)
private var downloadsShortcutCard: some View {
let count = downloadManager?.completedDownloads.count ?? 0
return Button {
appEnvironment?.navigationCoordinator.navigate(to: .downloads)
} label: {
HomeShortcutCardView(
icon: "arrow.down.circle",
title: String(localized: "home.downloads.title"),
count: count,
subtitle: formatCount(count, singular: "home.count.video", plural: "home.count.videos")
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("home.shortcut.downloads")
}
#endif
private var channelsShortcutCard: some View {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .manageChannels)
} label: {
HomeShortcutCardView(
icon: "person.2",
title: String(localized: "home.channels.title"),
count: channelsCount,
subtitle: formatCount(channelsCount, singular: "home.count.channel", plural: "home.count.channels")
)
}
#if os(tvOS)
.buttonStyle(TVHomeCardButtonStyle())
#else
.buttonStyle(.plain)
#endif
.accessibilityIdentifier("home.shortcut.channels")
}
private var subscriptionsShortcutCard: some View {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .subscriptionsFeed)
} label: {
HomeShortcutCardView(
icon: "play.square.stack",
title: String(localized: "home.subscriptions.title"),
count: 0,
subtitle: String(localized: "home.subscriptions.subtitle")
)
}
#if os(tvOS)
.buttonStyle(TVHomeCardButtonStyle())
#else
.buttonStyle(.plain)
#endif
.accessibilityIdentifier("home.shortcut.subscriptions")
}
private var mediaSourcesShortcutCard: some View {
let mediaSourcesCount = appEnvironment?.mediaSourcesManager.enabledSources.count ?? 0
let instancesCount = appEnvironment?.instancesManager.instances.count ?? 0
let count = mediaSourcesCount + instancesCount
return Button {
appEnvironment?.navigationCoordinator.navigate(to: .mediaSources)
} label: {
HomeShortcutCardView(
icon: "externaldrive.connected.to.line.below",
title: "Sources",
count: count,
subtitle: count == 1 ? "1 source" : "\(count) sources"
)
}
#if os(tvOS)
.buttonStyle(TVHomeCardButtonStyle())
#else
.buttonStyle(.plain)
#endif
.accessibilityIdentifier("home.shortcut.mediaSources")
}
@ViewBuilder
private func instanceContentShortcutCard(instanceID: UUID, contentType: InstanceContentType) -> some View {
if let instance = appEnvironment?.instancesManager.instances.first(where: { $0.id == instanceID }),
instance.isEnabled {
Button {
// Navigate to InstanceBrowseView with the correct tab selected
appEnvironment?.navigationCoordinator.navigate(to: .instanceBrowse(instance, initialTab: contentType.toBrowseTab()))
} label: {
HomeShortcutCardView(
icon: contentType.icon,
title: contentType.localizedTitle,
count: 0,
subtitle: instance.displayName
)
}
#if os(tvOS)
.buttonStyle(TVHomeCardButtonStyle())
#else
.buttonStyle(.plain)
#endif
}
}
@ViewBuilder
private func mediaSourceShortcutCard(sourceID: UUID) -> some View {
if let source = appEnvironment?.mediaSourcesManager.sources.first(where: { $0.id == sourceID }),
source.isEnabled {
Button {
// Navigate to MediaBrowserView at root path
appEnvironment?.navigationCoordinator.navigate(to: .mediaBrowser(source, path: "/"))
} label: {
HomeShortcutCardView(
icon: source.type.systemImage,
title: source.name,
count: 0,
subtitle: source.type.displayName
)
}
#if os(tvOS)
.buttonStyle(TVHomeCardButtonStyle())
#else
.buttonStyle(.plain)
#endif
}
}
// MARK: - Shortcut Row Views
private var openURLShortcutRow: some View {
Button {
showingOpenLink = true
} label: {
HomeShortcutRowView(
icon: "link",
title: String(localized: "home.shortcut.openURL"),
subtitle: ""
)
}
.buttonStyle(.plain)
}
private var remoteControlShortcutRow: some View {
let isHosting = appEnvironment?.localNetworkService.isHosting ?? false
return Button {
showingRemoteControl = true
} label: {
HomeShortcutRowView(
icon: "antenna.radiowaves.left.and.right",
title: String(localized: "home.shortcut.remoteControl"),
subtitle: "",
statusIndicator: Circle()
.fill(isHosting ? Color.green : Color.red)
.frame(width: 8, height: 8)
)
}
.buttonStyle(.plain)
}
private var playlistsShortcutRow: some View {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .playlists)
} label: {
HomeShortcutRowView(
icon: "list.bullet.rectangle",
title: String(localized: "home.playlists.title"),
subtitle: formatCount(playlists.count, singular: "home.count.playlist", plural: "home.count.playlists")
)
}
.buttonStyle(.plain)
}
private var bookmarksShortcutRow: some View {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .bookmarks)
} label: {
HomeShortcutRowView(
icon: "bookmark.fill",
title: String(localized: "home.bookmarks.title"),
subtitle: formatCount(bookmarksCount, singular: "home.count.bookmark", plural: "home.count.bookmarks")
)
}
.buttonStyle(.plain)
}
private var continueWatchingShortcutRow: some View {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .continueWatching)
} label: {
HomeShortcutRowView(
icon: "play.circle",
title: String(localized: "home.shortcut.continueWatching"),
subtitle: formatCount(continueWatchingCount, singular: "home.count.video", plural: "home.count.videos")
)
}
.buttonStyle(.plain)
}
private var historyShortcutRow: some View {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .history)
} label: {
HomeShortcutRowView(
icon: "clock",
title: String(localized: "home.history.title"),
subtitle: formatCount(historyCount, singular: "home.count.video", plural: "home.count.videos")
)
}
.buttonStyle(.plain)
}
#if !os(tvOS)
private var downloadsShortcutRow: some View {
let count = downloadManager?.completedDownloads.count ?? 0
return Button {
appEnvironment?.navigationCoordinator.navigate(to: .downloads)
} label: {
HomeShortcutRowView(
icon: "arrow.down.circle",
title: String(localized: "home.downloads.title"),
subtitle: formatCount(count, singular: "home.count.video", plural: "home.count.videos")
)
}
.buttonStyle(.plain)
}
#endif
private var channelsShortcutRow: some View {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .manageChannels)
} label: {
HomeShortcutRowView(
icon: "person.2",
title: String(localized: "home.channels.title"),
subtitle: formatCount(channelsCount, singular: "home.count.channel", plural: "home.count.channels")
)
}
.buttonStyle(.plain)
}
private var subscriptionsShortcutRow: some View {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .subscriptionsFeed)
} label: {
HomeShortcutRowView(
icon: "play.rectangle.on.rectangle",
title: String(localized: "home.subscriptions.title"),
subtitle: String(localized: "home.subscriptions.subtitle")
)
}
.buttonStyle(.plain)
}
private var mediaSourcesShortcutRow: some View {
let mediaSourcesCount = appEnvironment?.mediaSourcesManager.enabledSources.count ?? 0
let instancesCount = appEnvironment?.instancesManager.instances.count ?? 0
let count = mediaSourcesCount + instancesCount
return Button {
appEnvironment?.navigationCoordinator.navigate(to: .mediaSources)
} label: {
HomeShortcutRowView(
icon: "externaldrive.connected.to.line.below",
title: "Sources",
subtitle: count == 1 ? "1 source" : "\(count) sources"
)
}
.buttonStyle(.plain)
}
@ViewBuilder
private func instanceContentShortcutRow(instanceID: UUID, contentType: InstanceContentType) -> some View {
if let instance = appEnvironment?.instancesManager.instances.first(where: { $0.id == instanceID }),
instance.isEnabled {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .instanceBrowse(instance, initialTab: contentType.toBrowseTab()))
} label: {
HomeShortcutRowView(
icon: contentType.icon,
title: contentType.localizedTitle,
subtitle: instance.displayName
)
}
.buttonStyle(.plain)
}
}
@ViewBuilder
private func mediaSourceShortcutRow(sourceID: UUID) -> some View {
if let source = appEnvironment?.mediaSourcesManager.sources.first(where: { $0.id == sourceID }),
source.isEnabled {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .mediaBrowser(source, path: "/"))
} label: {
HomeShortcutRowView(
icon: source.type.systemImage,
title: source.name,
subtitle: source.type.displayName
)
}
.buttonStyle(.plain)
}
}
/// Formats a count with compact notation and proper singular/plural form.
private func formatCount(_ count: Int, singular: String.LocalizationValue, plural: String.LocalizationValue) -> String {
let formattedCount = CountFormatter.compact(count)
let key = count == 1 ? singular : plural
return String(localized: "\(formattedCount) \(String(localized: key))")
}
// MARK: - Queue Support
/// Queue source for recent bookmarks section
private var recentBookmarksQueueSource: QueueSource { .manual }
/// Queue source for continue watching section
private var continueWatchingQueueSource: QueueSource { .manual }
/// Queue source for recent history section
private var recentHistoryQueueSource: QueueSource { .manual }
/// Queue source for feed section
private var feedQueueSource: QueueSource { .subscriptions(continuation: nil) }
/// Returns queue source for instance content
private func instanceQueueSource(instanceID: UUID, contentType: InstanceContentType) -> QueueSource {
.manual
}
/// Stub callback for recent bookmarks queue continuation
@Sendable
private func loadMoreRecentBookmarksCallback() async throws -> ([Video], String?) {
return ([], nil) // No pagination for Home sections
}
/// Stub callback for continue watching queue continuation
@Sendable
private func loadMoreContinueWatchingCallback() async throws -> ([Video], String?) {
return ([], nil) // No pagination for Home sections
}
/// Stub callback for recent history queue continuation
@Sendable
private func loadMoreRecentHistoryCallback() async throws -> ([Video], String?) {
return ([], nil) // No pagination for Home sections
}
/// Stub callback for feed queue continuation
@Sendable
private func loadMoreFeedCallback() async throws -> ([Video], String?) {
return ([], nil) // Feed section doesn't paginate
}
/// Stub callback for instance content queue continuation
@Sendable
private func loadMoreInstanceContentCallback() async throws -> ([Video], String?) {
return ([], nil) // No pagination for Home sections
}
#if !os(tvOS)
/// Stub callback for recent downloads queue continuation
@Sendable
private func loadMoreRecentDownloadsCallback() async throws -> ([Video], String?) {
return ([], nil) // No pagination for Home sections
}
#endif
// MARK: - Sections
@ViewBuilder
private func sectionView(for section: HomeSectionItem) -> some View {
switch section {
case .continueWatching:
if !recentContinueWatching.isEmpty {
continueWatchingSection
}
case .feed:
if !feedCache.videos.isEmpty {
feedSection
}
case .bookmarks:
if !recentBookmarks.isEmpty {
bookmarksSection
}
case .history:
if !recentHistory.isEmpty {
historySection
}
case .downloads:
#if !os(tvOS)
if let downloads = downloadManager?.completedDownloads, !downloads.isEmpty {
downloadsSection(downloads: downloads)
}
#else
EmptyView()
#endif
case .instanceContent(let instanceID, let contentType):
instanceContentSection(instanceID: instanceID, contentType: contentType)
case .mediaSource(let sourceID):
mediaSourceSection(sourceID: sourceID)
}
}
private var continueWatchingSection: some View {
let limitedEntries = Array(recentContinueWatching.prefix(sectionItemsLimit))
let videoList = limitedEntries.map { $0.toVideo() }
return VStack(alignment: .leading, spacing: 0) {
sectionHeader(title: "home.section.continueWatching") {
appEnvironment?.navigationCoordinator.navigate(to: .continueWatching)
}
VideoListContent(listStyle: listStyle) {
ForEach(Array(limitedEntries.enumerated()), id: \.element.videoIdentifier) { index, entry in
VideoListRow(
isLast: index == limitedEntries.count - 1,
rowStyle: .regular,
listStyle: listStyle
) {
WatchEntryRowView(
entry: entry,
onRemove: {
dataManager?.removeFromHistory(videoID: entry.videoID)
loadData()
},
queueSource: continueWatchingQueueSource,
sourceLabel: String(localized: "queue.source.continueWatching"),
videoList: videoList,
videoIndex: index,
loadMoreVideos: loadMoreContinueWatchingCallback
)
}
#if !os(tvOS)
.videoSwipeActions(
video: videoList[index],
fixedActions: [
SwipeAction(
symbolImage: "trash.fill",
tint: .white,
background: .red
) { reset in
dataManager?.removeFromHistory(videoID: entry.videoID)
loadData()
reset()
}
]
)
#endif
}
}
}
}
private var feedSection: some View {
let limitedVideos = Array(feedCache.videos.prefix(sectionItemsLimit))
return VStack(alignment: .leading, spacing: 0) {
sectionHeader(title: "home.recentFeed.title") {
appEnvironment?.navigationCoordinator.navigate(to: .subscriptionsFeed)
}
VideoListContent(listStyle: listStyle) {
ForEach(Array(limitedVideos.enumerated()), id: \.element.id) { index, video in
VideoListRow(
isLast: index == limitedVideos.count - 1,
rowStyle: .regular,
listStyle: listStyle
) {
VideoRowView(video: video, style: .regular)
.tappableVideo(
video,
queueSource: feedQueueSource,
sourceLabel: String(localized: "queue.source.subscriptions"),
videoList: limitedVideos,
videoIndex: index,
loadMoreVideos: loadMoreFeedCallback
)
}
#if !os(tvOS)
.videoSwipeActions(video: video)
#endif
}
}
}
}
private var bookmarksSection: some View {
let limitedBookmarks = Array(recentBookmarks.prefix(sectionItemsLimit))
let videoList = limitedBookmarks.map { $0.toVideo() }
return VStack(alignment: .leading, spacing: 0) {
sectionHeader(title: "home.recentBookmarks.title") {
appEnvironment?.navigationCoordinator.navigate(to: .bookmarks)
}
VideoListContent(listStyle: listStyle) {
ForEach(Array(limitedBookmarks.enumerated()), id: \.element.videoID) { index, bookmark in
VideoListRow(
isLast: index == limitedBookmarks.count - 1,
rowStyle: .regular,
listStyle: listStyle
) {
BookmarkRowView(
bookmark: bookmark,
onRemove: {
dataManager?.removeBookmark(for: bookmark.videoID)
loadData()
},
queueSource: recentBookmarksQueueSource,
sourceLabel: String(localized: "queue.source.bookmarks"),
videoList: videoList,
videoIndex: index,
loadMoreVideos: loadMoreRecentBookmarksCallback
)
}
#if !os(tvOS)
.videoSwipeActions(
video: videoList[index],
fixedActions: [
SwipeAction(
symbolImage: "trash.fill",
tint: .white,
background: .red
) { reset in
dataManager?.removeBookmark(for: bookmark.videoID)
loadData()
reset()
}
]
)
#endif
}
}
}
}
private var historySection: some View {
let limitedHistory = Array(recentHistory.prefix(sectionItemsLimit))
let videoList = limitedHistory.map { $0.toVideo() }
return VStack(alignment: .leading, spacing: 0) {
sectionHeader(title: "home.recentHistory.title") {
appEnvironment?.navigationCoordinator.navigate(to: .history)
}
VideoListContent(listStyle: listStyle) {
ForEach(Array(limitedHistory.enumerated()), id: \.element.videoID) { index, entry in
VideoListRow(
isLast: index == limitedHistory.count - 1,
rowStyle: .regular,
listStyle: listStyle
) {
WatchEntryRowView(
entry: entry,
onRemove: {
dataManager?.removeFromHistory(videoID: entry.videoID)
loadData()
},
queueSource: recentHistoryQueueSource,
sourceLabel: String(localized: "queue.source.history"),
videoList: videoList,
videoIndex: index,
loadMoreVideos: loadMoreRecentHistoryCallback
)
}
#if !os(tvOS)
.videoSwipeActions(
video: videoList[index],
fixedActions: [
SwipeAction(
symbolImage: "trash.fill",
tint: .white,
background: .red
) { reset in
dataManager?.removeFromHistory(videoID: entry.videoID)
loadData()
reset()
}
]
)
#endif
}
}
}
}
#if !os(tvOS)
private func downloadsSection(downloads: [Download]) -> some View {
let limitedDownloads = Array(downloads.prefix(sectionItemsLimit))
// Use toVideo() instead of videoAndStream() to avoid O(n²) file I/O on main thread
// Downloads are looked up by video.id at playback time in PlayerService.playPreferringDownloaded()
let videoList = limitedDownloads.map { $0.toVideo() }
return VStack(alignment: .leading, spacing: 0) {
sectionHeader(title: "home.recentDownloads.title") {
appEnvironment?.navigationCoordinator.navigate(to: .downloads)
}
VideoListContent(listStyle: listStyle) {
ForEach(Array(limitedDownloads.enumerated()), id: \.element.id) { index, download in
VideoListRow(
isLast: index == limitedDownloads.count - 1,
rowStyle: .regular,
listStyle: listStyle
) {
DownloadRowView(
download: download,
isActive: false,
onDelete: {
Task {
await downloadManager?.delete(download)
}
},
queueSource: .manual,
sourceLabel: String(localized: "queue.source.downloads"),
videoList: videoList,
videoIndex: index,
loadMoreVideos: loadMoreRecentDownloadsCallback
)
}
.videoSwipeActions(
video: videoList[index],
fixedActions: [
SwipeAction(
symbolImage: "trash.fill",
tint: .white,
background: .red
) { reset in
Task {
await downloadManager?.delete(download)
}
reset()
}
]
)
}
}
}
}
#endif
@ViewBuilder
private func instanceContentSection(instanceID: UUID, contentType: InstanceContentType) -> some View {
// Only show if instance is enabled and has cached videos
if let instance = appEnvironment?.instancesManager.instances.first(where: { $0.id == instanceID }),
instance.isEnabled,
let videos = appEnvironment?.homeInstanceCache.videos(for: instanceID, contentType: contentType),
!videos.isEmpty {
let limitedVideos = Array(videos.prefix(sectionItemsLimit))
VStack(alignment: .leading, spacing: 0) {
Button {
appEnvironment?.navigationCoordinator.navigate(
to: .instanceBrowse(instance, initialTab: contentType.toBrowseTab())
)
} label: {
HStack(spacing: 4) {
Text("\(contentType.localizedTitle) from \(instance.displayName)")
.fontWeight(.semibold)
Image(systemName: "chevron.right")
.font(.caption)
}
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
.padding(.horizontal, 32)
.padding(.top, 16)
.padding(.bottom, 8)
.frame(maxWidth: .infinity, alignment: .leading)
VideoListContent(listStyle: listStyle) {
ForEach(Array(limitedVideos.enumerated()), id: \.element.id) { index, video in
VideoListRow(
isLast: index == limitedVideos.count - 1,
rowStyle: .regular,
listStyle: listStyle
) {
VideoRowView(video: video, style: .regular)
.tappableVideo(
video,
queueSource: instanceQueueSource(instanceID: instanceID, contentType: contentType),
sourceLabel: contentType.localizedTitle,
videoList: limitedVideos,
videoIndex: index,
loadMoreVideos: loadMoreInstanceContentCallback
)
}
#if !os(tvOS)
.videoSwipeActions(video: video)
#endif
}
}
}
}
}
@ViewBuilder
private func mediaSourceSection(sourceID: UUID) -> some View {
if let source = appEnvironment?.mediaSourcesManager.sources.first(where: { $0.id == sourceID }),
source.isEnabled {
VStack(alignment: .leading, spacing: 0) {
Text(source.name)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 32)
.padding(.top, 16)
.padding(.bottom, 8)
.frame(maxWidth: .infinity, alignment: .leading)
VideoListContent(listStyle: listStyle) {
VideoListRow(
isLast: true,
rowStyle: .regular,
listStyle: listStyle
) {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .mediaBrowser(source, path: "/"))
} label: {
HStack {
Image(systemName: source.type.systemImage)
.foregroundStyle(.secondary)
Text("Browse \(source.name)")
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
}
}
}
}
}
// MARK: - Data Loading
private func loadData() {
loadPlaylistsData()
loadBookmarksData()
loadContinueWatchingData()
loadHistoryData()
loadChannelsData()
loadRemoteDevicesData()
cleanupOrphanedHomeInstanceItems()
cleanupOrphanedHomeMediaSourceItems()
}
private func cleanupOrphanedHomeInstanceItems() {
guard let instancesManager = appEnvironment?.instancesManager,
let settingsManager = appEnvironment?.settingsManager else { return }
let validIDs = Set(instancesManager.instances.map(\.id))
settingsManager.cleanupOrphanedHomeInstanceItems(validInstanceIDs: validIDs)
}
private func cleanupOrphanedHomeMediaSourceItems() {
guard let mediaSourcesManager = appEnvironment?.mediaSourcesManager,
let settingsManager = appEnvironment?.settingsManager else { return }
let validIDs = Set(mediaSourcesManager.sources.map(\.id))
settingsManager.cleanupOrphanedHomeMediaSourceItems(validSourceIDs: validIDs)
}
private func loadPlaylistsData() {
playlists = dataManager?.playlists() ?? []
}
private func loadBookmarksData() {
bookmarksCount = dataManager?.bookmarksCount() ?? 0
recentBookmarks = dataManager?.bookmarks(limit: 50) ?? []
}
private func loadContinueWatchingData() {
let allHistory = dataManager?.watchHistory(limit: 100) ?? []
// Filter to in-progress only (same logic as ContinueWatchingView)
recentContinueWatching = allHistory.filter { !$0.isFinished && $0.watchedSeconds > 10 }
continueWatchingCount = recentContinueWatching.count
}
private func loadHistoryData() {
historyCount = dataManager?.watchHistoryCount() ?? 0
recentHistory = dataManager?.watchHistory(limit: 50) ?? []
}
private func loadChannelsData() {
channelsCount = dataManager?.subscriptions().count ?? 0
}
private func loadRemoteDevicesData() {
discoveredDevicesCount = appEnvironment?.remoteControlCoordinator.discoveredDevices.count ?? 0
}
/// Refreshes Home instance content sections from network if cache is stale.
/// Only refreshes enabled instance content sections (Popular/Trending/Feed).
private func refreshHomeInstanceContent() async {
guard let appEnvironment else { return }
// Get all visible instance content sections from settings
let visibleSections = settingsManager?.visibleSections() ?? []
for section in visibleSections {
if case .instanceContent(let instanceID, let contentType) = section {
// Refresh if cache is stale
if !appEnvironment.homeInstanceCache.isCacheValid(for: instanceID, contentType: contentType) {
await appEnvironment.homeInstanceCache.refresh(
instanceID: instanceID,
contentType: contentType,
using: appEnvironment
)
}
}
}
}
}
// MARK: - Preview
#Preview {
NavigationStack {
HomeView()
}
.appEnvironment(.preview)
}