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:
Arkadiusz Fal
2026-04-23 07:29:57 +02:00
parent d38b781858
commit 5a839da1bd
13 changed files with 511 additions and 2 deletions

View 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))
}
}

View File

@@ -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)

View File

@@ -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()