mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 10:25:02 +00:00
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.
This commit is contained in:
@@ -11,4 +11,15 @@ 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")
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ enum SettingsKey: String, CaseIterable {
|
|||||||
case deArrowAPIURL
|
case deArrowAPIURL
|
||||||
case deArrowThumbnailAPIURL
|
case deArrowThumbnailAPIURL
|
||||||
|
|
||||||
|
// Short link resolution
|
||||||
|
case resolveShortLinksEnabled
|
||||||
|
|
||||||
// Platform-specific
|
// Platform-specific
|
||||||
case macPlayerMode
|
case macPlayerMode
|
||||||
case playerSheetAutoResize
|
case playerSheetAutoResize
|
||||||
|
|||||||
@@ -87,4 +87,21 @@ extension SettingsManager {
|
|||||||
set(newValue, for: .deArrowThumbnailAPIURL)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ final class SettingsManager {
|
|||||||
var _deArrowAPIURL: String?
|
var _deArrowAPIURL: String?
|
||||||
var _deArrowThumbnailAPIURL: String?
|
var _deArrowThumbnailAPIURL: String?
|
||||||
|
|
||||||
|
// Short link resolution
|
||||||
|
var _resolveShortLinksEnabled: Bool?
|
||||||
|
|
||||||
// User Agent
|
// User Agent
|
||||||
var _customUserAgent: String?
|
var _customUserAgent: String?
|
||||||
var _randomizeUserAgentPerRequest: Bool?
|
var _randomizeUserAgentPerRequest: Bool?
|
||||||
@@ -419,6 +422,7 @@ final class SettingsManager {
|
|||||||
_deArrowReplaceThumbnails = nil
|
_deArrowReplaceThumbnails = nil
|
||||||
_deArrowAPIURL = nil
|
_deArrowAPIURL = nil
|
||||||
_deArrowThumbnailAPIURL = nil
|
_deArrowThumbnailAPIURL = nil
|
||||||
|
_resolveShortLinksEnabled = nil
|
||||||
_customUserAgent = nil
|
_customUserAgent = nil
|
||||||
_randomizeUserAgentPerRequest = nil
|
_randomizeUserAgentPerRequest = nil
|
||||||
_feedCacheValidityMinutes = nil
|
_feedCacheValidityMinutes = nil
|
||||||
|
|||||||
@@ -139,6 +139,46 @@
|
|||||||
},
|
},
|
||||||
"1.2M subscribers" : {
|
"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 %@" : {
|
"alert.openVideo.message %@" : {
|
||||||
"localizations" : {
|
"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" : {
|
"batchDownload.complete.allSkipped.subtitle" : {
|
||||||
"comment" : "Toast subtitle when all videos were already downloaded",
|
"comment" : "Toast subtitle when all videos were already downloaded",
|
||||||
"localizations" : {
|
"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" : {
|
"settings.youtubeEnhancements.title" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ final class NavigationCoordinator {
|
|||||||
/// when the player is expanded, so the sheet always sits above the visible layer.
|
/// when the player is expanded, so the sheet always sits above the visible layer.
|
||||||
var descriptionLinkQueueSheetVideo: Video?
|
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.
|
/// Whether the mini player playlist sheet is showing.
|
||||||
var isMiniPlayerPlaylistSheetPresented = false
|
var isMiniPlayerPlaylistSheetPresented = false
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ struct URLRouter: Sendable {
|
|||||||
|
|
||||||
// MARK: - Main Routing
|
// 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.
|
/// Route a URL to a navigation destination.
|
||||||
func route(_ url: URL) -> NavigationDestination? {
|
func route(_ url: URL) -> NavigationDestination? {
|
||||||
// Try custom scheme first
|
// Try custom scheme first
|
||||||
|
|||||||
123
Yattee/Services/Navigation/URLShortenerResolver.swift
Normal file
123
Yattee/Services/Navigation/URLShortenerResolver.swift
Normal file
@@ -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<String> = [
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
#if canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Description Text Utilities
|
// MARK: - Description Text Utilities
|
||||||
|
|
||||||
@@ -87,12 +93,40 @@ enum DescriptionText {
|
|||||||
|
|
||||||
// MARK: - OpenURL Action for Seeking
|
// 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 {
|
extension View {
|
||||||
/// Adds a URL handler that intercepts timestamp links (seeks the player) and
|
/// Adds a URL handler that intercepts timestamp links (seeks the player) and
|
||||||
/// known content URLs — YouTube/PeerTube video/channel/playlist links and external
|
/// known content URLs — YouTube/PeerTube video/channel/playlist links and external
|
||||||
/// video URLs — so they open in-app instead of the browser.
|
/// 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 {
|
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) {
|
if let seconds = DescriptionText.seekSeconds(from: url) {
|
||||||
Task {
|
Task {
|
||||||
await playerService?.seek(to: TimeInterval(seconds))
|
await playerService?.seek(to: TimeInterval(seconds))
|
||||||
@@ -100,11 +134,47 @@ extension View {
|
|||||||
return .handled
|
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)
|
NotificationCenter.default.post(name: .openDescriptionLink, object: url)
|
||||||
return .handled
|
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
|
return .systemAction
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
116
Yattee/Views/Components/ResolvedLinkPromptsModifier.swift
Normal file
116
Yattee/Views/Components/ResolvedLinkPromptsModifier.swift
Normal file
@@ -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<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { shouldHost && appEnvironment?.navigationCoordinator.resolvedShortLinkPrompt != nil },
|
||||||
|
set: { presented in
|
||||||
|
if !presented {
|
||||||
|
appEnvironment?.navigationCoordinator.resolvedShortLinkPrompt = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var ambiguousBinding: Binding<Bool> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -395,6 +395,13 @@ struct ExpandedPlayerSheet: View {
|
|||||||
.appEnvironment(appEnvironment)
|
.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)
|
#if os(iOS)
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
.playerStatusBarHidden(isInWideScreenLayout || !isPortraitPanelVisible)
|
.playerStatusBarHidden(isInWideScreenLayout || !isPortraitPanelVisible)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ struct YouTubeEnhancementsSettingsView: View {
|
|||||||
SponsorBlockSection(settings: settings)
|
SponsorBlockSection(settings: settings)
|
||||||
ReturnYouTubeDislikeSection(settings: settings)
|
ReturnYouTubeDislikeSection(settings: settings)
|
||||||
DeArrowSection(settings: settings)
|
DeArrowSection(settings: settings)
|
||||||
|
ResolveShortLinksSection(settings: settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if !os(tvOS)
|
#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 {
|
#Preview {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
YouTubeEnhancementsSettingsView()
|
YouTubeEnhancementsSettingsView()
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ struct YatteeApp: App {
|
|||||||
#endif
|
#endif
|
||||||
@State private var showingOpenLinkSheet = false
|
@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() {
|
init() {
|
||||||
// Configure Nuke image loading pipeline
|
// Configure Nuke image loading pipeline
|
||||||
ImageLoadingService.shared.configure()
|
ImageLoadingService.shared.configure()
|
||||||
@@ -192,6 +195,23 @@ struct YatteeApp: App {
|
|||||||
handleDescriptionLink(url)
|
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)
|
#if os(macOS)
|
||||||
.windowStyle(.hiddenTitleBar)
|
.windowStyle(.hiddenTitleBar)
|
||||||
|
|||||||
Reference in New Issue
Block a user