mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
58
Yattee/Services/Navigation/ConnectivityMonitor.swift
Normal file
58
Yattee/Services/Navigation/ConnectivityMonitor.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
352
Yattee/Services/Navigation/NavigationCoordinator.swift
Normal file
352
Yattee/Services/Navigation/NavigationCoordinator.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
204
Yattee/Services/Navigation/NavigationDestination.swift
Normal file
204
Yattee/Services/Navigation/NavigationDestination.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
364
Yattee/Services/Navigation/SidebarManager.swift
Normal file
364
Yattee/Services/Navigation/SidebarManager.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
348
Yattee/Services/Navigation/URLRouter.swift
Normal file
348
Yattee/Services/Navigation/URLRouter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user