[2026-04-28] Add apple-browsers .cursor rules (43 files) + executor v2 context
This commit is contained in:
@@ -0,0 +1,767 @@
|
||||
---
|
||||
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.
|
||||
Reference in New Issue
Block a user