diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index b0c2e2ca..51799aab 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -756,6 +756,28 @@ final class AppEnvironment { } } + // MARK: - Sharing + + /// URL for sharing a video, honoring the privacy-frontend sharing setting. + /// + /// When enabled, YouTube videos are shared as links to the active + /// instance's web watch page. Videos the active instance doesn't serve + /// (PeerTube, extracted sources) and instances without a web frontend + /// keep the video's original share URL. + func shareURL(for video: Video) -> URL { + guard settingsManager.privacyFrontendSharingEnabled, + case .global(let provider) = video.id.source, + provider == ContentSource.youtubeProvider, + let base = instancesManager.activeInstance?.watchPageBaseURL, + var components = URLComponents(url: base, resolvingAgainstBaseURL: false) + else { + return video.shareURL + } + components.path += "/watch" + components.queryItems = [URLQueryItem(name: "v", value: video.id.videoID)] + return components.url ?? video.shareURL + } + // MARK: - Notifications /// Ensures the notification infrastructure is enabled (system permission + master toggle + background refresh). diff --git a/Yattee/Core/Settings/SettingsManager+General.swift b/Yattee/Core/Settings/SettingsManager+General.swift index 6d979225..e59fd90d 100644 --- a/Yattee/Core/Settings/SettingsManager+General.swift +++ b/Yattee/Core/Settings/SettingsManager+General.swift @@ -580,6 +580,27 @@ extension SettingsManager { } } + // MARK: - Sharing Settings + + /// Whether share links to YouTube videos point at the active instance's + /// web watch page instead of youtube.com. Default is true. + /// Videos the active instance can't serve keep their original URL. + /// This is a local-only setting (not synced to iCloud). + var privacyFrontendSharingEnabled: Bool { + get { + if let cached = _privacyFrontendSharingEnabled { return cached } + // Default to true if not set + if localDefaults.object(forKey: "privacyFrontendSharingEnabled") == nil { + return true + } + return localDefaults.bool(forKey: "privacyFrontendSharingEnabled") + } + set { + _privacyFrontendSharingEnabled = newValue + localDefaults.set(newValue, forKey: "privacyFrontendSharingEnabled") + } + } + // MARK: - Link Action Settings /// Default action when opening links from share extension or URL schemes. diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index d9bb13d2..bafa8308 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -203,6 +203,9 @@ final class SettingsManager { // Handoff settings var _handoffEnabled: Bool? + // Sharing settings + var _privacyFrontendSharingEnabled: Bool? + // Remote Control settings var _remoteControlCustomDeviceName: String? var _remoteControlHideWhenBackgrounded: Bool? @@ -541,6 +544,7 @@ final class SettingsManager { _queueAutoPlayNext = nil _queueAutoPlayCountdown = nil _handoffEnabled = nil + _privacyFrontendSharingEnabled = nil _defaultLinkAction = nil _remoteControlCustomDeviceName = nil _remoteControlHideWhenBackgrounded = nil diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 418adb0c..d2d5e001 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -11598,6 +11598,26 @@ } } }, + "settings.behavior.privacyFrontendSharing" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use Privacy Frontend URLs When Sharing" + } + } + } + }, + "settings.behavior.privacyFrontendSharing.footer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share links to YouTube videos point to the current source's watch page instead of youtube.com. Videos the current source can't open keep their original link." + } + } + } + }, "settings.behavior.searchHistoryLimit" : { "comment" : "Label for search history limit picker", "localizations" : { @@ -11642,6 +11662,16 @@ } } }, + "settings.behavior.sharing.header" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sharing" + } + } + } + }, "settings.behavior.textAreaTapAction" : { "localizations" : { "en" : { diff --git a/Yattee/Models/Instance.swift b/Yattee/Models/Instance.swift index ace1a84c..892b45c0 100644 --- a/Yattee/Models/Instance.swift +++ b/Yattee/Models/Instance.swift @@ -174,6 +174,18 @@ struct Instance: Identifiable, Codable, Hashable, Sendable { frontendURL ?? url } + /// Base URL of the web watch page for building shareable YouTube links, + /// when this instance has one: Invidious serves its frontend at the API + /// URL, Piped needs the separately configured frontend URL (its API host + /// has no watch pages), and Yattee Server has no web frontend at all. + var watchPageBaseURL: URL? { + switch type { + case .invidious: return frontendURL ?? url + case .piped: return frontendURL + case .peertube, .yatteeServer: return nil + } + } + var contentSource: ContentSource { type.contentSource(for: url) } diff --git a/Yattee/Views/Components/PlayerPillButtonView.swift b/Yattee/Views/Components/PlayerPillButtonView.swift index 8f26e77e..b5227297 100644 --- a/Yattee/Views/Components/PlayerPillButtonView.swift +++ b/Yattee/Views/Components/PlayerPillButtonView.swift @@ -10,6 +10,8 @@ import SwiftUI struct PlayerPillButtonView: View { + @Environment(\.appEnvironment) private var appEnvironment + let configuration: ControlButtonConfiguration let isPlaying: Bool let hasNext: Bool @@ -199,7 +201,7 @@ struct PlayerPillButtonView: View { private var shareButton: some View { Group { if let video { - ShareLink(item: video.shareURL) { + ShareLink(item: appEnvironment?.shareURL(for: video) ?? video.shareURL) { genericButtonLabel } .buttonStyle(.plain) diff --git a/Yattee/Views/Components/VideoContextMenu.swift b/Yattee/Views/Components/VideoContextMenu.swift index f9931685..54984a6f 100644 --- a/Yattee/Views/Components/VideoContextMenu.swift +++ b/Yattee/Views/Components/VideoContextMenu.swift @@ -280,7 +280,7 @@ struct VideoContextMenuContent: View { // Share #if !os(tvOS) - ShareLink(item: video.shareURL) { + ShareLink(item: appEnvironment?.shareURL(for: video) ?? video.shareURL) { Label(String(localized: "video.context.share"), systemImage: "square.and.arrow.up") } #endif diff --git a/Yattee/Views/Components/VideoSwipeActionsModifier.swift b/Yattee/Views/Components/VideoSwipeActionsModifier.swift index 28bed229..f47a6fdc 100644 --- a/Yattee/Views/Components/VideoSwipeActionsModifier.swift +++ b/Yattee/Views/Components/VideoSwipeActionsModifier.swift @@ -191,7 +191,7 @@ struct VideoSwipeActionsModifier: ViewModifier { private func shareVideo() { #if os(iOS) let activityVC = UIActivityViewController( - activityItems: [video.shareURL], + activityItems: [appEnvironment?.shareURL(for: video) ?? video.shareURL], applicationActivities: nil ) if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, @@ -217,7 +217,7 @@ struct VideoSwipeActionsModifier: ViewModifier { #elseif os(macOS) let pasteboard = NSPasteboard.general pasteboard.clearContents() - pasteboard.setString(video.shareURL.absoluteString, forType: .string) + pasteboard.setString((appEnvironment?.shareURL(for: video) ?? video.shareURL).absoluteString, forType: .string) #endif } diff --git a/Yattee/Views/Player/ControlsSectionRenderer.swift b/Yattee/Views/Player/ControlsSectionRenderer.swift index a29b825d..b68b74c8 100644 --- a/Yattee/Views/Player/ControlsSectionRenderer.swift +++ b/Yattee/Views/Player/ControlsSectionRenderer.swift @@ -11,6 +11,8 @@ import SwiftUI /// Renders a horizontal row of control buttons based on layout configuration. struct ControlsSectionRenderer: View { + @Environment(\.appEnvironment) private var appEnvironment + let section: LayoutSection let actions: PlayerControlsActions let globalSettings: GlobalLayoutSettings @@ -925,7 +927,7 @@ struct ControlsSectionRenderer: View { @ViewBuilder private var shareButton: some View { if let video = actions.currentVideo { - ShareLink(item: video.shareURL) { + ShareLink(item: appEnvironment?.shareURL(for: video) ?? video.shareURL) { buttonContent(systemImage: "square.and.arrow.up", tint: .white) } } diff --git a/Yattee/Views/Player/MiniPlayerView.swift b/Yattee/Views/Player/MiniPlayerView.swift index ceef2223..50aa06e1 100644 --- a/Yattee/Views/Player/MiniPlayerView.swift +++ b/Yattee/Views/Player/MiniPlayerView.swift @@ -572,7 +572,7 @@ struct MiniPlayerView: View { @ViewBuilder private func shareButton(config: ControlButtonConfiguration) -> some View { if let video = currentVideo { - ShareLink(item: video.shareURL) { + ShareLink(item: appEnvironment?.shareURL(for: video) ?? video.shareURL) { Image(systemName: "square.and.arrow.up") .font(isTabAccessory ? .title3 : .title2) .frame(width: 32, height: 32) diff --git a/Yattee/Views/Player/macOS/MacOSControlsSectionRenderer.swift b/Yattee/Views/Player/macOS/MacOSControlsSectionRenderer.swift index 995d7e9e..0517d440 100644 --- a/Yattee/Views/Player/macOS/MacOSControlsSectionRenderer.swift +++ b/Yattee/Views/Player/macOS/MacOSControlsSectionRenderer.swift @@ -670,7 +670,7 @@ struct MacOSControlsSectionRenderer: View { @ViewBuilder private var shareButton: some View { if let video = actions.currentVideo { - ShareLink(item: video.shareURL) { + ShareLink(item: appEnvironment?.shareURL(for: video) ?? video.shareURL) { buttonLabel(systemImage: "square.and.arrow.up") } .buttonStyle(buttonStyleForContext) diff --git a/Yattee/Views/Settings/LayoutNavigationSettingsView.swift b/Yattee/Views/Settings/LayoutNavigationSettingsView.swift index 61e70670..42273e7d 100644 --- a/Yattee/Views/Settings/LayoutNavigationSettingsView.swift +++ b/Yattee/Views/Settings/LayoutNavigationSettingsView.swift @@ -22,6 +22,7 @@ struct LayoutNavigationSettingsView: View { #else VideoActionsSection(settings: settings) LinkActionSection(settings: settings) + SharingSection(settings: settings) ClipboardSection(settings: settings) #endif #if os(iOS) @@ -143,6 +144,32 @@ private struct LinkActionSection: View { } } +// MARK: - Sharing Section + +#if !os(tvOS) +private struct SharingSection: View { + @Environment(\.appEnvironment) private var appEnvironment + @Bindable var settings: SettingsManager + + /// Shown only when a source with a web watch page exists — without one + /// the toggle can't change anything. + private var hasPrivacyFrontendSource: Bool { + appEnvironment?.instancesManager.enabledInstances.contains { $0.watchPageBaseURL != nil } ?? false + } + + var body: some View { + if hasPrivacyFrontendSource { + SettingsFormSection("settings.behavior.sharing.header", footer: "settings.behavior.privacyFrontendSharing.footer") { + Toggle( + String(localized: "settings.behavior.privacyFrontendSharing"), + isOn: $settings.privacyFrontendSharingEnabled + ) + } + } + } +} +#endif + // MARK: - Clipboard Section private struct ClipboardSection: View { diff --git a/Yattee/Views/Video/VideoInfoView.swift b/Yattee/Views/Video/VideoInfoView.swift index 4761f066..fe986d72 100644 --- a/Yattee/Views/Video/VideoInfoView.swift +++ b/Yattee/Views/Video/VideoInfoView.swift @@ -1173,7 +1173,7 @@ struct VideoInfoView: View { // Share button if let video = displayedVideo { - ShareLink(item: video.shareURL) { + ShareLink(item: appEnvironment?.shareURL(for: video) ?? video.shareURL) { #if os(macOS) Label(String(localized: "video.share"), systemImage: "square.and.arrow.up") .font(.body)