mirror of
https://github.com/yattee/yattee.git
synced 2026-02-21 10:19:46 +00:00
Display the view options button, channel menu, and content type tabs immediately when the cached header is shown, instead of waiting for the full channel data to load. The spinner now appears only in the content area below the tabs.
2088 lines
74 KiB
Swift
2088 lines
74 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 subscription: Subscription?
|
|
@State private var isSubscribed = false
|
|
@State private var showingUnsubscribeConfirmation = false
|
|
@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
|
|
|
|
// 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.accentColor.color ?? .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
|
|
}
|
|
|
|
var body: some View {
|
|
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()
|
|
}
|
|
.onAppear {
|
|
// Update Handoff activity for this specific channel
|
|
appEnvironment?.handoffManager.updateActivity(for: .channel(channelID, source))
|
|
loadWatchEntries()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
|
|
loadWatchEntries()
|
|
}
|
|
}
|
|
|
|
private func loadWatchEntries() {
|
|
watchEntriesMap = appEnvironment?.dataManager.watchEntriesMap() ?? [:]
|
|
}
|
|
|
|
// 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 {
|
|
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
|
|
if supportsChannelTabs {
|
|
// Pill-style content type switcher
|
|
contentTypePicker
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 8)
|
|
} else {
|
|
// Non-tab instances: show description
|
|
channelDescription(channel)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
.animation(.easeInOut(duration: 0.25), value: isSearchActive)
|
|
.modifier(ChannelScrollOffsetModifier(
|
|
scrollOffset: $scrollOffset,
|
|
isPlayerExpanded: appEnvironment?.navigationCoordinator.isPlayerExpanded ?? false
|
|
))
|
|
.toolbar {
|
|
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)
|
|
}
|
|
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
showViewOptions = true
|
|
} label: {
|
|
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
|
}
|
|
.liquidGlassTransitionSource(id: "channelViewOptions", in: sheetTransition)
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
if #available(iOS 26, macOS 26, *) {
|
|
ToolbarSpacer(.fixed, placement: .primaryAction)
|
|
}
|
|
#endif
|
|
|
|
ToolbarItem(placement: .primaryAction) {
|
|
channelMenu
|
|
}
|
|
}
|
|
.sheet(isPresented: $showViewOptions) {
|
|
ViewOptionsSheet(
|
|
layout: $layout,
|
|
rowStyle: $rowStyle,
|
|
gridColumns: $gridColumns,
|
|
hideWatched: $hideWatched,
|
|
maxGridColumns: gridConfig.maxColumns
|
|
)
|
|
.liquidGlassSheetContent(sourceID: "channelViewOptions", in: sheetTransition)
|
|
}
|
|
#if os(iOS)
|
|
.toolbarBackground(collapseProgress > 0.8 ? .visible : .hidden, for: .navigationBar)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
#if os(iOS)
|
|
.if(supportsChannelSearch) { view in
|
|
view.searchable(
|
|
text: $searchText,
|
|
isPresented: $isSearchActive,
|
|
placement: .navigationBarDrawer(displayMode: .automatic),
|
|
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 = ""
|
|
}
|
|
}
|
|
.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"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Loading Content
|
|
|
|
/// Shows cached header with a spinner below while loading full channel data.
|
|
@ViewBuilder
|
|
private func loadingContent(_ 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)
|
|
if supportsChannelTabs {
|
|
contentTypePicker
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
// 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)
|
|
.toolbar {
|
|
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)
|
|
}
|
|
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
showViewOptions = true
|
|
} label: {
|
|
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
|
}
|
|
.liquidGlassTransitionSource(id: "channelViewOptions", in: sheetTransition)
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
if #available(iOS 26, macOS 26, *) {
|
|
ToolbarSpacer(.fixed, placement: .primaryAction)
|
|
}
|
|
#endif
|
|
|
|
ToolbarItem(placement: .primaryAction) {
|
|
channelMenu
|
|
}
|
|
}
|
|
.sheet(isPresented: $showViewOptions) {
|
|
ViewOptionsSheet(
|
|
layout: $layout,
|
|
rowStyle: $rowStyle,
|
|
gridColumns: $gridColumns,
|
|
hideWatched: $hideWatched,
|
|
maxGridColumns: gridConfig.maxColumns
|
|
)
|
|
.liquidGlassSheetContent(sourceID: "channelViewOptions", in: sheetTransition)
|
|
}
|
|
#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"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
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
|
|
// 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 avatarContentHeight: CGFloat = currentAvatarSize + 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
|
|
// 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)
|
|
|
|
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: currentAvatarSize * 0.4, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.frame(width: currentAvatarSize, height: currentAvatarSize)
|
|
.clipShape(Circle())
|
|
.overlay(
|
|
Circle()
|
|
.stroke(.white.opacity(0.8), lineWidth: 3)
|
|
)
|
|
.shadow(color: .black.opacity(0.4), radius: 10, y: 5)
|
|
|
|
// 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)
|
|
}
|
|
.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 {
|
|
let notificationsEnabled = channel.map {
|
|
appEnvironment?.dataManager.notificationsEnabled(for: $0.id.channelID) ?? false
|
|
} ?? 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
|
|
Button {
|
|
toggleSubscription()
|
|
} label: {
|
|
Label(
|
|
isSubscribed
|
|
? String(localized: "channel.menu.unsubscribe")
|
|
: String(localized: "channel.menu.subscribe"),
|
|
systemImage: isSubscribed ? "person.fill.xmark" : "person.badge.plus"
|
|
)
|
|
}
|
|
|
|
// Notifications toggle (only visible when subscribed)
|
|
if isSubscribed, let channel {
|
|
let notificationsEnabled = appEnvironment?.dataManager.notificationsEnabled(for: channel.id.channelID) ?? false
|
|
Button {
|
|
toggleNotifications()
|
|
} label: {
|
|
Label(
|
|
notificationsEnabled
|
|
? String(localized: "channel.menu.disableNotifications")
|
|
: String(localized: "channel.menu.enableNotifications"),
|
|
systemImage: notificationsEnabled
|
|
? "bell.slash"
|
|
: "bell"
|
|
)
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: channelMenuIcon)
|
|
}
|
|
}
|
|
|
|
// MARK: - Subscription Actions
|
|
|
|
private func toggleSubscription() {
|
|
if isSubscribed {
|
|
showingUnsubscribeConfirmation = true
|
|
} else {
|
|
Task { await subscribe() }
|
|
}
|
|
}
|
|
|
|
private func subscribe() async {
|
|
guard let channel,
|
|
let subscriptionService = appEnvironment?.subscriptionService,
|
|
let dataManager = appEnvironment?.dataManager else { return }
|
|
|
|
let author = Author(
|
|
id: channel.id.channelID,
|
|
name: channel.name,
|
|
thumbnailURL: channel.thumbnailURL,
|
|
subscriberCount: channel.subscriberCount
|
|
)
|
|
|
|
do {
|
|
try await subscriptionService.subscribe(to: author, source: source)
|
|
|
|
// Set default notification preference for new subscription
|
|
let defaultNotifications = appEnvironment?.settingsManager.defaultNotificationsForNewChannels ?? false
|
|
if defaultNotifications {
|
|
dataManager.setNotificationsEnabled(true, for: channel.id.channelID)
|
|
}
|
|
|
|
isSubscribed = true
|
|
refreshSubscription()
|
|
} catch {
|
|
appEnvironment?.toastManager.showError(
|
|
String(localized: "channel.subscribe.error.title"),
|
|
subtitle: error.localizedDescription
|
|
)
|
|
}
|
|
}
|
|
|
|
private func unsubscribe() {
|
|
guard let channel else { return }
|
|
|
|
Task {
|
|
do {
|
|
try await appEnvironment?.subscriptionService.unsubscribe(from: channel.id.channelID)
|
|
isSubscribed = false
|
|
subscription = nil
|
|
} 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")
|
|
}
|
|
.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")
|
|
}
|
|
.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)
|
|
.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, 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")
|
|
}
|
|
.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")
|
|
}
|
|
.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")
|
|
}
|
|
.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")
|
|
}
|
|
.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)
|
|
.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, 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")
|
|
}
|
|
.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")
|
|
}
|
|
.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)
|
|
.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, 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")
|
|
}
|
|
.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)
|
|
.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)
|
|
.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, 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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
// Load subscription state
|
|
subscription = appEnvironment.dataManager.subscription(for: channelID)
|
|
isSubscribed = subscription != nil
|
|
|
|
// 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
|
|
subscription = appEnvironment.dataManager.subscription(for: fetchedChannel.id.channelID)
|
|
isSubscribed = subscription != nil
|
|
}
|
|
} 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
|
|
}
|
|
}
|
|
|
|
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
|
|
subscription = appEnvironment?.dataManager.subscription(for: effectiveChannelID)
|
|
isSubscribed = subscription != nil
|
|
}
|
|
|
|
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 {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|