mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 17:59:45 +00:00
1305 lines
47 KiB
Swift
1305 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(String(localized: "settings.title"))
|
|
.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(verbatim: "\(contentType.localizedTitle) - \(instance.displayName)")
|
|
.fontWeight(.semibold)
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
}
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.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("mediaSources.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)
|
|
}
|