Route YouTube links tapped in descriptions through in-app playback

Description links to YouTube videos, channels, playlists, and external
video URLs now open in Yattee instead of Safari. When a video is
already playing, tapping a video link surfaces the existing
QueueActionSheet (Play Now / Play Next / Add to Queue) — the sheet is
hosted both at the app root and inside ExpandedPlayerSheet so it
appears above whichever layer is on screen.
This commit is contained in:
Arkadiusz Fal
2026-04-22 19:00:43 +02:00
parent 3afd0bdf78
commit b54c32edad
5 changed files with 103 additions and 1 deletions

View File

@@ -10,4 +10,5 @@ import Foundation
extension Notification.Name { extension Notification.Name {
static let showSettings = Notification.Name("showSettings") static let showSettings = Notification.Name("showSettings")
static let showOpenLinkSheet = Notification.Name("showOpenLinkSheet") static let showOpenLinkSheet = Notification.Name("showOpenLinkSheet")
static let openDescriptionLink = Notification.Name("openDescriptionLink")
} }

View File

@@ -66,6 +66,12 @@ final class NavigationCoordinator {
/// Whether the mini player queue sheet is showing. /// Whether the mini player queue sheet is showing.
var isMiniPlayerQueueSheetPresented = false 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. /// Whether the mini player playlist sheet is showing.
var isMiniPlayerPlaylistSheetPresented = false var isMiniPlayerPlaylistSheetPresented = false

View File

@@ -88,7 +88,9 @@ enum DescriptionText {
// MARK: - OpenURL Action for Seeking // MARK: - OpenURL Action for Seeking
extension View { 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 { func handleTimestampLinks(using playerService: PlayerService?) -> some View {
self.environment(\.openURL, OpenURLAction { url in self.environment(\.openURL, OpenURLAction { url in
if let seconds = DescriptionText.seekSeconds(from: url) { if let seconds = DescriptionText.seekSeconds(from: url) {
@@ -97,6 +99,12 @@ extension View {
} }
return .handled return .handled
} }
if URLRouter().route(url) != nil {
NotificationCenter.default.post(name: .openDescriptionLink, object: url)
return .handled
}
return .systemAction return .systemAction
}) })
} }

View File

@@ -382,6 +382,19 @@ struct ExpandedPlayerSheet: View {
.sheet(isPresented: $showingErrorSheet) { .sheet(isPresented: $showingErrorSheet) {
ErrorDetailsSheet(errorMessage: playerState?.errorMessage ?? "Unknown error") ErrorDetailsSheet(errorMessage: playerState?.errorMessage ?? "Unknown error")
} }
.sheet(item: Binding<Video?>(
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) #if os(iOS)
.toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .navigationBar)
.playerStatusBarHidden(isInWideScreenLayout || !isPortraitPanelVisible) .playerStatusBarHidden(isInWideScreenLayout || !isPortraitPanelVisible)

View File

@@ -109,6 +109,22 @@ struct YatteeApp: App {
OpenLinkSheet(prefilledURL: url) OpenLinkSheet(prefilledURL: url)
.appEnvironment(appEnvironment) .appEnvironment(appEnvironment)
} }
.sheet(item: Binding<Video?>(
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) #if !os(tvOS)
.sheet(isPresented: $showingDeepLinkDownloadSheet) { .sheet(isPresented: $showingDeepLinkDownloadSheet) {
if let video = deepLinkVideo { if let video = deepLinkVideo {
@@ -165,6 +181,11 @@ struct YatteeApp: App {
appEnvironment.navigationCoordinator.isPlayerExpanded = false appEnvironment.navigationCoordinator.isPlayerExpanded = false
showingOpenLinkSheet = true showingOpenLinkSheet = true
} }
.onReceive(NotificationCenter.default.publisher(for: .openDescriptionLink)) { notification in
if let url = notification.object as? URL {
handleDescriptionLink(url)
}
}
} }
#if os(macOS) #if os(macOS)
.windowStyle(.hiddenTitleBar) .windowStyle(.hiddenTitleBar)
@@ -363,6 +384,59 @@ struct YatteeApp: App {
appEnvironment.navigationCoordinator.navigate(to: destination) 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. /// Handle video deep link based on default link action setting.
private func handleVideoDeepLink(videoID: VideoID, originalURL: URL, action: DefaultLinkAction) { private func handleVideoDeepLink(videoID: VideoID, originalURL: URL, action: DefaultLinkAction) {
switch action { switch action {