mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -382,6 +382,19 @@ struct ExpandedPlayerSheet: View {
|
||||
.sheet(isPresented: $showingErrorSheet) {
|
||||
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)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.playerStatusBarHidden(isInWideScreenLayout || !isPortraitPanelVisible)
|
||||
|
||||
@@ -109,6 +109,22 @@ struct YatteeApp: App {
|
||||
OpenLinkSheet(prefilledURL: url)
|
||||
.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)
|
||||
.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 {
|
||||
|
||||
Reference in New Issue
Block a user