Files
yattee/Yattee/Views/Instances/InstanceBrowseView.swift
Arkadiusz Fal b3c4216013 Let every request reach an instance the same way
A handful of call sites built their own InvidiousAPI or PipedAPI over the
shared HTTP client, which quietly opted them out of everything an instance
may require of a request: the reverse-proxy basic auth, the certificate
policy, and the User-Agent some instances whitelist and answer 401 without.
The feed on an instance behind a proxy could load in one place and fail in
another for no reason the user could see. They now all go through the
service that already configures a client per instance.

Login was the site that had noticed. It reproduced the basic-auth handling
itself, including the header Invidious' login needs passed explicitly
because that POST runs on its own session to read the SID out of a cookie.
That logic moves into the service verbatim rather than being dropped: the
view now asks for a credential and is told nothing about how it was
obtained.

Paging is the one thing the service could not already express. Invidious
feeds have pages and Piped feeds do not, so the paged variant says so in
its return value instead of leaving each caller to know.

The browse view's subscriptions arrive as channels now rather than as raw
Invidious rows, so the avatar enrichment works over channels — a fetched
or cached avatar still wins over whatever the listing carried, which is
the precedence the conversion applied before.
2026-09-02 23:53:33 +02:00

1532 lines
60 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)
/// Which login the account-scoped content belongs to, so a page still in
/// flight cannot be appended to the next one's feed.
@State private var accountContentGeneration = 0
@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"
}
}
}
private var viewOptionsSheetContent: some View {
ViewOptionsSheet(
layout: $layout,
rowStyle: $rowStyle,
gridColumns: $gridColumnCount,
hideWatched: $hideWatched,
maxGridColumns: gridConfig.maxColumns
)
}
/// View options button lives on the leading edge on macOS, trailing elsewhere.
private var viewOptionsPlacement: ToolbarItemPlacement {
#if os(macOS)
.navigation
#else
.primaryAction
#endif
}
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)
.padding(.bottom, 20)
// Content
ScrollView {
VStack(spacing: 0) {
Spacer()
.frame(height: 20)
// Tab picker (hidden during search)
if !isInSearchMode {
tabPicker
.padding()
}
// 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
}
}
}
}
.scrollClipDisabled()
.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; lives in the toolbar on macOS)
#if !os(macOS)
if !isInSearchMode {
tabPicker
.padding()
}
#endif
// Feed channel filter strip (hidden during search)
if selectedTab == .feed && !feedSubscriptions.isEmpty && !isInSearchMode {
feedChannelFilterStrip
}
// Search filter strip (shown persistently after search submitted; lives in the toolbar on macOS)
#if !os(macOS)
if isInSearchMode && (searchViewModel?.hasSearched ?? false) && instance.supportsSearchFilters {
searchFiltersStrip
}
#endif
// 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 {
// View options first so it is the leftmost navigation item on macOS.
ToolbarItem(placement: viewOptionsPlacement) {
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
.liquidGlassTransitionSource(id: "instanceBrowseViewOptions", in: sheetTransition)
#if os(macOS)
.popover(isPresented: $showViewOptions, arrowEdge: .bottom) {
viewOptionsSheetContent
}
#endif
}
#if os(macOS)
if #available(macOS 26, *) {
// Render the items bare (conditional `if`, no opacity/disabled
// wrappers): wrapping a `.segmented` Picker inserts a hosting layer
// that makes the toolbar fall back to a Liquid-Glass pill instead of
// the native segmented control. The search filters button shows only
// while displaying search results; the principal picker swaps between
// the browse tab picker and the search content type picker.
if showsSearchFiltersInToolbar {
ToolbarItem(placement: .navigation) {
searchFiltersToolbarButton
}
}
if isToolbarPickerVisible {
ToolbarItem(placement: .principal) {
if showsSearchFiltersInToolbar {
searchContentTypePicker
} else {
tabPicker
}
}
}
} else if showsSearchFiltersInToolbar {
ToolbarItem(placement: .navigation) {
searchFiltersToolbarButton
}
ToolbarItem(placement: .principal) {
searchContentTypePicker
.fixedSize()
}
} else if !isInSearchMode {
ToolbarItem(placement: .principal) {
tabPicker
.fixedSize()
}
}
// Pin the trailing group (search field) to the right edge,
// matching the global Search view.
if #available(macOS 26, *) {
ToolbarSpacer(.flexible, placement: .primaryAction)
}
#endif
}
#endif
#if !os(macOS)
.sheet(isPresented: $showViewOptions) {
viewOptionsSheetContent
.liquidGlassSheetContent(sourceID: "instanceBrowseViewOptions", in: sheetTransition)
}
#endif
.task {
// Initialize search view model
if let appEnvironment {
searchViewModel = SearchViewModel(
instance: instance,
contentService: appEnvironment.contentService,
deArrowProvider: appEnvironment.deArrowBrandingProvider,
dataManager: appEnvironment.dataManager,
settingsManager: appEnvironment.settingsManager
)
}
refreshLoginState()
// 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()
}
// A credential can arrive over iCloud Keychain, or an account be added,
// while this stays on screen — the Feed tab depends on it and would
// otherwise stay missing until the view was rebuilt.
.onReceive(NotificationCenter.default.publisher(for: .accountStatusesDidChange)) { _ in
refreshLoginState()
}
.onReceive(NotificationCenter.default.publisher(for: .accountsDidChange)) { _ in
refreshLoginState()
}
// Selecting the other login on this instance changes whose feed and
// playlists these are. Nothing else here would notice: the instance,
// the tab and the "already has data" shortcut are all unchanged.
.onReceive(NotificationCenter.default.publisher(for: .dataContextDidChange)) { _ in
discardAccountScopedContent()
refreshLoginState()
Task { await startContentLoad() }
}
// Same reason, without the context moving: the credential behind this
// instance's account was replaced.
.onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in
discardAccountScopedContent()
refreshLoginState()
Task { await startContentLoad() }
}
.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)
}
}
#if !os(macOS)
.sheet(isPresented: $showFilterSheet) {
searchFiltersSheetContent
#if !os(tvOS)
.presentationDetents([.medium, .large])
#endif
}
#endif
}
// MARK: - Computed Properties
private var searchFiltersSheetContent: some View {
SearchFiltersSheet(onApply: {
Task {
await searchViewModel?.search(query: searchText)
}
}, filters: Binding(
get: { searchViewModel?.filters ?? .defaults },
set: { searchViewModel?.filters = $0 }
))
}
#if os(macOS)
private var isShowingSearchResults: Bool {
isInSearchMode && (searchViewModel?.hasSearched ?? false)
}
private var showsSearchFiltersInToolbar: Bool {
isShowingSearchResults && instance.supportsSearchFilters
}
private var isToolbarPickerVisible: Bool {
// Tab picker stays visible while typing; it swaps to the search content
// type picker only once a search has been submitted
!isShowingSearchResults || showsSearchFiltersInToolbar
}
private var searchContentTypePicker: some View {
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)
.onChange(of: searchViewModel?.filters.type) { _, _ in
Task {
await searchViewModel?.search(query: searchText)
}
}
}
private var searchFiltersToolbarButton: some View {
Button {
showFilterSheet = true
} label: {
Label(String(localized: "search.filters"), systemImage: (searchViewModel?.filters.isDefault ?? true)
? "line.3.horizontal.decrease.circle"
: "line.3.horizontal.decrease.circle.fill")
}
.popover(isPresented: $showFilterSheet, arrowEdge: .bottom) {
searchFiltersSheetContent
}
}
#endif
private var tabPicker: some View {
Picker("", selection: $selectedTab) {
ForEach(availableTabs) { tab in
#if os(macOS)
// Labels render icon-only in a macOS toolbar segmented control
Text(tab.title)
.tag(tab)
#else
Label(tab.title, systemImage: tab.systemImage)
.tag(tab)
#endif
}
}
.pickerStyle(.segmented)
}
/// The account whose playlists the Playlists tab shows.
private var playlistsAccount: Account? {
appEnvironment?.account(forInstance: instance)
}
private var availableTabs: [BrowseTab] {
if instance.supportsFeed && isLoggedIn {
// Capabilities decide, never the instance type: a Piped account
// reads its playlists through exactly the same service, so the tab
// is offered wherever an account can actually fill it.
if let account = playlistsAccount,
appEnvironment?.accountsManager.capabilities(for: account)?.supportsPlaylistReads == true {
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
) {
// These are the account's own playlists, so the detail view
// is told whose they are — that is what lets it fetch them
// authenticated and offer to change them.
NavigationLink(value: NavigationDestination.playlist(
.remote(playlist.id, instance: instance, title: playlist.title, accountID: playlistsAccount?.id)
)) {
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, accountID: playlistsAccount?.id)
)) {
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
/// Drops what belonged to the previously selected login.
///
/// Public browsing — popular and trending — is unauthenticated and stays.
private func discardAccountScopedContent() {
accountContentGeneration += 1
feedVideos = []
feedSubscriptions = []
userPlaylists = []
feedPage = 1
hasMoreFeedResults = false
isLoadingMoreFeed = false
// The high-water mark belongs to the discarded feed. Left behind, it
// gates pagination against the previous login's count: the next one
// would have to exceed a page it never loaded before it could ask for
// a second.
feedLoadedVideoCount = 0
}
private func refreshLoginState() {
guard instance.supportsAuthentication else { return }
isLoggedIn = appEnvironment?.isSignedIn(to: instance) ?? false
}
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
// Which login the account-scoped tabs are being loaded for. Cancelling
// the previous task is not enough on its own: a request that already
// came back races the cancellation, and its results would be installed
// after the purge that was meant to remove them. Popular and trending
// are unauthenticated and deliberately not fenced.
let generation = accountContentGeneration
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.credential(forInstance: 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]
let hasMore: Bool
let contentService = appEnvironment.contentService
switch instance.type {
case .invidious:
// Load subscriptions for channel filter
async let subscriptionsTask = contentService.subscriptions(for: instance, credential: credential)
// Load feed
async let feedTask = contentService.feed(for: instance, credential: credential, page: feedPage)
let (subscriptions, feedResponse) = try await (subscriptionsTask, feedTask)
// Fetch channel thumbnails in parallel (subscriptions API doesn't include them)
subscriptionChannels = await fetchChannelThumbnails(
for: subscriptions,
instance: instance
)
videos = feedResponse.videos
hasMore = feedResponse.hasMore
case .piped:
// Load subscriptions and feed in parallel
async let subscriptionsTask = contentService.subscriptions(for: instance, credential: credential)
async let feedTask = contentService.feed(for: instance, credential: credential)
let (pipedSubscriptions, feedVideos) = try await (subscriptionsTask, feedTask)
subscriptionChannels = pipedSubscriptions
videos = feedVideos
// Piped doesn't support pagination for feed
hasMore = false
default:
errorMessage = String(localized: "feed.error.notSupported")
isLoading = false
return
}
guard generation == accountContentGeneration else { return }
hasMoreFeedResults = hasMore
feedSubscriptions = subscriptionChannels.map { $0.enrichedThumbnail(using: appEnvironment.dataManager) }
feedVideos = videos
prefetchBranding(for: videos)
case .playlists:
// Addressed by account rather than by instance and secret: the
// service resolves the credential, and refuses backends that
// cannot list playlists at all.
guard let account = playlistsAccount else {
errorMessage = String(localized: "feed.error.notLoggedIn")
isLoading = false
return
}
let playlists = try await appEnvironment.accountService.userPlaylists(for: account)
guard generation == accountContentGeneration else { return }
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: [Channel],
instance: Instance
) async -> [Channel] {
guard let appEnvironment else { return subscriptions }
let credentialsManager = appEnvironment.invidiousCredentialsManager
let contentService = appEnvironment.contentService
// Build initial thumbnail map from cache
var thumbnailMap: [String: URL] = [:]
for subscription in subscriptions {
if let cachedURL = credentialsManager.thumbnailURL(forChannelID: subscription.id.channelID) {
thumbnailMap[subscription.id.channelID] = cachedURL
}
}
// Find channels that need fetching
let allChannelIDs = subscriptions.map(\.id.channelID)
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.id.channelID) {
let channelID = subscription.id.channelID
group.addTask {
do {
let channel = try await contentService.channel(id: channelID, instance: instance)
return (channelID, channel.thumbnailURL)
} catch {
return (channelID, 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. A fetched or cached
// avatar wins over whatever the subscriptions listing carried, which is
// the precedence the Invidious subscription conversion applied when this
// ran over the raw listing.
return subscriptions.map { subscription in
guard let thumbnailURL = thumbnailMap[subscription.id.channelID] else {
return subscription
}
return Channel(
id: subscription.id,
name: subscription.name,
description: subscription.description,
subscriberCount: subscription.subscriberCount,
videoCount: subscription.videoCount,
thumbnailURL: thumbnailURL,
bannerURL: subscription.bannerURL,
isVerified: subscription.isVerified
)
}
}
private func loadMoreFeedResults() {
guard hasMoreFeedResults, !isLoadingMoreFeed, !isLoading else { return }
guard let appEnvironment,
let sid = appEnvironment.credential(forInstance: instance) else { return }
isLoadingMoreFeed = true
feedLoadedVideoCount = feedVideos.count // Mark current count to prevent re-triggering
feedPage += 1
// The login can change while this page is in flight, and appending
// afterwards would mix one account's private videos into another's
// feed.
let generation = accountContentGeneration
Task {
do {
let feedResponse = try await appEnvironment.contentService.feed(
for: instance,
credential: sid,
page: feedPage
)
await MainActor.run {
guard generation == accountContentGeneration else { return }
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)
}