mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
1831 lines
72 KiB
Swift
1831 lines
72 KiB
Swift
//
|
|
// InstanceBrowseView.swift
|
|
// Yattee
|
|
//
|
|
// Displays content (Popular/Trending) for a specific backend instance.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct InstanceBrowseView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.openURL) private var openURL
|
|
|
|
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
|
|
/// Which account the displayed — or in-flight — account-scoped content is
|
|
/// for, so a change of effective account can be noticed at all.
|
|
@State private var accountContentAccountID: UUID?
|
|
@State private var feedVideos: [Video] = []
|
|
@State private var feedSubscriptions: [Channel] = []
|
|
|
|
@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 showEditInstance = false
|
|
@State private var showRemoveConfirmation = false
|
|
|
|
// Account actions raised from the three-dots menu. Same shapes as the
|
|
// edit sheet's accounts section: a nil-account login target means
|
|
// "create one", and the sign-out is held until its confirmation.
|
|
@State private var loginTarget: AccountLoginTarget?
|
|
@State private var pendingSignOut: Account?
|
|
@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
|
|
)
|
|
}
|
|
|
|
/// All accounts on this instance, in the accounts submenu.
|
|
private var instanceAccounts: [Account] {
|
|
appEnvironment?.accountsManager.accounts(forInstance: instance.id) ?? []
|
|
}
|
|
|
|
/// The account this screen's authenticated tabs actually use.
|
|
private var effectiveAccount: Account? {
|
|
appEnvironment?.account(forInstance: instance)
|
|
}
|
|
|
|
private func accountDisplayName(_ account: Account) -> String {
|
|
appEnvironment?.dataContextValidator.displayName(for: account) ?? instance.displayName
|
|
}
|
|
|
|
/// Shared content of the three-dots menu: refresh and accounts on top,
|
|
/// link actions and instance management below, view options last.
|
|
@ViewBuilder
|
|
private var instanceMenuContent: some View {
|
|
Button {
|
|
Task { await startContentLoad(forceRefresh: true) }
|
|
} label: {
|
|
Label(String(localized: "common.refresh"), systemImage: "arrow.clockwise")
|
|
}
|
|
|
|
if instance.supportsAuthentication {
|
|
Divider()
|
|
accountsMenuContent
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
Divider()
|
|
|
|
Button {
|
|
copyInstanceURL()
|
|
} label: {
|
|
Label(String(localized: "instance.browse.copyURL"), systemImage: "doc.on.doc")
|
|
}
|
|
|
|
Button {
|
|
openURL(instance.browseURL)
|
|
} label: {
|
|
Label(String(localized: "instance.browse.openInBrowser"), systemImage: "safari")
|
|
}
|
|
#endif
|
|
|
|
Divider()
|
|
|
|
Button {
|
|
showEditInstance = true
|
|
} label: {
|
|
Label(String(localized: "instance.browse.edit \(instance.displayName)"), systemImage: "pencil")
|
|
}
|
|
|
|
Button(role: .destructive) {
|
|
showRemoveConfirmation = true
|
|
} label: {
|
|
Label(String(localized: "instance.browse.remove \(instance.displayName)"), systemImage: "trash")
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button {
|
|
showViewOptions = true
|
|
} label: {
|
|
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
|
}
|
|
}
|
|
|
|
/// Sign-in entry when nobody is signed in; otherwise an accounts submenu
|
|
/// that switches the data context, adds a login, or signs the current one
|
|
/// out. Switching goes through the global context — the same semantics as
|
|
/// the switcher — which this view already reacts to.
|
|
@ViewBuilder
|
|
private var accountsMenuContent: some View {
|
|
if instanceAccounts.isEmpty {
|
|
Button {
|
|
loginTarget = .newAccount
|
|
} label: {
|
|
Label(String(localized: "login.loginToAccount"), systemImage: "person.badge.plus")
|
|
}
|
|
} else {
|
|
Menu {
|
|
ForEach(instanceAccounts) { account in
|
|
Button {
|
|
appEnvironment?.switchDataContext(to: .account(account.id))
|
|
} label: {
|
|
if account.id == effectiveAccount?.id {
|
|
Label(accountDisplayName(account), systemImage: "checkmark")
|
|
} else {
|
|
Text(accountDisplayName(account))
|
|
}
|
|
}
|
|
.disabled(account.id == effectiveAccount?.id)
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button {
|
|
loginTarget = .newAccount
|
|
} label: {
|
|
Label(String(localized: "accounts.addAccount"), systemImage: "person.badge.plus")
|
|
}
|
|
|
|
if let active = effectiveAccount {
|
|
Button(role: .destructive) {
|
|
pendingSignOut = active
|
|
} label: {
|
|
Label(
|
|
String(localized: "instance.browse.signOut \(accountDisplayName(active))"),
|
|
systemImage: "rectangle.portrait.and.arrow.forward"
|
|
)
|
|
}
|
|
}
|
|
} label: {
|
|
Label(String(localized: "accounts.title"), systemImage: "person.crop.circle")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func copyInstanceURL() {
|
|
#if os(iOS)
|
|
UIPasteboard.general.string = instance.browseURL.absoluteString
|
|
#elseif os(macOS)
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(instance.browseURL.absoluteString, forType: .string)
|
|
#endif
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
Menu {
|
|
instanceMenuContent
|
|
} label: {
|
|
Label(String(localized: "common.more"), systemImage: "ellipsis")
|
|
}
|
|
}
|
|
.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 {
|
|
// Instance menu first so it is the leftmost navigation item on macOS.
|
|
ToolbarItem(placement: viewOptionsPlacement) {
|
|
Menu {
|
|
instanceMenuContent
|
|
} label: {
|
|
Label(String(localized: "common.more"), systemImage: "ellipsis")
|
|
}
|
|
.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
|
|
#if os(tvOS)
|
|
.fullScreenCover(isPresented: $showEditInstance) {
|
|
TVSidebarDetailContainer(systemImage: "pencil.circle", title: String(localized: "sources.editSource")) {
|
|
EditSourceView(source: .remoteServer(instance))
|
|
}
|
|
.background(Color.black.ignoresSafeArea())
|
|
}
|
|
#else
|
|
.sheet(isPresented: $showEditInstance) {
|
|
EditSourceView(source: .remoteServer(instance))
|
|
}
|
|
#endif
|
|
.confirmationDialog(
|
|
String(localized: "sources.delete.confirmation.single \(instance.displayName)"),
|
|
isPresented: $showRemoveConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "common.delete"), role: .destructive) {
|
|
appEnvironment?.removeInstance(instance)
|
|
dismiss()
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {}
|
|
} message: {
|
|
// The same cascade runs from here as from Settings, so the same
|
|
// logins go with it.
|
|
if let accounts = InstanceDeletionDisclosure.message(for: instance, in: appEnvironment) {
|
|
Text(accounts)
|
|
}
|
|
}
|
|
#if os(tvOS)
|
|
.fullScreenCover(item: $loginTarget) { target in
|
|
InstanceLoginView(instance: instance, initialUsername: storedUsername(for: target)) { credential, username in
|
|
storeLogin(credential: credential, username: username, target: target)
|
|
}
|
|
}
|
|
#else
|
|
.sheet(item: $loginTarget) { target in
|
|
InstanceLoginView(instance: instance, initialUsername: storedUsername(for: target)) { credential, username in
|
|
storeLogin(credential: credential, username: username, target: target)
|
|
}
|
|
}
|
|
#endif
|
|
.confirmationDialog(
|
|
String(localized: "accounts.remove.confirm.title \(pendingSignOut.map(accountDisplayName) ?? instance.displayName)"),
|
|
isPresented: Binding(
|
|
get: { pendingSignOut != nil },
|
|
set: { if !$0 { pendingSignOut = nil } }
|
|
),
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "common.remove"), role: .destructive) {
|
|
confirmSignOut()
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {
|
|
pendingSignOut = nil
|
|
}
|
|
} message: {
|
|
Text(String(localized: "accounts.remove.confirm.message"))
|
|
}
|
|
.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. Either notification
|
|
// can also move *which* account is effective here: a status change
|
|
// because the resolution falls back to a sibling login on the same
|
|
// instance, an add or a remove because it changes what there is to fall
|
|
// back to. Content loaded for the previous one has to go with it.
|
|
.onReceive(NotificationCenter.default.publisher(for: .accountStatusesDidChange)) { _ in
|
|
handleEffectiveAccountChange()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .accountsDidChange)) { _ in
|
|
handleEffectiveAccountChange()
|
|
}
|
|
// 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()
|
|
if !resetSelectedTabIfUnavailable() {
|
|
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()
|
|
if !resetSelectedTabIfUnavailable() {
|
|
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)
|
|
}
|
|
|
|
/// The account's playlists, read from the shared cache rather than fetched
|
|
/// into this view.
|
|
///
|
|
/// Everything that changes a server-side playlist goes through that cache —
|
|
/// the picker that creates one while adding a video, the detail view that
|
|
/// renames or deletes one — so reading it is what makes those changes show
|
|
/// up here. It is keyed by account, which is also the discard on an account
|
|
/// change: whose playlists these are moves with `playlistsAccount`.
|
|
private var userPlaylists: [Playlist] {
|
|
guard let appEnvironment, let account = playlistsAccount else { return [] }
|
|
return appEnvironment.remotePlaylistsCache.playlists(for: account.id) ?? []
|
|
}
|
|
|
|
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.
|
|
/// The playlists are not listed here either: they are read from the shared
|
|
/// cache under the account's own id, so they leave with the account.
|
|
private func discardAccountScopedContent() {
|
|
accountContentGeneration += 1
|
|
feedVideos = []
|
|
feedSubscriptions = []
|
|
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
|
|
accountContentAccountID = nil
|
|
}
|
|
|
|
/// Drops account-scoped content when the account it belongs to is no longer
|
|
/// the one this instance resolves to.
|
|
///
|
|
/// - Returns: whether anything was discarded, so the caller can reload.
|
|
@discardableResult
|
|
private func discardAccountScopedContentIfAccountChanged() -> Bool {
|
|
guard let loadedFor = accountContentAccountID, loadedFor != playlistsAccount?.id else {
|
|
return false
|
|
}
|
|
|
|
discardAccountScopedContent()
|
|
return true
|
|
}
|
|
|
|
/// Handles a change to which account this instance resolves to, whatever
|
|
/// moved it: a status, or an account being added or removed.
|
|
private func handleEffectiveAccountChange() {
|
|
let accountChanged = discardAccountScopedContentIfAccountChanged()
|
|
refreshLoginState()
|
|
|
|
// Moving off a tab that no longer exists reloads by itself.
|
|
guard !resetSelectedTabIfUnavailable() else { return }
|
|
|
|
if accountChanged {
|
|
Task { await startContentLoad() }
|
|
}
|
|
}
|
|
|
|
/// Moves off a tab that has just stopped being offered.
|
|
///
|
|
/// The Feed and Playlists tabs exist only while an account backs them. When
|
|
/// one goes away the picker drops it, and a selection left pointing at it
|
|
/// leaves an unhighlighted tab strip over content nobody can reach.
|
|
///
|
|
/// - Returns: whether the selection moved, which reloads through
|
|
/// `onChange(of: selectedTab)`.
|
|
@discardableResult
|
|
private func resetSelectedTabIfUnavailable() -> Bool {
|
|
let tabs = availableTabs
|
|
guard !tabs.contains(selectedTab), let first = tabs.first else { return false }
|
|
|
|
selectedTab = first
|
|
return true
|
|
}
|
|
|
|
private func refreshLoginState() {
|
|
guard instance.supportsAuthentication else { return }
|
|
isLoggedIn = appEnvironment?.isSignedIn(to: instance) ?? false
|
|
}
|
|
|
|
/// Files a successful login against an account, mirroring the edit sheet.
|
|
@discardableResult
|
|
private func storeLogin(credential: String, username: String, target: AccountLoginTarget) -> Bool {
|
|
guard let appEnvironment else { return false }
|
|
return AccountSignInHandler(appEnvironment: appEnvironment, instance: instance)
|
|
.storeLogin(credential: credential, username: username, target: target)
|
|
}
|
|
|
|
/// The identifier an existing account last signed in with, when this device
|
|
/// holds it. A new account has nobody to name yet.
|
|
private func storedUsername(for target: AccountLoginTarget) -> String? {
|
|
guard let account = target.account else { return nil }
|
|
return appEnvironment?.accountCredentialsManager(for: account)?.username(for: account)
|
|
}
|
|
|
|
private func confirmSignOut() {
|
|
guard let appEnvironment, let account = pendingSignOut else { return }
|
|
defer { pendingSignOut = nil }
|
|
|
|
// Re-resolved rather than trusted: the dialog can outlive the account,
|
|
// which another device may have deleted while it was up.
|
|
guard let account = appEnvironment.accountsManager.account(id: account.id) else { return }
|
|
|
|
appEnvironment.signOutAndRemoveAccount(account)
|
|
}
|
|
|
|
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
|
|
// Never skipped: the list is a cache other screens change, and what was
|
|
// already fetched stays on screen while it is refetched.
|
|
case .playlists: hasData = false
|
|
}
|
|
|
|
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
|
|
|
|
// Recorded before the request goes out, not after it lands: an account
|
|
// change while the fetch is in flight has to be able to see whose fetch
|
|
// it was.
|
|
if selectedTab == .feed || selectedTab == .playlists {
|
|
accountContentAccountID = playlistsAccount?.id
|
|
}
|
|
|
|
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
|
|
// cache resolves the credential through the service, and refuses
|
|
// backends that cannot list playlists at all. Nothing is stored
|
|
// here — the rows are read back out of the cache, which is what
|
|
// lets a playlist created or deleted elsewhere show up.
|
|
guard let account = playlistsAccount else {
|
|
errorMessage = String(localized: "feed.error.notLoggedIn")
|
|
isLoading = false
|
|
return
|
|
}
|
|
|
|
try await appEnvironment.remotePlaylistsCache.refreshList(for: account)
|
|
}
|
|
} 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)
|
|
}
|