Files
yattee/Yattee/Views/Channel/ChannelView.swift
Arkadiusz Fal 01ff78aaa6 Keep destination checkmarks in sync with the main subscribe button
The main button's subscribe/unsubscribe never told the destination
membership loader, so "Subscribe On…" kept a stale checkmark for the
current account — and tapping that row would do the opposite of what
the mark promised.

Also: the menu item and success toast now name the account the write
lands on when more than one destination is offered, and "Subscribe On…"
no longer waits for the channel request — the cached header stands in
until the load answers.
2026-09-19 23:51:19 +02:00

2839 lines
104 KiB
Swift

//
// ChannelView.swift
// Yattee
//
// Channel view with zoom/scale header and video grid.
//
import SwiftUI
import NukeUI
struct ChannelView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
let channelID: String
let source: ContentSource
/// URL for external channel extraction (nil for YouTube/Invidious channels)
var channelURL: URL? = nil
/// Whether this is an external channel that requires extraction
private var isExternalChannel: Bool {
channelURL != nil
}
@Namespace private var sheetTransition
@State private var channel: Channel?
@State private var selectedTab: ChannelTab = .videos
@State private var isLoading = true
@State private var isLoadingMore = false
@State private var errorMessage: String?
@State private var isSubscribed = false
/// Which subscription lookup the button state belongs to. A channel change
/// or a change of who is being read as bumps it, so a reply from the
/// previous one is dropped instead of answering for the channel on screen.
@State private var subscriptionGeneration = 0
@State private var showingUnsubscribeConfirmation = false
/// What the destination rows know, kept here rather than inside the menu:
/// nothing in a platform menu's content is ever given a lifecycle to load
/// from.
@State private var destinationMembership = SubscriptionDestinationMembershipLoader()
#if os(tvOS)
@State private var showingSubscriptionDestinations = false
#endif
@State private var scrollOffset: CGFloat = 0
@State private var scrollToTop: Bool = false
@State private var cachedHeader: CachedChannelData?
// View options (persisted)
@AppStorage("channel.layout") private var layout: VideoListLayout = .list
@AppStorage("channel.rowStyle") private var rowStyle: VideoRowStyle = .regular
@AppStorage("channel.gridColumns") private var gridColumns = 2
@AppStorage("channel.hideWatched") private var hideWatched = false
/// List style from centralized settings.
private var listStyle: VideoListStyle {
appEnvironment?.settingsManager.listStyle ?? .inset
}
// UI state for view options
@State private var showViewOptions = false
@State private var viewWidth: CGFloat = 0
@State private var watchEntriesMap: [String: WatchEntry] = [:]
// Videos tab state
@State private var videos: [Video] = []
@State private var videosContinuation: String?
@State private var videosLoaded = false
// Playlists tab state
@State private var playlists: [Playlist] = []
@State private var playlistsContinuation: String?
@State private var playlistsLoaded = false
// Shorts tab state
@State private var shorts: [Video] = []
@State private var shortsContinuation: String?
@State private var shortsLoaded = false
// Streams tab state
@State private var streams: [Video] = []
@State private var streamsContinuation: String?
@State private var streamsLoaded = false
// Search state
@State private var searchText = ""
@State private var isSearchActive = false
@State private var hasSearched = false // True after user submits a search query
@State private var searchResults: ChannelSearchPage = .empty
@State private var isSearchLoading = false
// External channel state (page-based pagination for extracted channels)
@State private var externalCurrentPage = 1
#if os(tvOS)
// tvOS-specific state
@State private var isDescriptionScrollLocked = false
@State private var tvOSShowSearchTab = false
@FocusState private var isSubscribeFocused: Bool
@FocusState private var isTVSearchFieldFocused: Bool
@FocusState private var tvOSFocusedTab: TvOSChannelFocusTarget?
private enum TvOSChannelFocusTarget: Hashable {
case tab(ChannelTab)
case searchTab
}
#endif
// Header configuration
private let baseHeaderHeight: CGFloat = 280
private let searchBarExtraHeight: CGFloat = 70
private let collapsedHeaderHeight: CGFloat = 60
private let avatarSize: CGFloat = 80
private let collapsedAvatarSize: CGFloat = 36
/// Whether search bar adjustments are needed (only on compact/iPhone where search bar overlays content)
private var needsSearchBarAdjustment: Bool {
supportsChannelSearch && horizontalSizeClass == .compact
}
/// Header height adjusted for search bar on iPhone (iOS 18+ places search bar in navigation area)
private var headerHeight: CGFloat {
baseHeaderHeight + (needsSearchBarAdjustment ? searchBarExtraHeight : 0)
}
private var accentColor: Color {
appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor
}
// Grid layout configuration
private var gridConfig: GridLayoutConfiguration {
GridLayoutConfiguration(viewWidth: viewWidth, gridColumns: gridColumns)
}
// Filtered video arrays (for hideWatched)
private var filteredVideos: [Video] {
hideWatched ? videos.filter { watchEntriesMap[$0.id.videoID]?.isFinished != true } : videos
}
private var filteredShorts: [Video] {
hideWatched ? shorts.filter { watchEntriesMap[$0.id.videoID]?.isFinished != true } : shorts
}
private var filteredStreams: [Video] {
hideWatched ? streams.filter { watchEntriesMap[$0.id.videoID]?.isFinished != true } : streams
}
/// Whether inset grouped background should be shown.
private var showInsetBackground: Bool {
layout == .list && listStyle == .inset
}
/// Background color for the view based on list style.
private var viewBackgroundColor: Color {
showInsetBackground ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color
}
private var viewOptionsSheetContent: some View {
ViewOptionsSheet(
layout: $layout,
rowStyle: $rowStyle,
gridColumns: $gridColumns,
hideWatched: $hideWatched,
maxGridColumns: gridConfig.maxColumns
)
}
/// View options button lives on the leading edge on macOS, trailing elsewhere.
private var viewOptionsPlacement: ToolbarItemPlacement {
#if os(macOS)
.navigation
#else
.primaryAction
#endif
}
var body: some View {
// The search modifier is hoisted above this state-switching Group so the
// search bar is present in every load state (loading spinner / cached header
// / loaded content) instead of only appearing once `channel != nil`.
channelSearchable {
Group {
if let channel {
channelContent(channel)
} else if let cachedHeader {
// Show header with cached data + spinner for content area
loadingContent(cachedHeader)
} else if isLoading {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = errorMessage {
errorView(error)
}
}
}
.background(showInsetBackground ? viewBackgroundColor : .clear)
.task {
await loadChannel()
}
// Asked for here, where the answers arrive before the menu is built:
// the rows themselves live inside a platform menu, which runs no
// lifecycle of its own, and a mark that is not known by the time the
// menu opens is a mark the user never sees.
.task(id: channelToSubscribe?.id) {
guard let channelToSubscribe,
SubscriptionDestinations.areOffered(in: appEnvironment) else { return }
await destinationMembership.load(for: channelToSubscribe, in: appEnvironment)
}
.onAppear {
// Update Handoff activity for this specific channel
appEnvironment?.handoffManager.updateActivity(for: .channel(channelID, source))
loadWatchEntries()
}
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
loadWatchEntries()
}
// Whose subscriptions the button speaks for just changed — by selection,
// or by the credential behind the selected account being replaced — so
// it has to be asked again rather than left showing the last answer.
.onReceive(NotificationCenter.default.publisher(for: .dataContextDidChange)) { _ in
refreshSubscription()
}
.onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in
refreshSubscription()
}
}
/// Applies the channel `.searchable` field and its search handlers around the given
/// content. Kept outside `iOSChannelContent` so the search bar shows regardless of
/// channel load state. On tvOS there is no `.searchable` (it uses a custom search
/// tab), so this is a passthrough.
@ViewBuilder
private func channelSearchable(@ViewBuilder _ content: () -> some View) -> some View {
#if os(tvOS)
content()
#else
content()
#if os(iOS)
.if(supportsChannelSearch) { view in
// iPhone: search bar drawer in the navigation area (header reserves
// space for it). iPad: toolbar search button — the drawer would add
// a resting scroll position above the banner, letting the view
// scroll past the banner top.
view.searchable(
text: $searchText,
isPresented: $isSearchActive,
placement: horizontalSizeClass == .compact
? .navigationBarDrawer(displayMode: .automatic)
: .toolbar,
prompt: Text("channel.search.placeholder")
)
}
#elseif os(macOS)
.if(supportsChannelSearch) { view in
view.searchable(
text: $searchText,
isPresented: $isSearchActive,
placement: .toolbar,
prompt: Text("channel.search.placeholder")
)
}
#endif
.onSubmit(of: .search) {
Task {
await performSearch()
}
}
.onChange(of: searchText) { _, newValue in
if newValue.isEmpty && isSearchActive {
// User cleared the search text, reset search state
searchResults = .empty
}
}
.onChange(of: isSearchActive) { _, isActive in
if !isActive {
// Search was dismissed, clear results
hasSearched = false
searchResults = .empty
searchText = ""
}
}
#endif
}
private func loadWatchEntries() {
watchEntriesMap = appEnvironment?.dataManager.watchEntriesMap() ?? [:]
}
private func watchProgress(for video: Video) -> Double? {
guard let entry = watchEntriesMap[video.id.videoID] else { return nil }
let progress = entry.progress
return progress > 0 && progress < 1 ? progress : nil
}
// MARK: - Computed Properties for Scroll Animation
/// Progress from 0 (fully expanded) to 1 (fully collapsed)
private var collapseProgress: CGFloat {
let progress = scrollOffset / (headerHeight - collapsedHeaderHeight)
return min(max(progress, 0), 1)
}
/// Current avatar size interpolated between full and collapsed
private var currentAvatarSize: CGFloat {
avatarSize - (avatarSize - collapsedAvatarSize) * collapseProgress
}
/// Opacity for expanded content (fades out as we scroll)
private var expandedContentOpacity: CGFloat {
1 - min(collapseProgress * 2, 1)
}
/// Opacity for collapsed title (fades in as we scroll)
private var collapsedTitleOpacity: CGFloat {
max((collapseProgress - 0.5) * 2, 0)
}
// MARK: - Channel Content
@ViewBuilder
private func channelContent(_ channel: Channel) -> some View {
#if os(tvOS)
tvOSChannelContent(channel)
#else
iOSChannelContent(channel)
#endif
}
#if !os(tvOS)
@ViewBuilder
private func iOSChannelContent(_ channel: Channel) -> some View {
GeometryReader { geometry in
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 0) {
// Only hide header and tabs after user submits a search query
if !(isSearchActive && hasSearched) {
// Header with zoom/scale effect
header(channel)
.id("channelTop")
// Content based on instance type
// On macOS the content type picker lives in the toolbar instead.
#if os(macOS)
// Breathing room between the banner and the content below.
Color.clear.frame(height: 16)
if !supportsChannelTabs {
channelDescription(channel)
}
#else
if supportsChannelTabs {
// Pill-style content type switcher
contentTypePicker
.padding(.horizontal)
.padding(.vertical, 8)
} else {
// Non-tab instances: show description
channelDescription(channel)
}
#endif
}
// Tab content or search results
// Only show search results after user has submitted a query
if isSearchActive && hasSearched {
// Spacer to push content below nav bar + search bar
// Since we use .ignoresSafeArea(edges: .top), we need manual spacing
// iOS: safe area (~59pt) + nav bar (~44pt) + search bar (~56pt) = ~130pt extra
// macOS: safe area + toolbar (~52pt)
#if os(iOS)
Spacer()
.frame(height: geometry.safeAreaInsets.top + 130)
#elseif os(macOS)
Spacer()
.frame(height: geometry.safeAreaInsets.top + 52)
#else
Spacer()
.frame(height: geometry.safeAreaInsets.top)
#endif
searchResultsContent
} else if supportsChannelTabs {
tabContent
} else {
videosGrid
}
}
}
.onChange(of: scrollToTop) { _, shouldScroll in
if shouldScroll {
withAnimation {
proxy.scrollTo("channelTop", anchor: .top)
}
scrollToTop = false
}
}
}
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
}
.background(viewBackgroundColor)
.ignoresSafeArea(edges: .top)
.softTopScrollEdgeEffect()
#if os(macOS)
.overlay(alignment: .top) {
legacyToolbarScrim
}
#endif
.animation(.easeInOut(duration: 0.25), value: isSearchActive)
.modifier(ChannelScrollOffsetModifier(
scrollOffset: $scrollOffset,
isPlayerExpanded: appEnvironment?.navigationCoordinator.isPlayerExpanded ?? false
))
.toolbar {
#if os(macOS)
// Keep the content picker in the center `.principal` slot even while
// searching. Its presence is what pins the trailing `.searchable`
// field to the right edge — dropping it (or making it zero-width)
// lets the search field re-center. During search it's disabled so the
// user can't switch tabs while the content area shows search results.
if supportsChannelTabs {
ToolbarItem(placement: .principal) {
contentTypePicker
.fixedSize()
.disabled(isSearchActive && hasSearched)
}
}
#else
ToolbarItem(placement: .principal) {
// Collapsed title in toolbar
HStack(spacing: 8) {
if collapseProgress > 0.5 {
LazyImage(url: channel.thumbnailURL) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Circle()
.fill(.quaternary)
}
}
.frame(width: 28, height: 28)
.clipShape(Circle())
}
Text(channel.name)
.font(.headline)
.lineLimit(1)
}
.opacity(collapsedTitleOpacity)
}
#endif
ToolbarItem(placement: viewOptionsPlacement) {
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
.liquidGlassTransitionSource(id: "channelViewOptions", in: sheetTransition)
#if os(macOS)
.popover(isPresented: $showViewOptions, arrowEdge: .bottom) {
viewOptionsSheetContent
}
#endif
}
// macOS: channel avatar + name sit on the leading edge, next to view
// options. Excluded from the shared glass background so the view
// options button keeps its own capsule and the title renders plain.
#if os(macOS)
if #available(macOS 26, *) {
ToolbarItem(placement: .navigation) {
collapsedToolbarTitle(name: channel.name, thumbnailURL: channel.thumbnailURL)
}
.sharedBackgroundVisibility(.hidden)
} else {
ToolbarItem(placement: .navigation) {
collapsedToolbarTitle(name: channel.name, thumbnailURL: channel.thumbnailURL)
}
}
#endif
// iOS keeps a fixed gap between the trailing view options and channel
// menu buttons; on macOS view options moves to the leading edge, so no
// gap is needed here.
#if os(iOS)
if #available(iOS 26, *) {
ToolbarSpacer(.fixed, placement: .primaryAction)
}
#endif
ToolbarItem(placement: .primaryAction) {
channelMenu
}
}
#if !os(macOS)
.sheet(isPresented: $showViewOptions) {
viewOptionsSheetContent
.liquidGlassSheetContent(sourceID: "channelViewOptions", in: sheetTransition)
}
#endif
#if os(iOS)
.toolbarBackground(collapseProgress > 0.8 ? .visible : .hidden, for: .navigationBar)
.navigationBarTitleDisplayMode(.inline)
#endif
.confirmationDialog(
String(localized: "channel.unsubscribe.confirmation.title"),
isPresented: $showingUnsubscribeConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "channel.unsubscribe.confirmation.action"), role: .destructive) {
unsubscribe()
}
Button(String(localized: "common.cancel"), role: .cancel) {}
} message: {
Text(String(localized: "channel.unsubscribe.confirmation.message"))
}
.presentationCompactAdaptation(.sheet)
}
#endif
// MARK: - Loading Content
/// Shows cached header with a spinner below while loading full channel data.
@ViewBuilder
private func loadingContent(_ cached: CachedChannelData) -> some View {
#if os(tvOS)
tvOSLoadingContent(cached)
#else
iOSLoadingContent(cached)
#endif
}
#if !os(tvOS)
@ViewBuilder
private func iOSLoadingContent(_ cached: CachedChannelData) -> some View {
GeometryReader { geometry in
ScrollView {
LazyVStack(spacing: 0) {
header(name: cached.name, thumbnailURL: cached.thumbnailURL, bannerURL: cached.bannerURL)
.id("channelTop")
// Show tab picker during loading (doesn't depend on channel)
// On macOS the content type picker lives in the toolbar instead.
#if os(macOS)
Color.clear.frame(height: 16)
#else
if supportsChannelTabs {
contentTypePicker
.padding(.horizontal)
.padding(.vertical, 8)
}
#endif
// Centered spinner for content area below tabs
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 40)
}
}
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
}
.background(viewBackgroundColor)
.ignoresSafeArea(edges: .top)
.softTopScrollEdgeEffect()
#if os(macOS)
.overlay(alignment: .top) {
legacyToolbarScrim
}
#endif
.toolbar {
#if os(macOS)
if supportsChannelTabs {
ToolbarItem(placement: .principal) {
contentTypePicker
.fixedSize()
}
}
#else
ToolbarItem(placement: .principal) {
// Collapsed title in toolbar (using cached data)
HStack(spacing: 8) {
if collapseProgress > 0.5 {
LazyImage(url: cached.thumbnailURL) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Circle()
.fill(.ultraThinMaterial)
}
}
.frame(width: collapsedAvatarSize, height: collapsedAvatarSize)
.clipShape(Circle())
Text(cached.name)
.font(.headline)
.lineLimit(1)
}
}
.opacity(collapsedTitleOpacity)
}
#endif
ToolbarItem(placement: viewOptionsPlacement) {
Button {
showViewOptions = true
} label: {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
.liquidGlassTransitionSource(id: "channelViewOptions", in: sheetTransition)
#if os(macOS)
.popover(isPresented: $showViewOptions, arrowEdge: .bottom) {
viewOptionsSheetContent
}
#endif
}
// macOS: channel avatar + name sit on the leading edge, next to view
// options. Excluded from the shared glass background so the view
// options button keeps its own capsule and the title renders plain.
#if os(macOS)
if #available(macOS 26, *) {
ToolbarItem(placement: .navigation) {
collapsedToolbarTitle(name: cached.name, thumbnailURL: cached.thumbnailURL)
}
.sharedBackgroundVisibility(.hidden)
} else {
ToolbarItem(placement: .navigation) {
collapsedToolbarTitle(name: cached.name, thumbnailURL: cached.thumbnailURL)
}
}
#endif
// iOS keeps a fixed gap between the trailing view options and channel
// menu buttons; on macOS view options moves to the leading edge, so no
// gap is needed here.
#if os(iOS)
if #available(iOS 26, *) {
ToolbarSpacer(.fixed, placement: .primaryAction)
}
#endif
ToolbarItem(placement: .primaryAction) {
channelMenu
}
}
#if !os(macOS)
.sheet(isPresented: $showViewOptions) {
viewOptionsSheetContent
.liquidGlassSheetContent(sourceID: "channelViewOptions", in: sheetTransition)
}
#endif
#if os(iOS)
.toolbarBackground(collapseProgress > 0.8 ? .visible : .hidden, for: .navigationBar)
.navigationBarTitleDisplayMode(.inline)
#endif
.modifier(ChannelScrollOffsetModifier(
scrollOffset: $scrollOffset,
isPlayerExpanded: appEnvironment?.navigationCoordinator.isPlayerExpanded ?? false
))
.confirmationDialog(
String(localized: "channel.unsubscribe.confirmation.title"),
isPresented: $showingUnsubscribeConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "channel.unsubscribe.confirmation.action"), role: .destructive) {
unsubscribe()
}
Button(String(localized: "common.cancel"), role: .cancel) {}
} message: {
Text(String(localized: "channel.unsubscribe.confirmation.message"))
}
.presentationCompactAdaptation(.sheet)
}
#endif
#if os(tvOS)
// MARK: - tvOS Two-Column Layout
@ViewBuilder
private func tvOSChannelContent(_ channel: Channel) -> some View {
GeometryReader { geometry in
let leftWidth = geometry.size.width * 0.30
HStack(alignment: .top, spacing: 40) {
tvOSLeftColumn(channel: channel, geometry: geometry)
.frame(width: leftWidth, alignment: .leading)
.focusSection()
tvOSRightColumn
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.focusSection()
}
.padding(.horizontal, 60)
.padding(.vertical, 40)
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
}
.confirmationDialog(
String(localized: "channel.unsubscribe.confirmation.title"),
isPresented: $showingUnsubscribeConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "channel.unsubscribe.confirmation.action"), role: .destructive) {
unsubscribe()
}
Button(String(localized: "common.cancel"), role: .cancel) {}
} message: {
Text(String(localized: "channel.unsubscribe.confirmation.message"))
}
}
@ViewBuilder
private func tvOSLeftColumn(channel: Channel, geometry: GeometryProxy) -> some View {
tvOSLeftColumnContent(
name: channel.name,
thumbnailURL: channel.thumbnailURL,
subscriberCount: channel.subscriberCount,
description: channel.description
)
}
@ViewBuilder
private func tvOSLeftColumnContent(
name: String,
thumbnailURL: URL?,
subscriberCount: Int?,
description: String?
) -> some View {
VStack(alignment: .leading, spacing: 24) {
tvOSAvatar(name: name, thumbnailURL: thumbnailURL)
.frame(maxWidth: .infinity)
Text(name)
.font(.title2)
.fontWeight(.bold)
.foregroundStyle(.white)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
if let subscriberCount {
Text(CountFormatter.compact(subscriberCount))
.fontWeight(.bold)
+ Text(verbatim: " ")
+ Text(String(localized: "channel.subscribers"))
}
if !isDescriptionScrollLocked {
tvOSSubscribeButton
tvOSSubscriptionDestinationsButton
}
if let description, !description.isEmpty {
TVScrollableDescription(
description: description,
isScrollLocked: $isDescriptionScrollLocked
)
.frame(maxHeight: .infinity)
} else {
Spacer(minLength: 0)
}
}
.padding(.vertical, 8)
.frame(maxHeight: .infinity, alignment: .top)
.animation(.easeInOut(duration: 0.25), value: isDescriptionScrollLocked)
}
@ViewBuilder
private func tvOSAvatar(for channel: Channel) -> some View {
tvOSAvatar(name: channel.name, thumbnailURL: channel.thumbnailURL)
}
@ViewBuilder
private func tvOSAvatar(name: String, thumbnailURL: URL?) -> some View {
LazyImage(url: thumbnailURL) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Circle()
.fill(.ultraThinMaterial)
.overlay {
Text(String(name.prefix(1)))
.font(.system(size: 56, weight: .semibold))
.foregroundStyle(.secondary)
}
}
}
.frame(width: 140, height: 140)
.clipShape(Circle())
.overlay(
Circle()
.stroke(.white.opacity(0.8), lineWidth: 3)
)
.shadow(color: .black.opacity(0.4), radius: 10, y: 5)
}
private var tvOSSubscribeButton: some View {
Button {
toggleSubscription()
} label: {
Label(
isSubscribed
? String(localized: "channel.menu.unsubscribe")
: String(localized: "channel.menu.subscribe"),
systemImage: isSubscribed ? "person.fill.xmark" : "person.badge.plus"
)
.frame(maxWidth: .infinity, minHeight: 60)
.font(.headline)
}
.buttonStyle(.borderedProminent)
.disabled(!allowsCurrentContextMutation)
.focused($isSubscribeFocused)
}
/// The destination list, as its own button: a menu is unusable with a
/// remote, and the primary tap has to stay the current account's toggle.
@ViewBuilder
private var tvOSSubscriptionDestinationsButton: some View {
if let channelToSubscribe, SubscriptionDestinations.areOffered(in: appEnvironment) {
Button {
showingSubscriptionDestinations = true
} label: {
Label(
String(localized: "channel.menu.subscribeOn"),
systemImage: "person.crop.circle.badge.plus"
)
.frame(maxWidth: .infinity, minHeight: 60)
}
.buttonStyle(.bordered)
.fullScreenCover(isPresented: $showingSubscriptionDestinations) {
SubscriptionDestinationPicker(
channel: channelToSubscribe,
membership: destinationMembership
) { context, subscribed in
destinationDidToggle(context, subscribed: subscribed)
}
}
}
}
private var tvOSTabButtons: some View {
HStack(spacing: 12) {
ForEach([ChannelTab.videos, .shorts, .streams, .playlists]) { tab in
tvOSTabButton(for: tab)
}
if supportsChannelSearch {
tvOSSearchTabButton
}
}
}
@ViewBuilder
private var tvOSSearchTabButton: some View {
let isSelected = tvOSShowSearchTab
let action = {
tvOSFocusedTab = .searchTab
if !tvOSShowSearchTab {
tvOSShowSearchTab = true
isSearchActive = true
}
}
let label = Label(String(localized: "search.title"), systemImage: "magnifyingglass")
.fontWeight(isSelected ? .bold : .regular)
.lineLimit(1)
Button(action: action) { label }
.buttonStyle(.borderedProminent)
.tint(isSelected ? accentColor : Color.gray.opacity(0.3))
.focused($tvOSFocusedTab, equals: .searchTab)
}
@ViewBuilder
private func tvOSTabButton(for tab: ChannelTab) -> some View {
let isSelected = !tvOSShowSearchTab && selectedTab == tab
let action = {
tvOSFocusedTab = .tab(tab)
if tvOSShowSearchTab {
tvOSShowSearchTab = false
isSearchActive = false
}
if selectedTab != tab {
selectedTab = tab
Task {
await loadTabContentIfNeeded(tab)
}
}
}
let label = Label(tab.title, systemImage: tab.systemImage)
.fontWeight(isSelected ? .bold : .regular)
.lineLimit(1)
Button(action: action) { label }
.buttonStyle(.borderedProminent)
.tint(isSelected ? accentColor : Color.gray.opacity(0.3))
.focused($tvOSFocusedTab, equals: .tab(tab))
}
private var tvOSRightColumn: some View {
VStack(alignment: .leading, spacing: 20) {
if supportsChannelTabs {
tvOSTabButtons
}
if tvOSShowSearchTab {
tvOSChannelSearchContent
} else {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Group {
switch selectedTab {
case .videos:
videosGrid
case .shorts:
shortsGrid
case .streams:
streamsGrid
case .playlists:
playlistsGrid
case .about:
videosGrid
}
}
.id(selectedTab)
}
.padding(.vertical, 20)
}
.scrollClipDisabled()
}
}
}
private var tvOSChannelSearchContent: some View {
VStack(alignment: .leading, spacing: 20) {
HStack(spacing: 24) {
TextField(String(localized: "search.placeholder"), text: $searchText)
.textFieldStyle(.plain)
.focused($isTVSearchFieldFocused)
.onSubmit {
Task { await performSearch() }
}
}
.focusSection()
ScrollView {
if hasSearched || isSearchLoading {
searchResultsContent
.padding(.vertical, 20)
}
}
.scrollClipDisabled()
}
.onAppear {
isTVSearchFieldFocused = true
}
}
@ViewBuilder
private func tvOSLoadingContent(_ cached: CachedChannelData) -> some View {
GeometryReader { geometry in
let leftWidth = geometry.size.width * 0.30
HStack(alignment: .top, spacing: 40) {
tvOSLeftColumnContent(
name: cached.name,
thumbnailURL: cached.thumbnailURL,
subscriberCount: cached.subscriberCount,
description: cached.description
)
.frame(width: leftWidth, alignment: .leading)
.focusSection()
VStack {
Spacer()
ProgressView()
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.padding(.horizontal, 60)
.padding(.vertical, 40)
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
}
}
#endif
// MARK: - Header
#if os(macOS)
/// Pre-macOS 26 has no Liquid Glass toolbar capsules, so toolbar items sit
/// directly on the banner/scrolled content. A fixed window-background scrim
/// at the top keeps them legible in both appearances.
@ViewBuilder
private var legacyToolbarScrim: some View {
if #unavailable(macOS 26) {
LinearGradient(
stops: [
.init(color: Color(nsColor: .windowBackgroundColor).opacity(0.9), location: 0),
.init(color: Color(nsColor: .windowBackgroundColor).opacity(0.75), location: 0.5),
.init(color: .clear, location: 1)
],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 100)
.frame(maxWidth: .infinity)
.allowsHitTesting(false)
.ignoresSafeArea(edges: .top)
}
}
@ViewBuilder
private func collapsedToolbarTitle(name: String, thumbnailURL: URL?) -> some View {
let label = HStack(spacing: 8) {
LazyImage(url: thumbnailURL) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Circle()
.fill(.quaternary)
}
}
.frame(width: 28, height: 28)
.clipShape(Circle())
Text(name)
.font(.headline)
.lineLimit(1)
}
.padding(.horizontal, 10)
// Own glass capsule; the toolbar item opts out of the shared background
// so this doesn't merge with the view options button.
if #available(macOS 26, *) {
label
.padding(.vertical, 5)
.glassEffect()
} else {
label
}
}
#endif
private func header(_ channel: Channel) -> some View {
header(name: channel.name, thumbnailURL: channel.thumbnailURL, bannerURL: channel.bannerURL)
}
private func header(name: String, thumbnailURL: URL?, bannerURL: URL?) -> some View {
GeometryReader { geometry in
// Calculate avatar vertical position
let avatarBottomPadding: CGFloat = 20
#if os(macOS)
let headerAvatarSize = avatarSize
let nameSpace: CGFloat = 0
#else
// Smoothly interpolate the space for channel name (40pt when visible, 0 when collapsed)
// Name starts fading at progress 0, fully gone at 0.3
let nameSpaceProgress = min(collapseProgress / 0.3, 1.0)
let nameSpace: CGFloat = 40 * (1 - nameSpaceProgress)
let headerAvatarSize = currentAvatarSize
#endif
let avatarContentHeight: CGFloat = headerAvatarSize + nameSpace + avatarBottomPadding
let idealAvatarY = headerHeight - avatarContentHeight
// Minimum Y position to prevent going into nav bar
// On iOS 18+ iPhone, the search bar is present in the navigation area (~56pt + padding)
// We add extra height to the header, so minAvatarY just needs to clear nav bar + search
let searchBarReservedHeight: CGFloat = needsSearchBarAdjustment ? 70 : 0
let minAvatarY: CGFloat = 60 + searchBarReservedHeight
let clampedAvatarY = max(idealAvatarY, minAvatarY)
// iPad/Mac uses backgroundExtensionEffect (no zoom/scale needed)
// iPhone uses zoom/scale + pinOffset to keep banner fixed
let isRegularSizeClass = horizontalSizeClass != .compact
// Pin offset: when pulling down (scrollOffset < 0), offset banner up to keep it pinned (iPhone only)
let pinOffset = (!isRegularSizeClass && scrollOffset < 0) ? scrollOffset : 0
// Zoom scale for banner and gradient (iPhone only)
let zoomScale = isRegularSizeClass ? 1 : (1 + max(-scrollOffset / 400, 0))
ZStack(alignment: .top) {
// Banner image
// iPad/Mac: static with backgroundExtensionEffect for Liquid Glass sidebar
// iPhone: zoom/scale + pinOffset to keep banner fixed at top when pulling down
bannerImage(url: bannerURL)
.frame(width: geometry.size.width, height: headerHeight)
.clipped()
.scaleEffect(zoomScale, anchor: .top)
.offset(y: pinOffset)
.modifier(BackgroundExtensionModifier(
useBackgroundExtension: isRegularSizeClass
))
// Gradient overlay for readability - matches banner scale and position
LinearGradient(
colors: [.clear, .black.opacity(0.6)],
startPoint: .center,
endPoint: .bottom
)
.frame(width: geometry.size.width, height: headerHeight * 0.7)
.offset(y: headerHeight * 0.3)
.scaleEffect(zoomScale, anchor: .top)
.offset(y: pinOffset)
// Avatar and channel name - pinned to bottom of banner
#if os(macOS)
let avatarOpacity: CGFloat = 1
#else
// Smooth opacity fade based on collapse progress
let avatarOpacity = max(0, 1.0 - collapseProgress * 1.4)
// Channel name fades out faster than avatar (starts fading at 0, gone by 0.3)
let nameOpacity = max(0, 1.0 - collapseProgress * 3.3)
#endif
VStack(spacing: 8) {
LazyImage(url: thumbnailURL) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Circle()
.fill(.ultraThinMaterial)
.overlay {
Text(String(name.prefix(1)))
.font(.system(size: headerAvatarSize * 0.4, weight: .semibold))
.foregroundStyle(.secondary)
}
}
}
.frame(width: headerAvatarSize, height: headerAvatarSize)
.clipShape(Circle())
.overlay(
Circle()
.stroke(.white.opacity(0.8), lineWidth: 3)
)
.shadow(color: .black.opacity(0.4), radius: 10, y: 5)
#if !os(macOS)
// Channel name with smooth fade
Text(name)
.font(.title2)
.fontWeight(.bold)
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.6), radius: 4, y: 2)
.opacity(nameOpacity)
#endif
}
.offset(y: clampedAvatarY + pinOffset)
.opacity(avatarOpacity)
}
.frame(height: headerHeight)
}
.frame(height: headerHeight)
}
// MARK: - Banner Image
@ViewBuilder
private func bannerImage(url bannerURL: URL?) -> some View {
if let bannerURL {
LazyImage(url: bannerURL) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
LinearGradient(
colors: [.gray.opacity(0.3), .gray.opacity(0.5)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
} else {
// Gradient placeholder when no banner
LinearGradient(
colors: [.accentColor.opacity(0.6), .accentColor.opacity(0.3)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
// MARK: - Channel Description
/// Channel description for non-tab instances (shows full description)
@ViewBuilder
private func channelDescription(_ channel: Channel) -> some View {
if let description = channel.description, !description.isEmpty {
Text(description)
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.bottom, 8)
}
}
/// Whether the current instance supports channel tabs (Invidious, Yattee Server, or Piped - not external channels)
private var supportsChannelTabs: Bool {
// External channels only show videos (no tabs)
guard !isExternalChannel else { return false }
guard let instance = instanceForSource() else {
return false
}
return instance.type == .invidious || instance.type == .yatteeServer || instance.type == .piped
}
/// Whether the current instance supports channel search (Invidious or Yattee Server, not external channels or Piped)
private var supportsChannelSearch: Bool {
// External channels don't support search
guard !isExternalChannel else { return false }
guard let instance = instanceForSource() else { return false }
return instance.type == .invidious || instance.type == .yatteeServer
}
// MARK: - Channel Menu
/// Icon for the channel menu based on subscription/notification state
private var channelMenuIcon: String {
if isSubscribed {
// Use the immediately-available channel ID so notification status is correct
// during loading, before the network-loaded `channel` is set.
let effectiveChannelID = channel?.id.channelID ?? channelID
let notificationsEnabled = appEnvironment?.dataManager.notificationsEnabled(for: effectiveChannelID) ?? false
return notificationsEnabled ? "bell.fill" : "person.fill"
}
return "person.badge.plus"
}
/// Toolbar menu for subscribe/unsubscribe and notification actions
private var channelMenu: some View {
Menu {
// Subscribe/Unsubscribe button, naming the account it writes to —
// the same answer "Subscribe On…" would give for the same target.
Button {
toggleSubscription()
} label: {
Label(
mainSubscribeTitle,
systemImage: isSubscribed ? "person.fill.xmark" : "person.badge.plus"
)
}
.disabled(!allowsCurrentContextMutation)
if !allowsCurrentContextMutation {
Text(String(localized: "channel.subscribe.contextUnavailable"))
}
// Naming a destination is a different decision from the one the
// main button makes, so it survives an unusable current context.
// tvOS reaches the same list through its own button — this menu is
// never presented there.
#if !os(tvOS)
if let channelToSubscribe {
SubscriptionDestinationMenu(
channel: channelToSubscribe,
membership: destinationMembership
) { context, subscribed in
destinationDidToggle(context, subscribed: subscribed)
}
}
#endif
// Notifications toggle (only visible when subscribed)
if isSubscribed {
let effectiveChannelID = channel?.id.channelID ?? channelID
let notificationsEnabled = appEnvironment?.dataManager.notificationsEnabled(for: effectiveChannelID) ?? false
Button {
toggleNotifications()
} label: {
Label(
notificationsEnabled
? String(localized: "channel.menu.disableNotifications")
: String(localized: "channel.menu.enableNotifications"),
systemImage: notificationsEnabled
? "bell.slash"
: "bell"
)
}
}
} label: {
// A bare Image leaves the menu's accessibility title as the raw SF
// Symbol name; a Label carries the localized title to the AX bridge
// while `.iconOnly` keeps the toolbar rendering icon-only.
Label(String(localized: "channel.menu.title"), systemImage: channelMenuIcon)
.labelStyle(.iconOnly)
}
.accessibilityLabel(Text(String(localized: "channel.menu.title")))
}
// MARK: - Subscription Actions
/// Whether the main button may write to the account being browsed.
///
/// Local always may. A server account may only while it is usable: a
/// subscribe that cannot reach its account must be refused rather than
/// land in local data the user did not choose.
/// "Subscribe - Yattee iCloud" rather than a bare "Subscribe" — but only
/// while there is more than one place the write could land. With a single
/// destination the name answers a question nobody has.
private var mainSubscribeTitle: String {
guard let appEnvironment, SubscriptionDestinations.areOffered(in: appEnvironment) else {
return String(localized: isSubscribed ? "channel.menu.unsubscribe" : "channel.menu.subscribe")
}
let key: String.LocalizationValue = isSubscribed
? "channel.menu.unsubscribe.withAccount"
: "channel.menu.subscribe.withAccount"
let title = DataContextSwitcherLabels.title(
for: appEnvironment.accountsManager.currentContext,
in: appEnvironment
)
return String(format: String(localized: key), title)
}
private var allowsCurrentContextMutation: Bool {
guard let appEnvironment else { return false }
guard !appEnvironment.accountsManager.currentContext.isLocal else { return true }
return appEnvironment.dataContextValidator.allowsServerMutations
}
/// The channel as it must be stored: the id carries the source this view
/// was opened for, which an extracted channel does not report itself.
///
/// Before the load answers, the cached header stands in — the id is already
/// known and the header carries the name a subscription stores — so
/// "Subscribe On…" is usable as soon as the main button is, not only once
/// the channel request finishes. External channels keep waiting: their real
/// id only exists after extraction.
private var channelToSubscribe: Channel? {
if let channel {
return Channel(
id: ChannelID(source: source, channelID: channel.id.channelID),
name: channel.name,
subscriberCount: channel.subscriberCount,
thumbnailURL: channel.thumbnailURL
)
}
guard !isExternalChannel, let cachedHeader, !cachedHeader.name.isEmpty else { return nil }
return Channel(
id: ChannelID(source: source, channelID: channelID),
name: cachedHeader.name,
subscriberCount: cachedHeader.subscriberCount,
thumbnailURL: cachedHeader.thumbnailURL
)
}
private func toggleSubscription() {
if isSubscribed {
showingUnsubscribeConfirmation = true
} else {
Task { await subscribe() }
}
}
private func subscribe() async {
guard let channelToSubscribe,
let subscriptionService = appEnvironment?.subscriptionService,
let dataManager = appEnvironment?.dataManager else { return }
do {
try await subscriptionService.subscribe(to: channelToSubscribe)
// Set default notification preference for new subscription
let defaultNotifications = appEnvironment?.settingsManager.defaultNotificationsForNewChannels ?? false
if defaultNotifications {
dataManager.setNotificationsEnabled(true, for: channelToSubscribe.id.channelID)
}
isSubscribed = true
noteMainButtonToggled(subscribed: true)
refreshSubscription()
showSubscriptionToast(subscribed: true)
} catch {
appEnvironment?.toastManager.showError(
String(localized: "channel.subscribe.error.title"),
subtitle: error.localizedDescription
)
}
}
/// The same toast a destination row shows, for the main button's write:
/// title says what happened, subtitle names the account it landed on —
/// under the same rule as the menu label, only when there is more than one
/// account it could have landed on.
private func showSubscriptionToast(subscribed: Bool) {
guard let appEnvironment else { return }
let subtitle: String? = SubscriptionDestinations.areOffered(in: appEnvironment)
? DataContextSwitcherLabels.title(
for: appEnvironment.accountsManager.currentContext,
in: appEnvironment
)
: nil
appEnvironment.toastManager.showSuccess(
String(
localized: subscribed
? "channel.subscribe.destination.subscribed"
: "channel.subscribe.destination.unsubscribed"
),
subtitle: subtitle
)
}
/// The main button wrote to the currently selected context; the destination
/// row naming that context still carries the mark from before the write, so
/// it is told — otherwise its next tap does the opposite of what it shows.
private func noteMainButtonToggled(subscribed: Bool) {
guard let context = appEnvironment?.accountsManager.currentContext else { return }
destinationMembership.noteToggled(context, subscribed: subscribed)
}
/// A destination row toggled something. Only the account on screen changes
/// what the main button says, and notification preferences are local and
/// keyed by channel, so they follow the same rule.
///
/// Compared against the *effective* context, which is what the button reads
/// from: while the selected account is unavailable the button speaks for
/// local data, and the menu offers Local precisely so the user can act on it
/// — a toggle there has to be the one the button then shows.
private func destinationDidToggle(_ context: DataContext, subscribed: Bool) {
guard context == appEnvironment?.dataContextValidator.effectiveContext else { return }
if subscribed,
appEnvironment?.settingsManager.defaultNotificationsForNewChannels == true,
let channelID = channelToSubscribe?.id.channelID {
appEnvironment?.dataManager.setNotificationsEnabled(true, for: channelID)
}
refreshSubscription()
}
private func unsubscribe() {
guard let channel else { return }
Task {
do {
try await appEnvironment?.subscriptionService.unsubscribe(from: channel.id.channelID)
isSubscribed = false
noteMainButtonToggled(subscribed: false)
showSubscriptionToast(subscribed: false)
} catch {
appEnvironment?.toastManager.showError(
String(localized: "channel.unsubscribe.error.title"),
subtitle: error.localizedDescription
)
}
}
}
private func toggleNotifications() {
guard let channel else { return }
let currentEnabled = appEnvironment?.dataManager.notificationsEnabled(for: channel.id.channelID) ?? false
if currentEnabled {
appEnvironment?.dataManager.setNotificationsEnabled(false, for: channel.id.channelID)
} else {
Task {
guard let appEnvironment, await appEnvironment.ensureNotificationsEnabled() else { return }
appEnvironment.dataManager.setNotificationsEnabled(true, for: channel.id.channelID)
}
}
}
// MARK: - Content Type Picker
/// Pill-style segmented picker for switching between content types
private var contentTypePicker: some View {
Picker("", selection: $selectedTab) {
Text(ChannelTab.about.title).tag(ChannelTab.about)
Text(ChannelTab.videos.title).tag(ChannelTab.videos)
Text(ChannelTab.shorts.title).tag(ChannelTab.shorts)
Text(ChannelTab.streams.title).tag(ChannelTab.streams)
Text(ChannelTab.playlists.title).tag(ChannelTab.playlists)
}
.pickerStyle(.segmented)
.onChange(of: selectedTab) { oldTab, newTab in
if oldTab != newTab {
scrollToTop = true
Task {
await loadTabContentIfNeeded(newTab)
}
}
}
}
// MARK: - Tab Content
@ViewBuilder
private var tabContent: some View {
switch selectedTab {
case .about:
aboutContent
case .videos:
videosGrid
case .playlists:
playlistsGrid
case .shorts:
shortsGrid
case .streams:
streamsGrid
}
}
// MARK: - About Content
@ViewBuilder
private var aboutContent: some View {
if let channel {
let hasSubscriberCount = channel.subscriberCount != nil
let hasDescription = channel.description?.isEmpty == false
if hasSubscriberCount || hasDescription {
let content = VStack(alignment: .leading, spacing: 12) {
// Subscriber count (count is bold, "subscribers" is regular)
if let subscriberCount = channel.subscriberCount {
Text(CountFormatter.compact(subscriberCount))
.fontWeight(.bold)
+ Text(verbatim: " ")
+ Text(String(localized: "channel.subscribers"))
}
// Description
if let description = channel.description, !description.isEmpty {
Text(DescriptionText.attributed(description, linkColor: accentColor))
.font(.subheadline)
.tint(accentColor)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showInsetBackground {
content
.background(ListBackgroundStyle.card.color)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
} else {
content
}
} else {
ContentUnavailableView {
Label(String(localized: "channel.noDescription"), systemImage: "text.alignleft")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
}
}
}
// MARK: - Videos Grid
/// Queue source for continuation loading
private var videosQueueSource: QueueSource {
.channel(channelID: channelID, source: source, continuation: videosContinuation)
}
@ViewBuilder
private var videosGrid: some View {
switch layout {
case .list:
videosListContent
case .grid:
videosGridContent
}
}
@ViewBuilder
private var videosListContent: some View {
if videosLoaded && filteredVideos.isEmpty {
ContentUnavailableView {
Label(String(localized: "channel.noVideos"), systemImage: "play.rectangle")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
} else if !filteredVideos.isEmpty {
VideoListContent(listStyle: listStyle) {
ForEach(Array(filteredVideos.enumerated()), id: \.element.id) { index, video in
VideoListRow(
isLast: index == filteredVideos.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
VideoRowView(video: video, style: rowStyle, watchProgress: watchProgress(for: video))
.tappableVideo(
video,
queueSource: videosQueueSource,
sourceLabel: channel?.name,
videoList: filteredVideos,
videoIndex: index,
loadMoreVideos: loadMoreVideosCallback
)
}
#if !os(tvOS)
.videoSwipeActions(video: video)
#endif
}
// External channels use manual load more button
if isExternalChannel {
externalLoadMoreButton
} else {
LoadMoreTrigger(
isLoading: isLoadingMore && selectedTab == .videos,
hasMore: videosContinuation != nil
) {
Task { await loadMoreVideos() }
}
}
}
}
}
/// Manual load more button for external channels
private var externalLoadMoreButton: some View {
Group {
if videosContinuation != nil {
Button {
Task { await loadMoreVideos() }
} label: {
HStack {
if isLoadingMore {
ProgressView()
.padding(.trailing, 8)
}
Text(isLoadingMore
? String(localized: "externalChannel.loadingMore")
: String(localized: "externalChannel.loadMore"))
}
.frame(maxWidth: .infinity)
.padding()
}
.buttonStyle(.bordered)
.disabled(isLoadingMore)
.padding()
}
}
}
@ViewBuilder
private var videosGridContent: some View {
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(Array(filteredVideos.enumerated()), id: \.element.id) { index, video in
VideoCardView(video: video, watchProgress: watchProgress(for: video), isCompact: gridConfig.isCompactCards)
.tappableVideo(
video,
queueSource: videosQueueSource,
sourceLabel: channel?.name,
videoList: filteredVideos,
videoIndex: index,
loadMoreVideos: loadMoreVideosCallback
)
// Load more trigger (automatic for regular channels)
if !isExternalChannel && video.id == filteredVideos.last?.id && videosContinuation != nil && !isLoadingMore {
Color.clear
.frame(height: 1)
.onAppear {
Task {
await loadMoreVideos()
}
}
}
}
}
if videosLoaded && filteredVideos.isEmpty {
ContentUnavailableView {
Label(String(localized: "channel.noVideos"), systemImage: "play.rectangle")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
}
// External channels use manual load more button
if isExternalChannel {
externalLoadMoreButton
} else if isLoadingMore && selectedTab == .videos {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
}
// MARK: - Playlists Grid
@ViewBuilder
private var playlistsGrid: some View {
switch layout {
case .list:
playlistsListContent
case .grid:
playlistsGridContent
}
}
@ViewBuilder
private var playlistsListContent: some View {
// Loading state
if !playlistsLoaded && playlists.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
.onAppear {
Task { await loadPlaylists() }
}
}
if playlistsLoaded && playlists.isEmpty {
ContentUnavailableView {
Label(String(localized: "channel.noPlaylists"), systemImage: "list.bullet.rectangle")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
} else if !playlists.isEmpty {
VideoListContent(listStyle: listStyle) {
ForEach(Array(playlists.enumerated()), id: \.element.id) { index, playlist in
VideoListRow(
isLast: index == playlists.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
ChannelPlaylistRow(playlist: playlist, style: rowStyle)
}
}
LoadMoreTrigger(
isLoading: isLoadingMore && selectedTab == .playlists,
hasMore: playlistsContinuation != nil
) {
Task { await loadMorePlaylists() }
}
}
}
}
@ViewBuilder
private var playlistsGridContent: some View {
if !playlistsLoaded && playlists.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
.onAppear {
Task {
await loadPlaylists()
}
}
}
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(playlists) { playlist in
NavigationLink(value: NavigationDestination.playlist(.remote(playlist.id, instance: nil, title: playlist.title))) {
PlaylistCardView(playlist: playlist, isCompact: gridConfig.isCompactCards)
.contentShape(Rectangle())
}
.zoomTransitionSource(id: playlist.id)
.buttonStyle(.plain)
// Load more trigger
if playlist.id == playlists.last?.id && playlistsContinuation != nil && !isLoadingMore {
Color.clear
.frame(height: 1)
.onAppear {
Task {
await loadMorePlaylists()
}
}
}
}
}
if playlistsLoaded && playlists.isEmpty {
ContentUnavailableView {
Label(String(localized: "channel.noPlaylists"), systemImage: "list.bullet.rectangle")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
}
if isLoadingMore && selectedTab == .playlists {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
}
// MARK: - Shorts Grid
private var shortsQueueSource: QueueSource {
.channel(channelID: channelID, source: source, continuation: shortsContinuation)
}
@ViewBuilder
private var shortsGrid: some View {
switch layout {
case .list:
shortsListContent
case .grid:
shortsGridContent
}
}
@ViewBuilder
private var shortsListContent: some View {
// Loading state
if !shortsLoaded && filteredShorts.isEmpty && shorts.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
.onAppear {
Task { await loadShorts() }
}
}
if shortsLoaded && filteredShorts.isEmpty {
ContentUnavailableView {
Label(String(localized: "channel.noShorts"), systemImage: "bolt")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
} else if !filteredShorts.isEmpty {
VideoListContent(listStyle: listStyle) {
ForEach(Array(filteredShorts.enumerated()), id: \.element.id) { index, video in
VideoListRow(
isLast: index == filteredShorts.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
VideoRowView(video: video, style: rowStyle, watchProgress: watchProgress(for: video))
.tappableVideo(
video,
queueSource: shortsQueueSource,
sourceLabel: channel?.name,
videoList: filteredShorts,
videoIndex: index,
loadMoreVideos: loadMoreShortsCallback
)
}
#if !os(tvOS)
.videoSwipeActions(video: video)
#endif
}
LoadMoreTrigger(
isLoading: isLoadingMore && selectedTab == .shorts,
hasMore: shortsContinuation != nil
) {
Task { await loadMoreShorts() }
}
}
}
}
@ViewBuilder
private var shortsGridContent: some View {
if !shortsLoaded && filteredShorts.isEmpty && shorts.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
.onAppear {
Task {
await loadShorts()
}
}
}
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(Array(filteredShorts.enumerated()), id: \.element.id) { index, video in
VideoCardView(video: video, watchProgress: watchProgress(for: video), isCompact: gridConfig.isCompactCards)
.tappableVideo(
video,
queueSource: shortsQueueSource,
sourceLabel: channel?.name,
videoList: filteredShorts,
videoIndex: index,
loadMoreVideos: loadMoreShortsCallback
)
// Load more trigger
if video.id == filteredShorts.last?.id && shortsContinuation != nil && !isLoadingMore {
Color.clear
.frame(height: 1)
.onAppear {
Task {
await loadMoreShorts()
}
}
}
}
}
if shortsLoaded && filteredShorts.isEmpty {
ContentUnavailableView {
Label(String(localized: "channel.noShorts"), systemImage: "bolt")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
}
if isLoadingMore && selectedTab == .shorts {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
}
// MARK: - Streams Grid
private var streamsQueueSource: QueueSource {
.channel(channelID: channelID, source: source, continuation: streamsContinuation)
}
@ViewBuilder
private var streamsGrid: some View {
switch layout {
case .list:
streamsListContent
case .grid:
streamsGridContent
}
}
@ViewBuilder
private var streamsListContent: some View {
// Loading state
if !streamsLoaded && filteredStreams.isEmpty && streams.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
.onAppear {
Task { await loadStreams() }
}
}
if streamsLoaded && filteredStreams.isEmpty {
ContentUnavailableView {
Label(String(localized: "channel.noStreams"), systemImage: "video")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
} else if !filteredStreams.isEmpty {
VideoListContent(listStyle: listStyle) {
ForEach(Array(filteredStreams.enumerated()), id: \.element.id) { index, video in
VideoListRow(
isLast: index == filteredStreams.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
VideoRowView(video: video, style: rowStyle, watchProgress: watchProgress(for: video))
.tappableVideo(
video,
queueSource: streamsQueueSource,
sourceLabel: channel?.name,
videoList: filteredStreams,
videoIndex: index,
loadMoreVideos: loadMoreStreamsCallback
)
}
#if !os(tvOS)
.videoSwipeActions(video: video)
#endif
}
LoadMoreTrigger(
isLoading: isLoadingMore && selectedTab == .streams,
hasMore: streamsContinuation != nil
) {
Task { await loadMoreStreams() }
}
}
}
}
@ViewBuilder
private var streamsGridContent: some View {
if !streamsLoaded && filteredStreams.isEmpty && streams.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
.onAppear {
Task {
await loadStreams()
}
}
}
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(Array(filteredStreams.enumerated()), id: \.element.id) { index, video in
VideoCardView(video: video, watchProgress: watchProgress(for: video), isCompact: gridConfig.isCompactCards)
.tappableVideo(
video,
queueSource: streamsQueueSource,
sourceLabel: channel?.name,
videoList: filteredStreams,
videoIndex: index,
loadMoreVideos: loadMoreStreamsCallback
)
// Load more trigger
if video.id == filteredStreams.last?.id && streamsContinuation != nil && !isLoadingMore {
Color.clear
.frame(height: 1)
.onAppear {
Task {
await loadMoreStreams()
}
}
}
}
}
if streamsLoaded && filteredStreams.isEmpty {
ContentUnavailableView {
Label(String(localized: "channel.noStreams"), systemImage: "video")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
}
if isLoadingMore && selectedTab == .streams {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
}
// MARK: - Search Results
/// Queue source for search results (no continuation since channel search uses pages)
private var searchQueueSource: QueueSource {
.search(query: searchText, continuation: nil)
}
@ViewBuilder
private var searchResultsContent: some View {
if isSearchLoading && searchResults.items.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
} else if searchResults.items.isEmpty {
ContentUnavailableView.search(text: searchText)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
} else {
switch layout {
case .list:
searchResultsListContent
case .grid:
searchResultsGridContent
}
}
}
@ViewBuilder
private var searchResultsListContent: some View {
VideoListContent(listStyle: listStyle) {
ForEach(Array(searchResults.items.enumerated()), id: \.element.id) { index, item in
VideoListRow(
isLast: index == searchResults.items.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
switch item {
case .video(let video):
VideoRowView(video: video, style: rowStyle, watchProgress: watchProgress(for: video))
.tappableVideo(
video,
queueSource: searchQueueSource,
sourceLabel: channel?.name,
videoList: searchResultVideos,
videoIndex: searchResultVideos.firstIndex(where: { $0.id == video.id }) ?? 0,
loadMoreVideos: nil
)
#if !os(tvOS)
.videoSwipeActions(video: video)
#endif
case .playlist(let playlist):
ChannelPlaylistRow(playlist: playlist, style: rowStyle)
}
}
}
LoadMoreTrigger(
isLoading: isSearchLoading,
hasMore: searchResults.nextPage != nil
) {
Task { await loadMoreSearchResults() }
}
}
}
@ViewBuilder
private var searchResultsGridContent: some View {
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(searchResults.items) { item in
switch item {
case .video(let video):
VideoCardView(video: video, watchProgress: watchProgress(for: video), isCompact: gridConfig.isCompactCards)
.tappableVideo(
video,
queueSource: searchQueueSource,
sourceLabel: channel?.name,
videoList: searchResultVideos,
videoIndex: searchResultVideos.firstIndex(where: { $0.id == video.id }) ?? 0,
loadMoreVideos: nil
)
case .playlist(let playlist):
NavigationLink(value: NavigationDestination.playlist(.remote(playlist.id, instance: nil, title: playlist.title))) {
PlaylistCardView(playlist: playlist, isCompact: gridConfig.isCompactCards)
.contentShape(Rectangle())
}
.zoomTransitionSource(id: playlist.id)
.buttonStyle(.plain)
}
// Load more trigger
if item.id == searchResults.items.last?.id && searchResults.nextPage != nil && !isSearchLoading {
Color.clear
.frame(height: 1)
.onAppear {
Task {
await loadMoreSearchResults()
}
}
}
}
}
if isSearchLoading && !searchResults.items.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
}
/// Extracts just the videos from search results for queue navigation
private var searchResultVideos: [Video] {
searchResults.items.compactMap { item in
if case .video(let video) = item {
return video
}
return nil
}
}
// MARK: - Search Loading
private func performSearch() async {
guard !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let appEnvironment,
let instance = instanceForSource() else {
return
}
isSearchLoading = true
hasSearched = true
do {
let results = try await appEnvironment.contentService.channelSearch(
id: channelID,
query: searchText,
instance: instance,
page: 1
)
// Deduplicate items (API may return duplicates)
var seenIDs = Set<String>()
let uniqueItems = results.items.filter { seenIDs.insert($0.id).inserted }
searchResults = ChannelSearchPage(items: uniqueItems, nextPage: results.nextPage)
} catch {
// On error, show empty results
searchResults = .empty
}
isSearchLoading = false
}
private func loadMoreSearchResults() async {
guard let nextPage = searchResults.nextPage,
let appEnvironment,
let instance = instanceForSource(),
!isSearchLoading else {
return
}
isSearchLoading = true
do {
let moreResults = try await appEnvironment.contentService.channelSearch(
id: channelID,
query: searchText,
instance: instance,
page: nextPage
)
// Append new items, avoiding duplicates
let existingIDs = Set(searchResults.items.map(\.id))
let newItems = moreResults.items.filter { !existingIDs.contains($0.id) }
searchResults = ChannelSearchPage(
items: searchResults.items + newItems,
nextPage: newItems.isEmpty ? nil : moreResults.nextPage
)
} catch {
// On error, stop loading more
}
isSearchLoading = false
}
private func clearSearch() {
isSearchActive = false
hasSearched = false
searchResults = .empty
searchText = ""
}
// 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 loadChannel()
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Helpers
private func formatSubscribers(_ count: Int) -> String {
let formatted = CountFormatter.compact(count)
return String(localized: "channel.subscriberCount \(formatted)")
}
/// Returns the appropriate instance for the channel's content source
private func instanceForSource() -> Instance? {
appEnvironment?.instancesManager.instance(for: source)
}
// MARK: - Data Loading
private func loadChannel() async {
if isExternalChannel {
await loadExternalChannel()
} else {
await loadRegularChannel()
}
}
private func loadRegularChannel() async {
guard let appEnvironment,
let instance = instanceForSource() else {
errorMessage = "No instances configured"
isLoading = false
return
}
isLoading = true
errorMessage = nil
refreshSubscription()
// Load cached header data for immediate display
cachedHeader = CachedChannelData.load(for: channelID, using: appEnvironment.dataManager)
// Fetch channel and videos independently to handle partial failures gracefully
async let channelTask: Result<Channel, Error> = await {
do {
return .success(try await appEnvironment.contentService.channel(id: channelID, instance: instance))
} catch {
return .failure(error)
}
}()
async let videosTask: Result<ChannelVideosPage, Error> = await {
do {
return .success(try await appEnvironment.contentService.channelVideos(id: channelID, instance: instance, continuation: nil))
} catch {
return .failure(error)
}
}()
let (channelResult, videosResult) = await (channelTask, videosTask)
await MainActor.run {
var channelAPIFailed = false
var videosAPIFailed = false
// Handle channel result
switch channelResult {
case .success(let loadedChannel):
channel = loadedChannel
// Update subscription metadata if subscribed
appEnvironment.dataManager.updateSubscription(for: channelID, with: loadedChannel)
// Save to recent channels (unless incognito mode is enabled or recent channels disabled)
if appEnvironment.settingsManager.incognitoModeEnabled != true,
appEnvironment.settingsManager.saveRecentChannels {
appEnvironment.dataManager.addRecentChannel(loadedChannel)
}
case .failure(let error):
channelAPIFailed = true
// Channel API failed - build a Channel from cachedHeader so the view can display it
if let cached = cachedHeader {
channel = Channel(
id: ChannelID(source: source, channelID: channelID),
name: cached.name,
description: nil,
subscriberCount: cached.subscriberCount,
thumbnailURL: cached.thumbnailURL,
bannerURL: cached.bannerURL
)
}
LoggingService.shared.error("[ChannelView] Channel API failed", category: .api, details: error.localizedDescription)
}
// Handle videos result
switch videosResult {
case .success(let videosPage):
videos = videosPage.videos
videosContinuation = videosPage.continuation
videosLoaded = true
case .failure(let error):
videosAPIFailed = true
LoggingService.shared.error("[ChannelView] Videos API failed", category: .api, details: error.localizedDescription)
}
// Only show error if both APIs failed and we have no cached data
if channelAPIFailed && videosAPIFailed && channel == nil {
errorMessage = String(localized: "channelView.loadError")
}
isLoading = false
}
}
private func loadExternalChannel() async {
guard let appEnvironment,
let url = channelURL else {
errorMessage = "Invalid channel URL"
isLoading = false
return
}
guard let instance = appEnvironment.instancesManager.yatteeServerInstance else {
errorMessage = String(localized: "externalChannel.noYatteeServer")
isLoading = false
return
}
isLoading = true
errorMessage = nil
do {
let (fetchedChannel, fetchedVideos, fetchedContinuation) = try await appEnvironment.contentService.extractChannel(
url: url,
page: 1,
instance: instance
)
await MainActor.run {
channel = fetchedChannel
videos = fetchedVideos
videosContinuation = fetchedContinuation
externalCurrentPage = 1
videosLoaded = true
isLoading = false
// Check subscription status using extracted channel ID
refreshSubscription()
}
} catch let error as APIError {
await MainActor.run {
handleExternalChannelError(error)
isLoading = false
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
private func handleExternalChannelError(_ error: APIError) {
switch error {
case .httpError(let statusCode, let message):
if statusCode == 422 {
errorMessage = message ?? "This site doesn't support channel extraction."
} else if statusCode == 400 {
errorMessage = message ?? "Invalid channel URL."
} else {
errorMessage = message ?? "Server error (\(statusCode))."
}
default:
errorMessage = error.localizedDescription
}
}
/// Re-reads the button's state from the account being browsed.
///
/// The local store is only consulted when it *is* the active context: under
/// a server account it holds another account's channels, and reading it
/// first would show the wrong button until the provider answered.
private func refreshSubscription() {
// Use loaded channel's ID if available (important for external channels
// where navigation channelID is the URL, not the actual channel ID)
let effectiveChannelID = channel?.id.channelID ?? channelID
if appEnvironment?.dataContextValidator.effectiveContext.isLocal == true {
isSubscribed = appEnvironment?.dataManager.subscription(for: effectiveChannelID) != nil
}
subscriptionGeneration += 1
let generation = subscriptionGeneration
Task {
if let service = appEnvironment?.subscriptionService {
let subscribed = await service.isSubscribed(to: effectiveChannelID)
// This view is reused as the user navigates from channel to
// channel, and the answer may have been asked for under another
// account entirely. Either way it no longer describes the button
// it would be written to.
guard generation == subscriptionGeneration,
(channel?.id.channelID ?? channelID) == effectiveChannelID else { return }
isSubscribed = subscribed
}
}
}
private func loadMoreVideos() async {
if isExternalChannel {
await loadMoreExternalVideos()
} else {
await loadMoreRegularVideos()
}
}
private func loadMoreRegularVideos() async {
guard let appEnvironment,
let instance = instanceForSource(),
let continuation = videosContinuation,
!isLoadingMore else {
return
}
isLoadingMore = true
do {
let result = try await appEnvironment.contentService.channelVideos(
id: channelID,
instance: instance,
continuation: continuation
)
await MainActor.run {
// Filter out duplicates before appending
let existingIDs = Set(videos.map(\.id))
let newVideos = result.videos.filter { !existingIDs.contains($0.id) }
videos.append(contentsOf: newVideos)
// Stop pagination if all returned videos are duplicates
videosContinuation = newVideos.isEmpty ? nil : result.continuation
isLoadingMore = false
}
} catch {
await MainActor.run {
isLoadingMore = false
}
}
}
private func loadMoreExternalVideos() async {
guard let appEnvironment,
let url = channelURL,
let instance = appEnvironment.instancesManager.yatteeServerInstance,
videosContinuation != nil,
!isLoadingMore else {
return
}
isLoadingMore = true
do {
let nextPage = externalCurrentPage + 1
let (_, fetchedVideos, fetchedContinuation) = try await appEnvironment.contentService.extractChannel(
url: url,
page: nextPage,
instance: instance
)
await MainActor.run {
videos.append(contentsOf: fetchedVideos)
videosContinuation = fetchedContinuation
externalCurrentPage = nextPage
isLoadingMore = false
}
} catch {
await MainActor.run {
isLoadingMore = false
}
}
}
/// Callback for loading more videos via continuation (used by VideoInfoView navigation)
@Sendable
private func loadMoreVideosCallback() async throws -> ([Video], String?) {
guard let appEnvironment,
let instance = instanceForSource(),
let continuation = videosContinuation else {
throw NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No continuation available"])
}
let result = try await appEnvironment.contentService.channelVideos(
id: channelID,
instance: instance,
continuation: continuation
)
return (result.videos, result.continuation)
}
/// Callback for loading more shorts via continuation
@Sendable
private func loadMoreShortsCallback() async throws -> ([Video], String?) {
guard let appEnvironment,
let instance = instanceForSource(),
let continuation = shortsContinuation else {
throw NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No continuation available"])
}
let result = try await appEnvironment.contentService.channelShorts(
id: channelID,
instance: instance,
continuation: continuation
)
return (result.videos, result.continuation)
}
/// Callback for loading more streams via continuation
@Sendable
private func loadMoreStreamsCallback() async throws -> ([Video], String?) {
guard let appEnvironment,
let instance = instanceForSource(),
let continuation = streamsContinuation else {
throw NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No continuation available"])
}
let result = try await appEnvironment.contentService.channelStreams(
id: channelID,
instance: instance,
continuation: continuation
)
return (result.videos, result.continuation)
}
// MARK: - Tab Loading
private func loadTabContentIfNeeded(_ tab: ChannelTab) async {
switch tab {
case .about:
// No loading needed - description is already available
break
case .videos:
// Already loaded on initial load
break
case .playlists:
if !playlistsLoaded {
await loadPlaylists()
}
case .shorts:
if !shortsLoaded {
await loadShorts()
}
case .streams:
if !streamsLoaded {
await loadStreams()
}
}
}
// MARK: - Playlists Loading
private func loadPlaylists() async {
guard let appEnvironment,
let instance = instanceForSource() else {
return
}
do {
let result = try await appEnvironment.contentService.channelPlaylists(
id: channelID,
instance: instance,
continuation: nil
)
await MainActor.run {
playlists = result.playlists
playlistsContinuation = result.continuation
playlistsLoaded = true
}
} catch {
await MainActor.run {
playlistsLoaded = true
}
}
}
private func loadMorePlaylists() async {
guard let appEnvironment,
let instance = instanceForSource(),
let continuation = playlistsContinuation,
!isLoadingMore else {
return
}
isLoadingMore = true
do {
let result = try await appEnvironment.contentService.channelPlaylists(
id: channelID,
instance: instance,
continuation: continuation
)
await MainActor.run {
playlists.append(contentsOf: result.playlists)
playlistsContinuation = result.continuation
isLoadingMore = false
}
} catch {
await MainActor.run {
isLoadingMore = false
}
}
}
// MARK: - Shorts Loading
private func loadShorts() async {
guard let appEnvironment,
let instance = instanceForSource() else {
return
}
do {
let result = try await appEnvironment.contentService.channelShorts(
id: channelID,
instance: instance,
continuation: nil
)
await MainActor.run {
shorts = result.videos
shortsContinuation = result.continuation
shortsLoaded = true
}
} catch {
await MainActor.run {
shortsLoaded = true
}
}
}
private func loadMoreShorts() async {
guard let appEnvironment,
let instance = instanceForSource(),
let continuation = shortsContinuation,
!isLoadingMore else {
return
}
isLoadingMore = true
do {
let result = try await appEnvironment.contentService.channelShorts(
id: channelID,
instance: instance,
continuation: continuation
)
await MainActor.run {
// Filter out duplicates before appending
let existingIDs = Set(shorts.map(\.id))
let newShorts = result.videos.filter { !existingIDs.contains($0.id) }
shorts.append(contentsOf: newShorts)
// Stop pagination if all returned videos are duplicates
shortsContinuation = newShorts.isEmpty ? nil : result.continuation
isLoadingMore = false
}
} catch {
await MainActor.run {
isLoadingMore = false
}
}
}
// MARK: - Streams Loading
private func loadStreams() async {
guard let appEnvironment,
let instance = instanceForSource() else {
return
}
do {
let result = try await appEnvironment.contentService.channelStreams(
id: channelID,
instance: instance,
continuation: nil
)
await MainActor.run {
streams = result.videos
streamsContinuation = result.continuation
streamsLoaded = true
}
} catch {
await MainActor.run {
streamsLoaded = true
}
}
}
private func loadMoreStreams() async {
guard let appEnvironment,
let instance = instanceForSource(),
let continuation = streamsContinuation,
!isLoadingMore else {
return
}
isLoadingMore = true
do {
let result = try await appEnvironment.contentService.channelStreams(
id: channelID,
instance: instance,
continuation: continuation
)
await MainActor.run {
// Filter out duplicates before appending
let existingIDs = Set(streams.map(\.id))
let newStreams = result.videos.filter { !existingIDs.contains($0.id) }
streams.append(contentsOf: newStreams)
// Stop pagination if all returned videos are duplicates
streamsContinuation = newStreams.isEmpty ? nil : result.continuation
isLoadingMore = false
}
} catch {
await MainActor.run {
isLoadingMore = false
}
}
}
}
// MARK: - Scroll Offset Tracking Modifier
/// Tracks scroll offset for header zoom/scale effect
private struct ChannelScrollOffsetModifier: ViewModifier {
@Binding var scrollOffset: CGFloat
var isPlayerExpanded: Bool
func body(content: Content) -> some View {
#if os(macOS)
content
#else
content
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y
} action: { _, newValue in
// Skip updates when player is expanded to avoid multiple updates per frame
guard !isPlayerExpanded else { return }
if scrollOffset != newValue {
scrollOffset = newValue
}
}
#endif
}
}
// MARK: - Background Extension Modifier
/// Applies backgroundExtensionEffect on iOS 26+ for Liquid Glass sidebar (iPad/Mac only)
private struct BackgroundExtensionModifier: ViewModifier {
let useBackgroundExtension: Bool
func body(content: Content) -> some View {
if useBackgroundExtension {
if #available(iOS 26, macOS 26, tvOS 26, *) {
content.backgroundExtensionEffect()
} else {
content
}
} else {
content
}
}
}
// MARK: - Preview
#Preview {
NavigationStack {
ChannelView(channelID: "UCxxxxxx", source: .global(provider: ContentSource.youtubeProvider))
}
.appEnvironment(.preview)
}