mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +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 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")
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ enum SettingsKey: String, CaseIterable {
|
||||
case deArrowAPIURL
|
||||
case deArrowThumbnailAPIURL
|
||||
|
||||
// Short link resolution
|
||||
case resolveShortLinksEnabled
|
||||
|
||||
// Platform-specific
|
||||
case macPlayerMode
|
||||
case playerSheetAutoResize
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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 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
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user