From 5a839da1bdf9a8577bf33299501d9519ea310c11 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Thu, 23 Apr 2026 07:29:57 +0200 Subject: [PATCH] Resolve URL shorteners and prompt for ambiguous description links Tapping bit.ly/tinyurl/t.co/etc. in a description or comment previously opened Safari even when the destination was a playable YouTube URL. Added an opt-in "Resolve Short Links" toggle under YouTube Enhancements (off by default) that follows the redirect on tap: if the target is a YouTube/PeerTube/direct-media URL, open it in-app; otherwise prompt the user before falling back to yt-dlp extraction or the browser. Also added a confirmation dialog for non-shortener links that only matched the loose .externalVideo yt-dlp fallback, so arbitrary web pages in descriptions no longer silently kick off extraction. Prompts live on NavigationCoordinator and are dual-hosted by YatteeApp and ExpandedPlayerSheet so they remain visible whether or not the expanded player is covering the main view. --- Yattee/Core/Notifications.swift | 11 ++ Yattee/Core/Settings/SettingsKey.swift | 3 + .../Settings/SettingsManager+DeArrow.swift | 17 +++ Yattee/Core/SettingsManager.swift | 4 + Yattee/Localizable.xcstrings | 100 ++++++++++++++ .../Navigation/NavigationCoordinator.swift | 10 ++ Yattee/Services/Navigation/URLRouter.swift | 12 ++ .../Navigation/URLShortenerResolver.swift | 123 ++++++++++++++++++ Yattee/Utilities/DescriptionText.swift | 74 ++++++++++- .../ResolvedLinkPromptsModifier.swift | 116 +++++++++++++++++ Yattee/Views/Player/ExpandedPlayerSheet.swift | 7 + .../YouTubeEnhancementsSettingsView.swift | 16 +++ Yattee/YatteeApp.swift | 20 +++ 13 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 Yattee/Services/Navigation/URLShortenerResolver.swift create mode 100644 Yattee/Views/Components/ResolvedLinkPromptsModifier.swift diff --git a/Yattee/Core/Notifications.swift b/Yattee/Core/Notifications.swift index 60657582..0fcaa199 100644 --- a/Yattee/Core/Notifications.swift +++ b/Yattee/Core/Notifications.swift @@ -11,4 +11,15 @@ extension Notification.Name { static let showSettings = Notification.Name("showSettings") static let showOpenLinkSheet = Notification.Name("showOpenLinkSheet") static let openDescriptionLink = Notification.Name("openDescriptionLink") + /// Posted when a URL shortener (bit.ly, etc.) has been resolved to an + /// ambiguous destination — the app isn't certain it can play it, so the + /// user is prompted whether to try opening it in Yattee or in the browser. + /// `object` is the resolved `URL`. + static let promptResolvedShortLink = Notification.Name("promptResolvedShortLink") + /// Posted when a tapped link is not confidently a video (no YouTube / + /// PeerTube / direct-media match) but could potentially be extracted via + /// the Yattee server / yt-dlp. User is prompted whether to try extracting + /// or open it in the system browser instead. + /// `object` is the candidate `URL`. + static let promptAmbiguousExternalLink = Notification.Name("promptAmbiguousExternalLink") } diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index c65dd229..328db37c 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -41,6 +41,9 @@ enum SettingsKey: String, CaseIterable { case deArrowAPIURL case deArrowThumbnailAPIURL + // Short link resolution + case resolveShortLinksEnabled + // Platform-specific case macPlayerMode case playerSheetAutoResize diff --git a/Yattee/Core/Settings/SettingsManager+DeArrow.swift b/Yattee/Core/Settings/SettingsManager+DeArrow.swift index 23a607c7..f867e44d 100644 --- a/Yattee/Core/Settings/SettingsManager+DeArrow.swift +++ b/Yattee/Core/Settings/SettingsManager+DeArrow.swift @@ -87,4 +87,21 @@ extension SettingsManager { set(newValue, for: .deArrowThumbnailAPIURL) } } + + // MARK: - Short Link Resolution + + /// When enabled, taps on known URL shorteners (bit.ly, tinyurl, t.co, …) in + /// descriptions and comments follow the redirect and, if the destination is a + /// supported YouTube/PeerTube URL, open it in-app. Off by default because it + /// performs a network request to the shortener host on tap. + var resolveShortLinksEnabled: Bool { + get { + if let cached = _resolveShortLinksEnabled { return cached } + return bool(for: .resolveShortLinksEnabled, default: false) + } + set { + _resolveShortLinksEnabled = newValue + set(newValue, for: .resolveShortLinksEnabled) + } + } } diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index baf15d90..f23b8324 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -54,6 +54,9 @@ final class SettingsManager { var _deArrowAPIURL: String? var _deArrowThumbnailAPIURL: String? + // Short link resolution + var _resolveShortLinksEnabled: Bool? + // User Agent var _customUserAgent: String? var _randomizeUserAgentPerRequest: Bool? @@ -419,6 +422,7 @@ final class SettingsManager { _deArrowReplaceThumbnails = nil _deArrowAPIURL = nil _deArrowThumbnailAPIURL = nil + _resolveShortLinksEnabled = nil _customUserAgent = nil _randomizeUserAgentPerRequest = nil _feedCacheValidityMinutes = nil diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 32311f8d..ae336c2e 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -139,6 +139,46 @@ }, "1.2M subscribers" : { + }, + "alert.ambiguousLink.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Link" + } + } + } + }, + "alert.ambiguousLink.message %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + } + } + }, + "alert.ambiguousLink.tryInYattee" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try in Yattee" + } + } + } + }, + "alert.ambiguousLink.openInBrowser" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open in Browser" + } + } + } }, "alert.openVideo.message %@" : { "localizations" : { @@ -160,6 +200,46 @@ } } }, + "alert.resolvedShortLink.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Link" + } + } + } + }, + "alert.resolvedShortLink.message %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This short link expands to:\n%@" + } + } + } + }, + "alert.resolvedShortLink.openInYattee" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try in Yattee" + } + } + } + }, + "alert.resolvedShortLink.openInBrowser" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open in Browser" + } + } + } + }, "batchDownload.complete.allSkipped.subtitle" : { "comment" : "Toast subtitle when all videos were already downloaded", "localizations" : { @@ -14418,6 +14498,26 @@ } } }, + "settings.youtubeEnhancements.resolveShortLinks.footer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "When you tap a bit.ly, tinyurl, t.co, or similar short link in a description or comment, Yattee follows the redirect and opens the destination in-app if it's a supported video, channel, or playlist. Sends a network request to the shortener service." + } + } + } + }, + "settings.resolveShortLinks.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resolve Short Links" + } + } + } + }, "settings.youtubeEnhancements.title" : { "localizations" : { "en" : { diff --git a/Yattee/Services/Navigation/NavigationCoordinator.swift b/Yattee/Services/Navigation/NavigationCoordinator.swift index a8a9a911..56673e9c 100644 --- a/Yattee/Services/Navigation/NavigationCoordinator.swift +++ b/Yattee/Services/Navigation/NavigationCoordinator.swift @@ -72,6 +72,16 @@ final class NavigationCoordinator { /// when the player is expanded, so the sheet always sits above the visible layer. var descriptionLinkQueueSheetVideo: Video? + /// Resolved short-link URL awaiting user confirmation ("Try in Yattee" vs + /// "Open in Browser"). Dual-hosted like `descriptionLinkQueueSheetVideo` + /// so the dialog is visible whether or not the expanded player is covering + /// the main app view. + var resolvedShortLinkPrompt: URL? + + /// Candidate URL that isn't confidently a video (loose `.externalVideo` + /// match) awaiting user confirmation before yt-dlp extraction. Dual-hosted. + var ambiguousExternalLinkPrompt: URL? + /// Whether the mini player playlist sheet is showing. var isMiniPlayerPlaylistSheetPresented = false diff --git a/Yattee/Services/Navigation/URLRouter.swift b/Yattee/Services/Navigation/URLRouter.swift index 660d3ab4..38c2a689 100644 --- a/Yattee/Services/Navigation/URLRouter.swift +++ b/Yattee/Services/Navigation/URLRouter.swift @@ -12,6 +12,18 @@ struct URLRouter: Sendable { // MARK: - Main Routing + /// Route a URL only if we are *confident* the app can handle it natively — + /// YouTube/PeerTube video/channel/playlist, direct media (mp4/m3u8/etc), + /// or the custom `yattee://` scheme. Unlike `route(_:)` this deliberately + /// skips the `.externalVideo` yt-dlp fallback, which matches almost any + /// http/https URL and is therefore unsafe to trigger blindly after + /// resolving a URL shortener. + func routeConfidently(_ url: URL) -> NavigationDestination? { + guard let destination = route(url) else { return nil } + if case .externalVideo = destination { return nil } + return destination + } + /// Route a URL to a navigation destination. func route(_ url: URL) -> NavigationDestination? { // Try custom scheme first diff --git a/Yattee/Services/Navigation/URLShortenerResolver.swift b/Yattee/Services/Navigation/URLShortenerResolver.swift new file mode 100644 index 00000000..f607e547 --- /dev/null +++ b/Yattee/Services/Navigation/URLShortenerResolver.swift @@ -0,0 +1,123 @@ +// +// URLShortenerResolver.swift +// Yattee +// +// Best-effort resolver for known URL shortener services (bit.ly, tinyurl, t.co, …). +// Used to rescue taps on short links in video descriptions and comments: if the +// redirect target is a URL that `URLRouter` can handle, we open it in-app instead +// of bouncing out to Safari. +// +// Off-by-default feature — wired via `SettingsManager.resolveShortLinksEnabled`. +// + +import Foundation + +enum URLShortenerResolver { + /// Hosts whose URLs we try to resolve. Kept deliberately narrow so we don't + /// fire spurious network requests against arbitrary hosts the user taps. + /// `youtu.be` is intentionally excluded — `URLRouter` already handles it + /// directly without a network round-trip. + static let knownHosts: Set = [ + "bit.ly", + "tinyurl.com", + "t.co", + "goo.gl", + "ow.ly", + "buff.ly", + "is.gd", + "rebrand.ly", + "shorturl.at", + "cutt.ly", + "lnkd.in", + "tiny.cc", + "rb.gy" + ] + + /// Returns true if `url` is hosted on a known shortener service. + static func isShortener(_ url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + let normalizedHost = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host + return knownHosts.contains(normalizedHost) + } + + /// Resolves a shortener URL by following redirects. Returns `nil` on any + /// error (network failure, timeout, non-HTTP response). + /// + /// Internally uses `HEAD` first; if the shortener responds 405 Method Not + /// Allowed (some do), falls back to a single `GET`. Results are cached for + /// the app lifetime to make repeat taps instant. + static func resolve(_ url: URL) async -> URL? { + if let cached = await cache.get(url) { + return cached + } + + let resolved = await performResolve(url) + if let resolved { + await cache.set(url, resolved: resolved) + } + return resolved + } + + // MARK: - Implementation + + private static func performResolve(_ url: URL) async -> URL? { + // Try HEAD first — cheapest. Fall back to GET if anything throws or + // if HEAD doesn't yield a different URL (some servers reject HEAD with + // a connection reset before following redirects). + if let viaHead = try? await request(url, method: "HEAD"), viaHead != url { + return viaHead + } + return try? await request(url, method: "GET") + } + + private static func request(_ url: URL, method: String) async throws -> URL? { + var request = URLRequest(url: url, timeoutInterval: 5) + request.httpMethod = method + // A plain browser-ish UA avoids bot-blocking on a couple of services. + request.setValue( + "Mozilla/5.0 (compatible; Yattee URL resolver)", + forHTTPHeaderField: "User-Agent" + ) + + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 5 + config.timeoutIntervalForResource = 5 + config.httpCookieStorage = nil + config.urlCache = nil + let session = URLSession(configuration: config) + defer { session.finishTasksAndInvalidate() } + + let (_, response) = try await session.data(for: request) + // URLSession follows redirects by default, so `response.url` is the final URL. + // We accept any status code here — even a 4xx/5xx final response is fine + // because we only care about *where* the redirect chain landed, not whether + // that destination is currently serving content. + guard let finalURL = response.url, finalURL != url else { return nil } + return finalURL + } + + // Bounded in-memory cache. 128 entries is plenty — tapping the same link + // repeatedly in a session is common; cross-session persistence isn't needed. + private static let cache = ResolveCache(limit: 128) + + private actor ResolveCache { + private var storage: [URL: URL] = [:] + private var order: [URL] = [] + private let limit: Int + + init(limit: Int) { self.limit = limit } + + func get(_ url: URL) -> URL? { storage[url] } + + func set(_ url: URL, resolved: URL) { + if storage[url] == nil { + order.append(url) + if order.count > limit, let oldest = order.first { + order.removeFirst() + storage[oldest] = nil + } + } + storage[url] = resolved + } + } +} diff --git a/Yattee/Utilities/DescriptionText.swift b/Yattee/Utilities/DescriptionText.swift index 41d13cc8..9f57d387 100644 --- a/Yattee/Utilities/DescriptionText.swift +++ b/Yattee/Utilities/DescriptionText.swift @@ -7,6 +7,12 @@ import Foundation import SwiftUI +#if canImport(UIKit) +import UIKit +#endif +#if canImport(AppKit) +import AppKit +#endif // MARK: - Description Text Utilities @@ -87,12 +93,40 @@ enum DescriptionText { // MARK: - OpenURL Action for Seeking +/// Opens `url` in the user's default system browser (Safari on iOS/tvOS, +/// default browser on macOS). Used as the fallback when short-link resolution +/// fails or the destination isn't a URL the app can handle. +@MainActor +private func openInSystemBrowser(_ url: URL) { + #if canImport(UIKit) && !os(watchOS) + UIApplication.shared.open(url) + #elseif canImport(AppKit) + NSWorkspace.shared.open(url) + #endif +} + extension View { /// 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. + /// + /// When the user has enabled "Resolve Short Links" in YouTube Enhancements, + /// taps on known URL shorteners (bit.ly, tinyurl, t.co, …) whose hosts aren't + /// themselves routable are resolved asynchronously: if the redirect target is + /// a supported URL, it's opened in-app; otherwise we fall back to the system + /// browser with the original URL. func handleTimestampLinks(using playerService: PlayerService?) -> some View { - self.environment(\.openURL, OpenURLAction { url in + self.modifier(HandleTimestampLinksModifier(playerService: playerService)) + } +} + +private struct HandleTimestampLinksModifier: ViewModifier { + let playerService: PlayerService? + @Environment(\.appEnvironment) private var appEnvironment + + func body(content: Content) -> some View { + let resolveShortLinks = appEnvironment?.settingsManager.resolveShortLinksEnabled ?? false + return content.environment(\.openURL, OpenURLAction { url in if let seconds = DescriptionText.seekSeconds(from: url) { Task { await playerService?.seek(to: TimeInterval(seconds)) @@ -100,11 +134,47 @@ extension View { return .handled } - if URLRouter().route(url) != nil { + let router = URLRouter() + + // 1. Definitely-playable URLs (YouTube / PeerTube / direct media / + // custom scheme) open in-app without any prompt. + if router.routeConfidently(url) != nil { NotificationCenter.default.post(name: .openDescriptionLink, object: url) return .handled } + // 2. URL shorteners — resolve first, *then* decide. This has to run + // before the loose `route()` check below, because `route()` would + // otherwise match bit.ly/t.co/etc. as `.externalVideo` and send + // the *shortener* URL itself to yt-dlp. + if resolveShortLinks && URLShortenerResolver.isShortener(url) { + Task { @MainActor in + guard let resolved = await URLShortenerResolver.resolve(url) else { + openInSystemBrowser(url) + return + } + + if router.routeConfidently(resolved) != nil { + NotificationCenter.default.post(name: .openDescriptionLink, object: resolved) + } else { + // Ambiguous destination (e.g. a news article). Let the user + // decide whether to try opening in Yattee (falls back to + // yt-dlp extraction) or in the system browser. + NotificationCenter.default.post(name: .promptResolvedShortLink, object: resolved) + } + } + return .handled + } + + // 3. Non-shortener URLs that only match via the loose `.externalVideo` + // fallback (e.g. vimeo.com/…, news articles, any http(s)): we + // can't tell whether yt-dlp will successfully extract, so ask + // the user whether to try extracting or open in the browser. + if router.routeConfidently(url) == nil, router.route(url) != nil { + NotificationCenter.default.post(name: .promptAmbiguousExternalLink, object: url) + return .handled + } + return .systemAction }) } diff --git a/Yattee/Views/Components/ResolvedLinkPromptsModifier.swift b/Yattee/Views/Components/ResolvedLinkPromptsModifier.swift new file mode 100644 index 00000000..abb599d5 --- /dev/null +++ b/Yattee/Views/Components/ResolvedLinkPromptsModifier.swift @@ -0,0 +1,116 @@ +// +// ResolvedLinkPromptsModifier.swift +// Yattee +// +// Hosts the two confirmation dialogs used when a tapped description/comment +// link needs user approval: +// +// - `resolvedShortLinkPrompt` — a bit.ly/t.co/… shortener resolved to a URL +// that isn't confidently a playable video. +// - `ambiguousExternalLinkPrompt` — a non-shortener URL that only matches the +// loose `.externalVideo` yt-dlp fallback (e.g. an arbitrary webpage). +// +// The state lives on `NavigationCoordinator` so both the root app view and +// `ExpandedPlayerSheet` can host the dialogs. Only whichever host is currently +// on top presents the dialog (`shouldHost`), so the dialog is visible whether +// the expanded player is covering the main view or not — matching the +// `descriptionLinkQueueSheetVideo` pattern. +// + +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif +#if canImport(AppKit) +import AppKit +#endif + +struct ResolvedLinkPromptsModifier: ViewModifier { + /// Whether this host should actually present the dialogs right now. Used + /// so YatteeApp (root) only presents when the expanded player isn't up, + /// and ExpandedPlayerSheet only presents when it *is* up. + let shouldHost: Bool + /// Passed in explicitly (rather than read from `@Environment`) because + /// YatteeApp applies this modifier *outside* its `.appEnvironment(…)` + /// injection, where the environment value isn't set yet. Nil-safe so + /// ExpandedPlayerSheet (which holds an optional) can pass through. + let appEnvironment: AppEnvironment? + + func body(content: Content) -> some View { + content + .confirmationDialog( + String(localized: "alert.resolvedShortLink.title"), + isPresented: shortLinkBinding, + titleVisibility: .visible, + presenting: shouldHost ? appEnvironment?.navigationCoordinator.resolvedShortLinkPrompt : nil + ) { url in + Button(String(localized: "alert.resolvedShortLink.openInYattee")) { + NotificationCenter.default.post(name: .openDescriptionLink, object: url) + } + Button(String(localized: "alert.resolvedShortLink.openInBrowser")) { + openInSystemBrowser(url) + } + Button(String(localized: "common.cancel"), role: .cancel) {} + } message: { url in + Text(String(localized: "alert.resolvedShortLink.message \(url.absoluteString)")) + } + .confirmationDialog( + String(localized: "alert.ambiguousLink.title"), + isPresented: ambiguousBinding, + titleVisibility: .visible, + presenting: shouldHost ? appEnvironment?.navigationCoordinator.ambiguousExternalLinkPrompt : nil + ) { url in + Button(String(localized: "alert.ambiguousLink.tryInYattee")) { + NotificationCenter.default.post(name: .openDescriptionLink, object: url) + } + Button(String(localized: "alert.ambiguousLink.openInBrowser")) { + openInSystemBrowser(url) + } + Button(String(localized: "common.cancel"), role: .cancel) {} + } message: { url in + Text(String(localized: "alert.ambiguousLink.message \(url.absoluteString)")) + } + } + + private var shortLinkBinding: Binding { + Binding( + get: { shouldHost && appEnvironment?.navigationCoordinator.resolvedShortLinkPrompt != nil }, + set: { presented in + if !presented { + appEnvironment?.navigationCoordinator.resolvedShortLinkPrompt = nil + } + } + ) + } + + private var ambiguousBinding: Binding { + Binding( + get: { shouldHost && appEnvironment?.navigationCoordinator.ambiguousExternalLinkPrompt != nil }, + set: { presented in + if !presented { + appEnvironment?.navigationCoordinator.ambiguousExternalLinkPrompt = nil + } + } + ) + } + + @MainActor + private func openInSystemBrowser(_ url: URL) { + #if canImport(UIKit) && !os(watchOS) + UIApplication.shared.open(url) + #elseif canImport(AppKit) + NSWorkspace.shared.open(url) + #endif + } +} + +extension View { + /// Apply at both root (when `!isPlayerExpanded`) and expanded-player level + /// (when `isPlayerExpanded`) to keep the confirmation dialogs visible above + /// whichever layer is showing. `appEnvironment` must be passed in rather + /// than read from the environment because the root call site applies this + /// modifier outside the `.appEnvironment(…)` injection point. + func resolvedLinkPrompts(shouldHost: Bool, appEnvironment: AppEnvironment?) -> some View { + modifier(ResolvedLinkPromptsModifier(shouldHost: shouldHost, appEnvironment: appEnvironment)) + } +} diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index be76ff44..f1682543 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -395,6 +395,13 @@ struct ExpandedPlayerSheet: View { .appEnvironment(appEnvironment) } } + // Host the resolved/ambiguous link confirmation dialogs while the + // expanded player is up so they appear above this sheet rather than + // being buried underneath it on the root app view. + .resolvedLinkPrompts( + shouldHost: (appEnvironment?.navigationCoordinator.isPlayerExpanded == true), + appEnvironment: appEnvironment + ) #if os(iOS) .toolbar(.hidden, for: .navigationBar) .playerStatusBarHidden(isInWideScreenLayout || !isPortraitPanelVisible) diff --git a/Yattee/Views/Settings/YouTubeEnhancementsSettingsView.swift b/Yattee/Views/Settings/YouTubeEnhancementsSettingsView.swift index b91ff0b1..41ce5d06 100644 --- a/Yattee/Views/Settings/YouTubeEnhancementsSettingsView.swift +++ b/Yattee/Views/Settings/YouTubeEnhancementsSettingsView.swift @@ -16,6 +16,7 @@ struct YouTubeEnhancementsSettingsView: View { SponsorBlockSection(settings: settings) ReturnYouTubeDislikeSection(settings: settings) DeArrowSection(settings: settings) + ResolveShortLinksSection(settings: settings) } } #if !os(tvOS) @@ -93,6 +94,21 @@ private struct DeArrowSection: View { } } +// MARK: - Resolve Short Links Section + +private struct ResolveShortLinksSection: View { + @Bindable var settings: SettingsManager + + var body: some View { + SettingsFormSection(footer: "settings.youtubeEnhancements.resolveShortLinks.footer") { + Toggle( + String(localized: "settings.resolveShortLinks.title"), + isOn: $settings.resolveShortLinksEnabled + ) + } + } +} + #Preview { NavigationStack { YouTubeEnhancementsSettingsView() diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index 04c397f6..15388485 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -51,6 +51,9 @@ struct YatteeApp: App { #endif @State private var showingOpenLinkSheet = false + // Ambiguous-link prompts are stored on NavigationCoordinator so they can + // be dual-hosted by the root app view (here) and the expanded player sheet. + init() { // Configure Nuke image loading pipeline ImageLoadingService.shared.configure() @@ -192,6 +195,23 @@ struct YatteeApp: App { handleDescriptionLink(url) } } + .onReceive(NotificationCenter.default.publisher(for: .promptResolvedShortLink)) { notification in + if let url = notification.object as? URL { + appEnvironment.navigationCoordinator.resolvedShortLinkPrompt = url + } + } + .onReceive(NotificationCenter.default.publisher(for: .promptAmbiguousExternalLink)) { notification in + if let url = notification.object as? URL { + appEnvironment.navigationCoordinator.ambiguousExternalLinkPrompt = url + } + } + // Present confirmation dialogs at root level only when the expanded + // player isn't covering it. ExpandedPlayerSheet hosts the same + // dialogs when it *is* expanded, so they always sit on top. + .resolvedLinkPrompts( + shouldHost: !appEnvironment.navigationCoordinator.isPlayerExpanded, + appEnvironment: appEnvironment + ) } #if os(macOS) .windowStyle(.hiddenTitleBar)