Skip to content
This repository was archived by the owner on Feb 24, 2025. It is now read-only.

DuckPlayer: 31. PoC: Open Player Links in Youtube #3919

Merged
merged 10 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DuckDuckGo-iOS.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -5848,8 +5848,8 @@
653561012D4D2C680064F258 /* Logger+DuckPlayer.swift */,
D63FF8972C1B6A45006DE24D /* DuckPlayer.swift */,
65D091252D47DF2500226164 /* DuckPlayerView.swift */,
655A9C692D4D280D000A7841 /* DuckPlayerWebView.swift */,
65D091232D47DF1B00226164 /* DuckPlayerViewModel.swift */,
655A9C692D4D280D000A7841 /* DuckPlayerWebView.swift */,
D6037E682C32F2E7009AAEC0 /* DuckPlayerSettings.swift */,
D62EC3C12C248AF800FC9D04 /* DuckPlayerNavigationHandling.swift */,
D63FF8892C1B21C2006DE24D /* DuckPlayerNavigationHandler.swift */,
Expand Down
27 changes: 26 additions & 1 deletion DuckDuckGo/DuckPlayer/DuckPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ protocol DuckPlayerControlling: AnyObject {

/// The host view controller, if any.
var hostView: TabViewController? { get }

// Navigation Request Publisher to notify when DuckPlayer needs direct Youtube Nav
var youtubeNavigationRequest: PassthroughSubject<URL, Never> { get }

/// Initializes a new instance of DuckPlayer with the provided settings and feature flagger.
///
Expand Down Expand Up @@ -251,6 +254,9 @@ final class DuckPlayer: NSObject, DuckPlayerControlling {
case overlay = "duckPlayer"
}

// A published subject to notify when a Youtube navigation request is needed
var youtubeNavigationRequest: PassthroughSubject<URL, Never>

/// Initializes a new instance of DuckPlayer with the provided settings and feature flagger.
///
/// - Parameters:
Expand All @@ -260,6 +266,7 @@ final class DuckPlayer: NSObject, DuckPlayerControlling {
featureFlagger: FeatureFlagger = AppDependencyProvider.shared.featureFlagger) {
self.settings = settings
self.featureFlagger = featureFlagger
self.youtubeNavigationRequest = PassthroughSubject<URL, Never>()
super.init()
registerOrientationSubscriber()
}
Expand All @@ -270,6 +277,7 @@ final class DuckPlayer: NSObject, DuckPlayerControlling {
hostView?.view.removeGestureRecognizer(tapGestureRecognizer)
}
hostView = nil
cancellables.removeAll()
}

/// Sets the host view controller for presenting modals.
Expand Down Expand Up @@ -328,18 +336,35 @@ final class DuckPlayer: NSObject, DuckPlayerControlling {
}

// Loads a native DuckPlayerView
private var cancellables = Set<AnyCancellable>()

func loadNativeDuckPlayerVideo(videoID: String) {
Logger.duckplayer.debug("Starting loadNativeDuckPlayerVideo with ID: \(videoID)")
let viewModel = DuckPlayerViewModel(videoID: videoID)
guard let url = viewModel.getVideoURL() else {
Logger.duckplayer.debug("Failed to get video URL for ID: \(videoID)")
return
}
let webView = DuckPlayerWebView(url: url)

Logger.duckplayer.debug("Creating webView for videoID: \(videoID)")
// Create webView with viewModel
let webView = DuckPlayerWebView(viewModel: viewModel)

let duckPlayerView = DuckPlayerView(viewModel: viewModel, webView: webView)
let hostingController = UIHostingController(rootView: duckPlayerView)
hostingController.modalPresentationStyle = .formSheet
hostingController.isModalInPresentation = false

// Subscribe to the viewModel's publisher
viewModel.youtubeNavigationRequestPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self, weak hostingController] url in
Logger.duckplayer.debug("Received YouTube navigation request: \(url)")
self?.youtubeNavigationRequest.send(url)
hostingController?.dismiss(animated: true)
}
.store(in: &cancellables)

hostView?.present(hostingController, animated: true)
}

Expand Down
26 changes: 21 additions & 5 deletions DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ final class DuckPlayerNavigationHandler: NSObject {
/// Cancellable for observing DuckPlayer Mode changes
private var duckPlayerModeCancellable: AnyCancellable?

/// Cancellable for observing DuckPlayer Navigation Request
private var duckPlayerNavigationRequestCancellable: AnyCancellable?

private struct Constants {
static let SERPURL = "duckduckgo.com/"
static let refererHeader = "Referer"
Expand Down Expand Up @@ -133,6 +136,12 @@ final class DuckPlayerNavigationHandler: NSObject {
super.init()
}

deinit {
// Clean up Combine subscriptions
duckPlayerModeCancellable?.cancel()
duckPlayerNavigationRequestCancellable?.cancel()
}

/// Returns the file path for the Duck Player HTML template.
static var htmlTemplatePath: String {
guard let file = ContentScopeScripts.Bundle.path(forResource: Constants.templateName,
Expand Down Expand Up @@ -576,6 +585,16 @@ final class DuckPlayerNavigationHandler: NSObject {
}
}

/// Register a DuckPlayer Youtube Navigation Request observer
/// Used when DuckPlayer requires direct Youtube Navigation
@MainActor
private func setupYoutubeNavigationRequestObserver(webView: WKWebView) {
duckPlayerNavigationRequestCancellable = duckPlayer.youtubeNavigationRequest
.sink { [weak self] url in
self?.redirectToYouTubeVideo(url: url, webView: webView)
}
}

/// // Handle "open in YouTube" links (duck://player/openInYoutube)
///
/// - Parameter url: The `URL` used to determine the tab type.
Expand All @@ -600,11 +619,7 @@ final class DuckPlayerNavigationHandler: NSObject {
redirectToYouTubeVideo(url: url, webView: webView, forceNewTab: true)
}
}

deinit {
duckPlayerModeCancellable?.cancel()
duckPlayerModeCancellable = nil
}


/// Checks if a URL contains a hash
///
Expand Down Expand Up @@ -860,6 +875,7 @@ extension DuckPlayerNavigationHandler: DuckPlayerNavigationHandling {
duckPlayerOverlayUsagePixels?.webView = webView
duckPlayerOverlayUsagePixels?.duckPlayerMode = duckPlayer.settings.mode
setupPlayerModeObserver()
setupYoutubeNavigationRequestObserver(webView: webView)

// Ensure feature and mode are enabled
guard isDuckPlayerFeatureEnabled,
Expand Down
43 changes: 41 additions & 2 deletions DuckDuckGo/DuckPlayer/DuckPlayerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ struct DuckPlayerView: View {
static let videoAspectRatio: CGFloat = 9/16 // 16:9 in portrait
static let daxLogoSize: CGFloat = 24.0
static let daxLogo = "Home"
static let bottomButtonHeight: CGFloat = 44
}


Expand All @@ -48,6 +49,7 @@ struct DuckPlayerView: View {
.frame(height: Constants.headerHeight)

// Video Container
Spacer()
GeometryReader { geometry in
ZStack {
RoundedRectangle(cornerRadius: Constants.cornerRadius)
Expand All @@ -68,13 +70,37 @@ struct DuckPlayerView: View {
y: geometry.size.height / 2
)
}

if viewModel.shouldShowYouTubeButton {
Button {
viewModel.openInYouTube()
} label: {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.2))
Text("Watch this video on YouTube")
.daxButton()
.daxBodyRegular()
.foregroundColor(Color(designSystemColor: .accent))
.colorScheme(.dark)
}
}
.frame(height: Constants.bottomButtonHeight)
.padding(.horizontal, Constants.horizontalPadding)
.padding(.bottom, Constants.horizontalPadding)
}

Spacer()
}
}
.onFirstAppear {
viewModel.onFirstAppear()
}
.onAppear {
viewModel.onAppear()
}
.onDisappear {
viewModel.onDisappear()
}
}

private var header: some View {
Expand All @@ -86,7 +112,7 @@ struct DuckPlayerView: View {
.aspectRatio(contentMode: .fit)
.frame(width: Constants.daxLogoSize, height: Constants.daxLogoSize)

Text("Duck Player")
Text(UserText.duckPlayerFeatureName)
.foregroundColor(.white)
.font(.headline)

Expand All @@ -105,3 +131,16 @@ struct DuckPlayerView: View {
.background(Color.black)
}
}

#if DEBUG
struct DuckPlayerView_Previews: PreviewProvider {
static var previews: some View {
let viewModel = DuckPlayerViewModel(videoID: "dQw4w9WgXcQ")
DuckPlayerView(
viewModel: viewModel,
webView: DuckPlayerWebView(viewModel: viewModel)
)
.preferredColorScheme(.dark)
}
}
#endif
50 changes: 49 additions & 1 deletion DuckDuckGo/DuckPlayer/DuckPlayerViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,16 @@

import Combine
import Foundation
import UIKit

final class DuckPlayerViewModel: ObservableObject {

/// A publisher to notify when Youtube navigation is required
let youtubeNavigationRequestPublisher = PassthroughSubject<URL, Never>()

/// Current interface orientation
@Published private var isLandscape: Bool = false

enum Constants {
static let baseURL = "https://www.youtube-nocookie.com/embed/"

Expand All @@ -37,6 +44,7 @@ final class DuckPlayerViewModel: ObservableObject {

let videoID: String
var appSettings: AppSettings
@Published private(set) var url: URL?
let defaultParameters: [String: String] = [
Constants.relParameter: Constants.disabled,
Constants.playsInlineParameter: Constants.enabled
Expand All @@ -45,6 +53,7 @@ final class DuckPlayerViewModel: ObservableObject {
init(videoID: String, appSettings: AppSettings = AppDependencyProvider.shared.appSettings) {
self.videoID = videoID
self.appSettings = appSettings
self.url = getVideoURL()
}

func getVideoURL() -> URL? {
Expand All @@ -54,7 +63,46 @@ final class DuckPlayerViewModel: ObservableObject {
return URL(string: "\(Constants.baseURL)\(videoID)?\(queryString)")
}

func handleYouTubeNavigation(_ url: URL) {
youtubeNavigationRequestPublisher.send(url)
}

func openInYouTube() {
let url: URL = .youtube(videoID)
handleYouTubeNavigation(url)
}

func onFirstAppear() {
// Add any initialization logic here
updateOrientation()
NotificationCenter.default.addObserver(self,
selector: #selector(handleOrientationChange),
name: UIDevice.orientationDidChangeNotification,
object: nil)
}

func onAppear() {
// NOOP
}

func onDisappear() {
NotificationCenter.default.removeObserver(self,
name: UIDevice.orientationDidChangeNotification,
object: nil)
}

@objc private func handleOrientationChange() {
updateOrientation()
}

/// Updates the current interface orientation
func updateOrientation() {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
isLandscape = windowScene.interfaceOrientation.isLandscape
}
}

/// Whether the YouTube button should be visible
var shouldShowYouTubeButton: Bool {
!isLandscape
}
}
26 changes: 21 additions & 5 deletions DuckDuckGo/DuckPlayer/DuckPlayerWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,25 @@
import SwiftUI
import Core
import os.log
import Combine

struct DuckPlayerWebView: UIViewRepresentable {
let url: URL
let viewModel: DuckPlayerViewModel
let coordinator: Coordinator

struct Constants {
static let referrerHeader: String = "Referer"
static let referrerHeaderValue: String = "http://localhost"
}

init(viewModel: DuckPlayerViewModel) {
self.viewModel = viewModel
Logger.duckplayer.debug("Creating new coordinator")
self.coordinator = Coordinator(viewModel: viewModel)
}

func makeCoordinator() -> Coordinator {
Coordinator()
coordinator
}

func makeUIView(context: Context) -> WKWebView {
Expand Down Expand Up @@ -62,22 +70,30 @@ struct DuckPlayerWebView: UIViewRepresentable {
webView.uiDelegate = context.coordinator

// Set DDG's agent
webView.customUserAgent = DefaultUserAgentManager.shared.userAgent(isDesktop: false, url: url)
webView.customUserAgent = DefaultUserAgentManager.shared.userAgent(isDesktop: false, url: viewModel.getVideoURL())

return webView
}

func updateUIView(_ webView: WKWebView, context: Context) {
guard let url = viewModel.getVideoURL() else { return }
Logger.duckplayer.debug("Updating WebView with URL: \(url)")
var request = URLRequest(url: url)
request.setValue(Constants.referrerHeaderValue, forHTTPHeaderField: Constants.referrerHeader)
webView.load(request)
}

class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
let viewModel: DuckPlayerViewModel

init(viewModel: DuckPlayerViewModel) {
self.viewModel = viewModel
super.init()
}

private func handleYouTubeWatchURL(_ url: URL) {
Logger.duckplayer.log("[DuckPlayer] Detected YouTube watch URL: \(url.absoluteString)")
// To be implemented: Hand over youtube.com/watch URLs to main browser
Logger.duckplayer.debug("Detected YouTube watch URL: \(url.absoluteString)")
viewModel.handleYouTubeNavigation(url)
}

@MainActor
Expand Down
Loading
Loading