Files
yattee/Yattee/Views/Instances/InstanceBrowseView.swift
Arkadiusz Fal 281f5e0f13 Hide tvOS search type/filters until query entered
Match iOS behavior by gating the type switcher and filters menus on an
active query, and drop the .caption font so they render with the same
default button font as View Options.
2026-04-18 20:38:01 +02:00

1323 lines
52 KiB
Swift

//
// InstanceBrowseView.swift
// Yattee
//
// Displays content (Popular/Trending) for a specific backend instance.
//
import SwiftUI
struct InstanceBrowseView: View {
@Environment(\.appEnvironment) private var appEnvironment
let instance: Instance
let initialTab: BrowseTab?
@Namespace private var sheetTransition
@State private var selectedTab: BrowseTab = .popular
@State private var popularVideos: [Video] = []
@State private var trendingVideos: [Video] = []
@State private var isLoading = true
@State private var errorMessage: String?
// Search state (managed by SearchViewModel)
@State private var searchText = ""
@State private var searchViewModel: SearchViewModel?
@State private var showFilterSheet = false
// Feed state (for Invidious/Piped login)
@State private var feedVideos: [Video] = []
@State private var feedSubscriptions: [Channel] = []
// Playlists state (for Invidious login)
@State private var userPlaylists: [Playlist] = []
@State private var selectedFeedChannelID: String?
@State private var isLoggedIn = false
@State private var feedPage = 1
@State private var hasMoreFeedResults = true
@State private var isLoadingMoreFeed = false
@State private var contentLoadTask: Task<Void, Never>?
@State private var feedLoadedVideoCount = 0 // Track count when last load was triggered
// View options (persisted per instance)
@AppStorage var layout: VideoListLayout
@AppStorage var rowStyle: VideoRowStyle
@AppStorage var gridColumnCount: Int
@AppStorage var hideWatched: Bool
// View options UI state
@State private var showViewOptions = false
@State private var viewWidth: CGFloat = 0
@State private var watchEntriesMap: [String: WatchEntry] = [:]
@State private var hasInitializedTab = false
init(instance: Instance, initialTab: BrowseTab? = nil) {
self.instance = instance
self.initialTab = initialTab
// Initialize AppStorage with instance-scoped keys
_layout = AppStorage(wrappedValue: .list, "instanceBrowse.\(instance.id).layout")
_rowStyle = AppStorage(wrappedValue: .regular, "instanceBrowse.\(instance.id).rowStyle")
_gridColumnCount = AppStorage(wrappedValue: 2, "instanceBrowse.\(instance.id).gridColumns")
_hideWatched = AppStorage(wrappedValue: false, "instanceBrowse.\(instance.id).hideWatched")
}
/// List style from centralized settings.
private var listStyle: VideoListStyle {
appEnvironment?.settingsManager.listStyle ?? .inset
}
/// The first enabled Yattee Server instance (for avatar URLs).
private var yatteeServer: Instance? {
appEnvironment?.instancesManager.enabledYatteeServerInstances.first
}
private var yatteeServerURL: URL? { yatteeServer?.url }
/// Auth header for Yattee Server instances (when browsing a Yattee Server directly)
private var yatteeServerAuthHeader: String? {
guard instance.type == .yatteeServer else { return nil }
return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: instance)
}
/// Auth header for avatar loading (uses Yattee Server for YouTube channel avatars)
private var avatarAuthHeader: String? {
guard let server = yatteeServer else { return nil }
return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server)
}
enum BrowseTab: String, CaseIterable, Identifiable {
case popular
case trending
case feed
case playlists
var id: String { rawValue }
var title: String {
switch self {
case .popular: return String(localized: "popular.title")
case .trending: return String(localized: "trending.title")
case .feed: return String(localized: "feed.title")
case .playlists: return String(localized: "playlists.title")
}
}
var systemImage: String {
switch self {
case .popular: return "flame"
case .trending: return "chart.line.uptrend.xyaxis"
case .feed: return "person.crop.rectangle.stack"
case .playlists: return "play.square.stack"
}
}
}
var body: some View {
let backgroundStyle: ListBackgroundStyle = listStyle == .inset ? .grouped : .plain
GeometryReader { geometry in
#if os(tvOS)
VStack(spacing: 0) {
// tvOS: Search field, type filter, search filters, view options
HStack(spacing: 24) {
TextField("instance.browse.search.placeholder", text: $searchText)
.textFieldStyle(.plain)
.onSubmit {
searchViewModel?.cancelSuggestions()
Task { await searchViewModel?.search(query: searchText) }
}
if isInSearchMode {
// Type filter
filterMenu(
title: (searchViewModel?.filters.type ?? .video).title,
selection: Binding(
get: { searchViewModel?.filters.type ?? .video },
set: { searchViewModel?.filters.type = $0 }
),
options: SearchContentType.allCases,
labelForOption: { $0.title }
)
// Combined search filters menu
tvOSFiltersMenu
}
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
}
.focusSection()
.padding(.horizontal, 48)
.padding(.top, 20)
// Content
ScrollView {
VStack(spacing: 0) {
Spacer()
.frame(height: 20)
// Tab picker (hidden during search)
if !isInSearchMode {
Picker("", selection: $selectedTab) {
ForEach(availableTabs) { tab in
Label(tab.title, systemImage: tab.systemImage)
.tag(tab)
}
}
.pickerStyle(.segmented)
.padding()
}
// Feed channel filter strip (hidden during search)
if selectedTab == .feed && !feedSubscriptions.isEmpty && !isInSearchMode {
feedChannelFilterStrip
}
// Content
Group {
if isInSearchMode, let vm = searchViewModel {
if !vm.hasSearched {
if vm.suggestions.isEmpty {
searchHintView
} else {
suggestionsView
}
} else if vm.isSearching && !vm.hasResults {
ProgressView()
.frame(maxWidth: .infinity, minHeight: 200)
} else if let error = vm.errorMessage, !vm.hasResults {
searchErrorView(error)
} else if vm.hasResults {
searchResultsContent
} else {
searchEmptyView
}
} else if selectedTab == .playlists {
if isLoading && userPlaylists.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, minHeight: 200)
} else if let error = errorMessage, userPlaylists.isEmpty {
errorView(error)
} else if !userPlaylists.isEmpty {
switch layout {
case .list:
playlistsListContent
case .grid:
playlistsGridContent
}
} else {
playlistsEmptyView
}
} else if isLoading && currentVideos.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, minHeight: 200)
} else if let error = errorMessage, currentVideos.isEmpty {
errorView(error)
} else if !currentVideos.isEmpty {
switch layout {
case .list:
listContent
case .grid:
gridContent
}
} else {
emptyView
}
}
}
}
.refreshable {
await startContentLoad(forceRefresh: true)
}
.focusSection()
}
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
#else
backgroundStyle.color
.ignoresSafeArea()
.overlay(
ScrollView {
VStack(spacing: 0) {
// Tab picker (hidden during search)
if !isInSearchMode {
Picker("", selection: $selectedTab) {
ForEach(availableTabs) { tab in
Label(tab.title, systemImage: tab.systemImage)
.tag(tab)
}
}
.pickerStyle(.segmented)
.padding()
}
// Feed channel filter strip (hidden during search)
if selectedTab == .feed && !feedSubscriptions.isEmpty && !isInSearchMode {
feedChannelFilterStrip
}
// Search filter strip (shown persistently after search submitted)
if isInSearchMode && (searchViewModel?.hasSearched ?? false) && instance.supportsSearchFilters {
searchFiltersStrip
}
// Content
Group {
if isInSearchMode, let vm = searchViewModel {
// Search mode content
if !vm.hasSearched {
// Typing but not yet submitted - show suggestions or hint
if vm.suggestions.isEmpty {
searchHintView
} else {
suggestionsView
}
} else if vm.isSearching && !vm.hasResults {
ProgressView()
.frame(maxWidth: .infinity, minHeight: 200)
} else if let error = vm.errorMessage, !vm.hasResults {
searchErrorView(error)
} else if vm.hasResults {
searchResultsContent
} else {
searchEmptyView
}
} else if selectedTab == .playlists {
// Playlists tab content
if isLoading && userPlaylists.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, minHeight: 200)
} else if let error = errorMessage, userPlaylists.isEmpty {
errorView(error)
} else if !userPlaylists.isEmpty {
switch layout {
case .list:
playlistsListContent
case .grid:
playlistsGridContent
}
} else {
playlistsEmptyView
}
} else if isLoading && currentVideos.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, minHeight: 200)
} else if let error = errorMessage, currentVideos.isEmpty {
errorView(error)
} else if !currentVideos.isEmpty {
// Conditional layout based on user preference
switch layout {
case .list:
listContent
case .grid:
gridContent
}
} else {
emptyView
}
}
}
}
.refreshable {
await startContentLoad(forceRefresh: true)
}
)
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
#endif
}
#if !os(tvOS)
.navigationTitle(instance.displayName)
.toolbarTitleDisplayMode(.inlineLarge)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
.liquidGlassTransitionSource(id: "instanceBrowseViewOptions", in: sheetTransition)
}
}
#endif
.sheet(isPresented: $showViewOptions) {
ViewOptionsSheet(
layout: $layout,
rowStyle: $rowStyle,
gridColumns: $gridColumnCount,
hideWatched: $hideWatched,
maxGridColumns: gridConfig.maxColumns
)
.liquidGlassSheetContent(sourceID: "instanceBrowseViewOptions", in: sheetTransition)
}
.task {
// Initialize search view model
if let appEnvironment {
searchViewModel = SearchViewModel(
instance: instance,
contentService: appEnvironment.contentService,
deArrowProvider: appEnvironment.deArrowBrandingProvider,
dataManager: appEnvironment.dataManager,
settingsManager: appEnvironment.settingsManager
)
}
// Check login status for instances that support authentication
if instance.supportsAuthentication {
isLoggedIn = appEnvironment?.credentialsManager(for: instance)?.isLoggedIn(for: instance) ?? false
}
// Set initial tab only once (not on navigation back)
if !hasInitializedTab {
hasInitializedTab = true
if let initialTab {
selectedTab = initialTab
} else if instance.supportsAuthentication && isLoggedIn {
// Default to Feed tab when logged in
selectedTab = .feed
}
}
// Load watch entries for hide watched feature
loadWatchEntries()
await startContentLoad()
}
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
loadWatchEntries()
}
.onChange(of: selectedTab) { _, _ in
isLoading = true
errorMessage = nil
Task { await startContentLoad() }
}
#if os(iOS)
.searchable(
text: $searchText,
placement: .navigationBarDrawer(displayMode: .automatic),
prompt: Text(String(localized: "instance.browse.search.placeholder"))
)
.onSubmit(of: .search) {
searchViewModel?.cancelSuggestions()
Task {
await searchViewModel?.search(query: searchText)
}
}
#elseif !os(tvOS)
.searchable(
text: $searchText,
prompt: Text(String(localized: "instance.browse.search.placeholder"))
)
.onSubmit(of: .search) {
searchViewModel?.cancelSuggestions()
Task {
await searchViewModel?.search(query: searchText)
}
}
#endif
.onChange(of: searchText) { _, newValue in
if newValue.isEmpty {
searchViewModel?.clearResults()
} else {
searchViewModel?.fetchSuggestions(for: newValue)
}
}
.sheet(isPresented: $showFilterSheet) {
SearchFiltersSheet(onApply: {
Task {
await searchViewModel?.search(query: searchText)
}
}, filters: Binding(
get: { searchViewModel?.filters ?? .defaults },
set: { searchViewModel?.filters = $0 }
))
#if !os(tvOS)
.presentationDetents([.medium, .large])
#endif
}
}
// MARK: - Computed Properties
private var availableTabs: [BrowseTab] {
if instance.supportsFeed && isLoggedIn {
// Playlists tab only available for Invidious (Piped playlists to be added in future)
if instance.type == .invidious {
return [.feed, .popular, .trending, .playlists]
}
return [.feed, .popular, .trending]
}
return [.popular, .trending]
}
private var currentVideos: [Video] {
var videos: [Video]
switch selectedTab {
case .popular: videos = popularVideos
case .trending: videos = trendingVideos
case .feed: videos = filteredFeedVideos
case .playlists: videos = [] // Playlists tab doesn't show videos directly
}
// Filter out watched videos if enabled
if hideWatched {
videos = videos.filter { video in
guard let entry = watchEntriesMap[video.id.videoID] else { return true }
return !entry.isFinished
}
}
return videos
}
private var filteredFeedVideos: [Video] {
if let channelID = selectedFeedChannelID {
return feedVideos.filter { $0.author.id == channelID }
}
return feedVideos
}
private var isInSearchMode: Bool {
!searchText.trimmingCharacters(in: .whitespaces).isEmpty
}
// Grid layout configuration
private var gridConfig: GridLayoutConfiguration {
GridLayoutConfiguration(viewWidth: viewWidth, gridColumns: gridColumnCount)
}
/// Gets the watch progress (0.0-1.0) for a video, or nil if not watched/finished.
private func watchProgress(for video: Video) -> Double? {
guard let entry = watchEntriesMap[video.id.videoID] else { return nil }
let progress = entry.progress
// Only show progress bar for partially watched videos
return progress > 0 && progress < 1 ? progress : nil
}
/// Subscriptions sorted by most recent video upload date
private var sortedFeedSubscriptions: [Channel] {
var latestVideoDate: [String: Date] = [:]
for video in feedVideos {
let channelID = video.author.id
let videoDate = video.publishedAt ?? .distantPast
if let existing = latestVideoDate[channelID] {
if videoDate > existing {
latestVideoDate[channelID] = videoDate
}
} else {
latestVideoDate[channelID] = videoDate
}
}
return feedSubscriptions.sorted { (sub1: Channel, sub2: Channel) -> Bool in
let channelID1 = sub1.id.channelID
let channelID2 = sub2.id.channelID
let date1 = latestVideoDate[channelID1] ?? .distantPast
let date2 = latestVideoDate[channelID2] ?? .distantPast
return date1 > date2
}
}
// MARK: - Search Filters Strip
#if os(tvOS)
private func filterMenu<T: Hashable & Identifiable & CaseIterable>(
title: String,
selection: Binding<T>,
options: [T],
labelForOption: @escaping (T) -> String
) -> some View {
Menu {
ForEach(options) { option in
Button {
selection.wrappedValue = option
Task { await searchViewModel?.search(query: searchText) }
} label: {
if option == selection.wrappedValue {
Label(labelForOption(option), systemImage: "checkmark")
} else {
Text(labelForOption(option))
}
}
}
} label: {
Text(title)
}
}
private var tvOSFiltersMenu: some View {
Menu {
Menu(String(localized: "search.sort")) {
ForEach(SearchSortOption.allCases) { option in
Button {
searchViewModel?.filters.sort = option
Task { await searchViewModel?.search(query: searchText) }
} label: {
if searchViewModel?.filters.sort == option {
Label(option.title, systemImage: "checkmark")
} else {
Text(option.title)
}
}
}
}
Menu(String(localized: "search.uploadDate")) {
ForEach(SearchDateFilter.allCases) { option in
Button {
searchViewModel?.filters.date = option
Task { await searchViewModel?.search(query: searchText) }
} label: {
if searchViewModel?.filters.date == option {
Label(option.title, systemImage: "checkmark")
} else {
Text(option.title)
}
}
}
}
Menu(String(localized: "search.duration")) {
ForEach(SearchDurationFilter.allCases) { option in
Button {
searchViewModel?.filters.duration = option
Task { await searchViewModel?.search(query: searchText) }
} label: {
if searchViewModel?.filters.duration == option {
Label(option.title, systemImage: "checkmark")
} else {
Text(option.title)
}
}
}
}
Divider()
Button(role: .destructive) {
let currentType = searchViewModel?.filters.type ?? .video
searchViewModel?.filters = .defaults
searchViewModel?.filters.type = currentType
Task { await searchViewModel?.search(query: searchText) }
} label: {
Label(String(localized: "search.filters.reset"), systemImage: "arrow.counterclockwise")
}
.disabled(searchViewModel?.filters.isDefault ?? true)
} label: {
Label(String(localized: "search.filters"), systemImage: "line.3.horizontal.decrease")
}
}
#else
private var searchFiltersStrip: some View {
HStack(spacing: 12) {
Button {
showFilterSheet = true
} label: {
Image(systemName: (searchViewModel?.filters.isDefault ?? true)
? "line.3.horizontal.decrease.circle"
: "line.3.horizontal.decrease.circle.fill")
.font(.title2)
}
Picker("", selection: Binding(
get: { searchViewModel?.filters.type ?? .video },
set: { searchViewModel?.filters.type = $0 }
)) {
ForEach(SearchContentType.allCases) { type in
Text(type.title).tag(type)
}
}
.pickerStyle(.segmented)
}
.padding(.horizontal)
.padding(.vertical, 8)
.onChange(of: searchViewModel?.filters.type) { _, _ in
Task {
await searchViewModel?.search(query: searchText)
}
}
}
#endif
// MARK: - Feed Channel Filter Strip
private var feedChannelFilterStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 10) {
ForEach(sortedFeedSubscriptions) { subscription in
ChannelFilterChip(
channelID: subscription.id.channelID,
name: subscription.name,
avatarURL: subscription.thumbnailURL,
serverURL: yatteeServerURL,
isSelected: selectedFeedChannelID == subscription.id.channelID,
avatarSize: 44,
onTap: {
if selectedFeedChannelID == subscription.id.channelID {
selectedFeedChannelID = nil
} else {
selectedFeedChannelID = subscription.id.channelID
}
},
onGoToChannel: {
appEnvironment?.navigationCoordinator.navigate(
to: .channel(subscription.id.channelID, .global(provider: ContentSource.youtubeProvider))
)
},
onUnsubscribe: nil,
authHeader: avatarAuthHeader
)
}
}
.padding(.horizontal)
.padding(.vertical, 12)
}
#if os(tvOS)
.background(Color.black.opacity(0.3))
#else
.background(.bar)
.clipShape(RoundedRectangle(cornerRadius: 20))
.padding(.horizontal, 16)
#endif
}
// MARK: - List Layout
@ViewBuilder
private var listContent: some View {
VideoListContent(listStyle: listStyle) {
ForEach(Array(currentVideos.enumerated()), id: \.element.id) { index, video in
VideoListRow(
isLast: index == currentVideos.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
VideoRowView(
video: video,
style: rowStyle,
watchProgress: watchProgress(for: video)
)
.tappableVideo(
video,
queueSource: selectedTab == .feed ? .subscriptions(continuation: nil) : .manual,
sourceLabel: selectedTab.title,
videoList: currentVideos,
videoIndex: index
)
}
#if !os(tvOS)
.videoSwipeActions(video: video)
#endif
}
// Feed tab load more
if selectedTab == .feed {
LoadMoreTrigger(
isLoading: isLoadingMoreFeed,
hasMore: hasMoreFeedResults && feedVideos.count > feedLoadedVideoCount
) {
loadMoreFeedResults()
}
}
}
}
// MARK: - Grid Layout
@ViewBuilder
private var gridContent: some View {
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(Array(currentVideos.enumerated()), id: \.element.id) { index, video in
VideoCardView(
video: video,
watchProgress: watchProgress(for: video),
isCompact: gridConfig.isCompactCards
)
.tappableVideo(
video,
queueSource: selectedTab == .feed ? .subscriptions(continuation: nil) : .manual,
sourceLabel: selectedTab.title,
videoList: currentVideos,
videoIndex: index
)
.onAppear {
// Infinite scroll for feed tab - trigger when near end AND we have new content since last trigger
if selectedTab == .feed
&& index >= currentVideos.count - 3
&& hasMoreFeedResults
&& !isLoadingMoreFeed
&& feedVideos.count > feedLoadedVideoCount {
loadMoreFeedResults()
}
}
}
}
if selectedTab == .feed && isLoadingMoreFeed {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
}
// MARK: - Playlists Layout
@ViewBuilder
private var playlistsListContent: some View {
VideoListContent(listStyle: listStyle) {
ForEach(Array(userPlaylists.enumerated()), id: \.element.id) { index, playlist in
VideoListRow(
isLast: index == userPlaylists.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
NavigationLink(value: NavigationDestination.playlist(.remote(playlist.id, instance: instance, title: playlist.title))) {
SearchPlaylistRowView(playlist: playlist, style: rowStyle)
.contentShape(Rectangle())
}
.zoomTransitionSource(id: playlist.id)
.buttonStyle(.plain)
}
}
}
}
@ViewBuilder
private var playlistsGridContent: some View {
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(userPlaylists) { playlist in
NavigationLink(value: NavigationDestination.playlist(.remote(playlist.id, instance: instance, title: playlist.title))) {
PlaylistCardView(playlist: playlist, isCompact: gridConfig.isCompactCards)
.contentShape(Rectangle())
}
.zoomTransitionSource(id: playlist.id)
.buttonStyle(.plain)
}
}
}
private var playlistsEmptyView: some View {
ContentUnavailableView {
Label(String(localized: "playlists.empty.title"), systemImage: "play.square.stack")
} description: {
Text(String(localized: "playlists.empty.description"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Empty View
private var emptyView: some View {
ContentUnavailableView {
Label(String(localized: "common.noContent"), systemImage: "tray")
} description: {
Text(String(localized: "instance.browse.noVideos"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Error View
private func errorView(_ error: String) -> some View {
ContentUnavailableView {
Label(String(localized: "common.error"), systemImage: "exclamationmark.triangle")
} description: {
Text(error)
} actions: {
Button(String(localized: "common.retry")) {
Task { await startContentLoad(forceRefresh: true) }
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Search Results
@ViewBuilder
private var searchResultsContent: some View {
if let vm = searchViewModel {
switch layout {
case .list:
searchResultsListContent(vm: vm)
case .grid:
searchResultsGridContent(vm: vm)
}
}
}
@ViewBuilder
private func searchResultsListContent(vm: SearchViewModel) -> some View {
VideoListContent(listStyle: listStyle) {
ForEach(Array(vm.resultItems.enumerated()), id: \.element.id) { resultIndex, item in
VideoListRow(
isLast: resultIndex == vm.resultItems.count - 1,
rowStyle: rowStyle,
listStyle: listStyle,
contentWidth: item.isChannel ? rowStyle.thumbnailHeight : nil
) {
switch item {
case .video(let video, let videoIndex):
VideoRowView(
video: video,
style: rowStyle,
watchProgress: watchProgress(for: video)
)
.tappableVideo(
video,
queueSource: .search(query: searchText, continuation: nil),
sourceLabel: String(localized: "queue.source.search \(searchText)"),
videoList: vm.videos,
videoIndex: videoIndex
)
#if !os(tvOS)
.videoSwipeActions(video: video)
#endif
.onAppear {
if resultIndex >= vm.resultItems.count - 3 {
Task { await vm.loadMore() }
}
}
case .playlist(let playlist):
NavigationLink(value: NavigationDestination.playlist(.remote(playlist.id, instance: instance, title: playlist.title))) {
SearchPlaylistRowView(playlist: playlist, style: rowStyle)
.contentShape(Rectangle())
}
.zoomTransitionSource(id: playlist.id)
.buttonStyle(.plain)
.onAppear {
if resultIndex >= vm.resultItems.count - 3 {
Task { await vm.loadMore() }
}
}
case .channel(let channel):
NavigationLink(
value: NavigationDestination.channel(
channel.id.channelID,
channel.id.source
)
) {
ChannelRowView(channel: channel, style: rowStyle)
.contentShape(Rectangle())
}
.zoomTransitionSource(id: channel.id.channelID)
.buttonStyle(.plain)
.onAppear {
if resultIndex >= vm.resultItems.count - 3 {
Task { await vm.loadMore() }
}
}
}
}
}
LoadMoreTrigger(
isLoading: vm.isLoadingMore,
hasMore: vm.hasMoreResults
) {
Task { await vm.loadMore() }
}
}
}
@ViewBuilder
private func searchResultsGridContent(vm: SearchViewModel) -> some View {
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(Array(vm.resultItems.enumerated()), id: \.element.id) { resultIndex, item in
switch item {
case .video(let video, let videoIndex):
VideoCardView(
video: video,
watchProgress: watchProgress(for: video),
isCompact: gridConfig.isCompactCards
)
.tappableVideo(
video,
queueSource: .search(query: searchText, continuation: nil),
sourceLabel: String(localized: "queue.source.search \(searchText)"),
videoList: vm.videos,
videoIndex: videoIndex
)
.onAppear {
if resultIndex >= vm.resultItems.count - 3 {
Task { await vm.loadMore() }
}
}
case .playlist(let playlist):
NavigationLink(value: NavigationDestination.playlist(.remote(playlist.id, instance: instance, title: playlist.title))) {
PlaylistCardView(playlist: playlist, isCompact: gridConfig.isCompactCards)
.contentShape(Rectangle())
}
.zoomTransitionSource(id: playlist.id)
.buttonStyle(.plain)
.onAppear {
if resultIndex >= vm.resultItems.count - 3 {
Task { await vm.loadMore() }
}
}
case .channel(let channel):
NavigationLink(
value: NavigationDestination.channel(
channel.id.channelID,
channel.id.source
)
) {
ChannelCardGridView(channel: channel, isCompact: gridConfig.isCompactCards)
.contentShape(Rectangle())
}
.zoomTransitionSource(id: channel.id.channelID)
.buttonStyle(.plain)
.onAppear {
if resultIndex >= vm.resultItems.count - 3 {
Task { await vm.loadMore() }
}
}
}
}
}
if vm.isLoadingMore {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
}
private var searchHintView: some View {
ContentUnavailableView {
Label(String(localized: "search.hint.title"), systemImage: "magnifyingglass")
} description: {
Text(String(localized: "search.hint.description"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var suggestionsView: some View {
if let vm = searchViewModel {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(vm.suggestions, id: \.self) { suggestion in
Button {
dismissKeyboard()
searchText = suggestion
Task { await vm.search(query: suggestion) }
} label: {
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
Text(suggestion)
Spacer()
}
.padding(.horizontal)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Divider()
.padding(.leading)
}
}
}
}
private var searchEmptyView: some View {
ContentUnavailableView {
Label(String(localized: "search.noResults.title"), systemImage: "magnifyingglass")
} description: {
Text(String(localized: "search.noResults.description"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func searchErrorView(_ error: String) -> some View {
ContentUnavailableView {
Label(String(localized: "common.error"), systemImage: "exclamationmark.triangle")
} description: {
Text(error)
} actions: {
Button(String(localized: "common.retry")) {
Task { await searchViewModel?.search(query: searchText) }
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Data Loading
private func loadWatchEntries() {
watchEntriesMap = appEnvironment?.dataManager.watchEntriesMap() ?? [:]
}
private func startContentLoad(forceRefresh: Bool = false) async {
// Cancel any in-flight load before starting a new one
contentLoadTask?.cancel()
let task = Task {
await performLoadContent(forceRefresh: forceRefresh)
}
contentLoadTask = task
await task.value
}
private func performLoadContent(forceRefresh: Bool = false) async {
guard let appEnvironment else {
errorMessage = "App not initialized"
isLoading = false
return
}
// Skip loading if we already have data and not forcing refresh
let hasData: Bool
switch selectedTab {
case .popular: hasData = !popularVideos.isEmpty
case .trending: hasData = !trendingVideos.isEmpty
case .feed: hasData = !feedVideos.isEmpty
case .playlists: hasData = !userPlaylists.isEmpty
}
if hasData && !forceRefresh {
isLoading = false
return
}
isLoading = !hasData // Only show loading spinner when no existing data
errorMessage = nil
do {
switch selectedTab {
case .popular:
let videos = try await appEnvironment.contentService.popular(for: instance)
popularVideos = videos
prefetchBranding(for: videos)
case .trending:
let videos = try await appEnvironment.contentService.trending(for: instance)
trendingVideos = videos
prefetchBranding(for: videos)
case .feed:
guard let credential = appEnvironment.credentialsManager(for: instance)?.credential(for: instance) else {
errorMessage = String(localized: "feed.error.notLoggedIn")
isLoading = false
return
}
if forceRefresh {
feedPage = 1
hasMoreFeedResults = true
// Don't clear feedVideos here keep old data visible
// until the API call succeeds and replaces it.
}
// Load subscriptions and feed based on instance type
let subscriptionChannels: [Channel]
let videos: [Video]
switch instance.type {
case .invidious:
let api = InvidiousAPI(httpClient: appEnvironment.httpClient)
// Load subscriptions for channel filter
async let subscriptionsTask = api.subscriptions(instance: instance, sid: credential)
// Load feed
async let feedTask = api.feed(instance: instance, sid: credential, page: feedPage)
let (subscriptions, feedResponse) = try await (subscriptionsTask, feedTask)
// Fetch channel thumbnails in parallel (subscriptions API doesn't include them)
let enrichedSubscriptions = await fetchChannelThumbnails(
for: subscriptions,
instance: instance,
api: api
)
subscriptionChannels = enrichedSubscriptions.map { $0.toChannel(baseURL: instance.url) }
videos = feedResponse.videos
hasMoreFeedResults = feedResponse.hasMore
case .piped:
let api = PipedAPI(httpClient: appEnvironment.httpClient)
// Load subscriptions and feed in parallel
async let subscriptionsTask = api.subscriptions(instance: instance, authToken: credential)
async let feedTask = api.feed(instance: instance, authToken: credential)
let (pipedSubscriptions, feedVideos) = try await (subscriptionsTask, feedTask)
subscriptionChannels = pipedSubscriptions.map { $0.toChannel() }
videos = feedVideos
// Piped doesn't support pagination for feed
hasMoreFeedResults = false
default:
errorMessage = String(localized: "feed.error.notSupported")
isLoading = false
return
}
feedSubscriptions = subscriptionChannels.map { $0.enrichedThumbnail(using: appEnvironment.dataManager) }
feedVideos = videos
prefetchBranding(for: videos)
case .playlists:
// Playlists are currently only supported for Invidious
guard instance.type == .invidious else {
errorMessage = String(localized: "feed.error.notSupported")
isLoading = false
return
}
guard let credential = appEnvironment.credentialsManager(for: instance)?.credential(for: instance) else {
errorMessage = String(localized: "feed.error.notLoggedIn")
isLoading = false
return
}
let api = InvidiousAPI(httpClient: appEnvironment.httpClient)
let playlists = try await api.userPlaylists(instance: instance, sid: credential)
userPlaylists = playlists
}
} catch is CancellationError {
// Task was cancelled another load is taking over, don't touch state
return
} catch let error as APIError where error == .cancelled {
// HTTP request was cancelled another load is taking over, don't touch state
return
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
private func prefetchBranding(for videos: [Video]) {
guard let appEnvironment else { return }
let youtubeIDs = videos.compactMap { video -> String? in
if case .global = video.id.source { return video.id.videoID }
return nil
}
appEnvironment.deArrowBrandingProvider.prefetch(videoIDs: youtubeIDs)
}
/// Fetches channel thumbnails for subscriptions, using cache when available.
/// Only fetches from network for channels not already cached.
private func fetchChannelThumbnails(
for subscriptions: [InvidiousSubscription],
instance: Instance,
api: InvidiousAPI
) async -> [InvidiousSubscription] {
guard let credentialsManager = appEnvironment?.invidiousCredentialsManager else {
return subscriptions
}
// Build initial thumbnail map from cache
var thumbnailMap: [String: URL] = [:]
for subscription in subscriptions {
if let cachedURL = credentialsManager.thumbnailURL(forChannelID: subscription.authorId) {
thumbnailMap[subscription.authorId] = cachedURL
}
}
// Find channels that need fetching
let allChannelIDs = subscriptions.map(\.authorId)
let uncachedIDs = Set(credentialsManager.uncachedChannelIDs(from: allChannelIDs))
// Fetch only uncached channels in parallel
if !uncachedIDs.isEmpty {
let fetchedThumbnails = await withTaskGroup(of: (String, URL?).self) { group in
for subscription in subscriptions where uncachedIDs.contains(subscription.authorId) {
group.addTask {
do {
let channel = try await api.channel(id: subscription.authorId, instance: instance)
return (subscription.authorId, channel.thumbnailURL)
} catch {
return (subscription.authorId, nil)
}
}
}
var results: [String: URL] = [:]
for await (authorId, thumbnailURL) in group {
if let url = thumbnailURL {
results[authorId] = url
}
}
return results
}
// Merge fetched thumbnails and cache them
for (channelID, url) in fetchedThumbnails {
thumbnailMap[channelID] = url
}
// Save to cache on main actor
await MainActor.run {
credentialsManager.setThumbnailURLs(fetchedThumbnails)
}
}
// Create enriched subscriptions with thumbnails
return subscriptions.map { subscription in
var enriched = subscription
enriched.thumbnailURL = thumbnailMap[subscription.authorId]
return enriched
}
}
private func loadMoreFeedResults() {
guard hasMoreFeedResults, !isLoadingMoreFeed, !isLoading else { return }
guard let appEnvironment,
let sid = appEnvironment.invidiousCredentialsManager.sid(for: instance) else { return }
isLoadingMoreFeed = true
feedLoadedVideoCount = feedVideos.count // Mark current count to prevent re-triggering
feedPage += 1
Task {
do {
let api = InvidiousAPI(httpClient: appEnvironment.httpClient)
let feedResponse = try await api.feed(instance: instance, sid: sid, page: feedPage)
await MainActor.run {
feedVideos.append(contentsOf: feedResponse.videos)
hasMoreFeedResults = feedResponse.hasMore
isLoadingMoreFeed = false
prefetchBranding(for: feedResponse.videos)
}
} catch {
await MainActor.run {
isLoadingMoreFeed = false
}
}
}
}
private func dismissKeyboard() {
#if os(iOS)
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
#endif
}
}
// MARK: - Preview
#Preview {
NavigationStack {
InstanceBrowseView(
instance: Instance(
type: .invidious,
url: URL(string: "https://invidious.example.com")!,
name: "Example Instance"
)
)
}
.appEnvironment(.preview)
}