diff --git a/Yattee/Core/Notifications.swift b/Yattee/Core/Notifications.swift index a3ef70ae..60657582 100644 --- a/Yattee/Core/Notifications.swift +++ b/Yattee/Core/Notifications.swift @@ -10,4 +10,5 @@ import Foundation extension Notification.Name { static let showSettings = Notification.Name("showSettings") static let showOpenLinkSheet = Notification.Name("showOpenLinkSheet") + static let openDescriptionLink = Notification.Name("openDescriptionLink") } diff --git a/Yattee/Services/Navigation/NavigationCoordinator.swift b/Yattee/Services/Navigation/NavigationCoordinator.swift index 6a42c20c..a8a9a911 100644 --- a/Yattee/Services/Navigation/NavigationCoordinator.swift +++ b/Yattee/Services/Navigation/NavigationCoordinator.swift @@ -66,6 +66,12 @@ final class NavigationCoordinator { /// Whether the mini player queue sheet is showing. var isMiniPlayerQueueSheetPresented = false + /// Video to present in the queue action sheet (e.g. a video link tapped inside + /// a description). Set by the deep-link handler; cleared when the sheet dismisses. + /// Presented by YatteeApp when the player is collapsed and by ExpandedPlayerSheet + /// when the player is expanded, so the sheet always sits above the visible layer. + var descriptionLinkQueueSheetVideo: Video? + /// Whether the mini player playlist sheet is showing. var isMiniPlayerPlaylistSheetPresented = false diff --git a/Yattee/Utilities/DescriptionText.swift b/Yattee/Utilities/DescriptionText.swift index 7a8aafac..41d13cc8 100644 --- a/Yattee/Utilities/DescriptionText.swift +++ b/Yattee/Utilities/DescriptionText.swift @@ -88,7 +88,9 @@ enum DescriptionText { // MARK: - OpenURL Action for Seeking extension View { - /// Adds a URL handler that intercepts timestamp links and seeks the player. + /// Adds a URL handler that intercepts timestamp links (seeks the player) and + /// known content URLs — YouTube/PeerTube video/channel/playlist links and external + /// video URLs — so they open in-app instead of the browser. func handleTimestampLinks(using playerService: PlayerService?) -> some View { self.environment(\.openURL, OpenURLAction { url in if let seconds = DescriptionText.seekSeconds(from: url) { @@ -97,6 +99,12 @@ extension View { } return .handled } + + if URLRouter().route(url) != nil { + NotificationCenter.default.post(name: .openDescriptionLink, object: url) + return .handled + } + return .systemAction }) } diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index fed9f8e3..be76ff44 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -382,6 +382,19 @@ struct ExpandedPlayerSheet: View { .sheet(isPresented: $showingErrorSheet) { ErrorDetailsSheet(errorMessage: playerState?.errorMessage ?? "Unknown error") } + .sheet(item: Binding( + get: { appEnvironment?.navigationCoordinator.descriptionLinkQueueSheetVideo }, + set: { newValue in + if newValue == nil { + appEnvironment?.navigationCoordinator.descriptionLinkQueueSheetVideo = nil + } + } + )) { video in + if let appEnvironment { + QueueActionSheet(video: video) + .appEnvironment(appEnvironment) + } + } #if os(iOS) .toolbar(.hidden, for: .navigationBar) .playerStatusBarHidden(isInWideScreenLayout || !isPortraitPanelVisible) diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index 1cc8ae6c..aa596070 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -109,6 +109,22 @@ struct YatteeApp: App { OpenLinkSheet(prefilledURL: url) .appEnvironment(appEnvironment) } + .sheet(item: Binding( + get: { + // Only host the sheet here when the player is not expanded. + // When expanded, ExpandedPlayerSheet hosts it so it appears above the player window. + guard !appEnvironment.navigationCoordinator.isPlayerExpanded else { return nil } + return appEnvironment.navigationCoordinator.descriptionLinkQueueSheetVideo + }, + set: { newValue in + if newValue == nil { + appEnvironment.navigationCoordinator.descriptionLinkQueueSheetVideo = nil + } + } + )) { video in + QueueActionSheet(video: video) + .appEnvironment(appEnvironment) + } #if !os(tvOS) .sheet(isPresented: $showingDeepLinkDownloadSheet) { if let video = deepLinkVideo { @@ -165,6 +181,11 @@ struct YatteeApp: App { appEnvironment.navigationCoordinator.isPlayerExpanded = false showingOpenLinkSheet = true } + .onReceive(NotificationCenter.default.publisher(for: .openDescriptionLink)) { notification in + if let url = notification.object as? URL { + handleDescriptionLink(url) + } + } } #if os(macOS) .windowStyle(.hiddenTitleBar) @@ -363,6 +384,59 @@ struct YatteeApp: App { appEnvironment.navigationCoordinator.navigate(to: destination) } + /// Handle a link tapped inside a video description. + /// Video URLs that would interrupt active playback surface the queue sheet; + /// everything else reuses the standard deep-link pipeline. + private func handleDescriptionLink(_ url: URL) { + let router = URLRouter() + guard let destination = router.route(url) else { return } + + if case .video(let source, _) = destination, case .id(let videoID) = source { + let queueEnabled = appEnvironment.settingsManager.queueEnabled + let somethingPlaying = appEnvironment.playerService.state.currentVideo != nil + if queueEnabled && somethingPlaying { + Task { await presentQueueSheetFromDeepLink(videoID: videoID) } + return + } + } + + handleDeepLink(url) + } + + /// Fetch a video by ID and present the queue action sheet for it. + private func presentQueueSheetFromDeepLink(videoID: VideoID) async { + guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else { + appEnvironment.toastManager.showError( + String(localized: "deepLink.noInstance.title", defaultValue: "Can't open video"), + subtitle: String( + localized: "deepLink.noInstance.subtitle", + defaultValue: "No source is configured for this video." + ) + ) + return + } + + let toastID = appEnvironment.toastManager.show( + scopes: [.main, .player], + category: .loading, + title: String(localized: "deepLink.loading.title", defaultValue: "Loading video…"), + subtitle: instance.name, + autoDismissDelay: 30.0 + ) + + do { + let video = try await appEnvironment.contentService.video( + id: videoID.videoID, + instance: instance + ) + appEnvironment.toastManager.dismiss(id: toastID) + appEnvironment.navigationCoordinator.descriptionLinkQueueSheetVideo = video + } catch { + appEnvironment.toastManager.dismiss(id: toastID) + appEnvironment.navigationCoordinator.navigate(to: .video(.id(videoID))) + } + } + /// Handle video deep link based on default link action setting. private func handleVideoDeepLink(videoID: VideoID, originalURL: URL, action: DefaultLinkAction) { switch action {