mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 10:25:02 +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 {
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user