mirror of
https://github.com/yattee/yattee.git
synced 2026-06-04 13:54:19 +00:00
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.
1323 lines
52 KiB
Swift
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)
|
|
}
|