mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
1935 lines
74 KiB
Swift
1935 lines
74 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
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
#if !os(tvOS) && !os(macOS)
|
|
@Namespace private var sheetTransition
|
|
#endif
|
|
/// The device's own playlists. In an account context the card reads the
|
|
/// remote playlists cache instead — the same store the playlists screen
|
|
/// this shortcut leads to shows.
|
|
@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] = []
|
|
/// The account's server-side history, when that is what the history screen
|
|
/// would show. `nil` in local mode — and while the account's first fetch is
|
|
/// still out, which is what keeps the local numbers off an account's card.
|
|
@State private var serverHistory: ServerHistorySnapshot?
|
|
/// Which server-history load the state belongs to. Cancelling is not
|
|
/// enough on its own: a page that already came back races the switch, and
|
|
/// its rows would land on a context nobody is looking at any more.
|
|
@State private var serverHistoryGeneration = 0
|
|
#if !os(tvOS) && !os(macOS)
|
|
@State private var showingSettings = false
|
|
#endif
|
|
@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
|
|
#if os(tvOS)
|
|
/// Whether the first pass over the Home data has finished. Every section
|
|
/// reads empty before it does, so the empty state waits for this rather than
|
|
/// flashing over content that is on its way.
|
|
@State private var hasLoadedHomeData = false
|
|
#endif
|
|
|
|
/// What one probe of the account's server history brought back.
|
|
private struct ServerHistorySnapshot {
|
|
/// How many entries the probe saw, or `nil` when the fetch failed.
|
|
var count: Int?
|
|
/// The probe came back full, so the history goes on past what it saw
|
|
/// and `count` is a floor, not a total — the server has no count call.
|
|
var countIsLowerBound = false
|
|
/// The leading entries, hydrated for the section's rows.
|
|
var videos: [Video] = []
|
|
}
|
|
|
|
/// How many IDs the server-history probe asks for in its single page.
|
|
///
|
|
/// Large enough that most histories fit and the card shows a real total;
|
|
/// a history that fills it shows this number as a floor instead. IDs only
|
|
/// — nothing is hydrated past the section's few rows — so the size costs
|
|
/// the server one array slice.
|
|
private static let serverHistoryProbeSize = 500
|
|
|
|
private var dataManager: DataManager? { appEnvironment?.dataManager }
|
|
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
|
|
|
|
// MARK: - Context
|
|
|
|
/// The account whose data Home is showing, or `nil` when it is the device's.
|
|
///
|
|
/// The *effective* context, so a selected account that cannot be used right
|
|
/// now shows the local numbers — the same fallback the screens the
|
|
/// shortcuts lead to make.
|
|
private var contextAccount: Account? {
|
|
guard let appEnvironment,
|
|
case let .account(id) = appEnvironment.dataContextValidator.effectiveContext
|
|
else { return nil }
|
|
|
|
return appEnvironment.accountsManager.account(id: id)
|
|
}
|
|
|
|
/// Whose history the card and the section read — resolved by the same rule
|
|
/// as the history screen, so the shortcut can never promise a history the
|
|
/// screen behind it will not show.
|
|
private var historyDisplayMode: HistoryDisplayMode {
|
|
guard let appEnvironment else { return .local }
|
|
let account = contextAccount
|
|
|
|
return HistoryDisplayMode.resolve(
|
|
effectiveContext: appEnvironment.dataContextValidator.effectiveContext,
|
|
account: account,
|
|
capabilities: account.flatMap { appEnvironment.accountsManager.capabilities(for: $0) }
|
|
)
|
|
}
|
|
|
|
/// The account's playlists as last fetched, or `nil` in local mode — read
|
|
/// live from the same cache the playlists screen shows, so a create or a
|
|
/// delete there moves this count without a reload.
|
|
private var remotePlaylists: [Playlist]? {
|
|
guard let appEnvironment, let account = contextAccount else { return nil }
|
|
return appEnvironment.remotePlaylistsCache.playlists(for: account.id)
|
|
}
|
|
|
|
private var accentColor: Color {
|
|
appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
private var downloadManager: DownloadManager? { appEnvironment?.downloadManager }
|
|
#endif
|
|
|
|
private var columns: [GridItem] {
|
|
#if os(tvOS)
|
|
[GridItem(.adaptive(minimum: 280, maximum: 380), spacing: 16)]
|
|
#else
|
|
[GridItem(.adaptive(minimum: 150), spacing: 8)]
|
|
#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
|
|
}
|
|
|
|
/// The current layout for home sections (list or grid)
|
|
private var sectionLayout: HomeSectionLayout {
|
|
settingsManager?.homeSectionLayout ?? HomeSectionLayout.platformDefault
|
|
}
|
|
|
|
/// List style from centralized settings.
|
|
private var listStyle: VideoListStyle {
|
|
appEnvironment?.settingsManager.listStyle ?? .inset
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
/// Same edge the switcher sits on in Subscriptions/History/Playlists:
|
|
/// leading on macOS, trailing elsewhere.
|
|
private var switcherPlacement: ToolbarItemPlacement {
|
|
#if os(macOS)
|
|
.navigation
|
|
#else
|
|
.primaryAction
|
|
#endif
|
|
}
|
|
#endif
|
|
|
|
var body: some View {
|
|
// Split from `chrome` so the modifier chain stays within what the
|
|
// type-checker will resolve in one expression.
|
|
dataObservers(chrome)
|
|
}
|
|
|
|
private var chrome: some View {
|
|
mainContent
|
|
#if !os(tvOS)
|
|
.navigationTitle(String(localized: "tabs.home"))
|
|
.navigationSubtitleIfAvailable(
|
|
settingsManager?.incognitoModeEnabled == true
|
|
? String(localized: "home.incognitoMode.subtitle")
|
|
: nil
|
|
)
|
|
.toolbarTitleDisplayMode(.inlineLarge)
|
|
#endif
|
|
.toolbar {
|
|
#if !os(tvOS)
|
|
if settingsManager?.showAccountPickers ?? true {
|
|
ToolbarItem(placement: switcherPlacement) {
|
|
DataContextSwitcherMenu()
|
|
}
|
|
}
|
|
#endif
|
|
#if !os(tvOS) && !os(macOS)
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
showingSettings = true
|
|
} label: {
|
|
Image(systemName: "gear")
|
|
}
|
|
.accessibilityIdentifier("home.settingsButton")
|
|
.accessibilityLabel(String(localized: "settings.title"))
|
|
.liquidGlassTransitionSource(id: "homeSettings", in: sheetTransition)
|
|
}
|
|
#endif
|
|
}
|
|
#if !os(tvOS) && !os(macOS)
|
|
.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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#if os(macOS)
|
|
.frame(minWidth: 500, minHeight: 450)
|
|
#endif
|
|
}
|
|
#endif
|
|
.onAppear {
|
|
loadData()
|
|
}
|
|
.onChange(of: scenePhase) { _, newPhase in
|
|
if newPhase == .active {
|
|
loadData()
|
|
}
|
|
}
|
|
.task {
|
|
await feedCache.loadFromDiskIfNeeded()
|
|
await appEnvironment?.homeInstanceCache.loadFromDiskIfNeeded()
|
|
#if os(tvOS)
|
|
// The disk caches are the last of the on-device data to land, so the
|
|
// empty state is honest from here on. A network refresh below can
|
|
// still add content later; it must not hold an empty Home behind a
|
|
// spinner for as long as a request can take.
|
|
hasLoadedHomeData = true
|
|
#endif
|
|
await refreshHomeInstanceContent()
|
|
}
|
|
.onChange(of: appEnvironment?.navigationCoordinator.isPlayerExpanded) { _, isExpanded in
|
|
// Refresh when player is collapsed
|
|
if isExpanded == false {
|
|
loadData()
|
|
}
|
|
}
|
|
.onChange(of: appEnvironment?.navigationCoordinator.selectedTab) { _, newTab in
|
|
if newTab == .home {
|
|
loadData()
|
|
}
|
|
}
|
|
.onChange(of: showingCustomizeHome) { _, isShowing in
|
|
if !isShowing {
|
|
loadData()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The notification subscriptions, hung on the content as one group.
|
|
private func dataObservers(_ content: some View) -> some View {
|
|
content
|
|
.onReceive(NotificationCenter.default.publisher(for: .bookmarksDidChange)) { _ in
|
|
loadBookmarksData()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
|
|
// The device's own history changed; an account's server history is
|
|
// told about below, and a local write is not news about it.
|
|
loadLocalHistoryData()
|
|
loadContinueWatchingData()
|
|
}
|
|
// A watch was posted to the account's server history — the count and
|
|
// the section's front rows just moved.
|
|
.onReceive(NotificationCenter.default.publisher(for: .serverWatchHistoryDidUpdate)) { _ in
|
|
if case .server = historyDisplayMode {
|
|
loadServerHistoryIfNeeded()
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .playlistsDidChange)) { _ in
|
|
loadPlaylistsData()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .subscriptionsDidChange)) { _ in
|
|
loadChannelsData()
|
|
}
|
|
// Selecting another account — or local — changes whose channels count
|
|
// and feed this is. AppEnvironment already swapped or cleared the
|
|
// account-scoped caches; this re-reads the counts and, when the feed
|
|
// section is on Home, refills the feed the clearing just emptied.
|
|
.onReceive(NotificationCenter.default.publisher(for: .dataContextDidChange)) { _ in
|
|
loadData()
|
|
refreshFeedIfNeeded()
|
|
}
|
|
// The same, without the context moving: the credential behind the
|
|
// account was replaced, so the counts may now be someone else's.
|
|
.onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in
|
|
loadData()
|
|
refreshFeedIfNeeded()
|
|
}
|
|
// A status, an account or an instance changing moves reads between the
|
|
// account and local without the stored context moving at all.
|
|
.onReceive(NotificationCenter.default.publisher(for: .accountStatusesDidChange)) { _ in
|
|
loadData()
|
|
refreshFeedIfNeeded()
|
|
}
|
|
}
|
|
|
|
// MARK: - Main Content
|
|
|
|
#if os(tvOS)
|
|
/// True on tvOS when no Home section would render content — prevents a focus trap
|
|
/// where the detail pane would otherwise be completely empty.
|
|
///
|
|
/// Each case mirrors what `sectionView(for:)` actually renders: a section with
|
|
/// nothing to show renders nothing, so it must not count as content here.
|
|
private var isHomeEmpty: Bool {
|
|
let sections = settingsManager?.visibleSections() ?? HomeSectionItem.defaultOrder.filter { HomeSectionItem.defaultVisibility[$0] == true }
|
|
for section in sections {
|
|
switch section {
|
|
case .continueWatching:
|
|
if !recentContinueWatching.isEmpty { return false }
|
|
case .feed:
|
|
if !feedCache.videos.isEmpty { return false }
|
|
case .bookmarks:
|
|
if !recentBookmarks.isEmpty { return false }
|
|
case .history:
|
|
if case .server = historyDisplayMode {
|
|
if !(serverHistory?.videos.isEmpty ?? true) { return false }
|
|
} else if !recentHistory.isEmpty {
|
|
return false
|
|
}
|
|
case .downloads:
|
|
break
|
|
case .instanceContent(let instanceID, let contentType):
|
|
if hasInstanceContent(instanceID: instanceID, contentType: contentType) { return false }
|
|
case .mediaSource:
|
|
// Legacy, stripped on load and rendered as empty.
|
|
break
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// Whether an instance content section has something to render, matching the
|
|
/// conditions in `instanceContentSection(instanceID:contentType:)`.
|
|
private func hasInstanceContent(instanceID: UUID, contentType: InstanceContentType) -> Bool {
|
|
guard let instance = appEnvironment?.instancesManager.instances.first(where: { $0.id == instanceID }),
|
|
instance.isEnabled,
|
|
let videos = appEnvironment?.homeInstanceCache.videos(for: instanceID, contentType: contentType) else {
|
|
return false
|
|
}
|
|
return !videos.isEmpty
|
|
}
|
|
|
|
/// Whether any source is configured, read from the same managers the Sources
|
|
/// screen builds its list from. An empty Home is not the same thing as a
|
|
/// fresh install: with sources already added, telling the user to add one is
|
|
/// simply wrong.
|
|
private var hasConfiguredSources: Bool {
|
|
let hasInstances = !(appEnvironment?.instancesManager.instances.isEmpty ?? true)
|
|
let hasMediaSources = !(appEnvironment?.mediaSourcesManager.sources.isEmpty ?? true)
|
|
return hasInstances || hasMediaSources
|
|
}
|
|
|
|
private var emptyHomeView: some View {
|
|
VStack(spacing: 32) {
|
|
VStack(spacing: 12) {
|
|
if hasConfiguredSources {
|
|
// Sources are set up, Home simply has nothing to show yet.
|
|
Text(String(localized: "common.noContent"))
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
} else {
|
|
Text(String(localized: "home.tvos.empty.title"))
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
Text(String(localized: "home.tvos.empty.message"))
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
Button {
|
|
appEnvironment?.navigationCoordinator.selectedSidebarItem = .sources
|
|
} label: {
|
|
Label(String(localized: "home.tvos.empty.openSources"), systemImage: "externaldrive.connected.to.line.below")
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
.padding(60)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private var loadingHomeView: some View {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
#endif
|
|
|
|
@ViewBuilder
|
|
private var mainContent: some View {
|
|
#if os(tvOS)
|
|
if isHomeEmpty {
|
|
if hasLoadedHomeData {
|
|
emptyHomeView
|
|
} else {
|
|
loadingHomeView
|
|
}
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
homeContent
|
|
}
|
|
}
|
|
.scrollClipDisabled()
|
|
}
|
|
#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 !os(tvOS)
|
|
if hasVisibleShortcuts {
|
|
shortcutsSection
|
|
}
|
|
#endif
|
|
|
|
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 {
|
|
Group {
|
|
#if os(tvOS)
|
|
Text(title)
|
|
.fontWeight(.semibold)
|
|
#else
|
|
Button(action: action) {
|
|
HStack(spacing: 4) {
|
|
Text(title)
|
|
.fontWeight(.semibold)
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
}
|
|
.foregroundStyle(accentColor)
|
|
}
|
|
.buttonStyle(.plain)
|
|
#endif
|
|
}
|
|
.padding(.horizontal, listStyle == .inset ? 32 : 16)
|
|
.padding(.top, 16)
|
|
#if os(tvOS)
|
|
.padding(.bottom, 24)
|
|
#else
|
|
.padding(.bottom, 8)
|
|
#endif
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
// MARK: - Customize Button
|
|
|
|
#if !os(tvOS)
|
|
private var customizeButton: some View {
|
|
#if os(macOS)
|
|
HStack {
|
|
Spacer()
|
|
Button {
|
|
showingCustomizeHome = true
|
|
} label: {
|
|
Label(String(localized: "home.customize"), systemImage: "gear")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.regular)
|
|
Spacer()
|
|
}
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 32)
|
|
#else
|
|
Button {
|
|
showingCustomizeHome = true
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Spacer()
|
|
Image(systemName: "gear")
|
|
Text(String(localized: "home.customize"))
|
|
.fontWeight(.semibold)
|
|
Spacer()
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 32)
|
|
#endif
|
|
}
|
|
#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 = 16
|
|
#else
|
|
let gridSpacing: CGFloat = 8
|
|
#endif
|
|
|
|
let shortcuts = settingsManager?.visibleShortcuts() ?? HomeShortcutItem.defaultOrder
|
|
|
|
return LazyVGrid(columns: columns, spacing: gridSpacing) {
|
|
ForEach(Array(shortcuts.enumerated()), id: \.element.id) { index, shortcut in
|
|
shortcutCardView(for: shortcut)
|
|
.environment(\.homeShortcutColorfulColor, colorfulColor(atPosition: index))
|
|
#if !os(tvOS)
|
|
.contextMenu {
|
|
hideShortcutButton(for: shortcut)
|
|
editShortcutsButton
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
#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
|
|
}
|
|
|
|
/// Resolves the Regular-style fill color for a shortcut at the given grid
|
|
/// position from the selected palette (custom colors and accent color when
|
|
/// applicable).
|
|
private func colorfulColor(atPosition position: Int) -> Color {
|
|
let palette = settingsManager?.homeShortcutColorfulPalette ?? .accent
|
|
let customHex = settingsManager?.homeShortcutCustomPaletteColors ?? []
|
|
let accentColor = settingsManager?.resolvedAccentColor ?? .accentColor
|
|
return HomeShortcutColorfulPalette.color(forPosition: position, palette: palette, customHex: customHex, accentColor: accentColor)
|
|
}
|
|
|
|
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)
|
|
}
|
|
#if !os(tvOS)
|
|
.contextMenu {
|
|
hideShortcutButton(for: shortcut)
|
|
editShortcutsButton
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
/// Context menu action that hides the given shortcut from Home.
|
|
private func hideShortcutButton(for shortcut: HomeShortcutItem) -> some View {
|
|
Button {
|
|
guard let settingsManager else { return }
|
|
var visibility = settingsManager.homeShortcutVisibility
|
|
visibility[shortcut] = false
|
|
settingsManager.homeShortcutVisibility = visibility
|
|
} label: {
|
|
Label(String(localized: "home.hideShortcut"), systemImage: "eye.slash")
|
|
}
|
|
}
|
|
|
|
/// Context menu action that opens the Home settings sheet to reorder/toggle shortcuts.
|
|
private var editShortcutsButton: some View {
|
|
Button {
|
|
showingCustomizeHome = true
|
|
} label: {
|
|
Label(String(localized: "home.editShortcuts"), systemImage: "gear")
|
|
}
|
|
}
|
|
#endif
|
|
|
|
@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: "",
|
|
showsCount: false,
|
|
colorfulColor: HomeShortcutItem.openURL.cardColor
|
|
)
|
|
}
|
|
#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: "",
|
|
showsCount: false,
|
|
colorfulColor: HomeShortcutItem.remoteControl.cardColor,
|
|
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: playlistsCardCount,
|
|
subtitle: playlistsCardSubtitle,
|
|
colorfulColor: HomeShortcutItem.playlists.cardColor
|
|
)
|
|
}
|
|
#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"),
|
|
colorfulColor: HomeShortcutItem.bookmarks.cardColor
|
|
)
|
|
}
|
|
#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"),
|
|
colorfulColor: HomeShortcutItem.continueWatching.cardColor
|
|
)
|
|
}
|
|
#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: historyCardCount,
|
|
subtitle: historyCardSubtitle,
|
|
colorfulColor: HomeShortcutItem.history.cardColor
|
|
)
|
|
}
|
|
#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"),
|
|
colorfulColor: HomeShortcutItem.downloads.cardColor
|
|
)
|
|
}
|
|
.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"),
|
|
colorfulColor: HomeShortcutItem.channels.cardColor
|
|
)
|
|
}
|
|
#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"),
|
|
showsCount: false,
|
|
colorfulColor: HomeShortcutItem.subscriptions.cardColor
|
|
)
|
|
}
|
|
#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.enabledInstances.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",
|
|
colorfulColor: HomeShortcutItem.mediaSources.cardColor
|
|
)
|
|
}
|
|
#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,
|
|
showsCount: false,
|
|
colorfulColor: HomeShortcutItem.instanceContent(instanceID: instanceID, contentType: contentType).cardColor
|
|
)
|
|
}
|
|
#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,
|
|
showsCount: false,
|
|
colorfulColor: HomeShortcutItem.mediaSource(sourceID: sourceID).cardColor
|
|
)
|
|
}
|
|
#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: playlistsCardSubtitle
|
|
)
|
|
}
|
|
.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: historyCardSubtitle
|
|
)
|
|
}
|
|
.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.enabledInstances.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.
|
|
///
|
|
/// `lowerBound` marks a count the source could only see the start of — a
|
|
/// server history longer than one probe — and reads "500+ videos".
|
|
private func formatCount(_ count: Int, singular: String.LocalizationValue, plural: String.LocalizationValue, lowerBound: Bool = false) -> String {
|
|
let formattedCount = CountFormatter.compact(count) + (lowerBound ? "+" : "")
|
|
let key = count == 1 ? singular : plural
|
|
return String(localized: "\(formattedCount) \(String(localized: key))")
|
|
}
|
|
|
|
// MARK: - Context-Scoped Card Values
|
|
|
|
/// What the playlists card counts: the account's server-side list when one
|
|
/// is selected, the device's own otherwise.
|
|
private var playlistsCardCount: Int {
|
|
guard contextAccount != nil else { return playlists.count }
|
|
return remotePlaylists?.count ?? 0
|
|
}
|
|
|
|
/// An account list that has not come back yet says so rather than showing
|
|
/// a zero that is really "not asked yet".
|
|
private var playlistsCardSubtitle: String {
|
|
if contextAccount != nil, remotePlaylists == nil { return "—" }
|
|
return formatCount(playlistsCardCount, singular: "home.count.playlist", plural: "home.count.playlists")
|
|
}
|
|
|
|
private var historyCardCount: Int {
|
|
guard case .server = historyDisplayMode else { return historyCount }
|
|
return serverHistory?.count ?? 0
|
|
}
|
|
|
|
private var historyCardSubtitle: String {
|
|
guard case .server = historyDisplayMode else {
|
|
return formatCount(historyCount, singular: "home.count.video", plural: "home.count.videos")
|
|
}
|
|
// Still fetching, or the fetch failed — either way the local number
|
|
// is not this account's, so the card holds its tongue instead.
|
|
guard let count = serverHistory?.count else { return "—" }
|
|
return formatCount(
|
|
count,
|
|
singular: "home.count.video",
|
|
plural: "home.count.videos",
|
|
lowerBound: serverHistory?.countIsLowerBound == true
|
|
)
|
|
}
|
|
|
|
// 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 case .server = historyDisplayMode {
|
|
if let videos = serverHistory?.videos, !videos.isEmpty {
|
|
serverHistorySection(videos: videos)
|
|
}
|
|
} else 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:
|
|
// Media sources are shortcuts-only; any legacy media-source section is
|
|
// stripped by removeAllHomeMediaSourceSections() on load, so this is
|
|
// unreachable in practice. Rendered as empty for defense-in-depth.
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
if sectionLayout == .grid {
|
|
HomeHorizontalCards(
|
|
videos: videoList,
|
|
queueSource: continueWatchingQueueSource,
|
|
sourceLabel: String(localized: "queue.source.continueWatching"),
|
|
loadMoreVideos: loadMoreContinueWatchingCallback
|
|
)
|
|
} else {
|
|
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)
|
|
}
|
|
|
|
if sectionLayout == .grid {
|
|
HomeHorizontalCards(
|
|
videos: limitedVideos,
|
|
queueSource: feedQueueSource,
|
|
sourceLabel: String(localized: "queue.source.subscriptions"),
|
|
loadMoreVideos: loadMoreFeedCallback
|
|
)
|
|
} else {
|
|
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)
|
|
}
|
|
|
|
if sectionLayout == .grid {
|
|
HomeHorizontalCards(
|
|
videos: videoList,
|
|
queueSource: recentBookmarksQueueSource,
|
|
sourceLabel: String(localized: "queue.source.bookmarks"),
|
|
loadMoreVideos: loadMoreRecentBookmarksCallback
|
|
)
|
|
} else {
|
|
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)
|
|
}
|
|
|
|
if sectionLayout == .grid {
|
|
HomeHorizontalCards(
|
|
videos: videoList,
|
|
queueSource: recentHistoryQueueSource,
|
|
sourceLabel: String(localized: "queue.source.history"),
|
|
loadMoreVideos: loadMoreRecentHistoryCallback
|
|
)
|
|
} else {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The account's recent server-side history, already hydrated and limited.
|
|
///
|
|
/// Plain video rows, no swipe delete: a server delete belongs to the full
|
|
/// history screen, which owns the confirmation and the error for it.
|
|
private func serverHistorySection(videos: [Video]) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
sectionHeader(title: "home.recentHistory.title") {
|
|
appEnvironment?.navigationCoordinator.navigate(to: .history)
|
|
}
|
|
|
|
if sectionLayout == .grid {
|
|
HomeHorizontalCards(
|
|
videos: videos,
|
|
queueSource: recentHistoryQueueSource,
|
|
sourceLabel: String(localized: "queue.source.history"),
|
|
loadMoreVideos: loadMoreRecentHistoryCallback
|
|
)
|
|
} else {
|
|
VideoListContent(listStyle: listStyle) {
|
|
ForEach(Array(videos.enumerated()), id: \.element.id) { index, video in
|
|
VideoListRow(
|
|
isLast: index == videos.count - 1,
|
|
rowStyle: .regular,
|
|
listStyle: listStyle
|
|
) {
|
|
VideoRowView(video: video, style: .regular)
|
|
.tappableVideo(
|
|
video,
|
|
queueSource: recentHistoryQueueSource,
|
|
sourceLabel: String(localized: "queue.source.history"),
|
|
videoList: videos,
|
|
videoIndex: index,
|
|
loadMoreVideos: loadMoreRecentHistoryCallback
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#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 downloadsDir = downloadManager?.downloadsDirectory()
|
|
let videoList = limitedDownloads.map { $0.toVideo(downloadsDirectory: downloadsDir) }
|
|
|
|
return VStack(alignment: .leading, spacing: 0) {
|
|
sectionHeader(title: "home.recentDownloads.title") {
|
|
appEnvironment?.navigationCoordinator.navigate(to: .downloads)
|
|
}
|
|
|
|
if sectionLayout == .grid {
|
|
HomeHorizontalCards(
|
|
videos: videoList,
|
|
queueSource: .manual,
|
|
sourceLabel: String(localized: "queue.source.downloads"),
|
|
loadMoreVideos: loadMoreRecentDownloadsCallback
|
|
)
|
|
} else {
|
|
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,
|
|
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) {
|
|
Group {
|
|
#if os(tvOS)
|
|
Text(verbatim: "\(contentType.localizedTitle) - \(instance.displayName)")
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(accentColor)
|
|
#else
|
|
Button {
|
|
appEnvironment?.navigationCoordinator.navigate(
|
|
to: .instanceBrowse(instance, initialTab: contentType.toBrowseTab())
|
|
)
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Text(verbatim: "\(contentType.localizedTitle) - \(instance.displayName)")
|
|
.fontWeight(.semibold)
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
}
|
|
.foregroundStyle(accentColor)
|
|
}
|
|
.buttonStyle(.plain)
|
|
#endif
|
|
}
|
|
.padding(.horizontal, listStyle == .inset ? 32 : 16)
|
|
.padding(.top, 16)
|
|
#if os(tvOS)
|
|
.padding(.bottom, 24)
|
|
#else
|
|
.padding(.bottom, 8)
|
|
#endif
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
if sectionLayout == .grid {
|
|
HomeHorizontalCards(
|
|
videos: limitedVideos,
|
|
queueSource: instanceQueueSource(instanceID: instanceID, contentType: contentType),
|
|
sourceLabel: contentType.localizedTitle,
|
|
loadMoreVideos: loadMoreInstanceContentCallback
|
|
)
|
|
} else {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Data Loading
|
|
|
|
private func loadData() {
|
|
loadPlaylistsData()
|
|
loadBookmarksData()
|
|
loadContinueWatchingData()
|
|
loadHistoryData()
|
|
loadChannelsData()
|
|
loadRemoteDevicesData()
|
|
cleanupOrphanedHomeInstanceItems()
|
|
cleanupOrphanedHomeMediaSourceItems()
|
|
// Media sources are shortcuts-only; drop any legacy media-source sections.
|
|
appEnvironment?.settingsManager.removeAllHomeMediaSourceSections()
|
|
}
|
|
|
|
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() ?? []
|
|
refreshRemotePlaylistsIfNeeded()
|
|
}
|
|
|
|
/// Asks for the account's playlist list the first time this context needs
|
|
/// it. Reads come off the cache live, so the count appears when the fetch
|
|
/// lands and follows every mutation after — this only has to make sure the
|
|
/// first fetch happens even when the playlists screen was never opened.
|
|
private func refreshRemotePlaylistsIfNeeded() {
|
|
guard let appEnvironment, let account = contextAccount,
|
|
appEnvironment.remotePlaylistsCache.playlists(for: account.id) == nil
|
|
else { return }
|
|
|
|
Task {
|
|
// The cache fences by account generation and orders results
|
|
// itself; a failure simply leaves the count reading "—", and the
|
|
// playlists screen owns showing the error.
|
|
try? await appEnvironment.remotePlaylistsCache.refreshList(for: account)
|
|
}
|
|
}
|
|
|
|
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.isLive && $0.watchedSeconds > 10 }
|
|
continueWatchingCount = recentContinueWatching.count
|
|
}
|
|
|
|
private func loadHistoryData() {
|
|
loadLocalHistoryData()
|
|
loadServerHistoryIfNeeded()
|
|
}
|
|
|
|
/// The device's own history alone — what a local write changes. The
|
|
/// account's is the server's to change, and announced separately.
|
|
private func loadLocalHistoryData() {
|
|
historyCount = dataManager?.watchHistoryCount() ?? 0
|
|
recentHistory = dataManager?.watchHistory(limit: 50) ?? []
|
|
}
|
|
|
|
/// One probe of the account's server history: the count for the card, the
|
|
/// leading rows for the section.
|
|
private func loadServerHistoryIfNeeded() {
|
|
serverHistoryGeneration += 1
|
|
let generation = serverHistoryGeneration
|
|
|
|
guard case let .server(account) = historyDisplayMode, let appEnvironment else {
|
|
serverHistory = nil
|
|
return
|
|
}
|
|
|
|
let limit = sectionItemsLimit
|
|
|
|
Task {
|
|
do {
|
|
let ids = try await appEnvironment.accountService.watchHistory(
|
|
page: 1,
|
|
maxResults: Self.serverHistoryProbeSize,
|
|
for: account
|
|
)
|
|
guard generation == serverHistoryGeneration else { return }
|
|
|
|
// The server re-bumps a re-watch rather than duplicating it,
|
|
// but the rows key a ForEach — deduped rather than trusted.
|
|
var seen = Set<String>()
|
|
let uniqueIDs = ids.filter { seen.insert($0).inserted }
|
|
|
|
let videos = await hydrateServerHistory(
|
|
ids: Array(uniqueIDs.prefix(limit)),
|
|
account: account,
|
|
using: appEnvironment
|
|
)
|
|
guard generation == serverHistoryGeneration else { return }
|
|
|
|
serverHistory = ServerHistorySnapshot(
|
|
count: uniqueIDs.count,
|
|
countIsLowerBound: ids.count >= Self.serverHistoryProbeSize,
|
|
videos: videos
|
|
)
|
|
} catch {
|
|
guard generation == serverHistoryGeneration else { return }
|
|
// Failed is not local: the card shows "—" and no section,
|
|
// never the device's numbers under an account's name.
|
|
serverHistory = ServerHistorySnapshot(count: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rows for the leading server-history IDs, from local data where there is
|
|
/// some — a video watched on this device is already known in full — and a
|
|
/// metadata fetch for the rest. Bounded by the section limit, so this is a
|
|
/// handful of requests at most.
|
|
///
|
|
/// A fetch that fails leaves the row as its ID placeholder rather than
|
|
/// dropping it: the account watched it, and a history missing rows it
|
|
/// cannot describe is a wrong history.
|
|
private func hydrateServerHistory(
|
|
ids: [String],
|
|
account: Account,
|
|
using appEnvironment: AppEnvironment
|
|
) async -> [Video] {
|
|
guard let instance = appEnvironment.accountsManager.instance(for: account) else {
|
|
return ids.map { Video.metadataPlaceholder(for: $0) }
|
|
}
|
|
|
|
let known: [String: Video] = Dictionary(
|
|
uniqueKeysWithValues: ids.compactMap { id in
|
|
dataManager?.watchEntry(for: id).map { (id, $0.toVideo()) }
|
|
}
|
|
)
|
|
|
|
return await withTaskGroup(of: (Int, Video).self) { group in
|
|
for (index, id) in ids.enumerated() {
|
|
group.addTask { @MainActor in
|
|
if let video = known[id] { return (index, video) }
|
|
let fetched = try? await appEnvironment.contentService.video(id: id, instance: instance)
|
|
return (index, fetched ?? .metadataPlaceholder(for: id))
|
|
}
|
|
}
|
|
|
|
var videos = [Video?](repeating: nil, count: ids.count)
|
|
for await (index, video) in group {
|
|
videos[index] = video
|
|
}
|
|
return videos.compactMap { $0 }
|
|
}
|
|
}
|
|
|
|
private func loadChannelsData() {
|
|
guard let service = appEnvironment?.subscriptionService else {
|
|
channelsCount = 0
|
|
return
|
|
}
|
|
|
|
// Local accounts always have a count; server accounts only after their
|
|
// in-memory cache is populated — fetch it once in the background otherwise.
|
|
if let cached = service.cachedSubscriptionCount {
|
|
channelsCount = cached
|
|
} else {
|
|
// Captured so a fetch that outlives an account switch cannot land
|
|
// its count on whichever context is selected by the time it returns.
|
|
let requestContext = appEnvironment?.dataContextValidator.effectiveContext
|
|
Task {
|
|
if let channels = try? await service.fetchSubscriptions(),
|
|
requestContext == appEnvironment?.dataContextValidator.effectiveContext {
|
|
channelsCount = channels.count
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Refills the feed section after an account switch emptied its cache.
|
|
///
|
|
/// Only when Home actually shows the feed: without the section on screen
|
|
/// there is nothing to refill, and the Subscriptions tab refreshes for
|
|
/// itself when opened. The validity/loading gate is the same one
|
|
/// SubscriptionsView uses, so a switch that kept the cache (a status flap
|
|
/// fencing rather than clearing) does not refetch what is already there.
|
|
private func refreshFeedIfNeeded() {
|
|
guard let appEnvironment else { return }
|
|
|
|
let sections = settingsManager?.visibleSections() ?? []
|
|
guard sections.contains(.feed) else { return }
|
|
guard !feedCache.isCacheValid(using: settingsManager), !feedCache.isLoading else { return }
|
|
|
|
Task {
|
|
await feedCache.refresh(using: appEnvironment)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|