mirror of
https://github.com/yattee/yattee.git
synced 2026-05-13 10:55:03 +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:
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()
|
||||
|
||||
Reference in New Issue
Block a user