Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,58 @@
//
// ConnectivityMonitor.swift
// Yattee
//
// Network connectivity monitoring for offline mode support.
//
import Foundation
import Network
/// Monitors network connectivity status.
@Observable
final class ConnectivityMonitor: @unchecked Sendable {
/// Whether the device is currently online.
private(set) var isOnline: Bool = true
/// Whether we're on a cellular connection.
private(set) var isCellular: Bool = false
/// Whether the connection is considered expensive (cellular or hotspot).
private(set) var isExpensive: Bool = false
/// Whether the connection is constrained (low data mode).
private(set) var isConstrained: Bool = false
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "stream.yattee.connectivity")
// MARK: - Lifecycle
init() {
startMonitoring()
}
deinit {
stopMonitoring()
}
// MARK: - Monitoring
/// Start monitoring network connectivity.
func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor [weak self] in
self?.isOnline = path.status == .satisfied
self?.isCellular = path.usesInterfaceType(.cellular)
self?.isExpensive = path.isExpensive
self?.isConstrained = path.isConstrained
}
}
monitor.start(queue: queue)
}
/// Stop monitoring network connectivity.
func stopMonitoring() {
monitor.cancel()
}
}

View File

@@ -0,0 +1,352 @@
//
// NavigationCoordinator.swift
// Yattee
//
// Centralized navigation state management.
//
import SwiftUI
/// Sheet destinations for modal presentation.
enum SheetDestination: Identifiable {
case settings
case addToPlaylist(Video)
case downloadOptions(Video)
case newPlaylist
var id: String {
switch self {
case .settings: return "settings"
case .addToPlaylist(let video): return "addToPlaylist-\(video.id.id)"
case .downloadOptions(let video): return "downloadOptions-\(video.id.id)"
case .newPlaylist: return "newPlaylist"
}
}
}
/// Centralized navigation coordinator for the app.
@Observable
@MainActor
final class NavigationCoordinator {
/// Navigation path for NavigationStack.
var path = NavigationPath()
/// Currently selected tab.
var selectedTab: AppTab = .home {
didSet {
updateHandoffForCurrentTab()
}
}
/// Directly selected sidebar item (for extended navigation commands).
/// When set, UnifiedTabView syncs this to its selection state.
var selectedSidebarItem: SidebarItem?
/// Currently selected home sub-tab.
var selectedHomeTab: HomeTab = .playlists {
didSet {
if selectedTab == .home {
updateHandoffForCurrentTab()
}
}
}
/// Currently presented sheet.
var presentedSheet: SheetDestination?
/// Trigger to dismiss settings sheet (incremented when dismiss is needed).
var dismissSettingsTrigger = 0
/// Mini player video (if playing).
var nowPlaying: Video?
/// Whether the mini player is expanded to full screen.
var isPlayerExpanded = false
/// Whether the mini player queue sheet is showing.
var isMiniPlayerQueueSheetPresented = false
/// Whether the mini player playlist sheet is showing.
var isMiniPlayerPlaylistSheetPresented = false
/// Counter that increments each time the player should expand.
/// Used to trigger presentation even when isPlayerExpanded is already true.
var playerExpandTrigger = 0
/// Whether the player sheet is currently animating (presenting or dismissing).
var isPlayerSheetAnimating = false
/// Whether the next player expand should skip animation (for fullscreen exit).
var skipNextPlayerExpandAnimation = false
/// Trigger for remote control fullscreen toggle (incremented when toggle is needed).
var pendingFullscreenToggle = 0
/// Trigger to scroll the player into view (incremented when scroll is needed).
var scrollPlayerIntoViewTrigger = 0
/// Whether the player scroll animation is in progress.
var isPlayerScrollAnimating = false
/// Whether the player dismiss gesture is active (dragging down to dismiss).
var isPlayerDismissGestureActive = false
/// Whether the expanded player window is actually visible on screen.
/// Distinct from `isPlayerExpanded` which is the intent to show the player.
/// The window may not be visible if the scene is transitioning (e.g., Control Center open).
var isPlayerWindowVisible = false
/// Whether the player is currently expanding (for animation coordination with mini player).
var isPlayerExpanding = false
/// Whether the player is currently collapsing (for animation coordination with mini player).
var isPlayerCollapsing = false
/// Whether expanded comments view is currently showing (blocks sheet dismiss gesture).
var isCommentsExpanded = false
/// Whether user is adjusting volume/brightness sliders (blocks sheet dismiss gesture).
var isAdjustingPlayerSliders = false
/// Whether user is dragging the portrait panel to dismiss/reveal (blocks sheet dismiss gesture).
var isPanelDragging = false
/// Whether the portrait panel is currently visible (not hidden off-screen).
var isPortraitPanelVisible = true
/// Portrait panel frame in screen coordinates (for gesture conflict resolution).
var portraitPanelFrame: CGRect = .zero
/// Progress bar frame in screen coordinates (for gesture conflict resolution).
var progressBarFrame: CGRect = .zero
/// Current panscan value from UIKit pinch gesture (0.0 = fit, 1.0 = fill).
/// Updated by ExpandedPlayerWindow's pinch gesture handler.
var pinchPanscan: Double = 0.0
/// Whether a pinch gesture is currently active.
var isPinchGestureActive = false
/// Whether a seek gesture is currently active.
var isSeekGestureActive = false
/// Whether panscan should snap to fit/fill when released.
/// Updated by PlayerControlsView when layout settings change.
var shouldSnapPanscan: Bool = true
/// Base panscan value when pinch gesture started.
var pinchGestureBasePanscan: Double = 0.0
/// Whether panscan animation is in progress.
var isPanscanAnimating = false
/// Animates panscan to zero with ease-out curve, then calls completion.
func animatePanscanToZero(completion: (() -> Void)? = nil) {
animatePanscan(to: 0.0, completion: completion)
}
/// Toggles panscan between 0 and 1 with animated ease-out curve.
func togglePanscan() {
let target: Double = pinchPanscan > 0.5 ? 0.0 : 1.0
animatePanscan(to: target)
}
/// Animates panscan to target value with ease-out curve.
func animatePanscan(to target: Double, completion: (() -> Void)? = nil) {
let start = pinchPanscan
guard abs(start - target) > 0.01 else {
completion?()
return
}
isPanscanAnimating = true
let duration: Double = 0.25
let steps = 15
let stepDuration = duration / Double(steps)
for step in 0...steps {
let progress = Double(step) / Double(steps)
// Ease-out curve for smooth deceleration
let easedProgress = 1 - pow(1 - progress, 3)
let value = start + (target - start) * easedProgress
DispatchQueue.main.asyncAfter(deadline: .now() + stepDuration * Double(step)) { [weak self] in
self?.pinchPanscan = value
if step == steps {
self?.isPanscanAnimating = false
completion?()
}
}
}
}
/// Pending navigation destination (used when navigating from sheets/modals).
var pendingNavigation: NavigationDestination?
/// Reference to HandoffManager for updating activities on navigation.
private weak var handoffManager: HandoffManager?
/// Reference to MediaSourcesManager for navigating to media source directories.
private weak var mediaSourcesManager: MediaSourcesManager?
// MARK: - Handoff Integration
/// Set the HandoffManager reference for activity updates.
func setHandoffManager(_ manager: HandoffManager) {
self.handoffManager = manager
}
/// Set the MediaSourcesManager reference for media source navigation.
func setMediaSourcesManager(_ manager: MediaSourcesManager) {
self.mediaSourcesManager = manager
}
// MARK: - Navigation Actions
/// Navigate to a destination.
func navigate(to destination: NavigationDestination) {
pendingNavigation = destination
handoffManager?.updateActivity(for: destination)
}
/// Clear pending navigation after it's been handled.
func clearPendingNavigation() {
pendingNavigation = nil
}
/// Pop to the root of the current navigation stack.
func popToRoot() {
path.removeLast(path.count)
}
/// Pop one level back in the navigation stack.
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
// MARK: - Sheet Presentation
/// Dismiss the current sheet.
func dismissSheet() {
presentedSheet = nil
}
/// Dismiss the settings sheet from anywhere in the app.
func dismissSettings() {
dismissSettingsTrigger += 1
}
// MARK: - Player Actions
/// Expand the mini player to full screen.
func expandPlayer(animated: Bool = true) {
LoggingService.shared.debug("NavigationCoordinator: expandPlayer(animated=\(animated)) - isPlayerExpanded was \(isPlayerExpanded), trigger=\(playerExpandTrigger)", category: .player)
if !animated {
skipNextPlayerExpandAnimation = true
}
isPlayerExpanded = true
playerExpandTrigger += 1
LoggingService.shared.debug("NavigationCoordinator: expandPlayer complete - isPlayerExpanded=\(isPlayerExpanded), trigger=\(playerExpandTrigger)", category: .player)
}
/// Waits until player sheet animation completes.
func waitForPlayerSheetAnimation() async {
while isPlayerSheetAnimating {
try? await Task.sleep(for: .milliseconds(50))
}
}
/// Scrolls the player into view (uncovered position).
func scrollPlayerIntoView() {
isPlayerScrollAnimating = true
scrollPlayerIntoViewTrigger += 1
}
/// Waits until player scroll animation completes.
func waitForPlayerScrollAnimation() async {
while isPlayerScrollAnimating {
try? await Task.sleep(for: .milliseconds(50))
}
}
// MARK: - URL Handling
/// Handle an incoming URL.
func handle(url: URL) {
let router = URLRouter()
if let destination = router.route(url) {
navigate(to: destination)
}
}
// MARK: - Convenience Navigation
/// Navigate to the subscriptions tab/feed.
func navigateToSubscriptions() {
selectedTab = .subscriptions
}
/// Navigate to a video by its ID string.
/// Creates a VideoID assuming YouTube source.
func navigateToVideo(videoID: String) {
let id = VideoID(source: .global(provider: ContentSource.youtubeProvider), videoID: videoID)
navigate(to: .video(.id(id)))
}
/// Navigate to a video's channel/source.
/// For media source videos (SMB, WebDAV, local), navigates to the parent directory.
/// For extracted videos, navigates to the external channel URL.
/// For regular videos, navigates to the channel view.
func navigateToChannel(for video: Video, collapsePlayer: Bool = false) {
if collapsePlayer {
isPlayerCollapsing = true
isPlayerExpanded = false
}
// Handle media source videos (SMB, WebDAV, local folders)
if video.isFromMediaSource,
let sourceID = video.mediaSourceID,
let filePath = video.mediaSourceFilePath,
let source = mediaSourcesManager?.source(byID: sourceID) {
let directoryPath = (filePath as NSString).deletingLastPathComponent
navigate(to: .mediaBrowser(source, path: directoryPath))
} else if case .extracted = video.id.source, let authorURL = video.author.url {
navigate(to: .externalChannel(authorURL))
} else {
navigate(to: .channel(video.author.id, video.authorSource))
}
}
// MARK: - Handoff Updates
/// Updates Handoff activity based on current tab selection.
private func updateHandoffForCurrentTab() {
let destination: NavigationDestination?
switch selectedTab {
case .subscriptions:
destination = .subscriptionsFeed
case .home:
switch selectedHomeTab {
case .playlists:
destination = .playlists
case .history:
destination = .history
case .downloads:
destination = .downloads
}
case .search:
// Search updates handoff when a search is performed, not on tab selection
destination = nil
#if os(tvOS)
case .settings:
destination = nil
#endif
}
if let destination {
handoffManager?.updateActivity(for: destination)
}
}
}

View File

@@ -0,0 +1,204 @@
//
// NavigationDestination.swift
// Yattee
//
// Navigation destinations for the app.
//
import Foundation
import SwiftUI
/// Source for video navigation - either a loaded video or just an ID to fetch.
enum VideoSource: Hashable, Sendable {
case id(VideoID)
case loaded(Video)
}
/// Navigation destinations for the app.
enum NavigationDestination: Hashable {
/// Video info page - can be initialized with either a loaded video or just an ID.
case video(VideoSource, queueContext: VideoQueueContext? = nil)
case channel(String, ContentSource)
case playlist(PlaylistSource)
case continueWatching
case downloads
case downloadsStorage
case subscriptionsFeed
case settings
case playlists
case bookmarks
case history
case manageChannels
case search(String)
/// External video URL to be extracted via Yattee Server.
case externalVideo(URL)
/// External channel URL to be extracted via Yattee Server.
case externalChannel(URL)
/// Direct media URL (mp4, m3u8, etc.) to play without extraction.
case directMedia(URL)
/// Media sources list.
case mediaSources
/// Browse a specific media source by ID (for sidebar navigation).
case mediaSource(UUID)
/// Browse a specific media source at a path.
case mediaBrowser(MediaSource, path: String, showOnlyPlayable: Bool = false)
/// Browse a specific instance (Popular/Trending).
case instanceBrowse(Instance, initialTab: InstanceBrowseView.BrowseTab? = nil)
/// Import subscriptions from an instance.
case importSubscriptions(instance: Instance)
/// Import playlists from an instance.
case importPlaylists(instance: Instance)
}
extension NavigationDestination {
/// Returns the transition ID for zoom navigation animations, if applicable.
///
/// Used to connect source views (NavigationLinks) with destination views
/// for smooth zoom transitions. Returns nil for destinations that don't
/// support zoom transitions.
var transitionID: AnyHashable? {
switch self {
case .video(let source, _):
switch source {
case .id(let videoID): return videoID
case .loaded(let video): return video.id
}
case .channel(let channelID, _):
return channelID
case .playlist(let source):
return source.transitionID
default:
return nil
}
}
@ViewBuilder
func view() -> some View {
switch self {
case .video(let source, let queueContext):
switch source {
case .id(let videoID):
VideoInfoView(videoID: videoID)
.videoQueueContext(queueContext)
case .loaded(let video):
VideoInfoView(video: video)
.videoQueueContext(queueContext)
}
case .channel(let channelID, let source):
ChannelView(channelID: channelID, source: source)
case .playlist(let source):
UnifiedPlaylistDetailView(source: source)
case .continueWatching:
ContinueWatchingView()
case .downloads:
#if os(tvOS)
ContentUnavailableView {
Label(String(localized: "home.downloads.title"), systemImage: "arrow.down.circle")
} description: {
Text(String(localized: "home.downloads.notAvailable"))
}
#else
DownloadsView()
#endif
case .downloadsStorage:
#if os(tvOS)
ContentUnavailableView {
Label(String(localized: "settings.downloads.storage.title"), systemImage: "arrow.down.circle")
} description: {
Text(String(localized: "home.downloads.notAvailable"))
}
#else
DownloadsStorageView()
#endif
case .subscriptionsFeed:
SubscriptionsView()
case .settings:
SettingsView()
case .playlists:
PlaylistsListView()
case .bookmarks:
BookmarksListView()
case .history:
HistoryListView()
case .manageChannels:
ManageChannelsView()
case .search(let query):
SearchView(initialQuery: query)
case .externalVideo(let url):
ExternalVideoView(url: url)
case .directMedia(let url):
// Direct media URLs are typically handled inline by OpenLinkSheet,
// but if navigated to directly, show video info with the created video
VideoInfoView(video: DirectMediaHelper.createVideo(from: url))
case .externalChannel(let url):
// Use unified ChannelView with external channel URL
ChannelView(
channelID: url.absoluteString,
source: .extracted(extractor: "external", originalURL: url),
channelURL: url
)
case .mediaSources:
MediaSourcesView()
case .mediaSource(let id):
MediaSourceByIDView(sourceID: id)
case .mediaBrowser(let source, let path, let showOnlyPlayable):
MediaBrowserView(source: source, path: path, showOnlyPlayable: showOnlyPlayable)
case .instanceBrowse(let instance, let initialTab):
InstanceBrowseView(instance: instance, initialTab: initialTab)
case .importSubscriptions(let instance):
ImportSubscriptionsView(instance: instance)
case .importPlaylists(let instance):
ImportPlaylistsView(instance: instance)
}
}
}
// MARK: - Media Source by ID View
/// Helper view that looks up a MediaSource by ID and shows MediaBrowserView.
private struct MediaSourceByIDView: View {
let sourceID: UUID
@Environment(\.appEnvironment) private var appEnvironment
var body: some View {
if let source = appEnvironment?.mediaSourcesManager.source(byID: sourceID) {
MediaBrowserView(source: source, path: "/")
} else {
ContentUnavailableView {
Label(String(localized: "navigation.sourceNotFound"), systemImage: "externaldrive.badge.exclamationmark")
} description: {
Text(String(localized: "navigation.sourceNotFound.description"))
}
}
}
}
// MARK: - Navigation Destination Modifier
/// A view modifier that registers navigation destination handlers for all app destinations.
/// Apply this to views that contain NavigationLink(value: NavigationDestination) to ensure
/// the navigation stack can resolve all destination types.
///
/// Also applies zoom navigation transitions for supported destinations (video, channel, playlist)
/// when a zoom transition namespace is available in the environment.
struct NavigationDestinationHandlerModifier: ViewModifier {
func body(content: Content) -> some View {
content
.navigationDestination(for: NavigationDestination.self) { destination in
if let transitionID = destination.transitionID {
destination.view()
.zoomTransitionDestination(id: transitionID)
} else {
destination.view()
}
}
}
}
extension View {
/// Adds navigation destination handlers for all app navigation destinations.
/// Use this on views within a NavigationStack that contain NavigationLink(value:).
func withNavigationDestinations() -> some View {
modifier(NavigationDestinationHandlerModifier())
}
}

View File

@@ -0,0 +1,364 @@
//
// SidebarManager.swift
// Yattee
//
// Manages sidebar content by loading user data (subscriptions, playlists)
// and generating sidebar items for the TabSection-based navigation.
//
import Foundation
import Combine
/// Manages sidebar state and content generation.
@Observable @MainActor
final class SidebarManager {
// MARK: - Published Items
/// Channel items for the Channels section.
private(set) var channelItems: [SidebarItem] = []
/// Playlist items for the Collections section.
private(set) var playlistItems: [SidebarItem] = []
/// Media source items for the Media Sources section.
private(set) var mediaSourceItems: [SidebarItem] = []
/// Instance items for the Sources section.
private(set) var instanceItems: [SidebarItem] = []
/// All source items (instances + media sources) combined and sorted.
/// This is the primary property to use for displaying sources in a unified list.
private(set) var sortedSourceItems: [SidebarItem] = []
/// Whether there are no source items at all.
var hasNoSources: Bool {
instanceItems.isEmpty && mediaSourceItems.isEmpty
}
// MARK: - Dependencies
private weak var dataManager: DataManager?
private weak var settingsManager: SettingsManager?
private weak var mediaSourcesManager: MediaSourcesManager?
private weak var instancesManager: InstancesManager?
private var cancellables = Set<AnyCancellable>()
// MARK: - Cached Data (to avoid repeated DB queries during layout)
private var cachedSubscriptions: [Subscription] = []
private var cachedPlaylists: [LocalPlaylist] = []
// MARK: - Initialization
init() {
setupNotificationObservers()
}
/// Configure the manager with dependencies.
func configure(
dataManager: DataManager,
settingsManager: SettingsManager,
mediaSourcesManager: MediaSourcesManager? = nil,
instancesManager: InstancesManager? = nil
) {
self.dataManager = dataManager
self.settingsManager = settingsManager
self.mediaSourcesManager = mediaSourcesManager
self.instancesManager = instancesManager
loadData()
}
// MARK: - Data Loading
/// Loads subscriptions, playlists, media sources, and instances.
func loadData() {
loadChannels()
loadPlaylists()
loadSources()
}
/// Loads channel items from subscriptions.
private func loadChannels() {
guard let dataManager else { return }
// Cache subscriptions for use in avatarURL(for:)
cachedSubscriptions = dataManager.subscriptions()
let limitEnabled = settingsManager?.sidebarChannelsLimitEnabled ?? true
let maxChannels = settingsManager?.sidebarMaxChannels ?? SettingsManager.defaultSidebarMaxChannels
let sortOrder = settingsManager?.sidebarChannelSort ?? .lastUploaded
// Sort subscriptions
let sortedSubscriptions: [Subscription]
switch sortOrder {
case .alphabetical:
sortedSubscriptions = cachedSubscriptions.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
case .recentlySubscribed:
sortedSubscriptions = cachedSubscriptions.sorted { $0.subscribedAt > $1.subscribedAt }
case .lastUploaded:
sortedSubscriptions = cachedSubscriptions.sorted { sub1, sub2 in
let date1 = sub1.lastVideoPublishedAt ?? .distantPast
let date2 = sub2.lastVideoPublishedAt ?? .distantPast
return date1 > date2
}
case .custom:
// For custom, we'd need additional ordering data - for now fallback to alphabetical
sortedSubscriptions = cachedSubscriptions.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
// Apply limit (if enabled) and convert to sidebar items
if limitEnabled {
channelItems = sortedSubscriptions
.prefix(maxChannels)
.map { SidebarItem.from(subscription: $0) }
} else {
channelItems = sortedSubscriptions
.map { SidebarItem.from(subscription: $0) }
}
}
/// Loads playlist items from local playlists.
private func loadPlaylists() {
guard let dataManager else { return }
// Cache playlists for use in videoCount(for:)
cachedPlaylists = dataManager.playlists()
let sortOrder = settingsManager?.sidebarPlaylistSort ?? .alphabetical
let limitEnabled = settingsManager?.sidebarPlaylistsLimitEnabled ?? false
let maxPlaylists = settingsManager?.sidebarMaxPlaylists ?? SettingsManager.defaultSidebarMaxPlaylists
// Sort playlists
let sortedPlaylists: [LocalPlaylist]
switch sortOrder {
case .alphabetical:
sortedPlaylists = cachedPlaylists.sorted(by: { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending })
case .lastUpdated:
sortedPlaylists = cachedPlaylists.sorted(by: { $0.updatedAt > $1.updatedAt })
}
// Apply limit if enabled
if limitEnabled {
playlistItems = sortedPlaylists
.prefix(maxPlaylists)
.map { SidebarItem.from(playlist: $0) }
} else {
playlistItems = sortedPlaylists
.map { SidebarItem.from(playlist: $0) }
}
}
/// Loads instance and media source items with sorting and limiting.
/// Builds a unified sortedSourceItems list for display.
private func loadSources() {
let sortOrder = settingsManager?.sidebarSourceSort ?? .name
let limitEnabled = settingsManager?.sidebarSourcesLimitEnabled ?? false
let maxSources = settingsManager?.sidebarMaxSources ?? SettingsManager.defaultSidebarMaxSources
// Get raw data
let instances = instancesManager?.enabledInstances ?? []
let mediaSources = mediaSourcesManager?.enabledSources ?? []
// Build combined list with sort keys
struct SourceEntry {
let item: SidebarItem
let name: String
let date: Date
let typeOrder: Int // For type sorting: instances (0-99), media sources (100-199)
}
var entries: [SourceEntry] = []
// Add instances
for instance in instances {
let item = SidebarItem.from(instance: instance)
// Type order: group by instance type (invidious=0, piped=1, peertube=2, yatteeServer=3)
let typeOrder: Int
switch instance.type {
case .invidious: typeOrder = 0
case .piped: typeOrder = 1
case .peertube: typeOrder = 2
case .yatteeServer: typeOrder = 3
}
entries.append(SourceEntry(item: item, name: instance.displayName, date: instance.dateAdded, typeOrder: typeOrder))
}
// Add media sources (type order 100 to come after instances when sorting by type)
for source in mediaSources {
let item = SidebarItem.from(mediaSource: source)
// All media sources use same typeOrder (100) to sort alphabetically together
entries.append(SourceEntry(item: item, name: source.name, date: source.dateAdded, typeOrder: 100))
}
// Sort combined list
switch sortOrder {
case .name:
entries.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
case .type:
// Sort by type order, then by name within each type
entries.sort { a, b in
if a.typeOrder != b.typeOrder {
return a.typeOrder < b.typeOrder
}
return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
}
case .lastAdded:
entries.sort { $0.date > $1.date }
}
// Apply limit if enabled
if limitEnabled {
entries = Array(entries.prefix(maxSources))
}
// Update the unified sorted list
sortedSourceItems = entries.map { $0.item }
// Also update legacy separate lists for backwards compatibility
instanceItems = sortedSourceItems.filter { $0.isInstance }
mediaSourceItems = sortedSourceItems.filter { $0.isMediaSource }
}
// MARK: - Notification Observers
private func setupNotificationObservers() {
NotificationCenter.default.publisher(for: .subscriptionsDidChange)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.loadChannels()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .playlistsDidChange)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.loadPlaylists()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .mediaSourcesDidChange)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.loadSources()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .instancesDidChange)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.loadSources()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .sidebarSettingsDidChange)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.loadData()
}
.store(in: &cancellables)
}
// MARK: - Channel Data Access
/// Yattee Server URL for avatar fallback
private var yatteeServerURL: URL? {
instancesManager?.enabledYatteeServerInstances.first?.url
}
/// Returns avatar URL for a channel sidebar item.
/// Uses AvatarURLBuilder for Yattee Server fallback when direct URL is unavailable.
/// Uses cached subscriptions to avoid repeated DB queries during layout.
func avatarURL(for item: SidebarItem) -> URL? {
guard case .channel(let channelID, _, _) = item else { return nil }
let directURL = cachedSubscriptions.first { $0.channelID == channelID }?.avatarURL
return AvatarURLBuilder.avatarURL(
channelID: channelID,
directURL: directURL,
serverURL: yatteeServerURL,
size: 22 // Matches SidebarChannelIcon size
)
}
// MARK: - Playlist Data Access
/// Returns video count for a playlist sidebar item.
/// Uses cached playlists to avoid repeated DB queries during layout.
func videoCount(for item: SidebarItem) -> Int {
guard case .playlist(let id, _) = item else { return 0 }
return cachedPlaylists.first { $0.id == id }?.videoCount ?? 0
}
/// Returns thumbnail URL for a playlist sidebar item.
/// Uses cached playlists to avoid repeated DB queries during layout.
func thumbnailURL(for item: SidebarItem) -> URL? {
guard case .playlist(let id, _) = item else { return nil }
return cachedPlaylists.first { $0.id == id }?.thumbnailURL
}
}
// MARK: - Channel Sort Order
/// Defines how channels are sorted in the sidebar.
enum SidebarChannelSort: String, Codable, CaseIterable, Identifiable {
case alphabetical
case recentlySubscribed
case lastUploaded
case custom
var id: String { rawValue }
var localizedTitle: String {
switch self {
case .alphabetical:
return String(localized: "sidebar.sort.alphabetical")
case .recentlySubscribed:
return String(localized: "sidebar.sort.recentlySubscribed")
case .lastUploaded:
return String(localized: "sidebar.sort.lastUploaded")
case .custom:
return String(localized: "sidebar.sort.custom")
}
}
}
// MARK: - Playlist Sort Order
/// Defines how playlists are sorted in the sidebar.
enum SidebarPlaylistSort: String, Codable, CaseIterable, Identifiable {
case alphabetical
case lastUpdated
var id: String { rawValue }
var localizedTitle: String {
switch self {
case .alphabetical:
return String(localized: "sidebar.playlist.sort.alphabetical")
case .lastUpdated:
return String(localized: "sidebar.playlist.sort.lastUpdated")
}
}
}
// MARK: - Source Sort Order
/// Defines how sources (instances + media sources) are sorted in the sidebar.
enum SidebarSourceSort: String, Codable, CaseIterable, Identifiable {
case name
case type // Remote server vs files server
case lastAdded
var id: String { rawValue }
var localizedTitle: String {
switch self {
case .name:
return String(localized: "sidebar.source.sort.name")
case .type:
return String(localized: "sidebar.source.sort.type")
case .lastAdded:
return String(localized: "sidebar.source.sort.lastAdded")
}
}
}

View File

@@ -0,0 +1,348 @@
//
// URLRouter.swift
// Yattee
//
// URL parsing and routing for deep links and shared URLs.
//
import Foundation
/// Routes URLs to navigation destinations.
struct URLRouter: Sendable {
// MARK: - Main Routing
/// Route a URL to a navigation destination.
func route(_ url: URL) -> NavigationDestination? {
// Try custom scheme first
if url.scheme == "yattee" {
return parseCustomScheme(url)
}
// Try YouTube playlist URLs first (before video URLs since playlist pages can have v= param)
if let playlistID = parseYouTubePlaylistURL(url) {
return .playlist(.remote(PlaylistID(source: .global(provider: ContentSource.youtubeProvider), playlistID: playlistID), instance: nil))
}
// Try YouTube channel URLs
if let channelID = parseYouTubeChannelURL(url) {
return .channel(channelID, .global(provider: ContentSource.youtubeProvider))
}
// Try YouTube video URLs
if let videoID = parseYouTubeURL(url) {
return .video(.id(.global(videoID)))
}
// Try PeerTube URLs
if let (instance, videoID) = parsePeerTubeURL(url) {
return .video(.id(.federated(videoID, instance: instance, uuid: nil)))
}
// Try direct media URLs (mp4, m3u8, etc.) - no extraction needed
if DirectMediaHelper.isDirectMediaURL(url) {
return .directMedia(url)
}
// Fallback: Try external URL extraction for any http/https URL
// This will be handled by Yattee Server using yt-dlp
if isExternalVideoURL(url) {
return .externalVideo(url)
}
return nil
}
// MARK: - External URL Detection
/// Check if URL might be an external video that yt-dlp can handle.
private func isExternalVideoURL(_ url: URL) -> Bool {
// Must be http or https
guard let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
return false
}
// Must have a host
guard let host = url.host?.lowercased(), !host.isEmpty else {
return false
}
// Skip known non-video sites
let excludedHosts = [
"google.com", "www.google.com",
"bing.com", "www.bing.com",
"duckduckgo.com",
"apple.com", "www.apple.com",
"github.com", "www.github.com"
]
if excludedHosts.contains(host) {
return false
}
return true
}
// MARK: - Custom Scheme
/// Parse yattee:// scheme URLs.
private func parseCustomScheme(_ url: URL) -> NavigationDestination? {
guard let host = url.host else { return nil }
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
switch host {
case "video":
// yattee://video/{videoId}?source={source}&instance={url}
let videoID = url.lastPathComponent
guard !videoID.isEmpty else { return nil }
let sourceParam = components?.queryItems?.first(where: { $0.name == "source" })?.value
if sourceParam == "peertube",
let instanceStr = components?.queryItems?.first(where: { $0.name == "instance" })?.value,
let instanceURL = URL(string: instanceStr) {
return .video(.id(.federated(videoID, instance: instanceURL, uuid: nil)))
}
return .video(.id(.global(videoID)))
case "channel":
// yattee://channel/{channelId}?source={source}&instance={url}
let channelID = url.lastPathComponent
guard !channelID.isEmpty else { return nil }
let sourceParam = components?.queryItems?.first(where: { $0.name == "source" })?.value
let source: ContentSource
if sourceParam == "peertube",
let instanceStr = components?.queryItems?.first(where: { $0.name == "instance" })?.value,
let instanceURL = URL(string: instanceStr) {
source = .federated(provider: ContentSource.peertubeProvider, instance: instanceURL)
} else {
source = .global(provider: ContentSource.youtubeProvider)
}
return .channel(channelID, source)
case "playlist":
// yattee://playlist/{playlistId}
let playlistID = url.lastPathComponent
guard !playlistID.isEmpty else { return nil }
return .playlist(.remote(PlaylistID(source: .global(provider: ContentSource.youtubeProvider), playlistID: playlistID), instance: nil))
case "search":
// yattee://search?q={query}
guard let query = components?.queryItems?.first(where: { $0.name == "q" })?.value,
!query.isEmpty else {
return nil
}
return .search(query)
case "playlists":
// yattee://playlists
return .playlists
case "bookmarks":
// yattee://bookmarks
return .bookmarks
case "history":
// yattee://history
return .history
case "downloads":
// yattee://downloads
return .downloads
case "channels":
// yattee://channels (manage subscribed channels)
return .manageChannels
case "subscriptions":
// yattee://subscriptions
return .subscriptionsFeed
case "continue-watching":
// yattee://continue-watching
return .continueWatching
case "settings":
// yattee://settings
return .settings
case "open":
// yattee://open?url={encoded_url} - from share extension
if let urlParam = components?.queryItems?.first(where: { $0.name == "url" })?.value,
let decodedURL = URL(string: urlParam) {
// Route the decoded URL through normal routing
return route(decodedURL)
}
return nil
default:
return nil
}
}
// MARK: - YouTube URL Parsing
/// Parse YouTube URLs and extract video ID.
private func parseYouTubeURL(_ url: URL) -> String? {
let host = url.host?.lowercased() ?? ""
// youtube.com/watch?v=VIDEO_ID
if host.contains("youtube.com") {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
if let videoID = components?.queryItems?.first(where: { $0.name == "v" })?.value {
return videoID
}
// youtube.com/shorts/VIDEO_ID
if url.pathComponents.contains("shorts"),
let index = url.pathComponents.firstIndex(of: "shorts"),
url.pathComponents.count > index + 1 {
return url.pathComponents[index + 1]
}
// youtube.com/embed/VIDEO_ID
if url.pathComponents.contains("embed"),
let index = url.pathComponents.firstIndex(of: "embed"),
url.pathComponents.count > index + 1 {
return url.pathComponents[index + 1]
}
// youtube.com/live/VIDEO_ID
if url.pathComponents.contains("live"),
let index = url.pathComponents.firstIndex(of: "live"),
url.pathComponents.count > index + 1 {
return url.pathComponents[index + 1]
}
}
// youtu.be/VIDEO_ID
if host == "youtu.be" {
let videoID = url.lastPathComponent
if !videoID.isEmpty && videoID != "/" {
return videoID
}
}
// m.youtube.com
if host == "m.youtube.com" {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
return components?.queryItems?.first(where: { $0.name == "v" })?.value
}
return nil
}
// MARK: - PeerTube URL Parsing
/// Parse PeerTube URLs and extract instance URL and video ID.
private func parsePeerTubeURL(_ url: URL) -> (URL, String)? {
// PeerTube URLs are typically:
// https://instance.tld/w/VIDEO_ID
// https://instance.tld/videos/watch/VIDEO_ID
guard let host = url.host,
let scheme = url.scheme else {
return nil
}
// Skip known non-PeerTube hosts
let nonPeerTubeHosts = [
"youtube.com", "www.youtube.com", "m.youtube.com",
"youtu.be", "music.youtube.com",
"vimeo.com", "www.vimeo.com",
"dailymotion.com", "www.dailymotion.com"
]
if nonPeerTubeHosts.contains(host) {
return nil
}
let pathComponents = url.pathComponents
// /w/VIDEO_ID or /videos/watch/VIDEO_ID
if pathComponents.contains("w") || pathComponents.contains("videos") {
var videoID: String?
if let wIndex = pathComponents.firstIndex(of: "w"),
pathComponents.count > wIndex + 1 {
videoID = pathComponents[wIndex + 1]
} else if let watchIndex = pathComponents.firstIndex(of: "watch"),
pathComponents.count > watchIndex + 1 {
videoID = pathComponents[watchIndex + 1]
}
if let videoID, !videoID.isEmpty {
let instanceURL = URL(string: "\(scheme)://\(host)")!
return (instanceURL, videoID)
}
}
return nil
}
// MARK: - Channel URL Parsing
/// Parse YouTube channel URLs.
func parseYouTubeChannelURL(_ url: URL) -> String? {
let host = url.host?.lowercased() ?? ""
guard host.contains("youtube.com") else { return nil }
let pathComponents = url.pathComponents
// youtube.com/channel/CHANNEL_ID
if let channelIndex = pathComponents.firstIndex(of: "channel"),
pathComponents.count > channelIndex + 1 {
return pathComponents[channelIndex + 1]
}
// youtube.com/@HANDLE
if let component = pathComponents.first(where: { $0.hasPrefix("@") }) {
return component
}
// youtube.com/c/CUSTOM_NAME
if let cIndex = pathComponents.firstIndex(of: "c"),
pathComponents.count > cIndex + 1 {
return pathComponents[cIndex + 1]
}
// youtube.com/user/USERNAME
if let userIndex = pathComponents.firstIndex(of: "user"),
pathComponents.count > userIndex + 1 {
return pathComponents[userIndex + 1]
}
return nil
}
// MARK: - Playlist URL Parsing
/// Parse YouTube playlist URLs and extract playlist ID.
private func parseYouTubePlaylistURL(_ url: URL) -> String? {
let host = url.host?.lowercased() ?? ""
guard host.contains("youtube.com") else { return nil }
// youtube.com/playlist?list=PLAYLIST_ID
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
if let listParam = components?.queryItems?.first(where: { $0.name == "list" })?.value {
// Only return if this is primarily a playlist URL (path is /playlist)
// or if there's no video ID (pure playlist link)
let isPlaylistPath = url.pathComponents.contains("playlist")
let hasVideoID = components?.queryItems?.first(where: { $0.name == "v" })?.value != nil
// Return playlist ID only if it's a playlist page or watch page without video ID
if isPlaylistPath || !hasVideoID {
return listParam
}
}
return nil
}
}