768 lines
23 KiB
Markdown
768 lines
23 KiB
Markdown
---
|
|
source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/duckplayer.mdc
|
|
confidence: 0.9
|
|
namespace: work
|
|
last_synced: 2026-04-28
|
|
alwaysApply: false
|
|
---
|
|
|
|
# DuckPlayer Implementation Guide
|
|
|
|
## Overview
|
|
|
|
DuckPlayer provides video playback within the app, separate from the web view-based player. The architecture separates concerns into distinct components using a presenter pattern, native UI views, and JavaScript integration for seamless video playback experiences.
|
|
|
|
## Architecture Components
|
|
|
|
### Core Architecture Pattern
|
|
|
|
DuckPlayer follows a presenter-driven architecture with clear separation of concerns:
|
|
|
|
```swift
|
|
// ✅ CORRECT - Presenter coordinates between components
|
|
final class DuckPlayerNativeUIPresenter {
|
|
private let navigationHandler: NativeDuckPlayerNavigationHandler
|
|
private let state: DuckPlayerState
|
|
private let pixelFiring: DuckPlayerPixelFiring
|
|
|
|
func presentPlayer(for videoID: String) {
|
|
// Coordinates pill presentation, player setup, and analytics
|
|
updateState(videoID: videoID)
|
|
configurePillType()
|
|
firePixels()
|
|
}
|
|
}
|
|
|
|
// ❌ INCORRECT - Don't manage all responsibilities in one view
|
|
struct DuckPlayerView: View {
|
|
@State private var videoID: String = ""
|
|
@State private var isPresented = false
|
|
// Don't handle navigation, state, and analytics directly in views
|
|
}
|
|
```
|
|
|
|
### State Management Pattern
|
|
|
|
Use `DuckPlayerState` for centralized video state management:
|
|
|
|
```swift
|
|
// ✅ CORRECT - Centralized state management
|
|
final class DuckPlayerState {
|
|
var videoID: String?
|
|
var hasBeenShown: Bool = false
|
|
var timestamp: TimeInterval?
|
|
|
|
func reset() {
|
|
videoID = nil
|
|
hasBeenShown = false
|
|
timestamp = nil
|
|
}
|
|
}
|
|
|
|
// ❌ INCORRECT - Scattered state across components
|
|
struct DuckPlayerView: View {
|
|
@State private var videoID: String = ""
|
|
@State private var timestamp: TimeInterval = 0
|
|
// Don't duplicate state management
|
|
}
|
|
```
|
|
|
|
## Component Responsibilities
|
|
|
|
### DuckPlayerNativeUIPresenter
|
|
|
|
**Role**: Primary coordinator and state manager for the Native UI
|
|
|
|
**Key Responsibilities**:
|
|
- Manages presentation lifecycle of player UI components
|
|
- Coordinates between pill types (welcome, entry, re-entry)
|
|
- Handles user interactions and navigation events
|
|
- Manages constraint updates and visibility state
|
|
- Integrates with app navigation and browser features
|
|
- Handles orientation changes and UI adaptations
|
|
- Manages pixel firing for analytics tracking
|
|
- Controls toast notifications and dismiss count tracking
|
|
|
|
```swift
|
|
// ✅ CORRECT - Presenter pattern implementation
|
|
final class DuckPlayerNativeUIPresenter {
|
|
private weak var containerView: DuckPlayerContainer?
|
|
private let navigationHandler: NativeDuckPlayerNavigationHandler
|
|
private let state: DuckPlayerState
|
|
private let pixelFiring: DuckPlayerPixelFiring
|
|
|
|
func presentWelcomePill() {
|
|
// Configure welcome pill for first-time users
|
|
configureContainerForPill(.welcome)
|
|
fireWelcomePillPixel()
|
|
}
|
|
|
|
func presentEntryPill(for videoID: String) {
|
|
// Configure entry pill for returning users
|
|
state.videoID = videoID
|
|
configureContainerForPill(.entry)
|
|
fireEntryPillPixel()
|
|
}
|
|
|
|
func presentReEntryPill(for videoID: String) {
|
|
// Configure re-entry pill for previously watched videos
|
|
state.videoID = videoID
|
|
configureContainerForPill(.reEntry)
|
|
fireReEntryPillPixel()
|
|
}
|
|
}
|
|
```
|
|
|
|
### NativeDuckPlayerNavigationHandler
|
|
|
|
**Role**: Manages video playback navigation and browser integration
|
|
|
|
```swift
|
|
// ✅ CORRECT - Navigation handler pattern
|
|
final class NativeDuckPlayerNavigationHandler {
|
|
private let webView: WKWebView
|
|
private let presenter: DuckPlayerNativeUIPresenter
|
|
|
|
func handleYouTubeURL(_ url: URL) -> Bool {
|
|
guard shouldHandleNatively(url) else { return false }
|
|
|
|
let videoID = extractVideoID(from: url)
|
|
presenter.presentPlayer(for: videoID)
|
|
return true
|
|
}
|
|
|
|
private func shouldHandleNatively(_ url: URL) -> Bool {
|
|
// Check if URL should be handled by native player
|
|
return isYouTubeURL(url) && isNativeUIEnabled()
|
|
}
|
|
}
|
|
|
|
// ❌ INCORRECT - Don't handle navigation directly in views
|
|
struct DuckPlayerView: View {
|
|
func handleURL(_ url: URL) {
|
|
// Don't put navigation logic in views
|
|
}
|
|
}
|
|
```
|
|
|
|
## View Architecture
|
|
|
|
### Pill Management System
|
|
|
|
DuckPlayer uses a three-tier pill system based on user interaction history:
|
|
|
|
```swift
|
|
// ✅ CORRECT - Pill type management
|
|
enum DuckPlayerPillType {
|
|
case welcome // First-time users (priming modal not yet presented)
|
|
case entry // Returning users viewing new videos
|
|
case reEntry // Users returning to previously watched videos
|
|
}
|
|
|
|
final class DuckPlayerContainer: UIView {
|
|
private var currentPillType: DuckPlayerPillType?
|
|
|
|
func configurePill(_ type: DuckPlayerPillType, for videoID: String) {
|
|
switch type {
|
|
case .welcome:
|
|
presentWelcomePill()
|
|
case .entry:
|
|
presentEntryPill(videoID: videoID)
|
|
case .reEntry:
|
|
presentReEntryPill(videoID: videoID)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### SwiftUI View Components
|
|
|
|
Follow reactive patterns for view models:
|
|
|
|
```swift
|
|
// ✅ CORRECT - Reactive view model pattern
|
|
final class DuckPlayerWelcomePillViewModel: ObservableObject {
|
|
@Published var isAnimating = false
|
|
@Published var isPresented = false
|
|
|
|
private let pixelFiring: DuckPlayerPixelFiring
|
|
private let onDismiss: () -> Void
|
|
|
|
init(pixelFiring: DuckPlayerPixelFiring, onDismiss: @escaping () -> Void) {
|
|
self.pixelFiring = pixelFiring
|
|
self.onDismiss = onDismiss
|
|
}
|
|
|
|
func startAnimation() {
|
|
isAnimating = true
|
|
pixelFiring.fireWelcomePillShownPixel()
|
|
}
|
|
|
|
func handleUserTap() {
|
|
pixelFiring.fireWelcomePillTappedPixel()
|
|
onDismiss()
|
|
}
|
|
}
|
|
|
|
// ❌ INCORRECT - Don't handle business logic directly in views
|
|
struct DuckPlayerWelcomePillView: View {
|
|
@State private var isAnimating = false
|
|
|
|
var body: some View {
|
|
// Don't put pixel firing and business logic here
|
|
Button("Watch in DuckPlayer") {
|
|
// Business logic should be in view model
|
|
Analytics.shared.firePixel(.welcomePillTapped)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## UserScript Integration
|
|
|
|
### JavaScript Bridge Pattern
|
|
|
|
Use UserScript components for native-web communication:
|
|
|
|
```swift
|
|
// ✅ CORRECT - UserScript integration pattern
|
|
final class DuckPlayerUserScriptYouTube: NSObject, UserScript {
|
|
private let name = "DuckPlayerUserScriptYouTube"
|
|
private let source = DuckPlayerUserScriptSource.youtube
|
|
|
|
func messageReceived(_ message: Any) {
|
|
guard let dict = message as? [String: Any],
|
|
let messageType = dict["type"] as? String else { return }
|
|
|
|
switch messageType {
|
|
case "timestampUpdate":
|
|
handleTimestampUpdate(dict)
|
|
case "playerStateChange":
|
|
handlePlayerStateChange(dict)
|
|
case "error":
|
|
handleError(dict)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func handleTimestampUpdate(_ data: [String: Any]) {
|
|
guard let timestamp = data["timestamp"] as? TimeInterval else { return }
|
|
presenter.updateVideoTimestamp(timestamp)
|
|
}
|
|
}
|
|
|
|
// ❌ INCORRECT - Don't handle JavaScript communication directly in views
|
|
struct DuckPlayerWebView: UIViewRepresentable {
|
|
func makeUIView(context: Context) -> WKWebView {
|
|
let webView = WKWebView()
|
|
// Don't add message handlers directly here
|
|
return webView
|
|
}
|
|
}
|
|
```
|
|
|
|
### Event Queuing System
|
|
|
|
Implement event queuing for script readiness:
|
|
|
|
```swift
|
|
// ✅ CORRECT - Event queuing pattern
|
|
final class DuckPlayerUserScript {
|
|
private var eventQueue: [UserScriptEvent] = []
|
|
private var isScriptReady = false
|
|
|
|
func queueEvent(_ event: UserScriptEvent) {
|
|
if isScriptReady {
|
|
processEvent(event)
|
|
} else {
|
|
eventQueue.append(event)
|
|
}
|
|
}
|
|
|
|
func onScriptReady() {
|
|
isScriptReady = true
|
|
eventQueue.forEach { processEvent($0) }
|
|
eventQueue.removeAll()
|
|
}
|
|
}
|
|
```
|
|
|
|
## Analytics Integration
|
|
|
|
### Pixel Firing Protocol
|
|
|
|
Use protocol-based pixel firing with debouncing:
|
|
|
|
```swift
|
|
// ✅ CORRECT - Protocol-based pixel firing
|
|
protocol DuckPlayerPixelFiring {
|
|
func fireWelcomePillShownPixel()
|
|
func fireWelcomePillTappedPixel()
|
|
func fireEntryPillShownPixel()
|
|
func fireVideoPlaybackStartedPixel()
|
|
func fireDailyPixel(_ pixel: DuckPlayerDailyPixel)
|
|
}
|
|
|
|
final class DuckPlayerPixelHandler: DuckPlayerPixelFiring {
|
|
private let pixelKit: PixelKit
|
|
private let debouncer: PixelDebouncer
|
|
|
|
func fireWelcomePillShownPixel() {
|
|
debouncer.debounce {
|
|
pixelKit.fire(.duckPlayerWelcomePillShown)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ❌ INCORRECT - Don't fire pixels directly from views
|
|
struct DuckPlayerView: View {
|
|
var body: some View {
|
|
Button("Play") {
|
|
// Don't fire pixels directly
|
|
PixelKit.shared.fire(.duckPlayerPlayTapped)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### DuckPlayer Native Pixels
|
|
|
|
DuckPlayer Native fires various pixels to track user interactions and system events:
|
|
|
|
#### Pill Interaction Pixels
|
|
```swift
|
|
// Welcome Pill (first-time users)
|
|
.duckPlayerNativeWelcomePillShown // When welcome pill is displayed
|
|
.duckPlayerNativeWelcomePillTapped // When user taps welcome pill
|
|
.duckPlayerNativeWelcomePillDismissed // When welcome pill is dismissed
|
|
|
|
// Entry Pill (returning users, new videos)
|
|
.duckPlayerNativeEntryPillShown // When entry pill is displayed
|
|
.duckPlayerNativeEntryPillTapped // When user taps entry pill
|
|
.duckPlayerNativeEntryPillDismissed // When entry pill is dismissed
|
|
|
|
// Re-entry Pill (previously watched videos)
|
|
.duckPlayerNativeReEntryPillShown // When re-entry pill is displayed
|
|
.duckPlayerNativeReEntryPillTapped // When user taps re-entry pill
|
|
.duckPlayerNativeReEntryPillDismissed // When re-entry pill is dismissed
|
|
```
|
|
|
|
#### Video Playback Pixels
|
|
```swift
|
|
// Playback events
|
|
.duckPlayerNativeVideoPlaybackStarted // When video starts playing
|
|
.duckPlayerNativeVideoPlaybackPaused // When video is paused
|
|
.duckPlayerNativeVideoPlaybackResumed // When video is resumed
|
|
.duckPlayerNativeVideoPlaybackCompleted // When video finishes
|
|
|
|
// Daily unique playback tracking
|
|
.duckPlayerNativeDailyVideoPlayed // Fired once per day when any video is played
|
|
```
|
|
|
|
#### YouTube Error Pixels
|
|
|
|
DuckPlayer Native tracks YouTube-specific errors with both volume (impression) and daily-unique pixels:
|
|
|
|
```swift
|
|
// Sign-in Required Errors
|
|
.duckPlayerNativeYouTubeSignInErrorImpression // Every occurrence
|
|
.duckPlayerNativeYouTubeSignInErrorDaily // Once per day
|
|
|
|
// Age-Restricted Content Errors
|
|
.duckPlayerNativeYouTubeAgeRestrictedErrorImpression // Every occurrence
|
|
.duckPlayerNativeYouTubeAgeRestrictedErrorDaily // Once per day
|
|
|
|
// No-Embed Errors (embedding disabled)
|
|
.duckPlayerNativeYouTubeNoEmbedErrorImpression // Every occurrence
|
|
.duckPlayerNativeYouTubeNoEmbedErrorDaily // Once per day
|
|
|
|
// Unknown/Generic Errors
|
|
.duckPlayerNativeYouTubeUnknownErrorImpression // Every occurrence
|
|
.duckPlayerNativeYouTubeUnknownErrorDaily // Once per day
|
|
```
|
|
|
|
#### Error Handling Implementation
|
|
|
|
YouTube errors are handled in the UserScript layer:
|
|
|
|
```swift
|
|
// In DuckPlayerUserScriptPlayer.swift
|
|
@MainActor
|
|
private func onYoutubeError(params: Any, original: WKScriptMessage) -> Encodable? {
|
|
let (volumePixel, dailyPixel) = getPixelsForNativeYouTubeErrorParams(params)
|
|
DailyPixel.fire(pixel: dailyPixel)
|
|
Pixel.fire(pixel: volumePixel)
|
|
return [:] as [String: String]
|
|
}
|
|
|
|
private func getPixelsForNativeYouTubeErrorParams(_ params: Any) -> (Pixel.Event, Pixel.Event) {
|
|
if let paramsDict = params as? [String: Any],
|
|
let errorParam = paramsDict["error"] as? String {
|
|
switch errorParam {
|
|
case "sign-in-required":
|
|
return (.duckPlayerNativeYouTubeSignInErrorImpression, .duckPlayerNativeYouTubeSignInErrorDaily)
|
|
case "age-restricted":
|
|
return (.duckPlayerNativeYouTubeAgeRestrictedErrorImpression, .duckPlayerNativeYouTubeAgeRestrictedErrorDaily)
|
|
case "no-embed":
|
|
return (.duckPlayerNativeYouTubeNoEmbedErrorImpression, .duckPlayerNativeYouTubeNoEmbedErrorDaily)
|
|
default:
|
|
return (.duckPlayerNativeYouTubeUnknownErrorImpression, .duckPlayerNativeYouTubeUnknownErrorDaily)
|
|
}
|
|
}
|
|
return (.duckPlayerNativeYouTubeUnknownErrorImpression, .duckPlayerNativeYouTubeUnknownErrorDaily)
|
|
}
|
|
```
|
|
|
|
### Analytics Best Practices
|
|
|
|
```swift
|
|
// ✅ CORRECT - Centralized analytics tracking
|
|
final class DuckPlayerAnalytics {
|
|
private let pixelFiring: DuckPlayerPixelFiring
|
|
|
|
func trackPillPresentation(_ type: DuckPlayerPillType) {
|
|
switch type {
|
|
case .welcome:
|
|
pixelFiring.fireWelcomePillShownPixel()
|
|
case .entry:
|
|
pixelFiring.fireEntryPillShownPixel()
|
|
case .reEntry:
|
|
pixelFiring.fireReEntryPillShownPixel()
|
|
}
|
|
}
|
|
|
|
func trackVideoPlayback(duration: TimeInterval) {
|
|
let parameters = ["duration": String(duration)]
|
|
pixelFiring.fireVideoPlaybackPixel(parameters: parameters)
|
|
}
|
|
|
|
func trackYouTubeError(_ error: DuckPlayerError) {
|
|
// Errors are tracked in UserScript layer
|
|
// This method exists for future expansion
|
|
}
|
|
}
|
|
```
|
|
|
|
### Pixel Naming Convention
|
|
|
|
All DuckPlayer Native pixels follow this naming pattern:
|
|
- **Volume pixels**: `duckplayer_native_{event}_impression_ios_{formfactor}`
|
|
- **Daily pixels**: `duckplayer_native_{event}_daily-unique_ios_{formfactor}`
|
|
|
|
The formfactor (phone/tablet) is automatically appended by the pixel infrastructure.
|
|
|
|
## Toast Notification System
|
|
|
|
### Toast Implementation Pattern
|
|
|
|
```swift
|
|
// ✅ CORRECT - Toast notification system
|
|
final class DuckPlayerToastManager {
|
|
private weak var containerView: UIView?
|
|
|
|
func showToast(_ message: String, position: ToastPosition = .top) {
|
|
let toastView = DuckPlayerToastView(message: message)
|
|
|
|
containerView?.addSubview(toastView)
|
|
toastView.show(at: position) { [weak self] in
|
|
self?.hideToast(toastView)
|
|
}
|
|
}
|
|
|
|
private func hideToast(_ toastView: DuckPlayerToastView) {
|
|
toastView.hide {
|
|
toastView.removeFromSuperview()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DuckPlayerToastView: View {
|
|
let message: String
|
|
@State private var isVisible = false
|
|
|
|
var body: some View {
|
|
Text(message)
|
|
.padding()
|
|
.background(Color(designSystemColor: .surface))
|
|
.cornerRadius(8)
|
|
.scaleEffect(isVisible ? 1.0 : 0.8)
|
|
.opacity(isVisible ? 1.0 : 0.0)
|
|
.animation(.spring(response: 0.3), value: isVisible)
|
|
.onAppear {
|
|
isVisible = true
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Variant Management
|
|
|
|
### Variant Configuration Pattern
|
|
|
|
```swift
|
|
// ✅ CORRECT - Variant configuration system
|
|
enum DuckPlayerVariant: String, CaseIterable {
|
|
case classic = "Web"
|
|
case nativeOptIn = "Opt-in"
|
|
case nativeOptOut = "Opt-out"
|
|
|
|
var configuration: DuckPlayerConfiguration {
|
|
switch self {
|
|
case .classic:
|
|
return DuckPlayerConfiguration(
|
|
nativeUIEnabled: false,
|
|
playerMode: .alwaysAsk,
|
|
newTabBehavior: true,
|
|
serpIntegration: false
|
|
)
|
|
case .nativeOptIn:
|
|
return DuckPlayerConfiguration(
|
|
nativeUIEnabled: true,
|
|
playerMode: .askUserPreference,
|
|
autoplayEnabled: true,
|
|
serpIntegration: true,
|
|
primingModalEnabled: true
|
|
)
|
|
case .nativeOptOut:
|
|
return DuckPlayerConfiguration(
|
|
nativeUIEnabled: true,
|
|
playerMode: .automatic,
|
|
autoplayEnabled: true,
|
|
serpIntegration: true,
|
|
primingModalEnabled: false
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ❌ INCORRECT - Don't hardcode variant configurations
|
|
struct DuckPlayerSettings {
|
|
var isNativeEnabled: Bool {
|
|
// Don't hardcode variant logic
|
|
return UserDefaults.standard.bool(forKey: "native_enabled")
|
|
}
|
|
}
|
|
```
|
|
|
|
### Runtime Variant Switching
|
|
|
|
```swift
|
|
// ✅ CORRECT - Runtime variant management
|
|
final class DuckPlayerVariantManager {
|
|
private let appSettings: AppSettings
|
|
|
|
var currentVariant: DuckPlayerVariant {
|
|
get {
|
|
let rawValue = appSettings.duckPlayerVariant
|
|
return DuckPlayerVariant(rawValue: rawValue) ?? .classic
|
|
}
|
|
set {
|
|
appSettings.duckPlayerVariant = newValue.rawValue
|
|
applyVariantConfiguration(newValue.configuration)
|
|
}
|
|
}
|
|
|
|
private func applyVariantConfiguration(_ config: DuckPlayerConfiguration) {
|
|
appSettings.duckPlayerNativeUIEnabled = config.nativeUIEnabled
|
|
appSettings.duckPlayerSerpIntegration = config.serpIntegration
|
|
appSettings.duckPlayerAutoplayEnabled = config.autoplayEnabled
|
|
|
|
// Notify components of configuration change
|
|
NotificationCenter.default.post(name: .duckPlayerVariantChanged, object: config)
|
|
}
|
|
}
|
|
```
|
|
|
|
## Performance Optimization
|
|
|
|
### Lazy Loading Pattern
|
|
|
|
```swift
|
|
// ✅ CORRECT - Lazy loading for performance
|
|
final class DuckPlayerNativeUIPresenter {
|
|
private lazy var welcomePillViewModel = DuckPlayerWelcomePillViewModel(
|
|
pixelFiring: pixelFiring,
|
|
onDismiss: { [weak self] in self?.dismissWelcomePill() }
|
|
)
|
|
|
|
private lazy var entryPillViewModel = DuckPlayerEntryPillViewModel(
|
|
pixelFiring: pixelFiring,
|
|
onPlay: { [weak self] in self?.startVideoPlayback() }
|
|
)
|
|
|
|
func presentWelcomePill() {
|
|
// Only create view model when needed
|
|
containerView?.configurePill(.welcome, viewModel: welcomePillViewModel)
|
|
}
|
|
}
|
|
|
|
// ❌ INCORRECT - Don't create all view models upfront
|
|
final class DuckPlayerNativeUIPresenter {
|
|
private let welcomePillViewModel: DuckPlayerWelcomePillViewModel
|
|
private let entryPillViewModel: DuckPlayerEntryPillViewModel
|
|
private let reEntryPillViewModel: DuckPlayerMiniPillViewModel
|
|
|
|
init() {
|
|
// Don't create all view models immediately
|
|
welcomePillViewModel = DuckPlayerWelcomePillViewModel(...)
|
|
entryPillViewModel = DuckPlayerEntryPillViewModel(...)
|
|
reEntryPillViewModel = DuckPlayerMiniPillViewModel(...)
|
|
}
|
|
}
|
|
```
|
|
|
|
## Testing Patterns
|
|
|
|
### Presenter Testing
|
|
|
|
```swift
|
|
// ✅ CORRECT - Testing presenter components
|
|
final class DuckPlayerNativeUIPresenterTests: XCTestCase {
|
|
private var sut: DuckPlayerNativeUIPresenter!
|
|
private var mockNavigationHandler: MockNativeDuckPlayerNavigationHandler!
|
|
private var mockPixelFiring: MockDuckPlayerPixelFiring!
|
|
private var mockState: DuckPlayerState!
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
mockNavigationHandler = MockNativeDuckPlayerNavigationHandler()
|
|
mockPixelFiring = MockDuckPlayerPixelFiring()
|
|
mockState = DuckPlayerState()
|
|
|
|
sut = DuckPlayerNativeUIPresenter(
|
|
navigationHandler: mockNavigationHandler,
|
|
pixelFiring: mockPixelFiring,
|
|
state: mockState
|
|
)
|
|
}
|
|
|
|
func testPresentWelcomePill() {
|
|
// When
|
|
sut.presentWelcomePill()
|
|
|
|
// Then
|
|
XCTAssertTrue(mockPixelFiring.fireWelcomePillShownPixelCalled)
|
|
XCTAssertEqual(sut.currentPillType, .welcome)
|
|
}
|
|
}
|
|
```
|
|
|
|
### UserScript Testing
|
|
|
|
```swift
|
|
// ✅ CORRECT - Testing UserScript components
|
|
final class DuckPlayerUserScriptTests: XCTestCase {
|
|
private var sut: DuckPlayerUserScriptYouTube!
|
|
private var mockPresenter: MockDuckPlayerPresenter!
|
|
|
|
func testTimestampUpdateMessage() {
|
|
// Given
|
|
let message = ["type": "timestampUpdate", "timestamp": 120.5]
|
|
|
|
// When
|
|
sut.messageReceived(message)
|
|
|
|
// Then
|
|
XCTAssertEqual(mockPresenter.lastTimestampUpdate, 120.5)
|
|
}
|
|
}
|
|
```
|
|
|
|
## Common Patterns
|
|
|
|
### Error Handling
|
|
|
|
```swift
|
|
// ✅ CORRECT - Comprehensive error handling
|
|
enum DuckPlayerError: Error {
|
|
case videoNotFound
|
|
case networkError
|
|
case playbackError(underlying: Error)
|
|
case invalidConfiguration
|
|
}
|
|
|
|
final class DuckPlayerErrorHandler {
|
|
private let pixelFiring: DuckPlayerPixelFiring
|
|
|
|
func handleError(_ error: DuckPlayerError) {
|
|
switch error {
|
|
case .videoNotFound:
|
|
pixelFiring.fireErrorPixel(.videoNotFound)
|
|
showErrorToast("Video not available")
|
|
case .networkError:
|
|
pixelFiring.fireErrorPixel(.networkError)
|
|
showErrorToast("Network connection required")
|
|
case .playbackError(let underlying):
|
|
pixelFiring.fireErrorPixel(.playbackError, parameters: ["error": underlying.localizedDescription])
|
|
showErrorToast("Playback error occurred")
|
|
case .invalidConfiguration:
|
|
pixelFiring.fireErrorPixel(.invalidConfiguration)
|
|
// Handle configuration errors silently
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Memory Management
|
|
|
|
```swift
|
|
// ✅ CORRECT - Proper memory management
|
|
final class DuckPlayerNativeUIPresenter {
|
|
private weak var containerView: DuckPlayerContainer?
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
deinit {
|
|
cancellables.removeAll()
|
|
cleanupResources()
|
|
}
|
|
|
|
private func cleanupResources() {
|
|
// Clean up any retained resources
|
|
containerView?.removeFromSuperview()
|
|
state.reset()
|
|
}
|
|
}
|
|
```
|
|
|
|
## Migration Guidelines
|
|
|
|
### Integrating DuckPlayer
|
|
|
|
When adding DuckPlayer to new areas:
|
|
|
|
1. **Use the presenter pattern** - Don't put business logic in views
|
|
2. **Follow the pill system** - Implement appropriate pill types for user journey
|
|
3. **Integrate analytics** - Use the pixel firing protocol for tracking
|
|
4. **Handle variants** - Support all three DuckPlayer variants
|
|
5. **Test thoroughly** - Write tests for presenter, UserScript, and view components
|
|
|
|
### Common Integration Mistakes
|
|
|
|
```swift
|
|
// ❌ INCORRECT - Don't bypass the presenter
|
|
struct MyFeatureView: View {
|
|
@State private var showDuckPlayer = false
|
|
|
|
var body: some View {
|
|
Button("Play Video") {
|
|
// Don't create DuckPlayer components directly
|
|
showDuckPlayer = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// ✅ CORRECT - Use the presenter pattern
|
|
struct MyFeatureView: View {
|
|
private let duckPlayerPresenter: DuckPlayerNativeUIPresenter
|
|
|
|
var body: some View {
|
|
Button("Play Video") {
|
|
duckPlayerPresenter.presentPlayer(for: videoID)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
This guide provides the foundation for implementing and maintaining DuckPlayer components following established patterns and best practices in the DuckDuckGo browser codebase.
|