diff --git a/.gitignore b/.gitignore index 7e118aad0eb4..a3e28221200c 100644 --- a/.gitignore +++ b/.gitignore @@ -94,4 +94,7 @@ report /node_modules/@duckduckgo/content-scope-scripts/build/* !/node_modules/@duckduckgo/content-scope-scripts/build/android/ /node_modules/@duckduckgo/content-scope-scripts/build/android/* +!/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/ +/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/* +!/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/ !/node_modules/@duckduckgo/content-scope-scripts/build/android/contentScope.js \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 120a7e94448b..5dd704c19a31 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -42,6 +42,7 @@ android { } assets { srcDirs += files("$projectDir/../node_modules/@duckduckgo/privacy-dashboard/build/app".toString()) + srcDirs += files("$projectDir/../node_modules/@duckduckgo/content-scope-scripts/build/android/pages".toString()) } } } @@ -182,6 +183,8 @@ fladle { dependencies { implementation project(":custom-tabs-impl") implementation project(":custom-tabs-api") + implementation project(":duckplayer-impl") + implementation project(":duckplayer-api") implementation project(":history-impl") implementation project(":history-api") implementation project(":data-store-impl") diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index f21fc43a6f78..5984899c4c83 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -76,12 +76,16 @@ import com.duckduckgo.app.browser.commands.Command import com.duckduckgo.app.browser.commands.Command.HideOnboardingDaxDialog import com.duckduckgo.app.browser.commands.Command.LaunchPrivacyPro import com.duckduckgo.app.browser.commands.Command.LoadExtractedUrl +import com.duckduckgo.app.browser.commands.Command.ShareLink import com.duckduckgo.app.browser.commands.Command.ShowBackNavigationHistory import com.duckduckgo.app.browser.commands.Command.ShowPrivacyProtectionDisabledConfirmation import com.duckduckgo.app.browser.commands.Command.ShowPrivacyProtectionEnabledConfirmation import com.duckduckgo.app.browser.commands.NavigationCommand import com.duckduckgo.app.browser.commands.NavigationCommand.Navigate import com.duckduckgo.app.browser.customtabs.CustomTabPixelNames +import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_FEATURE_NAME +import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_PAGE_FEATURE_NAME +import com.duckduckgo.app.browser.duckplayer.DuckPlayerJSHelper import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.browser.favicon.FaviconSource import com.duckduckgo.app.browser.history.NavigationHistoryEntry @@ -140,6 +144,9 @@ import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.ExtendedOnboardingFeatureToggles import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_BANNER_SHOWN +import com.duckduckgo.app.pixels.AppPixelName.DUCK_PLAYER_SETTING_ALWAYS_DUCK_PLAYER +import com.duckduckgo.app.pixels.AppPixelName.DUCK_PLAYER_SETTING_ALWAYS_OVERLAY_YOUTUBE +import com.duckduckgo.app.pixels.AppPixelName.DUCK_PLAYER_SETTING_NEVER_OVERLAY_YOUTUBE import com.duckduckgo.app.pixels.AppPixelName.ONBOARDING_SEARCH_CUSTOM import com.duckduckgo.app.pixels.AppPixelName.ONBOARDING_VISIT_SITE_CUSTOM import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature @@ -162,6 +169,7 @@ import com.duckduckgo.app.trackerdetection.model.TrackerType import com.duckduckgo.app.trackerdetection.model.TrackingEvent import com.duckduckgo.app.usage.search.SearchCountDao import com.duckduckgo.app.widget.ui.WidgetCapabilities +import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.api.AutofillCapabilityChecker import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager @@ -176,6 +184,12 @@ import com.duckduckgo.common.utils.device.DeviceInfo import com.duckduckgo.downloads.api.DownloadStateListener import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload +import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED +import com.duckduckgo.duckplayer.api.DuckPlayer.UserPreferences +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.history.api.HistoryEntry.VisitedPage @@ -237,7 +251,6 @@ import org.junit.Rule import org.junit.Test import org.mockito.ArgumentMatchers.anyBoolean import org.mockito.ArgumentMatchers.anyString -import org.mockito.Mock import org.mockito.Mockito.never import org.mockito.Mockito.times import org.mockito.Mockito.verify @@ -262,146 +275,105 @@ class BrowserTabViewModelTest { @get:Rule var coroutineRule = CoroutineTestRule() - @Mock - private lateinit var mockEntityLookup: EntityLookup + private val mockEntityLookup: EntityLookup = mock() - @Mock - private lateinit var mockNetworkLeaderboardDao: NetworkLeaderboardDao + private val mockNetworkLeaderboardDao: NetworkLeaderboardDao = mock() - @Mock - private lateinit var mockStatisticsUpdater: StatisticsUpdater + private val mockStatisticsUpdater: StatisticsUpdater = mock() - @Mock - private lateinit var mockCommandObserver: Observer + private val mockCommandObserver: Observer = mock() - @Mock - private lateinit var mockSettingsStore: SettingsDataStore + private val mockSettingsStore: SettingsDataStore = mock() - @Mock - private lateinit var mockSavedSitesRepository: SavedSitesRepository + private val mockSavedSitesRepository: SavedSitesRepository = mock() - @Mock - private lateinit var mockNavigationHistory: NavigationHistory + private val mockNavigationHistory: NavigationHistory = mock() - @Mock - private lateinit var mockLongPressHandler: LongPressHandler + private val mockLongPressHandler: LongPressHandler = mock() - @Mock - private lateinit var mockOmnibarConverter: OmnibarEntryConverter + private val mockOmnibarConverter: OmnibarEntryConverter = mock() - @Mock - private lateinit var mockTabRepository: TabRepository + private val mockTabRepository: TabRepository = mock() - @Mock - private lateinit var webViewSessionStorage: WebViewSessionStorage + private val webViewSessionStorage: WebViewSessionStorage = mock() - @Mock - private lateinit var mockFaviconManager: FaviconManager + private val mockFaviconManager: FaviconManager = mock() - @Mock - private lateinit var mockAddToHomeCapabilityDetector: AddToHomeCapabilityDetector + private val mockAddToHomeCapabilityDetector: AddToHomeCapabilityDetector = mock() - @Mock - private lateinit var mockDismissedCtaDao: DismissedCtaDao + private val mockDismissedCtaDao: DismissedCtaDao = mock() - @Mock - private lateinit var mockSearchCountDao: SearchCountDao + private val mockSearchCountDao: SearchCountDao = mock() - @Mock - private lateinit var mockAppInstallStore: AppInstallStore + private val mockAppInstallStore: AppInstallStore = mock() - @Mock - private lateinit var mockPixel: Pixel + private val mockPixel: Pixel = mock() - @Mock - private lateinit var mockNewTabPixels: NewTabPixels + private val mockNewTabPixels: NewTabPixels = mock() - @Mock - private lateinit var mockHttpErrorPixels: HttpErrorPixels + private val mockHttpErrorPixels: HttpErrorPixels = mock() - @Mock - private lateinit var mockOnboardingStore: OnboardingStore + private val mockOnboardingStore: OnboardingStore = mock() - @Mock - private lateinit var mockAutoCompleteService: AutoCompleteService + private val mockAutoCompleteService: AutoCompleteService = mock() - @Mock - private lateinit var mockAutoCompleteScorer: AutoCompleteScorer + private val mockAutoCompleteScorer: AutoCompleteScorer = mock() - @Mock - private lateinit var mockWidgetCapabilities: WidgetCapabilities + private val mockWidgetCapabilities: WidgetCapabilities = mock() - @Mock - private lateinit var mockUserStageStore: UserStageStore + private val mockUserStageStore: UserStageStore = mock() - @Mock - private lateinit var mockContentBlocking: ContentBlocking + private val mockContentBlocking: ContentBlocking = mock() - @Mock - private lateinit var mockNavigationAwareLoginDetector: NavigationAwareLoginDetector + private val mockNavigationAwareLoginDetector: NavigationAwareLoginDetector = mock() - @Mock - private lateinit var mockUserEventsStore: UserEventsStore + private val mockUserEventsStore: UserEventsStore = mock() - @Mock - private lateinit var mockFileDownloader: FileDownloader + private val mockFileDownloader: FileDownloader = mock() - @Mock - private lateinit var geoLocationPermissions: GeoLocationPermissions + private val geoLocationPermissions: GeoLocationPermissions = mock() - @Mock - private lateinit var fireproofDialogsEventHandler: FireproofDialogsEventHandler + private val fireproofDialogsEventHandler: FireproofDialogsEventHandler = mock() - @Mock - private lateinit var mockEmailManager: EmailManager + private val mockEmailManager: EmailManager = mock() - @Mock - private lateinit var mockSpecialUrlDetector: SpecialUrlDetector + private val mockSpecialUrlDetector: SpecialUrlDetector = mock() - @Mock - private lateinit var mockAppLinksHandler: AppLinksHandler + private val mockAppLinksHandler: AppLinksHandler = mock() - @Mock - private lateinit var mockFeatureToggle: FeatureToggle + private val mockFeatureToggle: FeatureToggle = mock() - @Mock - private lateinit var mockGpcRepository: GpcRepository + private val mockGpcRepository: GpcRepository = mock() - @Mock - private lateinit var mockUnprotectedTemporary: UnprotectedTemporary + private val mockUnprotectedTemporary: UnprotectedTemporary = mock() - @Mock - private lateinit var mockAmpLinks: AmpLinks + private val mockAmpLinks: AmpLinks = mock() - @Mock - private lateinit var mockTrackingParameters: TrackingParameters + private val mockTrackingParameters: TrackingParameters = mock() - @Mock - private lateinit var mockDownloadCallback: DownloadStateListener + private val mockDownloadCallback: DownloadStateListener = mock() - @Mock - private lateinit var mockRemoteMessagingRepository: RemoteMessagingRepository + private val mockRemoteMessagingRepository: RemoteMessagingRepository = mock() - @Mock - private lateinit var voiceSearchAvailability: VoiceSearchAvailability + private val voiceSearchAvailability: VoiceSearchAvailability = mock() - @Mock - private lateinit var voiceSearchPixelLogger: VoiceSearchAvailabilityPixelLogger + private val voiceSearchPixelLogger: VoiceSearchAvailabilityPixelLogger = mock() - @Mock - private lateinit var mockSettingsDataStore: SettingsDataStore + private val mockSettingsDataStore: SettingsDataStore = mock() - @Mock - private lateinit var mockAdClickManager: AdClickManager + private val mockAdClickManager: AdClickManager = mock() - @Mock - private lateinit var mockUserAllowListRepository: UserAllowListRepository + private val mockUserAllowListRepository: UserAllowListRepository = mock() - @Mock - private lateinit var mockBrokenSiteContext: BrokenSiteContext + private val mockBrokenSiteContext: BrokenSiteContext = mock() - @Mock - private lateinit var mockFileChooserCallback: ValueCallback> + private val mockFileChooserCallback: ValueCallback> = mock() + + private val mockDuckPlayer: DuckPlayer = mock() + + private val mockAppBuildConfig: AppBuildConfig = mock() + + private val mockDuckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() private lateinit var remoteMessagingModel: RemoteMessagingModel @@ -485,7 +457,7 @@ class BrowserTabViewModelTest { private val mockAutoCompleteRepository: AutoCompleteRepository = mock() @Before - fun before() { + fun before() = runTest { MockitoAnnotations.openMocks(this) db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) .allowMainThreadQueries() @@ -508,6 +480,7 @@ class BrowserTabViewModelTest { lazyFaviconManager, ) + whenever(mockDuckPlayer.observeUserPreferences()).thenReturn(flowOf(UserPreferences(false, Disabled))) whenever(mockDismissedCtaDao.dismissedCtas()).thenReturn(dismissedCtaDaoChannel.consumeAsFlow()) whenever(mockTabRepository.flowTabs).thenReturn(flowOf(emptyList())) whenever(mockTabRepository.liveTabs).thenReturn(tabsLiveData) @@ -520,6 +493,11 @@ class BrowserTabViewModelTest { whenever(mockSSLCertificatesFeature.allowBypass()).thenReturn(mockEnabledToggle) whenever(mockExtendedOnboardingFeatureToggles.aestheticUpdates()).thenReturn(mockEnabledToggle) whenever(subscriptions.shouldLaunchPrivacyProForUrl(any())).thenReturn(false) + whenever(mockDuckDuckGoUrlDetector.isDuckDuckGoUrl(any())).thenReturn(false) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(any())).thenReturn(false) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(anyString())).thenReturn(false) + whenever(mockDuckPlayer.isDuckPlayerUri(anyString())).thenReturn(false) + whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(ENABLED) remoteMessagingModel = givenRemoteMessagingModel(mockRemoteMessagingRepository, mockPixel, coroutineRule.testDispatcherProvider) @@ -537,6 +515,7 @@ class BrowserTabViewModelTest { duckDuckGoUrlDetector = DuckDuckGoUrlDetectorImpl(), extendedOnboardingFeatureToggles = mockExtendedOnboardingFeatureToggles, subscriptions = mock(), + duckPlayer = mockDuckPlayer, ) val siteFactory = SiteFactoryImpl( @@ -567,6 +546,8 @@ class BrowserTabViewModelTest { whenever(fireproofDialogsEventHandler.event).thenReturn(fireproofDialogsEventHandlerLiveData) whenever(cameraHardwareChecker.hasCameraHardware()).thenReturn(true) whenever(mockPrivacyProtectionsPopupManager.viewState).thenReturn(flowOf(PrivacyProtectionsPopupViewState.Gone)) + whenever(mockAppBuildConfig.buildType).thenReturn("debug") + whenever(mockDuckPlayer.observeUserPreferences()).thenReturn(flowOf(UserPreferences(false, AlwaysAsk))) testee = BrowserTabViewModel( statisticsUpdater = mockStatisticsUpdater, @@ -630,6 +611,8 @@ class BrowserTabViewModelTest { history = mockNavigationHistory, newTabPixels = { mockNewTabPixels }, httpErrorPixels = { mockHttpErrorPixels }, + duckPlayer = mockDuckPlayer, + duckPlayerJSHelper = DuckPlayerJSHelper(mockDuckPlayer, mockAppBuildConfig, mockPixel, mockDuckDuckGoUrlDetector), ) testee.loadData("abc", null, false, false) @@ -2661,7 +2644,7 @@ class BrowserTabViewModelTest { } @Test - fun whenUserClicksOnRemoveFireproofingSnackbarUndoActionThenPixelSent() { + fun whenUserClicksOnRemoveFireproofingSnackbarUndoActionThenPixelSent() = runTest { givenFireproofWebsiteDomain("example.com") loadUrl("http://example.com/", isBrowserShowing = true) testee.onFireproofWebsiteMenuClicked() @@ -3754,9 +3737,10 @@ class BrowserTabViewModelTest { @Test fun whenEmailSignOutEventThenEmailSignEventCommandSent() = runTest { + emailStateFlow.emit(true) emailStateFlow.emit(false) - assertCommandIssued() + assertCommandIssuedTimes(2) } @Test @@ -4839,7 +4823,13 @@ class BrowserTabViewModelTest { fun whenProcessJsCallbackMessageWebShareSendCommand() = runTest { val url = "someUrl" loadUrl(url) - testee.processJsCallbackMessage("myFeature", "webShare", "myId", JSONObject("""{ "my":"object"}""")) + testee.processJsCallbackMessage( + "myFeature", + "webShare", + "myId", + JSONObject("""{ "my":"object"}"""), + "someUrl", + ) assertCommandIssued { assertEquals("object", this.data.params.getString("my")) assertEquals("myFeature", this.data.featureName) @@ -4853,7 +4843,13 @@ class BrowserTabViewModelTest { val url = "someUrl" loadUrl(url) whenever(mockSitePermissionsManager.getPermissionsQueryResponse(eq(url), any(), any())).thenReturn(SitePermissionQueryResponse.Granted) - testee.processJsCallbackMessage("myFeature", "permissionsQuery", "myId", JSONObject("""{ "name":"somePermission"}""")) + testee.processJsCallbackMessage( + "myFeature", + "permissionsQuery", + "myId", + JSONObject("""{ "name":"somePermission"}"""), + "someUrl", + ) assertCommandIssued { assertEquals("granted", this.data.params.getString("state")) assertEquals("myFeature", this.data.featureName) @@ -4865,14 +4861,26 @@ class BrowserTabViewModelTest { @Test fun whenProcessJsCallbackMessageScreenLockNotEnabledDoNotSendCommand() = runTest { whenever(mockEnabledToggle.isEnabled()).thenReturn(false) - testee.processJsCallbackMessage("myFeature", "screenLock", "myId", JSONObject("""{ "my":"object"}""")) + testee.processJsCallbackMessage( + "myFeature", + "screenLock", + "myId", + JSONObject("""{ "my":"object"}"""), + "someUrl", + ) assertCommandNotIssued() } @Test fun whenProcessJsCallbackMessageScreenLockEnabledSendCommand() = runTest { whenever(mockEnabledToggle.isEnabled()).thenReturn(true) - testee.processJsCallbackMessage("myFeature", "screenLock", "myId", JSONObject("""{ "my":"object"}""")) + testee.processJsCallbackMessage( + "myFeature", + "screenLock", + "myId", + JSONObject("""{ "my":"object"}"""), + "someUrl", + ) assertCommandIssued { assertEquals("object", this.data.params.getString("my")) assertEquals("myFeature", this.data.featureName) @@ -4884,17 +4892,189 @@ class BrowserTabViewModelTest { @Test fun whenProcessJsCallbackMessageScreenUnlockNotEnabledDoNotSendCommand() = runTest { whenever(mockEnabledToggle.isEnabled()).thenReturn(false) - testee.processJsCallbackMessage("myFeature", "screenUnlock", "myId", JSONObject("""{ "my":"object"}""")) + testee.processJsCallbackMessage( + "myFeature", + "screenUnlock", + "myId", + JSONObject("""{ "my":"object"}"""), + "someUrl", + ) assertCommandNotIssued() } @Test fun whenProcessJsCallbackMessageScreenUnlockEnabledSendCommand() = runTest { whenever(mockEnabledToggle.isEnabled()).thenReturn(true) - testee.processJsCallbackMessage("myFeature", "screenUnlock", "myId", JSONObject("""{ "my":"object"}""")) + testee.processJsCallbackMessage( + "myFeature", + "screenUnlock", + "myId", + JSONObject("""{ "my":"object"}"""), + "someUrl", + ) assertCommandIssued() } + @Test + fun whenProcessJsCallbackMessageGetUserPreferencesFromOverlayThenSendCommand() = runTest { + whenever(mockEnabledToggle.isEnabled()).thenReturn(true) + whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(overlayInteracted = true, privatePlayerMode = AlwaysAsk)) + testee.processJsCallbackMessage( + DUCK_PLAYER_FEATURE_NAME, + "getUserValues", + "id", + data = null, + "someUrl", + ) + assertCommandIssued() + } + + @Test + fun whenProcessJsCallbackMessageSetUserPreferencesDisabledFromDuckPlayerOverlayThenSendCommand() = runTest { + whenever(mockEnabledToggle.isEnabled()).thenReturn(true) + whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(overlayInteracted = true, privatePlayerMode = AlwaysAsk)) + testee.processJsCallbackMessage( + DUCK_PLAYER_FEATURE_NAME, + "setUserValues", + "id", + JSONObject("""{ overlayInteracted: "true", privatePlayerMode: {disabled: {} }}"""), + "someUrl", + ) + assertCommandIssued() + verify(mockDuckPlayer).setUserPreferences(any(), any()) + verify(mockPixel).fire(DUCK_PLAYER_SETTING_NEVER_OVERLAY_YOUTUBE) + } + + @Test + fun whenProcessJsCallbackMessageSetUserPreferencesEnabledFromDuckPlayerOverlayThenSendCommand() = runTest { + whenever(mockEnabledToggle.isEnabled()).thenReturn(true) + whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(overlayInteracted = true, privatePlayerMode = AlwaysAsk)) + testee.processJsCallbackMessage( + DUCK_PLAYER_FEATURE_NAME, + "setUserValues", + "id", + JSONObject("""{ overlayInteracted: "true", privatePlayerMode: {enabled: {} }}"""), + "someUrl", + ) + assertCommandIssued() + verify(mockDuckPlayer).setUserPreferences(any(), any()) + verify(mockPixel).fire(DUCK_PLAYER_SETTING_ALWAYS_OVERLAY_YOUTUBE) + } + + @Test + fun whenProcessJsCallbackMessageSetUserPreferencesFromDuckPlayerPageThenSendCommand() = runTest { + whenever(mockEnabledToggle.isEnabled()).thenReturn(true) + whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(overlayInteracted = true, privatePlayerMode = AlwaysAsk)) + testee.processJsCallbackMessage( + DUCK_PLAYER_PAGE_FEATURE_NAME, + "setUserValues", + "id", + JSONObject("""{ overlayInteracted: "true", privatePlayerMode: {enabled: {} }}"""), + "someUrl", + ) + assertCommandIssued() + verify(mockDuckPlayer).setUserPreferences(true, "enabled") + verify(mockPixel).fire(DUCK_PLAYER_SETTING_ALWAYS_DUCK_PLAYER) + } + + @Test + fun whenProcessJsCallbackMessageSendDuckPlayerPixelThenSendPixel() = runTest { + whenever(mockEnabledToggle.isEnabled()).thenReturn(true) + whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(overlayInteracted = true, privatePlayerMode = AlwaysAsk)) + testee.processJsCallbackMessage( + DUCK_PLAYER_FEATURE_NAME, + "sendDuckPlayerPixel", + "id", + JSONObject("""{ pixelName: "pixel", params: {}}"""), + "someUrl", + ) + verify(mockDuckPlayer).sendDuckPlayerPixel("pixel", mapOf()) + } + + @Test + fun whenProcessJsCallbackMessageOpenDuckPlayerWithUrlThenNavigate() = runTest { + whenever(mockEnabledToggle.isEnabled()).thenReturn(true) + whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(overlayInteracted = true, privatePlayerMode = AlwaysAsk)) + testee.processJsCallbackMessage( + DUCK_PLAYER_FEATURE_NAME, + "openDuckPlayer", + "id", + JSONObject("""{ href: "duck://player/1234" }"""), + "someUrl", + ) + assertCommandIssued() + } + + @Test + fun whenProcessJsCallbackMessageOpenDuckPlayerWithoutUrlThenDoNotNavigate() = runTest { + whenever(mockEnabledToggle.isEnabled()).thenReturn(true) + whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(overlayInteracted = true, privatePlayerMode = AlwaysAsk)) + testee.processJsCallbackMessage( + DUCK_PLAYER_FEATURE_NAME, + "openDuckPlayer", + "id", + null, + "someUrl", + ) + assertCommandNotIssued() + } + + @Test + fun whenJsCallbackMessageInitialSetupFromOverlayThenSendResponseToJs() = runTest { + whenever(mockEnabledToggle.isEnabled()).thenReturn(true) + whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(overlayInteracted = true, privatePlayerMode = AlwaysAsk)) + testee.processJsCallbackMessage( + DUCK_PLAYER_FEATURE_NAME, + "initialSetup", + "id", + null, + "someUrl", + ) + assertCommandIssued() + } + + @Test + fun whenJsCallbackMessageInitialSetupFromDuckPlayerPageThenSendResponseToDuckPlayer() = runTest { + whenever(mockEnabledToggle.isEnabled()).thenReturn(true) + whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(overlayInteracted = true, privatePlayerMode = AlwaysAsk)) + testee.processJsCallbackMessage( + DUCK_PLAYER_PAGE_FEATURE_NAME, + "initialSetup", + "id", + null, + "someUrl", + ) + assertCommandIssued() + } + + @Test + fun whenJsCallbackMessageOpenSettingsThenOpenSettings() = runTest { + whenever(mockEnabledToggle.isEnabled()).thenReturn(true) + testee.processJsCallbackMessage( + DUCK_PLAYER_PAGE_FEATURE_NAME, + "openSettings", + "id", + null, + "someUrl", + ) + assertCommandIssued() + } + + @Test + fun whenJsCallbackMessageOpenInfoThenOpenInfo() = runTest { + whenever(mockEnabledToggle.isEnabled()).thenReturn(true) + whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(ENABLED) + + testee.processJsCallbackMessage( + DUCK_PLAYER_PAGE_FEATURE_NAME, + "openInfo", + "id", + null, + "someUrl", + ) + assertCommandIssued() + } + @Test fun whenPrivacyProtectionMenuClickedThenListenerIsInvoked() = runTest { loadUrl("http://www.example.com/home.html") @@ -5570,6 +5750,70 @@ class BrowserTabViewModelTest { assertCommandIssued() } + @Test + fun whenNewPageWithUrlYouTubeNoCookieThenReplaceUrlWithDuckPlayer() = runTest { + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie("https://youtube-nocookie.com/?videoID=1234".toUri())).thenReturn(true) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie("duck://player/1234".toUri())).thenReturn(false) + whenever(mockDuckPlayer.createDuckPlayerUriFromYoutubeNoCookie("https://youtube-nocookie.com/?videoID=1234".toUri())).thenReturn( + "duck://player/1234", + ) + whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(ENABLED) + testee.browserViewState.value = browserViewState().copy(browserShowing = true) + + testee.navigationStateChanged(buildWebNavigation("https://youtube-nocookie.com/?videoID=1234")) + + assertEquals("duck://player/1234", omnibarViewState().omnibarText) + } + + @Test + fun whenNewPageWithUrlYouTubeNoCookieThenShowDuckPlayerIcon() = runTest { + whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(ENABLED) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie("https://youtube-nocookie.com/?videoID=1234".toUri())).thenReturn(true) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie("duck://player/1234".toUri())).thenReturn(false) + whenever(mockDuckPlayer.createDuckPlayerUriFromYoutubeNoCookie("https://youtube-nocookie.com/?videoID=1234".toUri())).thenReturn( + "duck://player/1234", + ) + whenever(mockDuckPlayer.isDuckPlayerUri("duck://player/1234")).thenReturn(true) + whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(ENABLED) + + testee.browserViewState.value = browserViewState().copy(browserShowing = true) + + testee.navigationStateChanged(buildWebNavigation("https://youtube-nocookie.com/?videoID=1234")) + + assertTrue(browserViewState().showDuckPlayerIcon) + } + + @Test + fun whenUrlUpdatedWithUrlYouTubeNoCookieThenReplaceUrlWithDuckPlayer() = runTest { + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie("https://youtube-nocookie.com/?videoID=1234".toUri())).thenReturn(true) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie("duck://player/1234".toUri())).thenReturn(false) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie("http://example.com".toUri())).thenReturn(false) + whenever(mockDuckPlayer.createDuckPlayerUriFromYoutubeNoCookie("https://youtube-nocookie.com/?videoID=1234".toUri())).thenReturn( + "duck://player/1234", + ) + whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(ENABLED) + + testee.browserViewState.value = browserViewState().copy(browserShowing = true) + + testee.navigationStateChanged(buildWebNavigation("http://example.com")) + testee.navigationStateChanged(buildWebNavigation("https://youtube-nocookie.com/?videoID=1234")) + + assertEquals("duck://player/1234", omnibarViewState().omnibarText) + } + + @Test + fun whenSharingDuckPlayerUrlThenReplaceWithYouTubeUrl() = runTest { + givenCurrentSite("duck://player/1234") + whenever(mockDuckPlayer.isDuckPlayerUri("duck://player/1234")).thenReturn(true) + whenever(mockDuckPlayer.createYoutubeWatchUrlFromDuckPlayer(any())).thenReturn("https://youtube.com/watch?v=1234") + + testee.onShareSelected() + + assertCommandIssued { + assertEquals("https://youtube.com/watch?v=1234", this.url) + } + } + private fun aCredential(): LoginCredentials { return LoginCredentials(domain = null, username = null, password = null) } @@ -5737,7 +5981,11 @@ class BrowserTabViewModelTest { url: String?, title: String? = null, isBrowserShowing: Boolean = true, - ) { + ) = runTest { + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(anyUri())).thenReturn(false) + whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(DISABLED) + whenever(mockDuckPlayer.observeUserPreferences()).thenReturn(flowOf(UserPreferences(false, Disabled))) + setBrowserShowing(isBrowserShowing) testee.navigationStateChanged(buildWebNavigation(originalUrl = url, currentUrl = url, title = title)) } @@ -5747,7 +5995,8 @@ class BrowserTabViewModelTest { originalUrl: String?, currentUrl: String?, isBrowserShowing: Boolean, - ) { + ) = runTest { + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(anyUri())).thenReturn(false) setBrowserShowing(isBrowserShowing) testee.navigationStateChanged(buildWebNavigation(originalUrl = originalUrl, currentUrl = currentUrl)) } @@ -5837,6 +6086,8 @@ class BrowserTabViewModelTest { private fun browserGlobalLayoutViewState() = testee.globalLayoutState.value!! as GlobalLayoutViewState.Browser private fun accessibilityViewState() = testee.accessibilityViewState.value!! + fun anyUri(): Uri = any() + class FakeCapabilityChecker(var enabled: Boolean) : AutofillCapabilityChecker { override suspend fun isAutofillEnabledByConfiguration(url: String) = enabled override suspend fun canInjectCredentialsToWebView(url: String) = enabled diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt index cc6877535a8a..be278749888f 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -68,6 +68,7 @@ import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.common.utils.device.DeviceInfo import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.cookies.api.CookieManagerProvider +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.history.api.NavigationHistory import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.subscriptions.api.Subscriptions @@ -132,6 +133,7 @@ class BrowserWebViewClientTest { private val pagePaintedHandler: PagePaintedHandler = mock() private val mediaPlayback: MediaPlayback = mock() private val subscriptions: Subscriptions = mock() + private val mockDuckPlayer: DuckPlayer = mock() private val navigationHistory: NavigationHistory = mock() @UiThreadTest @@ -165,6 +167,7 @@ class BrowserWebViewClientTest { navigationHistory, mediaPlayback, subscriptions, + mockDuckPlayer, ) testee.webViewClientListener = listener whenever(webResourceRequest.url).thenReturn(Uri.EMPTY) @@ -804,6 +807,8 @@ class BrowserWebViewClientTest { whenever(mockWebView.progress).thenReturn(100) whenever(mockWebView.safeCopyBackForwardList()).thenReturn(TestBackForwardList()) whenever(mockWebView.settings).thenReturn(mock()) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(anyString())).thenReturn(false) + whenever(mockDuckPlayer.isYoutubeWatchUrl(any())).thenReturn(false) testee.onPageStarted(mockWebView, EXAMPLE_URL, null) whenever(currentTimeProvider.elapsedRealtime()).thenReturn(10) testee.onPageFinished(mockWebView, EXAMPLE_URL) diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewRequestInterceptorTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewRequestInterceptorTest.kt index ca97197be1f3..2753e38780ee 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewRequestInterceptorTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewRequestInterceptorTest.kt @@ -19,7 +19,12 @@ package com.duckduckgo.app.browser import android.net.Uri -import android.webkit.* +import android.webkit.WebBackForwardList +import android.webkit.WebHistoryItem +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView import androidx.core.net.toUri import androidx.test.annotation.UiThreadTest import com.duckduckgo.adclick.api.AdClickManager @@ -38,6 +43,7 @@ import com.duckduckgo.app.trackerdetection.model.TrackerStatus import com.duckduckgo.app.trackerdetection.model.TrackerType import com.duckduckgo.app.trackerdetection.model.TrackingEvent import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.httpsupgrade.api.HttpsUpgrader import com.duckduckgo.privacy.config.api.Gpc @@ -47,13 +53,20 @@ import com.duckduckgo.user.agent.api.UserAgentProvider import com.duckduckgo.user.agent.impl.RealUserAgentProvider import com.duckduckgo.user.agent.impl.UserAgent import kotlinx.coroutines.test.runTest -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.ArgumentMatchers.anyMap import org.mockito.ArgumentMatchers.anyString -import org.mockito.kotlin.* +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever class WebViewRequestInterceptorTest { @@ -72,6 +85,7 @@ class WebViewRequestInterceptorTest { private val mockAdClickManager: AdClickManager = mock() private val mockCloakedCnameDetector: CloakedCnameDetector = mock() private val mockRequestFilterer: RequestFilterer = mock() + private val mockDuckPlayer: DuckPlayer = mock() private val fakeUserAgent: UserAgent = UserAgentFake() private val fakeToggle: FeatureToggle = FeatureToggleFake() private val fakeUserAllowListRepository = UserAllowListRepositoryFake() @@ -102,6 +116,7 @@ class WebViewRequestInterceptorTest { adClickManager = mockAdClickManager, cloakedCnameDetector = mockCloakedCnameDetector, requestFilterer = mockRequestFilterer, + duckPlayer = mockDuckPlayer, ) } @@ -158,6 +173,47 @@ class WebViewRequestInterceptorTest { verify(mockHttpsUpgrader, never()).upgrade(any()) } + @Test + fun whenInterceptUrlAndShouldUpgradeThenShouldUpgradeIsCalledAndNotDuckPlayer() = runTest { + configureShouldUpgrade() + configureDuckPlayer() + testee.shouldIntercept( + request = mockRequest, + documentUri = null, + webView = webView, + webViewClientListener = null, + ) + verify(mockHttpsUpgrader).upgrade(any()) + verify(mockDuckPlayer, never()).intercept(any(), any(), any()) + } + + @Test + fun whenInterceptUrlWithNullUrlThenDuckPlayerInterceptNotCalled() = runTest { + configureDuckPlayer() + whenever(mockRequest.url).thenReturn(null) + + testee.shouldIntercept( + request = mockRequest, + documentUri = null, + webView = webView, + webViewClientListener = null, + ) + + verify(mockDuckPlayer, never()).intercept(any(), any(), any()) + } + + @Test + fun whenInterceptUrlDuckPlayerInterceptIsCalled() = runTest { + configureDuckPlayer() + testee.shouldIntercept( + request = mockRequest, + documentUri = null, + webView = webView, + webViewClientListener = null, + ) + verify(mockDuckPlayer).intercept(any(), any(), any()) + } + @Test fun whenUrlShouldBeUpgradedButUrlIsNullThenNotUpgraded() = runTest { configureShouldUpgrade() @@ -779,6 +835,11 @@ class WebViewRequestInterceptorTest { whenever(mockRequest.isForMainFrame).thenReturn(true) } + private fun configureDuckPlayer() = runTest { + whenever(mockRequest.url).thenReturn(validUri()) + whenever(mockDuckPlayer.intercept(any(), any(), any())).thenReturn(mock()) + } + private fun configureShouldNotUpgrade() = runTest { whenever(mockHttpsUpgrader.shouldUpgrade(any())).thenReturn(false) diff --git a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt index 42cd11a662ae..112d9b18d791 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt @@ -19,6 +19,7 @@ package com.duckduckgo.app.cta.ui import android.content.Context import android.net.Uri import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.core.net.toUri import androidx.room.Room import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.app.browser.DuckDuckGoUrlDetectorImpl @@ -40,6 +41,7 @@ import com.duckduckgo.app.privacy.model.TestEntity import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.app.trackerdetection.model.Entity @@ -49,6 +51,11 @@ import com.duckduckgo.app.trackerdetection.model.TrackingEvent import com.duckduckgo.app.widget.ui.WidgetCapabilities import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.InstantSchedulersRule +import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED +import com.duckduckgo.duckplayer.api.DuckPlayer.UserPreferences +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.subscriptions.api.Subscriptions import java.util.concurrent.TimeUnit @@ -62,8 +69,7 @@ import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test -import org.mockito.Mock -import org.mockito.MockitoAnnotations +import org.mockito.ArgumentMatchers.anyString import org.mockito.kotlin.* @FlowPreview @@ -83,38 +89,29 @@ class CtaViewModelTest { private lateinit var db: AppDatabase - @Mock - private lateinit var mockWidgetCapabilities: WidgetCapabilities + private val mockWidgetCapabilities: WidgetCapabilities = mock() - @Mock - private lateinit var mockDismissedCtaDao: DismissedCtaDao + private val mockDismissedCtaDao: DismissedCtaDao = mock() - @Mock - private lateinit var mockPixel: Pixel + private val mockPixel: Pixel = mock() - @Mock - private lateinit var mockAppInstallStore: AppInstallStore + private val mockAppInstallStore: AppInstallStore = mock() - @Mock - private lateinit var mockSettingsDataStore: SettingsDataStore + private val mockSettingsDataStore: SettingsDataStore = mock() - @Mock - private lateinit var mockOnboardingStore: OnboardingStore + private val mockOnboardingStore: OnboardingStore = mock() - @Mock - private lateinit var mockUserAllowListRepository: UserAllowListRepository + private val mockUserAllowListRepository: UserAllowListRepository = mock() - @Mock - private lateinit var mockUserStageStore: UserStageStore + private val mockUserStageStore: UserStageStore = mock() - @Mock - private lateinit var mockTabRepository: TabRepository + private val mockTabRepository: TabRepository = mock() - @Mock - private lateinit var mockExtendedOnboardingFeatureToggles: ExtendedOnboardingFeatureToggles + private val mockExtendedOnboardingFeatureToggles: ExtendedOnboardingFeatureToggles = mock() - @Mock - private lateinit var mockSubscriptions: Subscriptions + private val mockDuckPlayer: DuckPlayer = mock() + + private val mockSubscriptions: Subscriptions = mock() private val requiredDaxOnboardingCtas: List = listOf( CtaId.DAX_INTRO, @@ -132,8 +129,7 @@ class CtaViewModelTest { private val mockDisabledToggle: Toggle = mock { on { it.isEnabled() } doReturn false } @Before - fun before() { - MockitoAnnotations.openMocks(this) + fun before() = runTest { db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) .allowMainThreadQueries() .build() @@ -145,6 +141,11 @@ class CtaViewModelTest { whenever(mockUserAllowListRepository.isDomainInUserAllowList(any())).thenReturn(false) whenever(mockDismissedCtaDao.dismissedCtas()).thenReturn(db.dismissedCtaDao().dismissedCtas()) whenever(mockTabRepository.flowTabs).thenReturn(db.tabsDao().flowTabs()) + whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(DISABLED) + whenever(mockDuckPlayer.isDuckPlayerUri(any())).thenReturn(false) + whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(false, AlwaysAsk)) + whenever(mockDuckPlayer.isYouTubeUrl(any())).thenReturn(false) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(anyString())).thenReturn(false) testee = CtaViewModel( appInstallStore = mockAppInstallStore, @@ -160,6 +161,7 @@ class CtaViewModelTest { duckDuckGoUrlDetector = DuckDuckGoUrlDetectorImpl(), extendedOnboardingFeatureToggles = mockExtendedOnboardingFeatureToggles, subscriptions = mockSubscriptions, + duckPlayer = mockDuckPlayer, ) } @@ -738,11 +740,28 @@ class CtaViewModelTest { @Test fun givenPrivacyProSiteWhenRefreshCtaWhileBrowsingThenReturnNull() = runTest { val privacyProUrl = "https://duckduckgo.com/pro" - whenever(mockSubscriptions.isPrivacyProUrl(privacyProUrl)).thenReturn(true) + whenever(mockSubscriptions.isPrivacyProUrl(privacyProUrl.toUri())).thenReturn(true) givenDaxOnboardingActive() val site = site(url = privacyProUrl) val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = true, site = site) + verify(mockPixel, never()).fire(eq(ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE), any(), any(), eq(UNIQUE)) + assertNull(value) + } + + @Test + fun givenDuckPlayerSiteWhenRefreshCtaWhileBrowsingThenReturnNull() = runTest { + givenDaxOnboardingActive() + val site = site(url = "duck://player/12345") + + whenever(mockDuckPlayer.isDuckPlayerUri(any())).thenReturn(true) + whenever(mockDuckPlayer.getDuckPlayerState()).thenReturn(ENABLED) + whenever(mockDuckPlayer.getUserPreferences()).thenReturn(UserPreferences(false, AlwaysAsk)) + whenever(mockDuckPlayer.isYouTubeUrl(any())).thenReturn(false) + whenever(mockDuckPlayer.isSimulatedYoutubeNoCookie(anyString())).thenReturn(false) + + val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = true, site = site) + verify(mockPixel).fire(eq(ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE), any(), any(), eq(UNIQUE)) assertNull(value) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/referencetests/DomainsReferenceTest.kt b/app/src/androidTest/java/com/duckduckgo/app/referencetests/DomainsReferenceTest.kt index 62141e153cec..9bc809b2efdf 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/referencetests/DomainsReferenceTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/referencetests/DomainsReferenceTest.kt @@ -54,6 +54,7 @@ import com.duckduckgo.app.trackerdetection.db.TdsEntityDao import com.duckduckgo.app.trackerdetection.db.WebTrackersBlockedDao import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.httpsupgrade.api.HttpsUpgrader import com.duckduckgo.privacy.config.api.ContentBlocking @@ -103,6 +104,7 @@ class DomainsReferenceTest(private val testCase: TestCase) { private var mockRequest: WebResourceRequest = mock() private val mockPrivacyProtectionCountDao: PrivacyProtectionCountDao = mock() private val mockRequestFilterer: RequestFilterer = mock() + private val mockDuckPlayer: DuckPlayer = mock() private val mockUserAllowListRepository: UserAllowListRepository = mock() private val fakeUserAgent: UserAgent = UserAgentFake() private val fakeToggle: FeatureToggle = FeatureToggleFake() @@ -172,6 +174,7 @@ class DomainsReferenceTest(private val testCase: TestCase) { adClickManager = mockAdClickManager, cloakedCnameDetector = CloakedCnameDetectorImpl(tdsCnameEntityDao, mockTrackerAllowlist, mockUserAllowListRepository), requestFilterer = mockRequestFilterer, + duckPlayer = mockDuckPlayer, ) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/referencetests/SurrogatesReferenceTest.kt b/app/src/androidTest/java/com/duckduckgo/app/referencetests/SurrogatesReferenceTest.kt index 1eac7fe79a32..83fb7607d59f 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/referencetests/SurrogatesReferenceTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/referencetests/SurrogatesReferenceTest.kt @@ -52,6 +52,7 @@ import com.duckduckgo.app.trackerdetection.db.TdsEntityDao import com.duckduckgo.app.trackerdetection.db.WebTrackersBlockedDao import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.httpsupgrade.api.HttpsUpgrader import com.duckduckgo.privacy.config.api.ContentBlocking @@ -99,6 +100,7 @@ class SurrogatesReferenceTest(private val testCase: TestCase) { private var mockRequest: WebResourceRequest = mock() private val mockPrivacyProtectionCountDao: PrivacyProtectionCountDao = mock() private val mockRequestFilterer: RequestFilterer = mock() + private val mockDuckPlayer: DuckPlayer = mock() private val fakeUserAgent: UserAgent = UserAgentFake() private val fakeToggle: FeatureToggle = FeatureToggleFake() private val fakeUserAllowListRepository = UserAllowListRepositoryFake() @@ -168,6 +170,7 @@ class SurrogatesReferenceTest(private val testCase: TestCase) { adClickManager = mockAdClickManager, cloakedCnameDetector = mockCloakedCnameDetector, requestFilterer = mockRequestFilterer, + duckPlayer = mockDuckPlayer, ) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0f8c8b211695..e6474231c8e2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -247,6 +247,7 @@ + @@ -258,6 +259,7 @@ + @@ -280,6 +282,7 @@ + diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 39b1f3ffbaa6..850d66dd613a 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -22,7 +22,11 @@ import android.annotation.SuppressLint import android.app.Activity.RESULT_OK import android.app.ActivityOptions import android.app.PendingIntent -import android.content.* +import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.content.pm.ResolveInfo @@ -31,7 +35,12 @@ import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.ColorDrawable import android.net.Uri -import android.os.* +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.os.Handler +import android.os.Looper +import android.os.Message import android.print.PrintAttributes import android.print.PrintManager import android.provider.MediaStore @@ -40,8 +49,14 @@ import android.text.Spannable import android.text.SpannableString import android.text.Spanned import android.text.style.StyleSpan -import android.view.* -import android.view.View.* +import android.view.ContextMenu +import android.view.KeyEvent +import android.view.MenuItem +import android.view.View +import android.view.View.GONE +import android.view.View.OnFocusChangeListener +import android.view.View.VISIBLE +import android.view.ViewGroup import android.view.ViewGroup.LayoutParams import android.view.inputmethod.EditorInfo import android.webkit.PermissionRequest @@ -53,7 +68,9 @@ import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebView.FindListener import android.webkit.WebView.HitTestResult -import android.webkit.WebView.HitTestResult.* +import android.webkit.WebView.HitTestResult.IMAGE_TYPE +import android.webkit.WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE +import android.webkit.WebView.HitTestResult.UNKNOWN_TYPE import android.widget.EditText import android.widget.FrameLayout import android.widget.TextView @@ -68,13 +85,21 @@ import androidx.core.net.toUri import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY import androidx.core.text.toSpannable -import androidx.core.view.* +import androidx.core.view.doOnLayout +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.core.view.postDelayed import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.commitNow import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.transaction -import androidx.lifecycle.* +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebMessageCompat @@ -157,7 +182,11 @@ import com.duckduckgo.app.browser.webshare.WebShareChooser import com.duckduckgo.app.browser.webview.WebContentDebugging import com.duckduckgo.app.browser.webview.WebViewBlobDownloadFeature import com.duckduckgo.app.browser.webview.safewebview.SafeWebViewFeature -import com.duckduckgo.app.cta.ui.* +import com.duckduckgo.app.cta.ui.Cta +import com.duckduckgo.app.cta.ui.CtaViewModel +import com.duckduckgo.app.cta.ui.DaxBubbleCta +import com.duckduckgo.app.cta.ui.HomePanelCta +import com.duckduckgo.app.cta.ui.OnboardingDaxDialogCta import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.fire.fireproofwebsite.data.website @@ -253,6 +282,8 @@ import com.duckduckgo.downloads.api.DownloadConfirmationDialogListener import com.duckduckgo.downloads.api.DownloadsFileActions import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload +import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.DuckPlayerSettingsNoParams import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.js.messaging.api.JsMessageCallback import com.duckduckgo.js.messaging.api.JsMessaging @@ -291,10 +322,16 @@ import javax.inject.Inject import javax.inject.Named import javax.inject.Provider import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.json.JSONObject import timber.log.Timber @@ -457,6 +494,10 @@ class BrowserTabFragment : @Named("ContentScopeScripts") lateinit var contentScopeScripts: JsMessaging + @Inject + @Named("DuckPlayer") + lateinit var duckPlayerScripts: JsMessaging + @Inject lateinit var webContentDebugging: WebContentDebugging @@ -505,6 +546,9 @@ class BrowserTabFragment : @Inject lateinit var webBrokenSiteForm: WebBrokenSiteForm + @Inject + lateinit var duckPlayer: DuckPlayer + /** * We use this to monitor whether the user was seeing the in-context Email Protection signup prompt * This is needed because the activity stack will be cleared if an external link is opened in our browser @@ -1550,6 +1594,7 @@ class BrowserTabFragment : is Command.WebViewError -> showError(it.errorType, it.url) is Command.SendResponseToJs -> contentScopeScripts.onResponse(it.data) + is Command.SendResponseToDuckPlayer -> duckPlayerScripts.onResponse(it.data) is Command.WebShareRequest -> webShareRequest.launch(it.data) is Command.ScreenLock -> screenLock(it.data) is Command.ScreenUnlock -> screenUnlock() @@ -1561,6 +1606,21 @@ class BrowserTabFragment : is Command.HideOnboardingDaxDialog -> hideOnboardingDaxDialog(it.onboardingCta) is Command.ShowRemoveSearchSuggestionDialog -> showRemoveSearchSuggestionDialog(it.suggestion) is Command.AutocompleteItemRemoved -> autocompleteItemRemoved() + is Command.OpenDuckPlayerSettings -> globalActivityStarter.start(binding.root.context, DuckPlayerSettingsNoParams) + is Command.OpenDuckPlayerPageInfo -> { + context?.resources?.configuration?.let { + duckPlayer.showDuckPlayerPrimeModal(it, childFragmentManager, fromDuckPlayerPage = true) + } + } + is Command.OpenDuckPlayerOverlayInfo -> { + context?.resources?.configuration?.let { + duckPlayer.showDuckPlayerPrimeModal(it, childFragmentManager, fromDuckPlayerPage = false) + } + } + is Command.SendSubscriptions -> { + contentScopeScripts.sendSubscriptionEvent(it.cssData) + duckPlayerScripts.sendSubscriptionEvent(it.duckPlayerData) + } else -> { // NO OP } @@ -2369,7 +2429,24 @@ class BrowserTabFragment : id: String?, data: JSONObject?, ) { - viewModel.processJsCallbackMessage(featureName, method, id, data) + appCoroutineScope.launch(dispatchers.main()) { + viewModel.processJsCallbackMessage(featureName, method, id, data, it.url) + } + } + }, + ) + duckPlayerScripts.register( + it, + object : JsMessageCallback() { + override fun process( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + ) { + appCoroutineScope.launch(dispatchers.main()) { + viewModel.processJsCallbackMessage(featureName, method, id, data, it.url) + } } }, ) @@ -3904,11 +3981,13 @@ class BrowserTabFragment : private fun renderToolbarMenus(viewState: BrowserViewState) { if (viewState.browserShowing) { omnibar.daxIcon?.isVisible = viewState.showDaxIcon - omnibar.shieldIcon?.isInvisible = !viewState.showPrivacyShield.isEnabled() || viewState.showDaxIcon + omnibar.duckPlayerIcon.isVisible = viewState.showDuckPlayerIcon + omnibar.shieldIcon?.isInvisible = !viewState.showPrivacyShield.isEnabled() || viewState.showDaxIcon || viewState.showDuckPlayerIcon omnibar.clearTextButton?.isVisible = viewState.showClearButton omnibar.searchIcon?.isVisible = viewState.showSearchIcon } else { omnibar.daxIcon.isVisible = false + omnibar.duckPlayerIcon.isVisible = false omnibar.shieldIcon?.isVisible = false omnibar.clearTextButton?.isVisible = viewState.showClearButton omnibar.searchIcon?.isVisible = true diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index dd4c9501c791..077bd38972d9 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -74,6 +74,9 @@ import com.duckduckgo.app.browser.commands.Command import com.duckduckgo.app.browser.commands.Command.* import com.duckduckgo.app.browser.commands.NavigationCommand import com.duckduckgo.app.browser.customtabs.CustomTabPixelNames +import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_FEATURE_NAME +import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_PAGE_FEATURE_NAME +import com.duckduckgo.app.browser.duckplayer.DuckPlayerJSHelper import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.browser.favicon.FaviconSource.ImageFavicon import com.duckduckgo.app.browser.favicon.FaviconSource.UrlFavicon @@ -171,6 +174,8 @@ import com.duckduckgo.downloads.api.DownloadCommand import com.duckduckgo.downloads.api.DownloadStateListener import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload +import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED import com.duckduckgo.history.api.NavigationHistory import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.newtabpage.impl.pixels.NewTabPixels @@ -272,6 +277,8 @@ class BrowserTabViewModel @Inject constructor( private val history: NavigationHistory, private val newTabPixels: Lazy, // Lazy to construct the instance and deps only when actually sending the pixel private val httpErrorPixels: Lazy, + private val duckPlayer: DuckPlayer, + private val duckPlayerJSHelper: DuckPlayerJSHelper, ) : WebViewClientListener, EditSavedSiteListener, DeleteBookmarkListener, @@ -506,6 +513,13 @@ class BrowserTabViewModel @Inject constructor( browserViewState.value = currentBrowserViewState().copy(privacyProtectionsPopupViewState = popupViewState) } .launchIn(viewModelScope) + + duckPlayer.observeUserPreferences() + .onEach { preferences -> + command.value = duckPlayerJSHelper.userPreferencesUpdated(preferences) + } + .flowOn(dispatchers.main()) + .launchIn(viewModelScope) } fun loadData( @@ -860,7 +874,6 @@ class BrowserTabViewModel @Inject constructor( clearPreviousUrl() } - Timber.w("SSLError: navigate to $urlToNavigate") site?.nextUrl = urlToNavigate command.value = NavigationCommand.Navigate(urlToNavigate, getUrlHeaders(urlToNavigate)) } @@ -1148,11 +1161,40 @@ class BrowserTabViewModel @Inject constructor( canGoForward = newWebNavigationState.canGoForward, ) - Timber.v("SSL Error: navigationStateChanged: $stateChange") when (stateChange) { - is WebNavigationStateChange.NewPage -> pageChanged(stateChange.url, stateChange.title) + is WebNavigationStateChange.NewPage -> { + val uri = stateChange.url.toUri() + viewModelScope.launch(dispatchers.io()) { + if (duckPlayer.getDuckPlayerState() == ENABLED && duckPlayer.isSimulatedYoutubeNoCookie(uri)) { + duckPlayer.createDuckPlayerUriFromYoutubeNoCookie(uri)?.let { + withContext(dispatchers.main()) { + pageChanged(it, stateChange.title) + } + } + } else { + withContext(dispatchers.main()) { + pageChanged(stateChange.url, stateChange.title) + } + } + } + } is WebNavigationStateChange.PageCleared -> pageCleared() - is WebNavigationStateChange.UrlUpdated -> urlUpdated(stateChange.url) + is WebNavigationStateChange.UrlUpdated -> { + val uri = stateChange.url.toUri() + viewModelScope.launch(dispatchers.io()) { + if (duckPlayer.getDuckPlayerState() == ENABLED && duckPlayer.isSimulatedYoutubeNoCookie(uri)) { + duckPlayer.createDuckPlayerUriFromYoutubeNoCookie(uri)?.let { + withContext(dispatchers.main()) { + urlUpdated(it) + } + } + } else { + withContext(dispatchers.main()) { + urlUpdated(stateChange.url) + } + } + } + } is WebNavigationStateChange.PageNavigationCleared -> disableUserNavigation() else -> {} } @@ -1223,6 +1265,7 @@ class BrowserTabViewModel @Inject constructor( isFireproofWebsite = isFireproofWebsite(), showDaxIcon = shouldShowDaxIcon(url, true), canPrintPage = domain != null, + showDuckPlayerIcon = shouldShowDuckPlayerIcon(url, true), ) if (duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(url)) { @@ -1297,6 +1340,14 @@ class BrowserTabViewModel @Inject constructor( return showPrivacyShield && duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(url) } + private fun shouldShowDuckPlayerIcon( + currentUrl: String?, + showPrivacyShield: Boolean, + ): Boolean { + val url = currentUrl ?: return false + return showPrivacyShield && duckPlayer.isDuckPlayerUri(url) + } + private suspend fun updateLoadingStatePrivacy(domain: String) { val privacyProtectionDisabled = isPrivacyProtectionDisabled(domain) withContext(dispatchers.main()) { @@ -1334,20 +1385,20 @@ class BrowserTabViewModel @Inject constructor( } } - private suspend fun getBookmark(url: String): SavedSite.Bookmark? { + private suspend fun getBookmark(url: String): Bookmark? { return withContext(dispatchers.io()) { savedSitesRepository.getBookmark(url) } } - private suspend fun getBookmarkFolder(bookmark: SavedSite.Bookmark?): BookmarkFolder? { + private suspend fun getBookmarkFolder(bookmark: Bookmark?): BookmarkFolder? { if (bookmark == null) return null return withContext(dispatchers.io()) { savedSitesRepository.getFolder(bookmark.parentId) } } - private suspend fun getFavorite(url: String): SavedSite.Favorite? { + private suspend fun getFavorite(url: String): Favorite? { return withContext(dispatchers.io()) { savedSitesRepository.getFavorite(url) } @@ -1911,7 +1962,7 @@ class BrowserTabViewModel @Inject constructor( val autoCompleteSuggestionsEnabled = appSettingsPreferencesStore.autoCompleteSuggestionsEnabled val showAutoCompleteSuggestions = hasFocus && query.isNotBlank() && hasQueryChanged && autoCompleteSuggestionsEnabled val showFavoritesAsSuggestions = if (!showAutoCompleteSuggestions) { - val urlFocused = hasFocus && query.isNotBlank() && !hasQueryChanged && UriString.isWebUrl(query) + val urlFocused = hasFocus && query.isNotBlank() && !hasQueryChanged && (UriString.isWebUrl(query) || duckPlayer.isDuckPlayerUri(query)) val emptyQueryBrowsing = query.isBlank() && currentBrowserViewState().browserShowing val favoritesAvailable = currentAutoCompleteViewState().favorites.isNotEmpty() hasFocus && (urlFocused || emptyQueryBrowsing) && favoritesAvailable @@ -1969,6 +2020,7 @@ class BrowserTabViewModel @Inject constructor( urlLoaded = url ?: "", ), showDaxIcon = shouldShowDaxIcon(url, showPrivacyShield), + showDuckPlayerIcon = shouldShowDuckPlayerIcon(url, showPrivacyShield), ) Timber.d("showPrivacyShield=$showPrivacyShield, showSearchIcon=$showSearchIcon, showClearButton=$showClearButton") @@ -2170,7 +2222,7 @@ class BrowserTabViewModel @Inject constructor( fun onEditSavedSiteRequested(savedSite: SavedSite) { viewModelScope.launch(dispatchers.io()) { val bookmarkFolder = - if (savedSite is SavedSite.Bookmark) { + if (savedSite is Bookmark) { getBookmarkFolder(savedSite) } else { null @@ -2403,7 +2455,13 @@ class BrowserTabViewModel @Inject constructor( fun onShareSelected() { url?.let { - command.value = ShareLink(removeAtbAndSourceParamsFromSearch(it), title.orEmpty()) + viewModelScope.launch(dispatchers.io()) { + transformUrlToShare(it).let { + withContext(dispatchers.main()) { + command.value = ShareLink(it, title.orEmpty()) + } + } + } } } @@ -2421,6 +2479,16 @@ class BrowserTabViewModel @Inject constructor( command.value = NavigationCommand.NavigateToHistory(stackIndex) } + private suspend fun transformUrlToShare(url: String): String { + return if (duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(url)) { + removeAtbAndSourceParamsFromSearch(url) + } else if (duckPlayer.isDuckPlayerUri(url)) { + transformDuckPlayerUrl(url) + } else { + url + } + } + private fun removeAtbAndSourceParamsFromSearch(url: String): String { if (!duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(url)) { return url @@ -2439,6 +2507,14 @@ class BrowserTabViewModel @Inject constructor( return builder.build().toString() } + private suspend fun transformDuckPlayerUrl(url: String): String { + return if (duckPlayer.isDuckPlayerUri(url)) { + duckPlayer.createYoutubeWatchUrlFromDuckPlayer(url.toUri()) ?: url + } else { + url + } + } + fun saveWebViewState( webView: WebView?, tabId: String, @@ -3091,11 +3167,12 @@ class BrowserTabViewModel @Inject constructor( ) } - fun processJsCallbackMessage( + suspend fun processJsCallbackMessage( featureName: String, method: String, id: String?, data: JSONObject?, + url: String?, ) { when (method) { "webShare" -> if (id != null && data != null) { @@ -3120,6 +3197,20 @@ class BrowserTabViewModel @Inject constructor( // NOOP } } + + when (featureName) { + DUCK_PLAYER_FEATURE_NAME, DUCK_PLAYER_PAGE_FEATURE_NAME -> { + withContext(dispatchers.io()) { + val response = duckPlayerJSHelper.processJsCallbackMessage(featureName, method, id, data, url) + withContext(dispatchers.main()) { + response?.let { + command.value = it + } + } + } + } + else -> {} + } } private fun webShare( diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt index 2fcf246be0dd..587ec3f3d211 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -68,6 +68,8 @@ import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.cookies.api.CookieManagerProvider +import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED import com.duckduckgo.history.api.NavigationHistory import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.subscriptions.api.Subscriptions @@ -106,6 +108,7 @@ class BrowserWebViewClient @Inject constructor( private val navigationHistory: NavigationHistory, private val mediaPlayback: MediaPlayback, private val subscriptions: Subscriptions, + private val duckPlayer: DuckPlayer, ) : WebViewClient() { var webViewClientListener: WebViewClientListener? = null @@ -268,6 +271,13 @@ class BrowserWebViewClient @Inject constructor( } false } + is SpecialUrlDetector.UrlType.DuckScheme -> { + webViewClientListener?.let { listener -> + loadUrl(listener, webView, url.toString()) + true + } + false + } } } catch (e: Throwable) { appCoroutineScope.launch(dispatcherProvider.io()) { @@ -374,7 +384,19 @@ class BrowserWebViewClient @Inject constructor( pageLoadedHandler.onPageLoaded(it, navigationList.currentItem?.title, safeStart, currentTimeProvider.elapsedRealtime()) shouldSendPagePaintedPixel(webView = webView, url = it) appCoroutineScope.launch(dispatcherProvider.io()) { - navigationHistory.saveToHistory(url, navigationList.currentItem?.title) + if (duckPlayer.getDuckPlayerState() == ENABLED && duckPlayer.isSimulatedYoutubeNoCookie(url)) { + duckPlayer.createDuckPlayerUriFromYoutubeNoCookie(url.toUri())?.let { + navigationHistory.saveToHistory( + it, + navigationList.currentItem?.title, + ) + } + } else { + if (duckPlayer.getDuckPlayerState() == ENABLED && duckPlayer.isYoutubeWatchUrl(url.toUri())) { + duckPlayer.duckPlayerNavigatedToYoutube() + } + navigationHistory.saveToHistory(url, navigationList.currentItem?.title) + } } start = null } diff --git a/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt b/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt index 2a5583425e7b..90f470be0920 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt @@ -23,13 +23,18 @@ import android.content.IntentFilter import android.content.pm.PackageManager import android.content.pm.ResolveInfo import android.net.Uri +import androidx.core.net.toUri import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType import com.duckduckgo.app.browser.applinks.ExternalAppIntentFlagsFeature +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.privacy.config.api.AmpLinkType import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.privacy.config.api.TrackingParameters import com.duckduckgo.subscriptions.api.Subscriptions import java.net.URISyntaxException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking import timber.log.Timber class SpecialUrlDetectorImpl( @@ -38,6 +43,8 @@ class SpecialUrlDetectorImpl( private val trackingParameters: TrackingParameters, private val subscriptions: Subscriptions, private val externalAppIntentFlagsFeature: ExternalAppIntentFlagsFeature, + private val duckPlayer: DuckPlayer, + private val scope: CoroutineScope, ) : SpecialUrlDetector { override fun determineType(initiatingUrl: String?, uri: Uri): UrlType { @@ -52,6 +59,7 @@ class SpecialUrlDetectorImpl( HTTP_SCHEME, HTTPS_SCHEME, DATA_SCHEME -> processUrl(initiatingUrl, uriString) JAVASCRIPT_SCHEME, ABOUT_SCHEME, FILE_SCHEME, SITE_SCHEME, BLOB_SCHEME -> UrlType.SearchQuery(uriString) FILETYPE_SCHEME, IN_TITLE_SCHEME, IN_URL_SCHEME -> UrlType.SearchQuery(uriString) + DUCK_SCHEME -> UrlType.DuckScheme(uriString) null -> { if (subscriptions.shouldLaunchPrivacyProForUrl("https://$uriString")) { UrlType.ShouldLaunchPrivacyProLink @@ -80,22 +88,30 @@ class SpecialUrlDetectorImpl( return UrlType.TrackingParameterLink(cleanedUrl = cleanedUrl) } - try { - val browsableIntent = Intent.parseUri(uriString, URI_ANDROID_APP_SCHEME).apply { - addCategory(Intent.CATEGORY_BROWSABLE) - } - val activities = queryActivities(browsableIntent) - val activity = getDefaultActivity(browsableIntent) ?: activities.firstOrNull() + val uri = uriString.toUri() + + val willNavigateToDuckPlayerDeferred = scope.async { duckPlayer.willNavigateToDuckPlayer(uri) } + + val willNavigateToDuckPlayer = runBlocking { willNavigateToDuckPlayerDeferred.await() } + + if (!willNavigateToDuckPlayer) { + try { + val browsableIntent = Intent.parseUri(uriString, URI_ANDROID_APP_SCHEME).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + } + val activities = queryActivities(browsableIntent) + val activity = getDefaultActivity(browsableIntent) ?: activities.firstOrNull() - val nonBrowserActivities = keepNonBrowserActivities(activities) - .filter { it.activityInfo.packageName == activity?.activityInfo?.packageName } + val nonBrowserActivities = keepNonBrowserActivities(activities) + .filter { it.activityInfo.packageName == activity?.activityInfo?.packageName } - nonBrowserActivities.singleOrNull()?.let { resolveInfo -> - val nonBrowserIntent = buildNonBrowserIntent(resolveInfo, uriString) - return UrlType.AppLink(appIntent = nonBrowserIntent, uriString = uriString) + nonBrowserActivities.singleOrNull()?.let { resolveInfo -> + val nonBrowserIntent = buildNonBrowserIntent(resolveInfo, uriString) + return UrlType.AppLink(appIntent = nonBrowserIntent, uriString = uriString) + } + } catch (e: URISyntaxException) { + Timber.w(e, "Failed to parse uri $uriString") } - } catch (e: URISyntaxException) { - Timber.w(e, "Failed to parse uri $uriString") } ampLinks.extractCanonicalFromAmpLink(uriString)?.let { ampLinkType -> @@ -204,6 +220,7 @@ class SpecialUrlDetectorImpl( private const val FILETYPE_SCHEME = "filetype" private const val IN_TITLE_SCHEME = "intitle" private const val IN_URL_SCHEME = "inurl" + private const val DUCK_SCHEME = "duck" const val SMS_MAX_LENGTH = 400 const val PHONE_MAX_LENGTH = 20 const val EMAIL_MAX_LENGTH = 1000 diff --git a/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt b/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt index e7af2f3512b4..5ca57ebb0029 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt @@ -33,6 +33,7 @@ import com.duckduckgo.common.utils.AppUrl import com.duckduckgo.common.utils.DefaultDispatcherProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.isHttp +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.httpsupgrade.api.HttpsUpgrader import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.request.filterer.api.RequestFilterer @@ -69,6 +70,7 @@ class WebViewRequestInterceptor( private val adClickManager: AdClickManager, private val cloakedCnameDetector: CloakedCnameDetector, private val requestFilterer: RequestFilterer, + private val duckPlayer: DuckPlayer, private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), ) : RequestInterceptor { @@ -92,7 +94,7 @@ class WebViewRequestInterceptor( documentUri: Uri?, webViewClientListener: WebViewClientListener?, ): WebResourceResponse? { - val url = request.url + val url: Uri? = request.url if (requestFilterer.shouldFilterOutRequest(request, documentUri.toString())) return WebResourceResponse(null, null, null) @@ -109,7 +111,7 @@ class WebViewRequestInterceptor( if (appUrlPixel(url)) return null if (shouldUpgrade(request)) { - val newUri = httpsUpgrader.upgrade(url) + val newUri = url?.let { httpsUpgrader.upgrade(url) } withContext(dispatchers.main()) { webView.loadUrl(newUri.toString(), getHeaders(request)) @@ -120,7 +122,11 @@ class WebViewRequestInterceptor( return WebResourceResponse(null, null, null) } - if (shouldAddGcpHeaders(request) && !requestWasInTheStack(url, webView)) { + if (url != null) { + duckPlayer.intercept(request, url, webView)?.let { return it } + } + + if (url != null && shouldAddGcpHeaders(request) && !requestWasInTheStack(url, webView)) { withContext(dispatchers.main()) { webViewClientListener?.redirectTriggeredByGpc() webView.loadUrl(url.toString(), getHeaders(request)) diff --git a/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt b/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt index 7e4c351df519..599092851335 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt @@ -40,6 +40,7 @@ import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.browser.api.brokensite.BrokenSiteData import com.duckduckgo.js.messaging.api.JsCallbackData +import com.duckduckgo.js.messaging.api.SubscriptionEventData import com.duckduckgo.savedsites.api.models.SavedSite import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissions @@ -219,7 +220,10 @@ sealed class Command { val url: String, ) : Command() + // TODO (cbarreiro) Rename to SendResponseToCSS data class SendResponseToJs(val data: JsCallbackData) : Command() + data class SendResponseToDuckPlayer(val data: JsCallbackData) : Command() + data class SendSubscriptions(val cssData: SubscriptionEventData, val duckPlayerData: SubscriptionEventData) : Command() data class WebShareRequest(val data: JsCallbackData) : Command() data class ScreenLock(val data: JsCallbackData) : Command() object ScreenUnlock : Command() @@ -233,4 +237,7 @@ sealed class Command { data class HideOnboardingDaxDialog(val onboardingCta: OnboardingDaxDialogCta) : Command() data class ShowRemoveSearchSuggestionDialog(val suggestion: AutoCompleteSuggestion) : Command() data object AutocompleteItemRemoved : Command() + object OpenDuckPlayerSettings : Command() + object OpenDuckPlayerOverlayInfo : Command() + object OpenDuckPlayerPageInfo : Command() } diff --git a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt index 4b67e980d882..274fc0c747cf 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt @@ -81,6 +81,7 @@ import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.impl.AndroidFileDownloader import com.duckduckgo.downloads.impl.DataUriDownloader import com.duckduckgo.downloads.impl.FileDownloadCallback +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.experiments.api.VariantManager import com.duckduckgo.httpsupgrade.api.HttpsUpgrader import com.duckduckgo.privacy.config.api.AmpLinks @@ -185,7 +186,17 @@ class BrowserModule { trackingParameters: TrackingParameters, subscriptions: Subscriptions, externalAppIntentFlagsFeature: ExternalAppIntentFlagsFeature, - ): SpecialUrlDetector = SpecialUrlDetectorImpl(packageManager, ampLinks, trackingParameters, subscriptions, externalAppIntentFlagsFeature) + duckPlayer: DuckPlayer, + @AppCoroutineScope appCoroutineScope: CoroutineScope, + ): SpecialUrlDetector = SpecialUrlDetectorImpl( + packageManager, + ampLinks, + trackingParameters, + subscriptions, + externalAppIntentFlagsFeature, + duckPlayer, + appCoroutineScope, + ) @Provides fun webViewRequestInterceptor( @@ -198,6 +209,7 @@ class BrowserModule { adClickManager: AdClickManager, cloakedCnameDetector: CloakedCnameDetector, requestFilterer: RequestFilterer, + duckPlayer: DuckPlayer, ): RequestInterceptor = WebViewRequestInterceptor( resourceSurrogates, @@ -209,6 +221,7 @@ class BrowserModule { adClickManager, cloakedCnameDetector, requestFilterer, + duckPlayer, ) @Provides diff --git a/app/src/main/java/com/duckduckgo/app/browser/duckplayer/DuckPlayerJSHelper.kt b/app/src/main/java/com/duckduckgo/app/browser/duckplayer/DuckPlayerJSHelper.kt new file mode 100644 index 000000000000..0c2f9a11bf35 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/duckplayer/DuckPlayerJSHelper.kt @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.duckplayer + +import androidx.core.net.toUri +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector +import com.duckduckgo.app.browser.commands.Command +import com.duckduckgo.app.browser.commands.Command.OpenDuckPlayerOverlayInfo +import com.duckduckgo.app.browser.commands.Command.OpenDuckPlayerPageInfo +import com.duckduckgo.app.browser.commands.Command.OpenDuckPlayerSettings +import com.duckduckgo.app.browser.commands.Command.SendResponseToDuckPlayer +import com.duckduckgo.app.browser.commands.Command.SendResponseToJs +import com.duckduckgo.app.browser.commands.Command.SendSubscriptions +import com.duckduckgo.app.browser.commands.NavigationCommand.Navigate +import com.duckduckgo.app.pixels.AppPixelName.DUCK_PLAYER_SETTING_ALWAYS_DUCK_PLAYER +import com.duckduckgo.app.pixels.AppPixelName.DUCK_PLAYER_SETTING_ALWAYS_OVERLAY_YOUTUBE +import com.duckduckgo.app.pixels.AppPixelName.DUCK_PLAYER_SETTING_ALWAYS_SERP +import com.duckduckgo.app.pixels.AppPixelName.DUCK_PLAYER_SETTING_NEVER_OVERLAY_YOUTUBE +import com.duckduckgo.app.pixels.AppPixelName.DUCK_PLAYER_SETTING_NEVER_SERP +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED +import com.duckduckgo.duckplayer.api.DuckPlayer.UserPreferences +import com.duckduckgo.duckplayer.api.PrivatePlayerMode +import com.duckduckgo.js.messaging.api.JsCallbackData +import com.duckduckgo.js.messaging.api.SubscriptionEventData +import javax.inject.Inject +import org.json.JSONObject +import timber.log.Timber + +const val DUCK_PLAYER_PAGE_FEATURE_NAME = "duckPlayerPage" +const val DUCK_PLAYER_FEATURE_NAME = "duckPlayer" +private const val OVERLAY_INTERACTED = "overlayInteracted" +private const val PRIVATE_PLAYER_MODE = "privatePlayerMode" + +class DuckPlayerJSHelper @Inject constructor( + private val duckPlayer: DuckPlayer, + private val appBuildConfig: AppBuildConfig, + private val pixel: Pixel, + private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, +) { + private suspend fun getUserPreferences(featureName: String, method: String, id: String): JsCallbackData { + val userValues = duckPlayer.getUserPreferences() + + return JsCallbackData( + JSONObject( + """ + { + $OVERLAY_INTERACTED: ${userValues.overlayInteracted}, + $PRIVATE_PLAYER_MODE: { + "${userValues.privatePlayerMode.value}": {} + } + } + """, + ), + featureName, + method, + id, + ) + } + + fun userPreferencesUpdated(userPreferences: UserPreferences): SendSubscriptions { + return JSONObject( + """ + { + $OVERLAY_INTERACTED: ${userPreferences.overlayInteracted}, + $PRIVATE_PLAYER_MODE: { + "${userPreferences.privatePlayerMode.value}": {} + } + } + """, + ).let { json -> + SendSubscriptions( + cssData = SubscriptionEventData(DUCK_PLAYER_FEATURE_NAME, "onUserValuesChanged", json), + duckPlayerData = SubscriptionEventData(DUCK_PLAYER_PAGE_FEATURE_NAME, "onUserValuesChanged", json), + ) + } + } + + private suspend fun getInitialSetup(featureName: String, method: String, id: String): JsCallbackData { + val userValues = duckPlayer.getUserPreferences() + val privatePlayerMode = if (duckPlayer.getDuckPlayerState() == ENABLED) userValues.privatePlayerMode else PrivatePlayerMode.Disabled + + val jsonObject = JSONObject( + """ + { + "settings": { + "pip": { + "state": "disabled" + } + }, + "userValues": { + $OVERLAY_INTERACTED: ${userValues.overlayInteracted}, + $PRIVATE_PLAYER_MODE: { + "${privatePlayerMode.value}": {} + } + }, + ui: { + "allowFirstVideo": ${duckPlayer.shouldHideDuckPlayerOverlay()} + } + } + """, + ) + duckPlayer.duckPlayerOverlayHidden() + + when (featureName) { + DUCK_PLAYER_PAGE_FEATURE_NAME -> { + jsonObject.put("platform", JSONObject("""{ name: "android" }""")) + jsonObject.put("locale", java.util.Locale.getDefault().language) + jsonObject.put("env", if (appBuildConfig.isDebug) "development" else "production") + } + DUCK_PLAYER_FEATURE_NAME -> { + jsonObject.put("platform", JSONObject("""{ name: "android" }""")) + jsonObject.put("locale", java.util.Locale.getDefault().language) + } + } + + return JsCallbackData( + jsonObject, + featureName, + method, + id, + ) + } + + private suspend fun setUserPreferences(data: JSONObject) { + val overlayInteracted = data.getBoolean(OVERLAY_INTERACTED) + val privatePlayerModeObject = data.getJSONObject(PRIVATE_PLAYER_MODE) + duckPlayer.setUserPreferences(overlayInteracted, privatePlayerModeObject.keys().next()) + } + + private suspend fun sendDuckPlayerPixel(data: JSONObject) { + val pixelName = data.getString("pixelName") + val paramsMap = data.getJSONObject("params").keys().asSequence().associateWith { + data.getJSONObject("params").getString(it) + } + duckPlayer.sendDuckPlayerPixel(pixelName, paramsMap) + } + + suspend fun processJsCallbackMessage( + featureName: String, + method: String, + id: String?, + data: JSONObject?, + url: String?, + ): Command? { + when (method) { + "getUserValues" -> if (id != null) { + return SendResponseToJs(getUserPreferences(featureName, method, id)) + } + + "setUserValues" -> if (id != null && data != null) { + setUserPreferences(data) + return when (featureName) { + DUCK_PLAYER_FEATURE_NAME -> { + SendResponseToJs(getUserPreferences(featureName, method, id)).also { + if (data.getJSONObject(PRIVATE_PLAYER_MODE).keys().next() == "enabled") { + if (url != null && duckDuckGoUrlDetector.isDuckDuckGoUrl(url)) { + pixel.fire(DUCK_PLAYER_SETTING_ALWAYS_SERP) + } else { + pixel.fire(DUCK_PLAYER_SETTING_ALWAYS_OVERLAY_YOUTUBE) + } + } else if (data.getJSONObject(PRIVATE_PLAYER_MODE).keys().next() == "disabled") { + if (url != null && duckDuckGoUrlDetector.isDuckDuckGoUrl(url)) { + pixel.fire(DUCK_PLAYER_SETTING_NEVER_SERP) + } else { + pixel.fire(DUCK_PLAYER_SETTING_NEVER_OVERLAY_YOUTUBE) + } + } + } + } + DUCK_PLAYER_PAGE_FEATURE_NAME -> { + SendResponseToDuckPlayer(getUserPreferences(featureName, method, id)).also { + if (data.getJSONObject(PRIVATE_PLAYER_MODE).keys().next() == "enabled") { + pixel.fire(DUCK_PLAYER_SETTING_ALWAYS_DUCK_PLAYER) + } + } + } else -> { + null + } + } + } + + "sendDuckPlayerPixel" -> if (data != null) { + sendDuckPlayerPixel(data) + return null + } + "openDuckPlayer" -> { + return data?.getString("href")?.let { + Navigate(it.toUri().buildUpon().appendQueryParameter("origin", "overlay").build().toString(), mapOf()) + } + } + "initialSetup" -> { + return when (featureName) { + DUCK_PLAYER_FEATURE_NAME -> { + SendResponseToJs(getInitialSetup(featureName, method, id ?: "")) + } + DUCK_PLAYER_PAGE_FEATURE_NAME -> { + SendResponseToDuckPlayer(getInitialSetup(featureName, method, id ?: "")) + } + else -> { + null + } + } + } + "reportPageException", "reportInitException" -> { + Timber.tag(method).d(data.toString()) + } + "openSettings" -> { + return OpenDuckPlayerSettings + } + "openInfo" -> { + return when (featureName) { + DUCK_PLAYER_FEATURE_NAME -> OpenDuckPlayerOverlayInfo + DUCK_PLAYER_PAGE_FEATURE_NAME -> OpenDuckPlayerPageInfo + else -> null + } + } + else -> { + return null + } + } + return null + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/QueryUrlConverter.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/QueryUrlConverter.kt index 9ca17326b3da..20d67ab92ebe 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/omnibar/QueryUrlConverter.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/QueryUrlConverter.kt @@ -38,14 +38,14 @@ class QueryUrlConverter @Inject constructor(private val requestRewriter: Request ): String { val isUrl = when (queryOrigin) { is QueryOrigin.FromAutocomplete -> queryOrigin.isNav - is QueryOrigin.FromUser -> UriString.isWebUrl(searchQuery) + is QueryOrigin.FromUser -> UriString.isWebUrl(searchQuery) || UriString.isDuckUri(searchQuery) } if (isUrl == true) { return convertUri(searchQuery) } - if (URLUtil.isDataUrl(searchQuery)) { + if (URLUtil.isDataUrl(searchQuery) || URLUtil.isAssetUrl(searchQuery)) { return searchQuery } diff --git a/app/src/main/java/com/duckduckgo/app/browser/viewstate/BrowserViewState.kt b/app/src/main/java/com/duckduckgo/app/browser/viewstate/BrowserViewState.kt index 937a1f691bb4..66dd8df17782 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/viewstate/BrowserViewState.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/viewstate/BrowserViewState.kt @@ -58,6 +58,7 @@ data class BrowserViewState( val browserError: WebViewErrorResponse = WebViewErrorResponse.OMITTED, val sslError: SSLErrorType = SSLErrorType.NONE, val privacyProtectionsPopupViewState: PrivacyProtectionsPopupViewState = PrivacyProtectionsPopupViewState.Gone, + val showDuckPlayerIcon: Boolean = false, ) sealed class HighlightableButton { diff --git a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt index 3b0199f50f9c..a44aa5f1e79e 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt @@ -18,6 +18,7 @@ package com.duckduckgo.app.cta.ui import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread +import androidx.core.net.toUri import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.cta.db.DismissedCtaDao import com.duckduckgo.app.cta.model.CtaId @@ -30,13 +31,18 @@ import com.duckduckgo.app.global.model.domain import com.duckduckgo.app.global.model.orderedTrackerBlockedEntities import com.duckduckgo.app.onboarding.store.* import com.duckduckgo.app.onboarding.ui.page.extendedonboarding.ExtendedOnboardingFeatureToggles +import com.duckduckgo.app.pixels.AppPixelName.ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.app.widget.ui.WidgetCapabilities import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk import com.duckduckgo.subscriptions.api.Subscriptions import dagger.SingleInstanceIn import javax.inject.Inject @@ -62,6 +68,7 @@ class CtaViewModel @Inject constructor( private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, private val extendedOnboardingFeatureToggles: ExtendedOnboardingFeatureToggles, private val subscriptions: Subscriptions, + private val duckPlayer: DuckPlayer, ) { @ExperimentalCoroutinesApi @VisibleForTesting @@ -308,8 +315,23 @@ class CtaViewModel @Inject constructor( } } - private fun isSiteNotAllowedForOnboarding(url: String?): Boolean { - return url == null || subscriptions.isPrivacyProUrl(url) + private suspend fun isSiteNotAllowedForOnboarding(url: String?): Boolean { + val uri = url?.toUri() ?: return true + + if (subscriptions.isPrivacyProUrl(uri)) return true + + val isDuckPlayerUrl = + duckPlayer.getDuckPlayerState() == DuckPlayerState.ENABLED && + ( + (duckPlayer.getUserPreferences().privatePlayerMode == AlwaysAsk && duckPlayer.isYouTubeUrl(uri)) || + duckPlayer.isDuckPlayerUri(url) || duckPlayer.isSimulatedYoutubeNoCookie(url) + ) + + if (isDuckPlayerUrl) { + pixel.fire(pixel = ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE, type = UNIQUE) + } + + return isDuckPlayerUrl } private fun daxDialogIntroShown(): Boolean = dismissedCtaDao.exists(CtaId.DAX_INTRO) diff --git a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt index 119ef2a391d5..8bfa5c38847a 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt @@ -46,6 +46,7 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { ONBOARDING_DAX_ALL_CTA_HIDDEN("m_odc_h"), ONBOARDING_DAX_CTA_OK_BUTTON("m_odc_ok"), ONBOARDING_DAX_CTA_CANCEL_BUTTON("m_onboarding_dax_cta_cancel"), + ONBOARDING_SKIP_MAJOR_NETWORK_UNIQUE("m_onboarding_skip_major_network_unique"), BROWSER_MENU_ALLOWLIST_ADD("mb_wla"), BROWSER_MENU_ALLOWLIST_REMOVE("mb_wlr"), @@ -347,6 +348,12 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { TAB_MANAGER_LIST_VIEW_BUTTON_CLICKED("m_tab_manager_list_view_button_clicked"), TAB_MANAGER_VIEW_MODE_TOGGLED_DAILY("m_tab_manager_view_mode_toggled_daily"), + DUCK_PLAYER_SETTING_ALWAYS_OVERLAY_YOUTUBE("duck-player_setting_always_overlay_youtube"), + DUCK_PLAYER_SETTING_ALWAYS_SERP("duck-player_setting_always_overlay_serp"), + DUCK_PLAYER_SETTING_NEVER_SERP("duck-player_setting_never_overlay_serp"), + DUCK_PLAYER_SETTING_NEVER_OVERLAY_YOUTUBE("duck-player_setting_never_overlay_youtube"), + DUCK_PLAYER_SETTING_ALWAYS_DUCK_PLAYER("duck-player_setting_always_duck-player"), + ADD_BOOKMARK_CONFIRM_EDITED("m_add_bookmark_confirm_edit"), REFERRAL_INSTALL_UTM_CAMPAIGN("m_android_install"), diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt index 907d4570239d..b8ae6427a0aa 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt @@ -64,6 +64,7 @@ import com.duckduckgo.macos.api.MacOsScreenWithEmptyParams import com.duckduckgo.mobile.android.app.tracking.ui.AppTrackingProtectionScreens.AppTrackerActivityWithEmptyParams import com.duckduckgo.mobile.android.app.tracking.ui.AppTrackingProtectionScreens.AppTrackerOnboardingActivityWithEmptyParamsParams import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.settings.api.DuckPlayerSettingsPlugin import com.duckduckgo.settings.api.ProSettingsPlugin import com.duckduckgo.sync.api.SyncActivityWithEmptyParams import com.duckduckgo.windows.api.ui.WindowsScreenWithEmptyParams @@ -101,6 +102,12 @@ class SettingsActivity : DuckDuckGoActivity() { _proSettingsPlugin.getPlugins() } + @Inject + lateinit var _duckPlayerSettingsPlugin: PluginPoint + private val duckPlayerSettingsPlugin by lazy { + _duckPlayerSettingsPlugin.getPlugins() + } + private val viewsPrivacy get() = binding.includeSettings.contentSettingsPrivacy @@ -169,6 +176,14 @@ class SettingsActivity : DuckDuckGoActivity() { viewsPro.addView(plugin.getView(this)) } } + + if (duckPlayerSettingsPlugin.isEmpty()) { + viewsSettings.settingsSectionDuckPlayer.gone() + } else { + duckPlayerSettingsPlugin.forEach { plugin -> + viewsSettings.settingsSectionDuckPlayer.addView(plugin.getView(this)) + } + } } private fun configureInternalFeatures() { @@ -200,6 +215,7 @@ class SettingsActivity : DuckDuckGoActivity() { updateSyncSetting(visible = it.showSyncSetting) updateAutoconsent(it.isAutoconsentEnabled) updatePrivacyPro(it.isPrivacyProEnabled) + updateDuckPlayer(it.isDuckPlayerEnabled) } }.launchIn(lifecycleScope) @@ -218,6 +234,14 @@ class SettingsActivity : DuckDuckGoActivity() { } } + private fun updateDuckPlayer(isDuckPlayerEnabled: Boolean) { + if (isDuckPlayerEnabled) { + viewsSettings.settingsSectionDuckPlayer.show() + } else { + viewsSettings.settingsSectionDuckPlayer.gone() + } + } + private fun updateAutofill(autofillEnabled: Boolean) = with(viewsSettings.autofillLoginsSetting) { visibility = if (autofillEnabled) { View.VISIBLE diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt index 59823b92dfee..22abfba72720 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt @@ -32,6 +32,9 @@ import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.common.utils.ConflatedJob import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED_WIH_HELP_LINK +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED import com.duckduckgo.mobile.android.app.tracking.AppTrackingProtection import com.duckduckgo.subscriptions.api.Subscriptions import com.duckduckgo.sync.api.DeviceSyncState @@ -58,6 +61,7 @@ class SettingsViewModel @Inject constructor( private val dispatcherProvider: DispatcherProvider, private val autoconsent: Autoconsent, private val subscriptions: Subscriptions, + private val duckPlayer: DuckPlayer, ) : ViewModel(), DefaultLifecycleObserver { data class ViewState( @@ -70,6 +74,7 @@ class SettingsViewModel @Inject constructor( val showSyncSetting: Boolean = false, val isAutoconsentEnabled: Boolean = false, val isPrivacyProEnabled: Boolean = false, + val isDuckPlayerEnabled: Boolean = false, ) sealed class Command { @@ -130,6 +135,7 @@ class SettingsViewModel @Inject constructor( showSyncSetting = deviceSyncState.isFeatureEnabled(), isAutoconsentEnabled = autoconsent.isSettingEnabled(), isPrivacyProEnabled = subscriptions.isEligible(), + isDuckPlayerEnabled = duckPlayer.getDuckPlayerState().let { it == ENABLED || it == DISABLED_WIH_HELP_LINK }, ), ) } diff --git a/app/src/main/res/drawable/ic_duckplayer.xml b/app/src/main/res/drawable/ic_duckplayer.xml new file mode 100644 index 000000000000..5a7c406a36d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_duckplayer.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/content_settings_settings.xml b/app/src/main/res/layout/content_settings_settings.xml index b0d6ef4333dd..6a2745a92d24 100644 --- a/app/src/main/res/layout/content_settings_settings.xml +++ b/app/src/main/res/layout/content_settings_settings.xml @@ -79,6 +79,13 @@ android:layout_height="wrap_content" app:primaryText="@string/settingsAccessibility" /> + + + + + = emptyList() - private val handlers: List = listOf(ContentScopeHandler(), breakageHandler) + private val handlers: List = listOf(ContentScopeHandler(), breakageHandler, DuckPlayerHandler()) @JavascriptInterface override fun process(message: String, secret: String) { @@ -119,4 +119,25 @@ class ContentScopeScriptsJsMessaging @Inject constructor( override val featureName: String = "webCompat" override val methods: List = listOf("webShare", "permissionsQuery", "screenLock", "screenUnlock") } + + inner class DuckPlayerHandler : JsMessageHandler { + override fun process(jsMessage: JsMessage, secret: String, jsMessageCallback: JsMessageCallback?) { + // TODO (cbarreiro): Add again when https://app.asana.com/0/0/1207602010403610/f is fixed + // if (jsMessage.id == null) return + jsMessageCallback?.process(featureName, jsMessage.method, jsMessage.id ?: "", jsMessage.params) + } + + override val allowedDomains: List = emptyList() + override val featureName: String = "duckPlayer" + override val methods: List = listOf( + "getUserValues", + "sendDuckPlayerPixel", + "setUserValues", + "openDuckPlayer", + "openInfo", + "initialSetup", + "reportPageException", + "reportInitException", + ) + } } diff --git a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt index 452df739ecad..770248ec05fe 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt @@ -96,7 +96,8 @@ class RealContentScopeScriptsTest { "\"unprotectedTemporary\":\\[" + "\\{\"domain\":\"example\\.com\",\"reason\":\"reason\"\\}," + "\\{\"domain\":\"foo\\.com\",\"reason\":\"reason2\"\\}\\]\\}, \\[\"foo\\.com\"\\], " + - "\\{\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\},\"sessionKey\":\"5678\",\"desktopModeEnabled\":false," + + "\\{\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\},\"locale\":\"en\"," + + "\"sessionKey\":\"5678\",\"desktopModeEnabled\":false," + "\"messageSecret\":\"([\\da-f]{32})\"," + "\"messageCallback\":\"([\\da-f]{32})\"," + "\"javascriptInterface\":\"([\\da-f]{32})\"\\}\\)$", @@ -122,7 +123,8 @@ class RealContentScopeScriptsTest { "\"unprotectedTemporary\":\\[" + "\\{\"domain\":\"example\\.com\",\"reason\":\"reason\"\\}," + "\\{\"domain\":\"foo\\.com\",\"reason\":\"reason2\"\\}\\]\\}, \\[\"example\\.com\"\\], " + - "\\{\"globalPrivacyControlValue\":false,\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\},\"sessionKey\":\"5678\"," + + "\\{\"globalPrivacyControlValue\":false,\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\}," + + "\"locale\":\"en\",\"sessionKey\":\"5678\"," + "\"desktopModeEnabled\":false,\"messageSecret\":\"([\\da-f]{32})\"," + "\"messageCallback\":\"([\\da-f]{32})\"," + "\"javascriptInterface\":\"([\\da-f]{32})\"\\}\\)$", @@ -147,7 +149,8 @@ class RealContentScopeScriptsTest { "\"unprotectedTemporary\":\\[" + "\\{\"domain\":\"example\\.com\",\"reason\":\"reason\"\\}," + "\\{\"domain\":\"foo\\.com\",\"reason\":\"reason2\"\\}\\]\\}, \\[\"example\\.com\"\\], " + - "\\{\"globalPrivacyControlValue\":true,\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\},\"sessionKey\":\"5678\"," + + "\\{\"globalPrivacyControlValue\":true,\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\},\"locale\":\"en\"," + + "\"sessionKey\":\"5678\"," + "\"desktopModeEnabled\":false,\"messageSecret\":\"([\\da-f]{32})\"," + "\"messageCallback\":\"([\\da-f]{32})\"," + "\"javascriptInterface\":\"([\\da-f]{32})\"\\}\\)$", @@ -173,7 +176,8 @@ class RealContentScopeScriptsTest { "\"config2\":\\{\"state\":\"disabled\"\\}\\}," + "\"unprotectedTemporary\":\\[" + "\\{\"domain\":\"example\\.com\",\"reason\":\"reason\"\\}\\]\\}, \\[\"example\\.com\"\\], " + - "\\{\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\},\"sessionKey\":\"5678\",\"desktopModeEnabled\":false," + + "\\{\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\},\"locale\":\"en\",\"sessionKey\":\"5678\"," + + "\"desktopModeEnabled\":false," + "\"messageSecret\":\"([\\da-f]{32})\"," + "\"messageCallback\":\"([\\da-f]{32})\"," + "\"javascriptInterface\":\"([\\da-f]{32})\"\\}\\)$", @@ -199,7 +203,8 @@ class RealContentScopeScriptsTest { "\"unprotectedTemporary\":\\[" + "\\{\"domain\":\"example\\.com\",\"reason\":\"reason\"\\}," + "\\{\"domain\":\"foo\\.com\",\"reason\":\"reason2\"\\}\\]\\}, \\[\"example\\.com\"\\], " + - "\\{\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\},\"sessionKey\":\"5678\",\"desktopModeEnabled\":true," + + "\\{\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\},\"locale\":\"en\"," + + "\"sessionKey\":\"5678\",\"desktopModeEnabled\":true," + "\"messageSecret\":\"([\\da-f]{32})\"," + "\"messageCallback\":\"([\\da-f]{32})\"," + "\"javascriptInterface\":\"([\\da-f]{32})\"\\}\\)$", @@ -279,7 +284,8 @@ class RealContentScopeScriptsTest { "\"unprotectedTemporary\":\\[" + "\\{\"domain\":\"example\\.com\",\"reason\":\"reason\"\\}," + "\\{\"domain\":\"foo\\.com\",\"reason\":\"reason2\"\\}\\]\\}, \\[\"example\\.com\"\\], " + - "\\{\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\},\"sessionKey\":\"5678\",\"desktopModeEnabled\":false," + + "\\{\"versionNumber\":1234,\"platform\":\\{\"name\":\"android\"\\},\"locale\":\"en\"," + + "\"sessionKey\":\"5678\",\"desktopModeEnabled\":false," + "\"messageSecret\":\"([\\da-f]{32})\"," + "\"messageCallback\":\"([\\da-f]{32})\"," + "\"javascriptInterface\":\"([\\da-f]{32})\"\\}\\)$", diff --git a/duckplayer/duckplayer-api/.gitignore b/duckplayer/duckplayer-api/.gitignore new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/duckplayer/duckplayer-api/build.gradle b/duckplayer/duckplayer-api/build.gradle new file mode 100644 index 000000000000..9dea13c49d65 --- /dev/null +++ b/duckplayer/duckplayer-api/build.gradle @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' +} + +apply from: "$rootProject.projectDir/gradle/android-library.gradle" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation Kotlin.stdlib.jdk7 + implementation AndroidX.core.ktx + implementation KotlinX.coroutines.core + implementation AndroidX.fragment.ktx + coreLibraryDesugaring Android.tools.desugarJdkLibs + implementation project(path: ':common-utils') + implementation project(':navigation-api') +} + + +android { + namespace "com.duckduckgo.duckplayer.api" + testOptions { + unitTests { + includeAndroidResources = true + } + } + compileOptions { + coreLibraryDesugaringEnabled = true + } +} diff --git a/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayer.kt b/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayer.kt new file mode 100644 index 000000000000..e0341f35986a --- /dev/null +++ b/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayer.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.api + +import android.content.res.Configuration +import android.net.Uri +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import androidx.fragment.app.FragmentManager +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled +import kotlinx.coroutines.flow.Flow + +/** + * DuckPlayer interface provides a set of methods for interacting with the DuckPlayer. + */ +interface DuckPlayer { + + /** + * Retrieves the current state of the DuckPlayer. + * + * This method is used to check the current state of the DuckPlayer. The state can be one of the following: + * - ENABLED: The DuckPlayer is enabled and can be used. + * - DISABLED: The DuckPlayer is disabled and cannot be used. + * - DISABLED_WIH_HELP_LINK: The DuckPlayer is disabled and cannot be used, but a help link is provided for troubleshooting. + * + * @return The current state of the DuckPlayer as a DuckPlayerState enum. + */ + suspend fun getDuckPlayerState(): DuckPlayerState + + /** + * Sends a pixel with the given name and data. + * + * @param pixelName The name of the pixel. + * @param pixelData The data associated with the pixel. + */ + suspend fun sendDuckPlayerPixel(pixelName: String, pixelData: Map) + + /** + * Retrieves the user preferences. + * + * @return The user values. + */ + suspend fun getUserPreferences(): UserPreferences + + /** + * Checks if the DuckPlayer overlay should be hidden after navigating back from Duck Player + * + * @return True if the overlay should be hidden, false otherwise. + */ + fun shouldHideDuckPlayerOverlay(): Boolean + + /** + * Notifies the DuckPlayer that the overlay was hidden after navigating back from Duck Player + */ + fun duckPlayerOverlayHidden() + + /** + * Notifies the DuckPlayer that the user navigated to YouTube successfully, so subsequent requests would redirect to Duck Player + */ + fun duckPlayerNavigatedToYoutube() + + /** + * Retrieves a flow of user preferences. + * + * @return The flow user preferences. + */ + fun observeUserPreferences(): Flow + + /** + * Sets the user preferences. + * + * @param overlayInteracted A boolean indicating whether the overlay was interacted with. + * @param privatePlayerMode The mode of the private player. + */ + suspend fun setUserPreferences(overlayInteracted: Boolean, privatePlayerMode: String) + + /** + * Creates a DuckPlayer URI from a YouTube no-cookie URI. + * + * @param uri The YouTube no-cookie URI. + * @return The DuckPlayer URI. + */ + suspend fun createDuckPlayerUriFromYoutubeNoCookie(uri: Uri): String? + + /** + * Checks if a string is a DuckPlayer URI. + * + * @param uri The string to check. + * @return True if the string is a DuckPlayer URI, false otherwise. + */ + fun isDuckPlayerUri(uri: String): Boolean + + /** + * Creates a YouTube URI from a DuckPlayer URI. + * @param uri The DuckPlayer URI. + * @return The YouTube URI. + */ + suspend fun createYoutubeWatchUrlFromDuckPlayer(uri: Uri): String? + + /** + * Checks if a URI is a simulated YouTube no-cookie URI. + * + * @param uri The URI to check. + * @return True if the URI is a YouTube no-cookie URI, false otherwise. + */ + suspend fun isSimulatedYoutubeNoCookie(uri: Uri): Boolean + + /** + * Checks if a URI is a YouTube watch URL. + * + * @param uri The URI to check. + * @return True if the URI is a YouTube no-cookie URI, false otherwise. + */ + suspend fun isYoutubeWatchUrl(uri: Uri): Boolean + + /** + * Checks if a URI is a YouTube URL. + * + * @param uri The URI to check. + * @return True if the URI is a YouTube no-cookie URI, false otherwise. + */ + fun isYouTubeUrl(uri: Uri): Boolean + + /** + * Checks if a string is a YouTube no-cookie URI. + * + * @param uri The string to check. + * @return True if the string is a YouTube no-cookie URI, false otherwise. + */ + suspend fun isSimulatedYoutubeNoCookie(uri: String): Boolean + + /** + * Notify Duck Player of a resource request and allow Duck Player to return the data. + * + * If the return value is null, it means Duck Player won't add any response data. + * Otherwise, the return response and data will be used. + */ + suspend fun intercept( + request: WebResourceRequest, + url: Uri, + webView: WebView, + ): WebResourceResponse? + + /** + * Shows the Duck Player Prime modal. + * + * @param configuration The configuration of the device. + * @param fragmentManager The fragment manager. + */ + fun showDuckPlayerPrimeModal(configuration: Configuration, fragmentManager: FragmentManager, fromDuckPlayerPage: Boolean) + + /** + * Checks whether a URL will trigger Duck Player loading based on URL and user settings + * + * @param destinationUrl The destination URL. + * @return True if the URL should launch Duck Player, false otherwise. + */ + suspend fun willNavigateToDuckPlayer(destinationUrl: Uri): Boolean + + /** + * Data class representing user preferences for Duck Player. + * + * @property overlayInteracted A boolean indicating whether the overlay was interacted with. + * @property privatePlayerMode The mode of the private player. [Enabled], [AlwaysAsk], or [Disabled] + */ + data class UserPreferences( + val overlayInteracted: Boolean, + val privatePlayerMode: PrivatePlayerMode, + ) + + enum class DuckPlayerState { + ENABLED, + DISABLED, + DISABLED_WIH_HELP_LINK, + } +} diff --git a/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayerFeatureName.kt b/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayerFeatureName.kt new file mode 100644 index 000000000000..732cd7625f69 --- /dev/null +++ b/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayerFeatureName.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2022 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.api + +/** List of [DuckPlayerFeatureName] that belong to the DuckPlayer feature */ +enum class DuckPlayerFeatureName(val value: String) { + DuckPlayer("duckPlayer"), +} diff --git a/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayerSettingsScreens.kt b/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayerSettingsScreens.kt new file mode 100644 index 000000000000..886fcdb528c4 --- /dev/null +++ b/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayerSettingsScreens.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.api + +import com.duckduckgo.navigation.api.GlobalActivityStarter + +/** + * Use this model to launch the Duck Player Settings screen + */ +object DuckPlayerSettingsNoParams : GlobalActivityStarter.ActivityParams diff --git a/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/PrivatePlayerMode.kt b/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/PrivatePlayerMode.kt new file mode 100644 index 000000000000..9ec15c22f7ad --- /dev/null +++ b/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/PrivatePlayerMode.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.api + +sealed class PrivatePlayerMode { + abstract val value: String + + data object Enabled : PrivatePlayerMode() { + override val value: String = "enabled" + } + + data object AlwaysAsk : PrivatePlayerMode() { + override val value: String = "alwaysAsk" + } + + data object Disabled : PrivatePlayerMode() { + override val value: String = "disabled" + } +} diff --git a/duckplayer/duckplayer-impl/build.gradle b/duckplayer/duckplayer-impl/build.gradle new file mode 100644 index 000000000000..58806aa61260 --- /dev/null +++ b/duckplayer/duckplayer-impl/build.gradle @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'com.squareup.anvil' + id 'com.google.devtools.ksp' version "$ksp_version" +} + +apply from: "$rootProject.projectDir/gradle/android-library.gradle" + +dependencies { + implementation project(":duckplayer-api") + implementation project(':feature-toggles-api') + implementation project(':common-utils') + implementation project(':browser-api') + + anvil project(path: ':anvil-compiler') + implementation project(path: ':anvil-annotations') + implementation project(path: ':di') + implementation project(':common-ui') + implementation project(':content-scope-scripts-api') + implementation project(':app-build-config-api') + implementation project(':js-messaging-api') + implementation project(':navigation-api') + implementation project(':remote-messaging-api') + implementation project(':settings-api') + implementation project(':statistics-api') + api AndroidX.dataStore.preferences + + + ksp AndroidX.room.compiler + + implementation AndroidX.appCompat + implementation KotlinX.coroutines.android + implementation KotlinX.coroutines.core + implementation AndroidX.constraintLayout + implementation AndroidX.core.ktx + implementation AndroidX.lifecycle.runtime.ktx + implementation AndroidX.lifecycle.viewModelKtx + implementation Google.android.material + implementation Google.dagger + implementation JakeWharton.timber + + implementation "com.airbnb.android:lottie:_" + implementation "com.squareup.logcat:logcat:_" + + testImplementation Testing.junit4 + testImplementation "org.mockito.kotlin:mockito-kotlin:_" + testImplementation project(path: ':common-test') + testImplementation "androidx.test.ext:junit-ktx:_" + testImplementation CashApp.turbine + testImplementation Testing.robolectric + testImplementation(KotlinX.coroutines.test) { + // https://github.com/Kotlin/kotlinx.coroutines/issues/2023 + // conflicts with mockito due to direct inclusion of byte buddy + exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug" + } + + coreLibraryDesugaring Android.tools.desugarJdkLibs +} + +android { + namespace "com.duckduckgo.duckplayer.impl" + anvil { + generateDaggerFactories = true // default is false + } + testOptions { + unitTests { + includeAndroidResources = true + } + } + compileOptions { + coreLibraryDesugaringEnabled = true + } +} + diff --git a/duckplayer/duckplayer-impl/src/main/AndroidManifest.xml b/duckplayer/duckplayer-impl/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..819a5674cbe8 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerContentScopeConfigPlugin.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerContentScopeConfigPlugin.kt new file mode 100644 index 000000000000..e19ce5fca4d2 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerContentScopeConfigPlugin.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.appbuildconfig.api.isInternalBuild +import com.duckduckgo.contentscopescripts.api.ContentScopeConfigPlugin +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED +import com.duckduckgo.duckplayer.api.DuckPlayerFeatureName +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject +import kotlinx.coroutines.runBlocking +import org.json.JSONObject + +@ContributesMultibinding(AppScope::class) +class DuckPlayerContentScopeConfigPlugin @Inject constructor( + private val duckPlayerFeatureRepository: DuckPlayerFeatureRepository, + private val appBuildConfig: AppBuildConfig, + private val duckPlayer: DuckPlayer, +) : ContentScopeConfigPlugin { + + override fun config(): String { + val featureName = DuckPlayerFeatureName.DuckPlayer.value + + val config = duckPlayerFeatureRepository.getDuckPlayerRemoteConfigJson().let { jsonString -> + if (appBuildConfig.isInternalBuild() && runBlocking { duckPlayer.getDuckPlayerState() == ENABLED }) { + runCatching { + JSONObject(jsonString).takeIf { it.getString("state") == "internal" }?.apply { + put("state", "enabled") + }?.toString() ?: jsonString + }.getOrDefault(jsonString) + } else { + jsonString + } + } + + return "\"$featureName\":$config" + } + + override fun preferences(): String? { + return null + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerDataStore.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerDataStore.kt new file mode 100644 index 000000000000..14227f3c917a --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerDataStore.kt @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2022 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckplayer.impl.SharedPreferencesDuckPlayerDataStore.Keys.DUCK_PLAYER_DISABLED_HELP_PAGE +import com.duckduckgo.duckplayer.impl.SharedPreferencesDuckPlayerDataStore.Keys.DUCK_PLAYER_RC +import com.duckduckgo.duckplayer.impl.SharedPreferencesDuckPlayerDataStore.Keys.DUCK_PLAYER_USER_ONBOARDED +import com.duckduckgo.duckplayer.impl.SharedPreferencesDuckPlayerDataStore.Keys.DUCK_PLAYER_YOUTUBE_PATH +import com.duckduckgo.duckplayer.impl.SharedPreferencesDuckPlayerDataStore.Keys.DUCK_PLAYER_YOUTUBE_REFERRER_HEADERS +import com.duckduckgo.duckplayer.impl.SharedPreferencesDuckPlayerDataStore.Keys.OVERLAY_INTERACTED +import com.duckduckgo.duckplayer.impl.SharedPreferencesDuckPlayerDataStore.Keys.PRIVATE_PLAYER_MODE +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +interface DuckPlayerDataStore { + suspend fun getDuckPlayerRemoteConfigJson(): String + + suspend fun setDuckPlayerRemoteConfigJson(value: String) + + suspend fun getOverlayInteracted(): Boolean + + fun observeOverlayInteracted(): Flow + + suspend fun setOverlayInteracted(value: Boolean) + + suspend fun getPrivatePlayerMode(): String + + fun observePrivatePlayerMode(): Flow + + suspend fun setPrivatePlayerMode(value: String) + + suspend fun getDuckPlayerDisabledHelpPageLink(): String? + + suspend fun storeDuckPlayerDisabledHelpPageLink(duckPlayerDisabledHelpPageLink: String?) + + suspend fun getYouTubeWatchPath(): String + + suspend fun storeYouTubeWatchPath(youtubePath: String) + + suspend fun getYoutubeEmbedUrl(): String + + suspend fun storeYoutubeEmbedUrl(embedUrl: String) + + suspend fun getYouTubeUrl(): String + + suspend fun storeYouTubeUrl(youtubeUrl: String) + + suspend fun getYouTubeVideoIDQueryParam(): String + + suspend fun storeYouTubeVideoIDQueryParam(youtubeVideoIDQueryParams: String) + + suspend fun getYouTubeReferrerQueryParams(): List + + suspend fun storeYouTubeReferrerQueryParams(youtubeReferrerQueryParams: List) + + suspend fun getYouTubeReferrerHeaders(): List + + suspend fun storeYouTubeReferrerHeaders(youtubeReferrerHeaders: List) + + suspend fun setUserOnboarded() + + suspend fun getUserOnboarded(): Boolean +} + +@ContributesBinding(AppScope::class) +class SharedPreferencesDuckPlayerDataStore @Inject constructor( + @DuckPlayer private val store: DataStore, +) : DuckPlayerDataStore { + + private object Keys { + val OVERLAY_INTERACTED = booleanPreferencesKey(name = "OVERLAY_INTERACTED") + val DUCK_PLAYER_RC = stringPreferencesKey(name = "DUCK_PLAYER_RC") + val PRIVATE_PLAYER_MODE = stringPreferencesKey(name = "PRIVATE_PLAYER_MODE") + val DUCK_PLAYER_DISABLED_HELP_PAGE = stringPreferencesKey(name = "DUCK_PLAYER_DISABLED_HELP_PAGE") + val DUCK_PLAYER_YOUTUBE_PATH = stringPreferencesKey(name = "DUCK_PLAYER_YOUTUBE_PATH") + val DUCK_PLAYER_YOUTUBE_REFERRER_HEADERS = stringSetPreferencesKey(name = "DUCK_PLAYER_YOUTUBE_REFERRER_HEADERS") + val DUCK_PLAYER_YOUTUBE_REFERRER_QUERY_PARAMS = stringSetPreferencesKey(name = "DUCK_PLAYER_YOUTUBE_REFERRER_QUERY_PARAMS") + val DUCK_PLAYER_YOUTUBE_URL = stringPreferencesKey(name = "DUCK_PLAYER_YOUTUBE_URL") + val DUCK_PLAYER_YOUTUBE_VIDEO_ID_QUERY_PARAMS = stringPreferencesKey(name = "DUCK_PLAYER_YOUTUBE_VIDEO_ID_QUERY_PARAMS") + val DUCK_PLAYER_YOUTUBE_EMBED_URL = stringPreferencesKey(name = "DUCK_PLAYER_YOUTUBE_EMBED_URL") + val DUCK_PLAYER_USER_ONBOARDED = booleanPreferencesKey(name = "DUCK_PLAYER_USER_ONBOARDED") + } + + private val overlayInteracted: Flow + get() = store.data + .map { prefs -> + prefs[OVERLAY_INTERACTED] ?: false + } + .distinctUntilChanged() + + private val duckPlayerRC: Flow + get() = store.data + .map { prefs -> + prefs[DUCK_PLAYER_RC] ?: "" + } + .distinctUntilChanged() + + private val privatePlayerMode: Flow + get() = store.data + .map { prefs -> + prefs[PRIVATE_PLAYER_MODE] ?: "ALWAYS_ASK" + } + .distinctUntilChanged() + + private val duckPlayerDisabledHelpPageLink: Flow + get() = store.data + .map { prefs -> + prefs[DUCK_PLAYER_DISABLED_HELP_PAGE] ?: "" + } + .distinctUntilChanged() + + private val youtubePath: Flow + get() = store.data + .map { prefs -> + prefs[DUCK_PLAYER_YOUTUBE_PATH] ?: "" + } + .distinctUntilChanged() + + private val youtubeReferrerHeaders: Flow> + get() = store.data + .map { prefs -> + prefs[DUCK_PLAYER_YOUTUBE_REFERRER_HEADERS]?.toList() ?: listOf() + } + .distinctUntilChanged() + + private val youtubeReferrerQueryParams: Flow> + get() = store.data + .map { prefs -> + prefs[Keys.DUCK_PLAYER_YOUTUBE_REFERRER_QUERY_PARAMS]?.toList() ?: listOf() + } + .distinctUntilChanged() + + private val youtubeUrl: Flow + get() = store.data + .map { prefs -> + prefs[Keys.DUCK_PLAYER_YOUTUBE_URL] ?: "" + } + .distinctUntilChanged() + + private val youtubeVideoIDQueryParams: Flow + get() = store.data + .map { prefs -> + prefs[Keys.DUCK_PLAYER_YOUTUBE_VIDEO_ID_QUERY_PARAMS] ?: "" + } + .distinctUntilChanged() + + private val youtubeEmbedUrl: Flow + get() = store.data + .map { prefs -> + prefs[Keys.DUCK_PLAYER_YOUTUBE_EMBED_URL] ?: "" + } + .distinctUntilChanged() + + private val duckPlayerUserOnboarded: Flow + get() = store.data + .map { prefs -> + prefs[Keys.DUCK_PLAYER_USER_ONBOARDED] ?: false + } + .distinctUntilChanged() + + override suspend fun getDuckPlayerRemoteConfigJson(): String { + return duckPlayerRC.first() + } + + override suspend fun setDuckPlayerRemoteConfigJson(value: String) { + store.edit { prefs -> prefs[DUCK_PLAYER_RC] = value } + } + + override suspend fun getOverlayInteracted(): Boolean { + return overlayInteracted.first() + } + + override fun observeOverlayInteracted(): Flow { + return overlayInteracted + } + + override suspend fun setOverlayInteracted(value: Boolean) { + store.edit { prefs -> prefs[OVERLAY_INTERACTED] = value } + } + + override suspend fun getPrivatePlayerMode(): String { + return privatePlayerMode.first() + } + + override fun observePrivatePlayerMode(): Flow { + return privatePlayerMode + } + + override suspend fun setPrivatePlayerMode(value: String) { + store.edit { prefs -> prefs[PRIVATE_PLAYER_MODE] = value } + } + + override suspend fun storeDuckPlayerDisabledHelpPageLink(duckPlayerDisabledHelpPageLink: String?) { + store.edit { prefs -> prefs[DUCK_PLAYER_DISABLED_HELP_PAGE] = duckPlayerDisabledHelpPageLink ?: "" } + } + + override suspend fun getDuckPlayerDisabledHelpPageLink(): String? { + return duckPlayerDisabledHelpPageLink.first().let { it.ifBlank { null } } + } + + override suspend fun storeYouTubeWatchPath(youtubePath: String) { + store.edit { prefs -> prefs[DUCK_PLAYER_YOUTUBE_PATH] = youtubePath } + } + + override suspend fun storeYouTubeReferrerHeaders(youtubeReferrerHeaders: List) { + store.edit { prefs -> prefs[DUCK_PLAYER_YOUTUBE_REFERRER_HEADERS] = youtubeReferrerHeaders.toSet() } + } + + override suspend fun getYouTubeReferrerHeaders(): List { + return youtubeReferrerHeaders.first() + } + + override suspend fun storeYouTubeReferrerQueryParams(youtubeReferrerQueryParams: List) { + store.edit { prefs -> prefs[Keys.DUCK_PLAYER_YOUTUBE_REFERRER_QUERY_PARAMS] = youtubeReferrerQueryParams.toSet() } + } + + override suspend fun getYouTubeReferrerQueryParams(): List { + return youtubeReferrerQueryParams.first() + } + + override suspend fun storeYouTubeUrl(youtubeUrl: String) { + store.edit { prefs -> prefs[Keys.DUCK_PLAYER_YOUTUBE_URL] = youtubeUrl } + } + + override suspend fun getYouTubeUrl(): String { + return youtubeUrl.first() + } + + override suspend fun storeYouTubeVideoIDQueryParam(youtubeVideoIDQueryParams: String) { + store.edit { prefs -> prefs[Keys.DUCK_PLAYER_YOUTUBE_VIDEO_ID_QUERY_PARAMS] = youtubeVideoIDQueryParams } + } + + override suspend fun getYouTubeVideoIDQueryParam(): String { + return youtubeVideoIDQueryParams.first() + } + + override suspend fun storeYoutubeEmbedUrl(embedUrl: String) { + store.edit { prefs -> prefs[Keys.DUCK_PLAYER_YOUTUBE_EMBED_URL] = embedUrl } + } + + override suspend fun getYoutubeEmbedUrl(): String { + return youtubeEmbedUrl.first() + } + + override suspend fun getYouTubeWatchPath(): String { + return youtubePath.first() + } + + override suspend fun setUserOnboarded() { + store.edit { prefs -> prefs[DUCK_PLAYER_USER_ONBOARDED] = true } + } + + override suspend fun getUserOnboarded(): Boolean { + return duckPlayerUserOnboarded.first() + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerDataStoreModule.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerDataStoreModule.kt new file mode 100644 index 000000000000..9fc0ef68bb11 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerDataStoreModule.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import javax.inject.Qualifier + +@ContributesTo(AppScope::class) +@Module +object DuckPlayerDataStoreModule { + + private val Context.duckPlayerDataStore: DataStore by preferencesDataStore( + name = "duck_player", + ) + + @Provides + @DuckPlayer + fun provideDuckPlayerDataStore(context: Context): DataStore = context.duckPlayerDataStore +} + +@Qualifier +internal annotation class DuckPlayer diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerEnabledRMFMatchingAttribute.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerEnabledRMFMatchingAttribute.kt new file mode 100644 index 000000000000..06ad6f88f37b --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerEnabledRMFMatchingAttribute.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled +import com.duckduckgo.remote.messaging.api.AttributeMatcherPlugin +import com.duckduckgo.remote.messaging.api.JsonMatchingAttribute +import com.duckduckgo.remote.messaging.api.JsonToMatchingAttributeMapper +import com.duckduckgo.remote.messaging.api.MatchingAttribute +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject + +@ContributesMultibinding( + scope = AppScope::class, + boundType = JsonToMatchingAttributeMapper::class, +) +@ContributesMultibinding( + scope = AppScope::class, + boundType = AttributeMatcherPlugin::class, +) +@SingleInstanceIn(AppScope::class) +class DuckPlayerEnabledRMFMatchingAttribute @Inject constructor( + private val duckPlayer: DuckPlayer, + private val duckPlayerFeature: DuckPlayerFeature, +) : JsonToMatchingAttributeMapper, AttributeMatcherPlugin { + override suspend fun evaluate(matchingAttribute: MatchingAttribute): Boolean? { + val duckPlayerEnabled = duckPlayerFeature.self().isEnabled() && duckPlayerFeature.enableDuckPlayer().isEnabled() && + duckPlayer.getUserPreferences().privatePlayerMode.let { it == AlwaysAsk || it == Enabled } + return when (matchingAttribute) { + is DuckPlayerEnabledMatchingAttribute -> duckPlayerEnabled == matchingAttribute.remoteValue + else -> null + } + } + + override fun map( + key: String, + jsonMatchingAttribute: JsonMatchingAttribute, + ): MatchingAttribute? { + return when (key) { + DuckPlayerEnabledMatchingAttribute.KEY -> { + jsonMatchingAttribute.value?.let { + DuckPlayerEnabledMatchingAttribute(jsonMatchingAttribute.value as Boolean) + } + } + else -> null + } + } +} + +internal data class DuckPlayerEnabledMatchingAttribute( + val remoteValue: Boolean, +) : MatchingAttribute { + companion object { + const val KEY = "duckPlayerEnabled" + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeature.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeature.kt new file mode 100644 index 000000000000..cba33e4eb068 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeature.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "duckPlayer", + settingsStore = DuckPlayerFatureSettingsStore::class, +) +/** + * This is the class that represents the duckPlayer feature flags + */ +interface DuckPlayerFeature { + /** + * @return `true` when the remote config has the global "duckPlayer" feature flag enabled + * If the remote feature is not present defaults to `false` + */ + @Toggle.DefaultValue(false) + fun self(): Toggle + + /** + * @return `true` when the remote config has the "enableDuckPlayer" feature flag enabled + * If the remote feature is not present defaults to `false` + */ + @Toggle.DefaultValue(false) + fun enableDuckPlayer(): Toggle +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeaturePlugin.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeaturePlugin.kt new file mode 100644 index 000000000000..4c2580e45f3d --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeaturePlugin.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckplayer.api.DuckPlayerFeatureName +import com.duckduckgo.privacy.config.api.PrivacyFeaturePlugin +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class DuckPlayerFeaturePlugin @Inject constructor( + private val duckPlayerFeatureRepository: DuckPlayerFeatureRepository, +) : PrivacyFeaturePlugin { + + override fun store(featureName: String, jsonString: String): Boolean { + if (featureName == this.featureName) { + duckPlayerFeatureRepository.setDuckPlayerRemoteConfigJson(jsonString) + return true + } + return false + } + + override val featureName: String = DuckPlayerFeatureName.DuckPlayer.value +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeatureRepository.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeatureRepository.kt new file mode 100644 index 000000000000..139a38e1a986 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeatureRepository.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckplayer.api.DuckPlayer.UserPreferences +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +interface DuckPlayerFeatureRepository { + fun getDuckPlayerRemoteConfigJson(): String + + fun setDuckPlayerRemoteConfigJson(jsonString: String) + + suspend fun getUserPreferences(): UserPreferences + + fun observeUserPreferences(): Flow + + suspend fun setUserPreferences(userPreferences: UserPreferences) + + suspend fun storeDuckPlayerDisabledHelpPageLink(duckPlayerDisabledHelpPageLink: String?) + + suspend fun getDuckPlayerDisabledHelpPageLink(): String? + + suspend fun storeYouTubePath(youtubePath: String) + + suspend fun storeYoutubeEmbedUrl(embedUrl: String) + + suspend fun storeYouTubeUrl(youtubeUrl: String) + + suspend fun storeYouTubeReferrerHeaders(youtubeReferrerHeaders: List) + + suspend fun storeYouTubeReferrerQueryParams(youtubeReferrerQueryParams: List) + + suspend fun storeYouTubeVideoIDQueryParam(youtubeVideoIDQueryParam: String) + + suspend fun getVideoIDQueryParam(): String + suspend fun getYouTubeReferrerQueryParams(): List + suspend fun getYouTubeReferrerHeaders(): List + suspend fun getYouTubeWatchPath(): String + suspend fun getYouTubeUrl(): String + suspend fun getYouTubeEmbedUrl(): String + suspend fun isOnboarded(): Boolean + suspend fun setUserOnboarded() +} + +@ContributesBinding(AppScope::class) +class RealDuckPlayerFeatureRepository @Inject constructor( + private val duckPlayerDataStore: DuckPlayerDataStore, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, +) : DuckPlayerFeatureRepository { + + private var duckPlayerRC = "" + + init { + loadToMemory() + } + + private fun loadToMemory() { + appCoroutineScope.launch(dispatcherProvider.io()) { + duckPlayerRC = + duckPlayerDataStore.getDuckPlayerRemoteConfigJson() + } + } + + override fun getDuckPlayerRemoteConfigJson(): String { + return duckPlayerRC + } + + override fun setDuckPlayerRemoteConfigJson(jsonString: String) { + appCoroutineScope.launch(dispatcherProvider.io()) { + duckPlayerDataStore.setDuckPlayerRemoteConfigJson(jsonString) + loadToMemory() + } + } + + override suspend fun setUserPreferences( + userPreferences: UserPreferences, + ) { + withContext(dispatcherProvider.io()) { + duckPlayerDataStore.setOverlayInteracted(userPreferences.overlayInteracted) + duckPlayerDataStore.setPrivatePlayerMode(userPreferences.privatePlayerMode.value) + } + } + + override fun observeUserPreferences(): Flow { + return duckPlayerDataStore.observePrivatePlayerMode() + .combine(duckPlayerDataStore.observeOverlayInteracted()) { privatePlayerMode, overlayInteracted -> + UserPreferences( + overlayInteracted = overlayInteracted, + privatePlayerMode = when (privatePlayerMode) { + Enabled.value -> Enabled + Disabled.value -> Disabled + else -> AlwaysAsk + }, + ) + } + } + + override suspend fun getUserPreferences(): UserPreferences { + return UserPreferences( + overlayInteracted = duckPlayerDataStore.getOverlayInteracted(), + privatePlayerMode = when (duckPlayerDataStore.getPrivatePlayerMode()) { + Enabled.value -> Enabled + Disabled.value -> Disabled + else -> AlwaysAsk + }, + ) + } + + override suspend fun storeDuckPlayerDisabledHelpPageLink(duckPlayerDisabledHelpPageLink: String?) { + duckPlayerDataStore.storeDuckPlayerDisabledHelpPageLink(duckPlayerDisabledHelpPageLink) + } + + override suspend fun getDuckPlayerDisabledHelpPageLink(): String? { + return duckPlayerDataStore.getDuckPlayerDisabledHelpPageLink() + } + + override suspend fun storeYouTubePath(youtubePath: String) { + duckPlayerDataStore.storeYouTubeWatchPath(youtubePath) + } + + override suspend fun storeYouTubeReferrerHeaders(youtubeReferrerHeaders: List) { + duckPlayerDataStore.storeYouTubeReferrerHeaders(youtubeReferrerHeaders) + } + + override suspend fun storeYouTubeReferrerQueryParams(youtubeReferrerQueryParams: List) { + duckPlayerDataStore.storeYouTubeReferrerQueryParams(youtubeReferrerQueryParams) + } + + override suspend fun storeYouTubeUrl(youtubeUrl: String) { + duckPlayerDataStore.storeYouTubeUrl(youtubeUrl) + } + + override suspend fun storeYouTubeVideoIDQueryParam(youtubeVideoIDQueryParam: String) { + duckPlayerDataStore.storeYouTubeVideoIDQueryParam(youtubeVideoIDQueryParam) + } + + override suspend fun storeYoutubeEmbedUrl(embedUrl: String) { + duckPlayerDataStore.storeYoutubeEmbedUrl(embedUrl) + } + + override suspend fun getVideoIDQueryParam(): String { + return duckPlayerDataStore.getYouTubeVideoIDQueryParam() + } + + override suspend fun getYouTubeReferrerQueryParams(): List { + return duckPlayerDataStore.getYouTubeReferrerQueryParams() + } + + override suspend fun getYouTubeReferrerHeaders(): List { + return duckPlayerDataStore.getYouTubeReferrerHeaders() + } + + override suspend fun getYouTubeWatchPath(): String { + return duckPlayerDataStore.getYouTubeWatchPath() + } + + override suspend fun getYouTubeUrl(): String { + return duckPlayerDataStore.getYouTubeUrl() + } + + override suspend fun getYouTubeEmbedUrl(): String { + return duckPlayerDataStore.getYoutubeEmbedUrl() + } + + override suspend fun isOnboarded(): Boolean { + return duckPlayerDataStore.getUserOnboarded() + } + + override suspend fun setUserOnboarded() { + duckPlayerDataStore.setUserOnboarded() + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeatureSettingsStore.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeatureSettingsStore.kt new file mode 100644 index 000000000000..8369d7c55e00 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeatureSettingsStore.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2022 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.FeatureSettings +import com.duckduckgo.feature.toggles.api.RemoteFeatureStoreNamed +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.Json +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@ContributesBinding(AppScope::class) +@RemoteFeatureStoreNamed(DuckPlayerFeature::class) +class DuckPlayerFatureSettingsStore @Inject constructor( + private val duckPlayerFeatureRepository: DuckPlayerFeatureRepository, + @AppCoroutineScope private val coroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, +) : FeatureSettings.Store { + + private val jsonAdapter by lazy { buildJsonAdapter() } + + override fun store(jsonString: String) { + coroutineScope.launch(dispatcherProvider.io()) { + try { + jsonAdapter.fromJson(jsonString)?.let { + duckPlayerFeatureRepository.storeDuckPlayerDisabledHelpPageLink(it.duckPlayerDisabledHelpPageLink) + duckPlayerFeatureRepository.storeYouTubePath(it.youtubePath) + duckPlayerFeatureRepository.storeYoutubeEmbedUrl(it.youtubeEmbedUrl) + duckPlayerFeatureRepository.storeYouTubeUrl(it.youTubeUrl) + duckPlayerFeatureRepository.storeYouTubeReferrerHeaders(it.youTubeReferrerHeaders) + duckPlayerFeatureRepository.storeYouTubeReferrerQueryParams(it.youTubeReferrerQueryParams) + duckPlayerFeatureRepository.storeYouTubeVideoIDQueryParam(it.youTubeVideoIDQueryParam) + } ?: run { + // If no help link page present, we clear the stored one + duckPlayerFeatureRepository.storeDuckPlayerDisabledHelpPageLink(null) + } + } catch (e: Exception) { + Timber.d("Failed to store DuckPlayer settings", e) + // If no help link page present, we clear the stored one + duckPlayerFeatureRepository.storeDuckPlayerDisabledHelpPageLink(null) + } + } + } + + private fun buildJsonAdapter(): JsonAdapter { + val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + return moshi.adapter(DuckPlayerSetting::class.java) + } +} + +@JsonClass(generateAdapter = true) +data class DuckPlayerSetting( + @field:Json(name = "duckPlayerDisabledHelpPageLink") + val duckPlayerDisabledHelpPageLink: String?, + @field:Json(name = "youtubePath") + val youtubePath: String, + @field:Json(name = "youtubeEmbedUrl") + val youtubeEmbedUrl: String, + @field:Json(name = "youTubeUrl") + val youTubeUrl: String, + @field:Json(name = "youTubeReferrerHeaders") + val youTubeReferrerHeaders: List, + @field:Json(name = "youTubeReferrerQueryParams") + val youTubeReferrerQueryParams: List, + @field:Json(name = "youTubeVideoIDQueryParams") + val youTubeVideoIDQueryParam: String, + +) diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerLocalFilesPath.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerLocalFilesPath.kt new file mode 100644 index 000000000000..6d37816dc4a5 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerLocalFilesPath.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import android.content.res.AssetManager +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import java.io.IOException +import javax.inject.Inject + +interface DuckPlayerLocalFilesPath { + val assetsPath: List +} + +@ContributesBinding(AppScope::class) +class RealDuckPlayerLocalFilesPath @Inject constructor(private val assetManager: AssetManager) : DuckPlayerLocalFilesPath { + + override val assetsPath: List = getAllAssetFilePaths("duckplayer") + + private fun getAllAssetFilePaths(directory: String): List { + val filePaths = mutableListOf() + val files = assetManager.list(directory) ?: return emptyList() + + files.forEach { + val fullPath = "$directory/$it" + try { + assetManager.open(fullPath) + filePaths.add(fullPath.removePrefix("duckplayer/")) + } catch (e: IOException) { + filePaths.addAll(getAllAssetFilePaths(fullPath)) + } + } + return filePaths + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerOnboardedRMFMatchingAttribute.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerOnboardedRMFMatchingAttribute.kt new file mode 100644 index 000000000000..a94db5bae23d --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerOnboardedRMFMatchingAttribute.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.remote.messaging.api.AttributeMatcherPlugin +import com.duckduckgo.remote.messaging.api.JsonMatchingAttribute +import com.duckduckgo.remote.messaging.api.JsonToMatchingAttributeMapper +import com.duckduckgo.remote.messaging.api.MatchingAttribute +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject + +@ContributesMultibinding( + scope = AppScope::class, + boundType = JsonToMatchingAttributeMapper::class, +) +@ContributesMultibinding( + scope = AppScope::class, + boundType = AttributeMatcherPlugin::class, +) +@SingleInstanceIn(AppScope::class) +class DuckPlayerOnboardedRMFMatchingAttribute @Inject constructor( + private val duckPlayerFeatureRepository: DuckPlayerFeatureRepository, +) : JsonToMatchingAttributeMapper, AttributeMatcherPlugin { + override suspend fun evaluate(matchingAttribute: MatchingAttribute): Boolean? { + return when (matchingAttribute) { + is DuckPlayerOnboardedMatchingAttribute -> duckPlayerFeatureRepository.isOnboarded() == matchingAttribute.remoteValue + else -> null + } + } + + override fun map( + key: String, + jsonMatchingAttribute: JsonMatchingAttribute, + ): MatchingAttribute? { + return when (key) { + DuckPlayerOnboardedMatchingAttribute.KEY -> { + jsonMatchingAttribute.value?.let { + DuckPlayerOnboardedMatchingAttribute(jsonMatchingAttribute.value as Boolean) + } + } + else -> null + } + } +} + +internal data class DuckPlayerOnboardedMatchingAttribute( + val remoteValue: Boolean, +) : MatchingAttribute { + companion object { + const val KEY = "duckPlayerOnboarded" + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerPixelName.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerPixelName.kt new file mode 100644 index 000000000000..60759dc26693 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerPixelName.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import com.duckduckgo.app.statistics.pixels.Pixel + +enum class DuckPlayerPixelName(override val pixelName: String) : Pixel.PixelName { + DUCK_PLAYER_OVERLAY_YOUTUBE_IMPRESSIONS("duck-player_overlay_youtube_impressions"), + DUCK_PLAYER_VIEW_FROM_YOUTUBE_MAIN_OVERLAY("duck-player_view-from_youtube_main-overlay"), + DUCK_PLAYER_OVERLAY_YOUTUBE_WATCH_HERE("duck-player_overlay_youtube_watch_here"), + DUCK_PLAYER_WATCH_ON_YOUTUBE("duck-player_watch_on_youtube"), + DUCK_PLAYER_DAILY_UNIQUE_VIEW("duck-player_daily-unique-view"), + DUCK_PLAYER_VIEW_FROM_YOUTUBE_AUTOMATIC("duck-player_view-from_youtube_automatic"), + DUCK_PLAYER_VIEW_FROM_OTHER("duck-player_view-from_other"), + DUCK_PLAYER_VIEW_FROM_SERP("duck-player_view-from_serp"), + DUCK_PLAYER_SETTINGS_ALWAYS_SETTINGS("duck-player_setting_always_settings"), + DUCK_PLAYER_SETTINGS_BACK_TO_DEFAULT("duck-player_setting_back-to-default"), + DUCK_PLAYER_SETTINGS_NEVER_SETTINGS("duck-player_setting_never_settings"), + DUCK_PLAYER_SETTINGS_PRESSED("duck_player_setting_pressed"), +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerScriptsJsMessaging.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerScriptsJsMessaging.kt new file mode 100644 index 000000000000..157d19fd7f2e --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerScriptsJsMessaging.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import android.webkit.JavascriptInterface +import android.webkit.WebView +import androidx.core.net.toUri +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.extensions.toTldPlusOne +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.js.messaging.api.JsCallbackData +import com.duckduckgo.js.messaging.api.JsMessage +import com.duckduckgo.js.messaging.api.JsMessageCallback +import com.duckduckgo.js.messaging.api.JsMessageHandler +import com.duckduckgo.js.messaging.api.JsMessageHelper +import com.duckduckgo.js.messaging.api.JsMessaging +import com.duckduckgo.js.messaging.api.JsRequestResponse +import com.duckduckgo.js.messaging.api.SubscriptionEvent +import com.duckduckgo.js.messaging.api.SubscriptionEventData +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.Moshi +import javax.inject.Inject +import javax.inject.Named +import kotlinx.coroutines.runBlocking +import logcat.logcat + +@ContributesBinding(ActivityScope::class) +@Named("DuckPlayer") +class DuckPlayerScriptsJsMessaging @Inject constructor( + private val jsMessageHelper: JsMessageHelper, + private val dispatcherProvider: DispatcherProvider, +) : JsMessaging { + private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build() + + private lateinit var webView: WebView + private lateinit var jsMessageCallback: JsMessageCallback + + private val handlers = listOf( + DuckPlayerPageHandler(), + ) + + @JavascriptInterface + override fun process(message: String, secret: String) { + try { + val adapter = moshi.adapter(JsMessage::class.java) + val jsMessage = adapter.fromJson(message) + val url = runBlocking(dispatcherProvider.main()) { + webView.url?.toUri()?.host + } + jsMessage?.let { + logcat { jsMessage.toString() } + if (this.secret == secret && context == jsMessage.context && isUrlAllowed(url)) { + handlers.firstOrNull { + it.methods.contains(jsMessage.method) && it.featureName == jsMessage.featureName + }?.process(jsMessage, secret, jsMessageCallback) + } + } + } catch (e: Exception) { + logcat { "Exception is ${e.message}" } + } + } + + override fun register(webView: WebView, jsMessageCallback: JsMessageCallback?) { + if (jsMessageCallback == null) throw Exception("Callback cannot be null") + this.webView = webView + this.jsMessageCallback = jsMessageCallback + this.webView.addJavascriptInterface(this, context) + } + + override fun sendSubscriptionEvent(subscriptionEventData: SubscriptionEventData) { + val subscriptionEvent = SubscriptionEvent( + context, + subscriptionEventData.featureName, + subscriptionEventData.subscriptionName, + subscriptionEventData.params, + ) + jsMessageHelper.sendSubscriptionEvent(subscriptionEvent, callbackName, secret, webView) + } + + override fun onResponse(response: JsCallbackData) { + val jsResponse = JsRequestResponse.Success( + context = context, + featureName = response.featureName, + method = response.method, + id = response.id, + result = response.params, + ) + + jsMessageHelper.sendJsResponse(jsResponse, callbackName, secret, webView) + } + + override val context: String = "specialPages" + override val callbackName: String = "messageCallback" + override val secret: String = "duckduckgo-android-messaging-secret" + override val allowedDomains: List = listOf() + + private fun isUrlAllowed(url: String?): Boolean { + if (allowedDomains.isEmpty()) return true + val eTld = url?.toTldPlusOne() ?: return false + return (allowedDomains.contains(eTld)) + } + + inner class DuckPlayerPageHandler : JsMessageHandler { + override fun process(jsMessage: JsMessage, secret: String, jsMessageCallback: JsMessageCallback?) { + jsMessageCallback?.process(featureName, jsMessage.method, jsMessage.id ?: "", jsMessage.params) + } + + override val allowedDomains: List = emptyList() + override val featureName: String = "duckPlayerPage" + override val methods: List = listOf( + "initialSetup", + "openSettings", + "openInfo", + "setUserValues", + "reportPageException", + "reportInitException", + ) + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettings.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettings.kt new file mode 100644 index 000000000000..d9f94e14745e --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettings.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import android.content.Context +import android.view.View +import com.duckduckgo.anvil.annotations.PriorityKey +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.common.ui.view.listitem.OneLineListItem +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckplayer.api.DuckPlayerSettingsNoParams +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_SETTINGS_PRESSED +import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.settings.api.DuckPlayerSettingsPlugin +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(ActivityScope::class) +@PriorityKey(100) +class DuckPlayerSettingsTitle @Inject constructor( + private val globalActivityStarter: GlobalActivityStarter, + private val pixel: Pixel, +) : DuckPlayerSettingsPlugin { + override fun getView(context: Context): View { + return OneLineListItem(context).apply { + setPrimaryText(context.getString(R.string.duck_player_setting_title)) + setOnClickListener { + pixel.fire(DUCK_PLAYER_SETTINGS_PRESSED) + globalActivityStarter.start(this.context, DuckPlayerSettingsNoParams, null) + } + } + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettingsActivity.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettingsActivity.kt new file mode 100644 index 000000000000..7549457853c5 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettingsActivity.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import android.os.Bundle +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.ContributeToActivityStarter +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.browser.api.ui.BrowserScreens.WebViewActivityWithParams +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.view.addClickableLink +import com.duckduckgo.common.ui.view.dialog.RadioListAlertDialogBuilder +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckplayer.api.DuckPlayerSettingsNoParams +import com.duckduckgo.duckplayer.api.PrivatePlayerMode +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled +import com.duckduckgo.duckplayer.impl.DuckPlayerSettingsViewModel.ViewState +import com.duckduckgo.duckplayer.impl.databinding.ActivityDuckPlayerSettingsBinding +import com.duckduckgo.navigation.api.GlobalActivityStarter +import javax.inject.Inject +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@InjectWith(ActivityScope::class) +@ContributeToActivityStarter(DuckPlayerSettingsNoParams::class) +class DuckPlayerSettingsActivity : DuckDuckGoActivity() { + + private val viewModel: DuckPlayerSettingsViewModel by bindViewModel() + private val binding: ActivityDuckPlayerSettingsBinding by viewBinding() + + @Inject + lateinit var globalActivityStarter: GlobalActivityStarter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(binding.root) + + binding.duckPlayerSettingsText.addClickableLink( + annotation = "learn_more_link", + textSequence = getText(R.string.duck_player_settings_activity_description), + onClick = { viewModel.duckPlayerLearnMoreClicked() }, + ) + + setupToolbar(binding.includeToolbar.toolbar) + + configureUiEventHandlers() + observeViewModel() + } + + private fun configureUiEventHandlers() { + binding.duckPlayerModeSelector.setClickListener { + viewModel.duckPlayerModeSelectorClicked() + } + binding.duckPlayerDisabledLearnMoreButton.setOnClickListener { + viewModel.onContingencyLearnMoreClicked() + } + } + + private fun observeViewModel() { + viewModel.viewState + .flowWithLifecycle(lifecycle, Lifecycle.State.CREATED) + .onEach { viewState -> renderViewState(viewState) } + .launchIn(lifecycleScope) + + viewModel.commands + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { processCommand(it) } + .launchIn(lifecycleScope) + } + + private fun processCommand(it: DuckPlayerSettingsViewModel.Command) { + when (it) { + is DuckPlayerSettingsViewModel.Command.OpenPlayerModeSelector -> { + launchPlayerModeSelector(it.current) + } + is DuckPlayerSettingsViewModel.Command.OpenLearnMore -> { + globalActivityStarter.start( + this, + WebViewActivityWithParams( + url = it.learnMoreLink, + screenTitle = getString(R.string.duck_player_setting_title), + ), + ) + } + is DuckPlayerSettingsViewModel.Command.LaunchDuckPlayerContingencyPage -> { + globalActivityStarter.start( + this, + WebViewActivityWithParams( + url = it.helpPageLink, + screenTitle = getString(R.string.duck_player_unavailable), + ), + ) + } + } + } + + private fun launchPlayerModeSelector(privatePlayerMode: PrivatePlayerMode) { + val options = + listOf( + Pair(Enabled, R.string.duck_player_mode_always), + Pair(AlwaysAsk, R.string.duck_player_mode_always_ask), + Pair(Disabled, R.string.duck_player_mode_never), + ) + RadioListAlertDialogBuilder(this) + .setTitle(getString(R.string.duck_player_mode_dialog_title)) + .setOptions( + options.map { it.second }, + options.map { it.first }.indexOf(privatePlayerMode) + 1, + ) + .setPositiveButton(R.string.duck_player_save) + .setNegativeButton(R.string.duck_player_cancel) + .addEventListener( + object : RadioListAlertDialogBuilder.EventListener() { + override fun onPositiveButtonClicked(selectedItem: Int) { + val selectedPlayerMode = + when (selectedItem) { + 1 -> Enabled + 2 -> AlwaysAsk + else -> Disabled + } + viewModel.onPlayerModeSelected(selectedPlayerMode) + } + }, + ) + .show() + } + + private fun renderViewState(viewState: ViewState) { + when (viewState) { + is ViewState.Enabled -> { + binding.duckPlayerModeSelector.isEnabled = true + binding.duckPlayerDisabledSection.isVisible = false + setDuckPlayerSectionVisibility(true) + } + is ViewState.DisabledWithHelpLink -> { + binding.duckPlayerModeSelector.isEnabled = false + binding.duckPlayerDisabledSection.isVisible = true + setDuckPlayerSectionVisibility(false) + } + } + binding.duckPlayerModeSelector.setSecondaryText( + when (viewState.privatePlayerMode) { + Enabled -> getString(R.string.duck_player_mode_always) + Disabled -> getString(R.string.duck_player_mode_never) + else -> getString(R.string.duck_player_mode_always_ask) + }, + ) + } + + private fun setDuckPlayerSectionVisibility(isVisible: Boolean) { + binding.duckPlayerSettingsTitle.isVisible = isVisible + binding.duckPlayerSettingsIcon.isVisible = isVisible + binding.duckPlayerSettingsText.isVisible = isVisible + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettingsViewModel.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettingsViewModel.kt new file mode 100644 index 000000000000..d4e430440614 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettingsViewModel.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED_WIH_HELP_LINK +import com.duckduckgo.duckplayer.api.PrivatePlayerMode +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_SETTINGS_ALWAYS_SETTINGS +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_SETTINGS_BACK_TO_DEFAULT +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_SETTINGS_NEVER_SETTINGS +import com.duckduckgo.duckplayer.impl.DuckPlayerSettingsViewModel.Command.OpenLearnMore +import com.duckduckgo.duckplayer.impl.DuckPlayerSettingsViewModel.Command.OpenPlayerModeSelector +import com.duckduckgo.duckplayer.impl.DuckPlayerSettingsViewModel.ViewState.DisabledWithHelpLink +import com.duckduckgo.duckplayer.impl.DuckPlayerSettingsViewModel.ViewState.Enabled +import javax.inject.Inject +import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +@ContributesViewModel(ActivityScope::class) +class DuckPlayerSettingsViewModel @Inject constructor( + private val duckPlayer: DuckPlayer, + private val duckPlayerFeatureRepository: DuckPlayerFeatureRepository, + private val pixel: Pixel, +) : ViewModel() { + + private val commandChannel = Channel(capacity = 1, onBufferOverflow = DROP_OLDEST) + val commands = commandChannel.receiveAsFlow() + + val viewState: StateFlow = duckPlayer.observeUserPreferences() + .map { + val helpPageLink = duckPlayerFeatureRepository.getDuckPlayerDisabledHelpPageLink() + if (duckPlayer.getDuckPlayerState() == DISABLED_WIH_HELP_LINK && helpPageLink?.isNotEmpty() == true) { + DisabledWithHelpLink(it.privatePlayerMode, helpPageLink) + } else { + Enabled(it.privatePlayerMode) + } + } + .stateIn( + viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = runBlocking { Enabled(duckPlayer.getUserPreferences().privatePlayerMode) }, + ) + + sealed class Command { + data class OpenPlayerModeSelector(val current: PrivatePlayerMode) : Command() + data class OpenLearnMore(val learnMoreLink: String) : Command() + data class LaunchDuckPlayerContingencyPage(val helpPageLink: String) : Command() + } + + sealed class ViewState(open val privatePlayerMode: PrivatePlayerMode = AlwaysAsk) { + data class Enabled(override val privatePlayerMode: PrivatePlayerMode) : ViewState(privatePlayerMode) + data class DisabledWithHelpLink(override val privatePlayerMode: PrivatePlayerMode, val helpPageLink: String) : ViewState(privatePlayerMode) + } + fun duckPlayerModeSelectorClicked() { + viewModelScope.launch { + commandChannel.send(OpenPlayerModeSelector(duckPlayer.getUserPreferences().privatePlayerMode)) + } + } + + fun onPlayerModeSelected(selectedPlayerMode: PrivatePlayerMode) { + viewModelScope.launch { + duckPlayer.setUserPreferences(overlayInteracted = false, privatePlayerMode = selectedPlayerMode.value).also { + val pixelName = when (selectedPlayerMode) { + is PrivatePlayerMode.Enabled -> { DUCK_PLAYER_SETTINGS_ALWAYS_SETTINGS } + is AlwaysAsk -> { DUCK_PLAYER_SETTINGS_BACK_TO_DEFAULT } + is Disabled -> { DUCK_PLAYER_SETTINGS_NEVER_SETTINGS } + } + pixel.fire(pixelName) + } + } + } + + fun duckPlayerLearnMoreClicked() { + viewModelScope.launch { + commandChannel.send(OpenLearnMore("https://duckduckgo.com/duckduckgo-help-pages/duck-player/")) + } + } + + fun onContingencyLearnMoreClicked() { + viewModelScope.launch { + duckPlayerFeatureRepository.getDuckPlayerDisabledHelpPageLink()?.let { + commandChannel.send(Command.LaunchDuckPlayerContingencyPage(it)) + } + } + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/JSONObjectAdapter.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/JSONObjectAdapter.kt new file mode 100644 index 000000000000..87f09b7415ea --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/JSONObjectAdapter.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson +import okio.Buffer +import org.json.JSONException +import org.json.JSONObject + +internal class JSONObjectAdapter { + + @FromJson + fun fromJson(reader: JsonReader): JSONObject? { + // Here we're expecting the JSON object, it is processed as Map by Moshi + return (reader.readJsonValue() as? Map<*, *>)?.let { data -> + try { + JSONObject(data) + } catch (e: JSONException) { + // Handle exception + return null + } + } + } + + @ToJson + fun toJson( + writer: JsonWriter, + value: JSONObject?, + ) { + value?.let { writer.run { value(Buffer().writeUtf8(value.toString())) } } + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt new file mode 100644 index 000000000000..4a9642ce6a5f --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt @@ -0,0 +1,381 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import android.content.res.Configuration +import android.net.Uri +import android.webkit.MimeTypeMap +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import androidx.core.net.toUri +import androidx.fragment.app.FragmentManager +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.UrlScheme.Companion.duck +import com.duckduckgo.common.utils.UrlScheme.Companion.https +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED_WIH_HELP_LINK +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED +import com.duckduckgo.duckplayer.api.DuckPlayer.UserPreferences +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_DAILY_UNIQUE_VIEW +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_OVERLAY_YOUTUBE_IMPRESSIONS +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_OVERLAY_YOUTUBE_WATCH_HERE +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_VIEW_FROM_OTHER +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_VIEW_FROM_SERP +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_VIEW_FROM_YOUTUBE_AUTOMATIC +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_VIEW_FROM_YOUTUBE_MAIN_OVERLAY +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_WATCH_ON_YOUTUBE +import com.duckduckgo.duckplayer.impl.ui.DuckPlayerPrimeBottomSheet +import com.duckduckgo.duckplayer.impl.ui.DuckPlayerPrimeDialogFragment +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import java.io.InputStream +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +private const val YOUTUBE_HOST = "youtube.com" +private const val YOUTUBE_MOBILE_HOST = "m.youtube.com" +private const val DUCK_PLAYER_VIDEO_ID_QUERY_PARAM = "videoID" +private const val DUCK_PLAYER_OPEN_IN_YOUTUBE_PATH = "openInYoutube" +private const val DUCK_PLAYER_DOMAIN = "player" +private const val DUCK_PLAYER_URL_BASE = "$duck://$DUCK_PLAYER_DOMAIN/" +private const val DUCK_PLAYER_ASSETS_PATH = "duckplayer/" +private const val DUCK_PLAYER_ASSETS_INDEX_PATH = "${DUCK_PLAYER_ASSETS_PATH}index.html" + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RealDuckPlayer @Inject constructor( + private val duckPlayerFeatureRepository: DuckPlayerFeatureRepository, + private val duckPlayerFeature: DuckPlayerFeature, + private val pixel: Pixel, + private val duckPlayerLocalFilesPath: DuckPlayerLocalFilesPath, + private val mimeTypeMap: MimeTypeMap, + private val dispatchers: DispatcherProvider, +) : DuckPlayer { + + private var shouldForceYTNavigation = false + private var shouldHideOverlay = false + private val isFeatureEnabled: Boolean by lazy { + duckPlayerFeature.self().isEnabled() && duckPlayerFeature.enableDuckPlayer().isEnabled() + } + + private lateinit var duckPlayerDisabledHelpLink: String + + override suspend fun getDuckPlayerState(): DuckPlayerState { + if (!::duckPlayerDisabledHelpLink.isInitialized) { + duckPlayerDisabledHelpLink = duckPlayerFeatureRepository.getDuckPlayerDisabledHelpPageLink() ?: "" + } + return if (isFeatureEnabled) { + ENABLED + } else if (duckPlayerDisabledHelpLink.isNotBlank()) { + DISABLED_WIH_HELP_LINK + } else { + DISABLED + } + } + + override suspend fun setUserPreferences( + overlayInteracted: Boolean, + privatePlayerMode: String, + ) { + val playerMode = when { + privatePlayerMode.contains("disabled") -> Disabled + privatePlayerMode.contains("enabled") -> Enabled + else -> AlwaysAsk + } + duckPlayerFeatureRepository.setUserPreferences(UserPreferences(overlayInteracted, playerMode)) + } + + override suspend fun getUserPreferences(): UserPreferences { + return duckPlayerFeatureRepository.getUserPreferences().let { + UserPreferences(it.overlayInteracted, it.privatePlayerMode) + } + } + + override fun shouldHideDuckPlayerOverlay(): Boolean { + return shouldHideOverlay + } + + override fun duckPlayerOverlayHidden() { + shouldHideOverlay = false + } + + private suspend fun shouldNavigateToDuckPlayer(): Boolean { + if (!isFeatureEnabled) return false + val result = getUserPreferences().privatePlayerMode == Enabled && !shouldForceYTNavigation + return result + } + + override fun duckPlayerNavigatedToYoutube() { + shouldForceYTNavigation = false + } + + override fun observeUserPreferences(): Flow { + return duckPlayerFeatureRepository.observeUserPreferences().map { + UserPreferences(it.overlayInteracted, it.privatePlayerMode) + } + } + + override suspend fun sendDuckPlayerPixel( + pixelName: String, + pixelData: Map, + ) { + if (!isFeatureEnabled) return + val duckPlayerPixelName = when (pixelName) { + "overlay" -> DUCK_PLAYER_OVERLAY_YOUTUBE_IMPRESSIONS + "play.use" -> DUCK_PLAYER_VIEW_FROM_YOUTUBE_MAIN_OVERLAY + "play.do_not_use" -> DUCK_PLAYER_OVERLAY_YOUTUBE_WATCH_HERE + else -> { null } + } + + duckPlayerPixelName?.let { + pixel.fire(duckPlayerPixelName, parameters = pixelData) + if (duckPlayerPixelName == DUCK_PLAYER_OVERLAY_YOUTUBE_IMPRESSIONS) { + duckPlayerFeatureRepository.setUserOnboarded() + } + } + } + + private suspend fun createYoutubeNoCookieFromDuckPlayer(uri: Uri): String? { + if (!isFeatureEnabled) return null + val embedUrl = duckPlayerFeatureRepository.getYouTubeEmbedUrl() + uri.pathSegments?.firstOrNull()?.let { videoID -> + return "$https://www.$embedUrl?$DUCK_PLAYER_VIDEO_ID_QUERY_PARAM=$videoID" + } + return null + } + + override suspend fun createYoutubeWatchUrlFromDuckPlayer(uri: Uri): String? { + val videoIdQueryParam = duckPlayerFeatureRepository.getVideoIDQueryParam() + val youTubeWatchPath = duckPlayerFeatureRepository.getYouTubeWatchPath() + val youTubeHost = duckPlayerFeatureRepository.getYouTubeUrl() + uri.getQueryParameter(videoIdQueryParam)?.let { videoID -> + return "$https://$youTubeHost/$youTubeWatchPath?$videoIdQueryParam=$videoID" + } ?: uri.pathSegments.firstOrNull { it != youTubeWatchPath }?.let { videoID -> + return "$https://$youTubeHost/$youTubeWatchPath?$videoIdQueryParam=$videoID" + } + return null + } + + private suspend fun youTubeRequestedFromDuckPlayer() { + shouldForceYTNavigation = true + if (getUserPreferences().privatePlayerMode == AlwaysAsk) { + shouldHideOverlay = true + } + if (isFeatureEnabled && + getUserPreferences().privatePlayerMode != Disabled + ) { + pixel.fire(DUCK_PLAYER_WATCH_ON_YOUTUBE) + } + } + private fun isDuckPlayerUri(uri: Uri): Boolean { + if (uri.normalizeScheme()?.scheme != duck) return false + if (uri.userInfo != null) return false + uri.host?.let { host -> + if (!host.contains(DUCK_PLAYER_DOMAIN)) return false + return !host.contains("!") + } + return false + } + + override fun isDuckPlayerUri(uri: String): Boolean { + return isDuckPlayerUri(uri.toUri()) + } + + override suspend fun isSimulatedYoutubeNoCookie(uri: Uri): Boolean { + val validPaths = duckPlayerLocalFilesPath.assetsPath + val embedUrl = duckPlayerFeatureRepository.getYouTubeEmbedUrl() + return ( + uri.host?.removePrefix("www.") == + embedUrl && ( + uri.pathSegments.firstOrNull() == null || + validPaths.any { uri.path?.contains(it) == true } || + (uri.pathSegments.firstOrNull() != "embed" && uri.getQueryParameter(DUCK_PLAYER_VIDEO_ID_QUERY_PARAM) != null) + ) + ) + } + + override suspend fun isSimulatedYoutubeNoCookie(uri: String): Boolean { + return isSimulatedYoutubeNoCookie(uri.toUri()) + } + + private fun getDuckPlayerAssetsPath(url: Uri): String? { + return url.path?.takeIf { it.isNotBlank() }?.removePrefix("/")?.let { "$DUCK_PLAYER_ASSETS_PATH$it" } + } + + override suspend fun isYoutubeWatchUrl(uri: Uri): Boolean { + val youTubeWatchPath = duckPlayerFeatureRepository.getYouTubeWatchPath() + return isYouTubeUrl(uri) && uri.pathSegments.firstOrNull() == youTubeWatchPath + } + + override fun isYouTubeUrl(uri: Uri): Boolean { + val host = uri.host?.removePrefix("www.") + return host == YOUTUBE_HOST || host == YOUTUBE_MOBILE_HOST + } + + override suspend fun createDuckPlayerUriFromYoutubeNoCookie(uri: Uri): String? { + if (!isFeatureEnabled) return null + return uri.getQueryParameter(DUCK_PLAYER_VIDEO_ID_QUERY_PARAM)?.let { + "$DUCK_PLAYER_URL_BASE$it" + } + } + + private suspend fun createDuckPlayerUriFromYoutube(uri: Uri): String { + val videoIdQueryParam = duckPlayerFeatureRepository.getVideoIDQueryParam() + return "$DUCK_PLAYER_URL_BASE${uri.getQueryParameter(videoIdQueryParam)}?origin=auto" + } + + override suspend fun intercept( + request: WebResourceRequest, + url: Uri, + webView: WebView, + ): WebResourceResponse? { + if (isDuckPlayerUri(url)) { + return processDuckPlayerUri(url, webView) + } else { + if (!isFeatureEnabled) return null + if (isYoutubeWatchUrl(url)) { + return processYouTubeWatchUri(request, url, webView) + } else if (isSimulatedYoutubeNoCookie(url)) { + return processSimulatedYouTubeNoCookieUri(url, webView) + } + } + return null + } + private fun processSimulatedYouTubeNoCookieUri( + url: Uri, + webView: WebView, + ): WebResourceResponse { + val path = getDuckPlayerAssetsPath(url) + val mimeType = mimeTypeMap.getMimeTypeFromExtension(path?.substringAfterLast(".")) + + if (path != null && mimeType != null) { + try { + val inputStream: InputStream = webView.context.assets.open(path) + return WebResourceResponse(mimeType, "UTF-8", inputStream) + } catch (e: Exception) { + return WebResourceResponse(null, null, null) + } + } else { + val inputStream: InputStream = webView.context.assets.open(DUCK_PLAYER_ASSETS_INDEX_PATH) + return WebResourceResponse("text/html", "UTF-8", inputStream).also { + pixel.fire(DUCK_PLAYER_DAILY_UNIQUE_VIEW, type = DAILY) + } + } + } + + private suspend fun processYouTubeWatchUri( + request: WebResourceRequest, + url: Uri, + webView: WebView, + ): WebResourceResponse? { + val referer = request.requestHeaders.keys.firstOrNull { it in duckPlayerFeatureRepository.getYouTubeReferrerHeaders() } + ?.let { url.getQueryParameter(it) } + val previousUrl = duckPlayerFeatureRepository.getYouTubeReferrerQueryParams() + .firstOrNull { url.getQueryParameter(it) != null } + ?.let { url.getQueryParameter(it) } + val currentUrl = withContext(dispatchers.main()) { webView.url } + + val videoIdQueryParam = duckPlayerFeatureRepository.getVideoIDQueryParam() + val requestedVideoId = url.getQueryParameter(videoIdQueryParam) + + val isSimulated: suspend (String?) -> Boolean = { uri -> + uri?.let { isSimulatedYoutubeNoCookie(it.toUri()) } == true + } + + val isMatchingVideoId: (String?) -> Boolean = { uri -> + uri?.toUri()?.getQueryParameter(DUCK_PLAYER_VIDEO_ID_QUERY_PARAM) == requestedVideoId + } + + if (isSimulated(referer) && isMatchingVideoId(referer) || + isSimulated(previousUrl) && isMatchingVideoId(previousUrl) + ) { + withContext(dispatchers.main()) { + webView.loadUrl("$DUCK_PLAYER_URL_BASE$DUCK_PLAYER_OPEN_IN_YOUTUBE_PATH?$videoIdQueryParam=$requestedVideoId") + } + return WebResourceResponse(null, null, null) + } else if (isSimulated(currentUrl) && isMatchingVideoId(currentUrl)) { + return null + } else if (shouldNavigateToDuckPlayer()) { + withContext(dispatchers.main()) { + webView.loadUrl(createDuckPlayerUriFromYoutube(url)) + } + pixel.fire(DUCK_PLAYER_VIEW_FROM_YOUTUBE_AUTOMATIC) + return WebResourceResponse(null, null, null) + } + return null + } + + private suspend fun processDuckPlayerUri( + url: Uri, + webView: WebView, + ): WebResourceResponse { + if (url.pathSegments?.firstOrNull()?.equals(DUCK_PLAYER_OPEN_IN_YOUTUBE_PATH, ignoreCase = true) == true || + !isFeatureEnabled || + getUserPreferences().privatePlayerMode == Disabled + ) { + createYoutubeWatchUrlFromDuckPlayer(url)?.let { youtubeUrl -> + youTubeRequestedFromDuckPlayer() + withContext(dispatchers.main()) { + webView.loadUrl(youtubeUrl) + } + } + } else { + createYoutubeNoCookieFromDuckPlayer(url)?.let { youtubeUrl -> + withContext(dispatchers.main()) { + webView.loadUrl(youtubeUrl) + } + val origin = url.getQueryParameter("origin") + if (origin == "serp") { + pixel.fire(DUCK_PLAYER_VIEW_FROM_SERP) + } else if (origin != "overlay" && origin != "auto") { + pixel.fire(DUCK_PLAYER_VIEW_FROM_OTHER) + } + } + } + return WebResourceResponse(null, null, null) + } + + override fun showDuckPlayerPrimeModal(configuration: Configuration, fragmentManager: FragmentManager, fromDuckPlayerPage: Boolean) { + if (!isFeatureEnabled) return + if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + DuckPlayerPrimeDialogFragment.newInstance(fromDuckPlayerPage).show(fragmentManager, null) + } else { + DuckPlayerPrimeBottomSheet.newInstance(fromDuckPlayerPage).show(fragmentManager, null) + } + } + + override suspend fun willNavigateToDuckPlayer( + destinationUrl: Uri, + ): Boolean { + return ( + isFeatureEnabled && + isYoutubeWatchUrl(destinationUrl) && + getUserPreferences().privatePlayerMode == Enabled + ) + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/di/DuckPlayerModule.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/di/DuckPlayerModule.kt new file mode 100644 index 000000000000..1e7b29dcd722 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/di/DuckPlayerModule.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl.di + +import android.content.Context +import android.content.res.AssetManager +import android.webkit.MimeTypeMap +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import dagger.SingleInstanceIn + +@Module +@ContributesTo(AppScope::class) +class DuckPlayerModule { + @Provides + @SingleInstanceIn(AppScope::class) + fun provideMimeTypeMap(): MimeTypeMap { + return MimeTypeMap.getSingleton() + } + + @Provides + @SingleInstanceIn(AppScope::class) + fun provideAssetManager(context: Context): AssetManager { + return context.assets + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/ui/DuckPlayerPrimeBottomSheet.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/ui/DuckPlayerPrimeBottomSheet.kt new file mode 100644 index 000000000000..29aa0b1f3207 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/ui/DuckPlayerPrimeBottomSheet.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl.ui + +import android.app.Dialog +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.lottie.LottieCompositionFactory +import com.airbnb.lottie.LottieDrawable +import com.duckduckgo.duckplayer.impl.R +import com.duckduckgo.duckplayer.impl.databinding.ModalDuckPlayerBinding +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class DuckPlayerPrimeBottomSheet : BottomSheetDialogFragment() { + + private lateinit var binding: ModalDuckPlayerBinding + private val isFromDuckPlayerPage: Boolean by lazy { requireArguments().getBoolean(FROM_DUCK_PLAYER_PAGE) } + + override fun getTheme(): Int = R.style.DuckPlayerBottomSheetDialogTheme + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + binding = ModalDuckPlayerBinding.inflate(inflater, container, false) + LottieCompositionFactory.fromRawRes(context, R.raw.duckplayer) + binding.duckPlayerAnimation.setAnimation(R.raw.duckplayer) + binding.duckPlayerAnimation.playAnimation() + binding.duckPlayerAnimation.repeatCount = LottieDrawable.INFINITE + binding.title.text = + if (isFromDuckPlayerPage) { + getString(R.string.duck_player_info_modal_title_from_duck_player_page) + } else { + getString(R.string.duck_player_info_modal_title_from_overlay) + } + binding.dismissButton.setOnClickListener { + dismiss() + } + binding.closeButton.setOnClickListener { + dismiss() + } + return binding.root + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + return dialog + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + dismiss() + } + + companion object { + fun newInstance(fromDuckPlayerPage: Boolean): DuckPlayerPrimeBottomSheet = + DuckPlayerPrimeBottomSheet().also { + it.arguments = Bundle().apply { + putBoolean(FROM_DUCK_PLAYER_PAGE, fromDuckPlayerPage) + } + } + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/ui/DuckPlayerPrimeDialogFragment.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/ui/DuckPlayerPrimeDialogFragment.kt new file mode 100644 index 000000000000..7a98ce2fe9cc --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/ui/DuckPlayerPrimeDialogFragment.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl.ui + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.fragment.app.DialogFragment +import com.airbnb.lottie.LottieCompositionFactory +import com.airbnb.lottie.LottieDrawable +import com.duckduckgo.duckplayer.impl.R +import com.duckduckgo.duckplayer.impl.databinding.ModalDuckPlayerBinding + +const val FROM_DUCK_PLAYER_PAGE = "fromDuckPlayerPage" + +class DuckPlayerPrimeDialogFragment : DialogFragment() { + + private lateinit var binding: ModalDuckPlayerBinding + private val isFromDuckPlayerPage: Boolean by lazy { requireArguments().getBoolean(FROM_DUCK_PLAYER_PAGE) } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + binding = ModalDuckPlayerBinding.inflate(inflater, container, false) + LottieCompositionFactory.fromRawRes(context, R.raw.duckplayer) + binding.duckPlayerAnimation.setAnimation(R.raw.duckplayer) + binding.duckPlayerAnimation.playAnimation() + binding.duckPlayerAnimation.repeatCount = LottieDrawable.INFINITE + binding.title.text = + if (isFromDuckPlayerPage) { + getString(R.string.duck_player_info_modal_title_from_duck_player_page) + } else { + getString(R.string.duck_player_info_modal_title_from_overlay) + } + binding.dismissButton.setOnClickListener { + dismiss() + } + binding.closeButton.setOnClickListener { + dismiss() + } + return binding.root + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_TITLE, com.duckduckgo.mobile.android.R.style.Widget_DuckDuckGo_DialogFullScreen) + } + override fun onStart() { + super.onStart() + dialog?.window?.let { + WindowCompat.getInsetsController(it, binding.root).apply { + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + hide(WindowInsetsCompat.Type.statusBars()) + } + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + dismiss() + } + companion object { + fun newInstance(fromDuckPlayerPage: Boolean): DuckPlayerPrimeDialogFragment = + DuckPlayerPrimeDialogFragment().also { + it.arguments = Bundle().apply { + putBoolean(FROM_DUCK_PLAYER_PAGE, fromDuckPlayerPage) + } + } + } +} diff --git a/duckplayer/duckplayer-impl/src/main/res/drawable/clean_tube_128.xml b/duckplayer/duckplayer-impl/src/main/res/drawable/clean_tube_128.xml new file mode 100644 index 000000000000..9d0df9e99301 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/drawable/clean_tube_128.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/duckplayer/duckplayer-impl/src/main/res/drawable/duck_player_animation_background.xml b/duckplayer/duckplayer-impl/src/main/res/drawable/duck_player_animation_background.xml new file mode 100644 index 000000000000..b6fd54151c06 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/drawable/duck_player_animation_background.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/duckplayer/duckplayer-impl/src/main/res/drawable/rounded_top_corners_bottom_sheet_background.xml b/duckplayer/duckplayer-impl/src/main/res/drawable/rounded_top_corners_bottom_sheet_background.xml new file mode 100644 index 000000000000..7b719e31ea46 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/drawable/rounded_top_corners_bottom_sheet_background.xml @@ -0,0 +1,23 @@ + + + + + + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/drawable/youtube_warning_96.xml b/duckplayer/duckplayer-impl/src/main/res/drawable/youtube_warning_96.xml new file mode 100644 index 000000000000..a330b269a631 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/drawable/youtube_warning_96.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/duckplayer/duckplayer-impl/src/main/res/layout/activity_duck_player_settings.xml b/duckplayer/duckplayer-impl/src/main/res/layout/activity_duck_player_settings.xml new file mode 100644 index 000000000000..1a2969f97ded --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/layout/activity_duck_player_settings.xml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/layout/modal_duck_player.xml b/duckplayer/duckplayer-impl/src/main/res/layout/modal_duck_player.xml new file mode 100644 index 000000000000..1fe6affb4412 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/layout/modal_duck_player.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/raw/duckplayer.json b/duckplayer/duckplayer-impl/src/main/res/raw/duckplayer.json new file mode 100644 index 000000000000..cda1d1f38ec1 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/raw/duckplayer.json @@ -0,0 +1 @@ +{"v":"4.8.0","meta":{"g":"LottieFiles AE 3.5.7","a":"","k":"","d":"","tc":""},"fr":29.9700012207031,"ip":0,"op":368.000014988947,"w":320,"h":180,"nm":"Comp 1","ddd":0,"assets":[{"id":"image_0","w":20,"h":20,"u":"","p":"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSgBBwcHCggKEwoKEygaFhooKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKP/AABEIABQAFAMBEQACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/APprWtVg0i08+4ySTtRF6sa3w+HlXlyxIqVFBXZh6X4zt7u7SC4t2tw52q+/cM+/AxXZWyydOPNF3sYwxKk7NWOrrzDpOU+IGnz3dnbz26NIIC29VGTg45x7Y/WvTyytGnNxlpc5sTBySa6HE6Vp1xqN4kFujElhubHCD1Jr2a1aFGDlJnJCDm7I9hr5M9QKACgAoA//2Q==","e":1},{"id":"image_1","w":42,"h":6,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACoAAAAGCAYAAAC1rQwWAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAAAKElEQVQokWP8////AYbBDy6wMDAw2A+0K4gBTAPtAGLBqEOpDYaMQwFnXQTaXe9dvQAAAABJRU5ErkJggg==","e":1},{"id":"image_2","w":67,"h":17,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEMAAAARCAYAAACGjBGPAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAABsUlEQVRYhe2WPS9EQRSGnxl31374WB+7IkE0ZEVIhEoiOoVKI9lo/AR+gl+gkvgNCoVGJWELjQaJRISCsGJ9rYRgl3tHoSFzd+9dca8NnvLMOZN33jlnMkIptcE/ADsGMPrTKioF+dMCKgmj6Ir5BFdLkFuDl2sQQagZgEQKoj0+SvQPoZRSWvT5BI5mIH9mUyKhbRZaprxX5y9pvTPMpxJGAFhwNg+BZmgc81ZeGby8KnIPCpur/URDjSAYELZruhk3KyWM+EBmoWLM2D402dwzeTWdc6WAoaRkuFc/uv6A5tbdKSicw+OBu1wPubyzSO+6MwLAUrC1b3F8YWlruhn5U/dKCln3uR6RvXWYiyJc2NTpZhgx9zvK0JeEfCfRkP38O9fpMd2M2kF3u4kgRPu+JOQ76WwVtMfLMyQREyQ7bPpAz0zB1TKoQukd45NQFS5LhBdIIZgYMTjKWOTunfPrItDVJgkYuoH2/4ybVTieA/RHBoBoP3QvgqwuT3llk7b/jjeNvx82kvwclxFomf6NRgDFOuMj+cz7j9Soh3A3yIBP0nwn7WzG36HImPxR3gAgSnf+tTIOIgAAAABJRU5ErkJggg==","e":1},{"id":"image_3","w":141,"h":49,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAI0AAAAxCAYAAADnViqrAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAAE6ElEQVR4nO2cTW8jNRjH/36ZSSZJ36sWVKkFLQtCohIcVkhw4MQNiQNfAS58BC6c+AYc+ATcEReEhFhx4bSIwyLx0iKBtIt2K9Ldtklmxh6bwzJJJnGaTJrMOIl/t9jJ+Innbz+PH4+HnJyc3IXDkQNOCHmnbCMciwUt2wDH4sFNhYQQcM7BGAMhpGibHCWjlEKSJJBSGuuHRMMYg+/7TiwrDGMMjDF4nocoiqCUytRnREMpHSkYologOjI2okgNoNUZmu2wAUIIKpUKOp1OpjwjGs/zsoLRErWrr1FrfwMuH4y8uAaB8F5Ga+0DRMFbs7XcUSqEEHieByFEr+z09FSnlUEQ9L6tQmz/+yn8+JdcjbTq7+Ny86Oh8nTKs83taa0hpRyagh09tNaZ2aY70wzezPWnX+QWDADUW19Bei+gU3+3W+Z5HjzPm8beQmCMGX23DYQxwXmLQ+n5tuMxjZ01CWZYTw9qwygaKh8jaH83tQGNyy8zouHcuEizhnS1GMdx2aZ0aYUU9/6s4WHTA1DM7Myoxu3nQxwfdYziSTFWVaKfQDC9tFlyBi7+mvr3q85lh+Lbn9fxsOmjKMEAQKIIfn0Q4O79NSTXTLpG0bCkeWMDmHzUMyZJbny9eWOTjT/+1kAky8u7nl14uP93MLK+EL+RTvuMsSKay0UaCNsimsdPOZpX5bvzP/6p4viwA2rQbmHW2RQv2MzZRfmCAQCZEJy3GHbWhgeT23uyDK3tSUnEI1xkobKmprlugdFaQ+vZroWrvj3L/rXA7LILEU2aVbQtsTcLZh0PHWzHuEdqpc84Ww2JRtUsYOPQ1+TmiThNe9H3sgoGmH1wH/garx6EM71mfjTeeLE9stYoGsmPbtgkgeCH3c/LKhjg2X+btds9Purg1nPlCIcSjTdvt7C3YX4sAhjhnqLq60joNpiaLl8TVe9As43uZ6XU0sUzKVrrmW8/EALceamNw90Yp48qaF5ySDXfgVfhCnsbEq8chCPdUkpXNJmAjvi42PwYm83PcmeGFanhcuPDTJmUcildVJrjmRf7mxL7m/O7/qQMBvvdXW4ACIIgc2Or7R+w/uRzUD3av/Uj2T6ebH8C6d8y1hNClkY481g52UqSJIii3rNUGdGYdqOJaiFofw8v/h1EXxkvqtgOYv81hMHbALEjOeWYHVEUZVaImTsshABjLBN/aFpHu/FecRY6rMKUUhiKTsMwnKufdiwGWmsIIYzbP0ZfEscxhBCglC5NDOKYHK31tQnLkQHIuB86VpflTJ445ooTjSM3TjSO3DjROHLjROPIzcTpW0rpUOJvlRh3qC49BlNk/6SbpUXn1SYSDed8KTcc88I5hxAic0QVeDagKpVKaf3DOUcURYXthY0dFpRSJ5g+PM8bmk3KFAzQe3FDYe2N+wLn3AlmgP4To7acTy/SjrGisaFDbKN/prEpxivKlrGtrMozI3noD4ZtemlAUbaMFY2U0glngP7VSpIkVvRPkXaMFY1SCkIIKzrGBoQQQyO6yJWLCaVUoSdYJ1pyp/mJVQ6Kr8vTKKUQhqHL0wxStJoXjfShpVXAntDfsTA40ThyQ23KMzgWA5p5o6fDMQF0a2trZVdEjumgvu9jb2/PCccxEY1GA0T/n5WK4xjn5+fodDpWpcYddkAIQb1ex+7uLv4DTsP3dx/5jJUAAAAASUVORK5CYII=","e":1},{"id":"image_4","w":141,"h":18,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAI0AAAASCAYAAABmbl0zAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAABzUlEQVRoge3YsUsCURwH8K+VHppGmBWXKIIQt4Z/gBwt0n/R0ugf4F8gTq5Jg6JTszlGuAUORdtNguDkkUle3B3na2qonmH6Xmr8Ptvdgy+/4cu9987HGLurVquoVCqwbRuE8ESjURQKBei6Dl+9XmflcnnZM5E1EAgEUCwW4dN1nY1Go2XPQ9ZELBbD1rTCHFsWLvp9HLruzIEvm5u4UlU8RCKiZiQrZjAYYIu3cOA4uDQMRDzv16Enr6841zQYodDCA5LVtMF7efr8PFdhAEBhDGemudBQZLVxvzTzFuZDaDL59JxKpZDL5RARuG11u120Wi2Mx2NhmWQ23NKIlEgkkM/n4ff7heYmk0mk02mUSiVMvpSUyMXdnkTKZrPCC/MhHo9D0zQp2WQ66aUJBoNS88PhsNR88p300hiGIS3b8zyp+YRPemna7TY6nY7wXMdx0Gg0MBwOhWeTn0k/CDPGUKvV0Gw2oaqqkEzXddHr9WBZlpA88jvc0jwueE542t7+9s40TZj0/+Zf4G5P9zs7uN7fnyvwdncXN3t7Cw1FVpsvk8mwaYtHto3029vMYT1FQVfybYks349nmr6ioK8ofzULWRPSb0/k/3kHh4mHa1Tzd/gAAAAASUVORK5CYII=","e":1},{"id":"image_5","w":29,"h":4,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB0AAAAECAYAAABySjRcAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAAAMElEQVQYlb3NQREAIAwEsYABrFQKUpBeB4eL7sy+I1TokKlXaByDbbxJEBaEi5pCP6MOPNpI69kPAAAAAElFTkSuQmCC","e":1},{"id":"image_6","w":101,"h":4,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGUAAAAECAYAAACX1PEwAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAAAQUlEQVQ4jWO8e/fuf4ZRMCgAIyPjQiUlpQTG0UgZdGAi00C7YBSggI9MTEwLWBgYGBoH2iWjAAKYmJg2KCoqXgAApAUNMclgLKQAAAAASUVORK5CYII=","e":1},{"id":"image_7","w":141,"h":87,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAI0AAABXCAYAAAA5+8bsAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAAgAElEQVR4nIy9yY9kWZbe97vTG2zyIaYcIrIyK7Oyqqu7uiWSDXVVEU2hB7ABCVxoQQgCCBDgQv8BoZWU0Eb/gaCdIC0krqSVBEESJbZEiRLZ1exidXV11pBZGRmZGaOHu5m96Y5a3PvMzSOzWrSAwz3Mnr337N5zz/nOd75zTfzD/+g/+yDGCETmRyx/SinL8xIpJVJy45gY443nyrvKe199/vq887nzMZIoIcr5nfk4SVOuD1J5ogS8zscDVQKI+X/e48cXbC+esqo03o/sL7dEPFo2nJ/fxtqRy/6S3kas9zRVxe3TU5rFgt5GoMFGT7U6Y3PrLkJWION8M2UMyueL8Xq0jj/U0SOV1yJlnKgAf3OYyhhGIjFFYrTI+XyHX/F4asol83zlW4tAyOcp75U2EvFEbL6H8nz+7SF64o2fSPTxxrV89CBlfo/1+XazKaBTEv8JQEricFNCcLhQSgIhIKVESrI8l8pxgvLnjeHKz+fzJTGPICASKYEU4nBuBCQhIB0dxvx6vheSzOeVQIrIFCF5at+zv/ic3W7LtN9j3Yit82T7EPPF1cgXjz9BKsk0joDCSEH0I0+efUFtNArNvh9BCSYbWa42SG2gWrE8OWO5PmO9WiG0JolEQiDmG74xBkeDkebPlSCJPCECUiqzElIZ03L4wTAExEQsL6R0Pd7z2KdUrn14//F8JIKEFAUpKUihvD//zO+NIv+kmMrYX/+ej0k+QAwkEfIVZH5el49/uDF55CJCCCilEOLaoI4f802++n8hRP6RgiAFuECSkIRECHFYOEIIEAIlJcV+8kQnTULmGxWQQkSOe9K4I/RXeNujFFx0e4ypCcPIsjEolXDOsev3THZCIjFKs1ytGIae0TtEgEYbphTZjyN3bp+jokXKBFLjpolPn/+c1brBB4EyDSGCRHPr/lss1qfcfv0tqqq6NuzDhB6NzTyXSSISROHzBJWxFiIWAxBlLGJ+lwCRLeX6PEePNL+WEoqYJ/twLYihGKcAEeZVl401FQMSyUMMiJTfHWP8SsPM9ytASEJKJRIk9Bx6bljxPKFHxvSqgfyqhxACKSVJiBxSAIxCfMUAIARSiPwBhTi8nxSQIUIMxGFPslf48YLt86doVWGM4uU04lyg71+AEOwR2JCwwaH1/JkUQ4joEPAJfNB4N9EPE6rSpJR4/vyS4B3r5QJEoqoa7r12B28tCsfV7hKlNVprnn/6IXZK/PzHf87d19/g9QdfY3N+i8SXY3FKKU8GebHMY5dSKqs6ENPszQ9zm1e4FIgoiDHemIfjH1LKEymyx775eiAlRxKOFEK2vxRQMV8zhnxMjOFgMF/lAJCzkebnYjFwzewBlED4eLhBIUR2mam4XyEQMiFmA6M4BUDEYhDFwwAgs6cRR4Y3n/dVI7sRllIiDTtit0PYPW7cU1eKVmv02W0uXl6y7ydiDNRGcX7rnH03sO8sUsGibogxgTRM0wRInr14SVUZRutJKTKOA01q0MYQQiRFweVuB0Ji7SV1ZYgxoqTk3p1bdP3INFmq1tDHgWhf8PizLQ9/+XNu3bnH2Z3XeOP+W5imQQh5Y3nkcHQ9rodQkSKihJaDh71+0/XE/ZWPHPvnMJeNNJSQFA/GKVIiJV9wUyQd3c+XzlgW/aveZ76cIKETKVu2EEjJDavLK+gY0yjk7HdFWRGAEAmROISlV73V8Q39lUMQEmF7he8/I9mJabvHVBVRwK7v2W07un7Ahsh6tWLf7dh1HcM40rYtdd2wbBour7bUWmMDTH66/sQph9+YEvuup2ka7OQxWmHdhJSKyVliSqzXa6Zp4tPPvqCqDEloXNezaCquxpHkJrzruXrpuHj2iE9/9iHnr7/OW+++x6I5ASWuscGrHzQlREyHv8uSOh6ofMz/r3e/BowpRWL0JDyJSEoeYsoGEgOkbCzzz4w9gYORfNX1jnGrKOEiYxpRcIiSiJiOPI24ETZuGIPIF8vH3gw9QghiSsWgrs/xVZ6mOLG86rwjTi9x08jViwsW2nBx8YIYImdnp4zjQPSOk+WSttHU1ZrLl3vOT07RRrDrBrq+y6FxcrSNYXfRMUwj7bJhHCaGYQIpCCFgJ89y2dL1+ww8haBtW1JKbLdbAOq6Ztt1xQA0vTHUbYUUEqkFvh+IEa52jt3LCx5++GPO773OGw/e4Y233wGhSDEexZ+bxnPj97HRzMf/Cm/wJQxCAhEIPocmET0UXHidWKQSNsMrt/FlQ3n1tevnFFqIiE6SJDMGEYhDhBYyEQMH1ymEICoQSIS8zgSSvA4xsngc+aqXSYa82otblgFIiJSQtmd4+ZIXTx8Sxx0pRYZuwIlEVdcII3B+IIYRpKBqFI+fP2W1XKG0xDmHJ7vVW8sVUQguh54UIpWG5eKMwY6sVmsEitGOCGMgwcXFBVprfAhIlT/5ZnPCOI503Z7dbsd6vWYcR05P1wzDQLe3KKUIIQPLFCKjByEVTdPy5LNP2b684PFnn/Lur/8mq5NTXllWJDQx2TImCkQgJl9ePZo0cQR0v+KRDcYTo8vzISIkSRQSUsBEDvjnOPW+Xshfcb4jOJEjjbxxAzrKHF5+1U0JcZweyJKSAVEgUAh1/cYYI1EWgylpNEnnzEQIRBI5gmcAhBKa0F3gt58xvnhCZS1SghSae2++QXSWcRxQ2tB1e6bRUi2XPHvxgqurPUoooo/UdcPUT6QIO9Gz7XqqumEcJ0braJol2hicdUglkEohlWAcHaZukIBSqnyIxLNnTzHGFC5Jst/vUUrx/PlzvPdImf10SqCNJkSKR4HoHCLB1eUF+90VTx8/4pu/+Vu89d43SeiSGWWcc5i4gjEE8pAN3Zy4gh9TLNfNnkUQCTdS6rK4yTSQzBMFwufrwise6ld7mZtRQRCjL0kKaGYbOkrxUjpyR0eeksQhRTwAt6gQMoK4GXqO6ZmcHqUDkJIkYr/DTxdMl08I+w4RJlKwdMPEYrHg8uIpUkjGMWOS0U5IDTEG+sFiqgbvExLJZC1KKmxyTP1AiIF+GIgxsTzZEFLMKXLKnkvbihgDwSd8SqQQMFrTNA0xRpZySYyRXTGWs9NTdrs9IQSklIQQqKqKEALBe5wLCKWoTMM4jSQSTdXQ1DV2GvnJD/4F24sXfOM3fhtTN2WsrzHETOfkFX0zdFw/0s1BLYaTUszPRUiikHtJIFPOu2fzOjZGUbK5a2Pj8Nrx4zirPn7oAyBjNo7jVPsY08AMjeNs0TKzPClmy7oObOXmBYBFIBBRIogoLPbqBcPzz4jTjn7fMYwTdVsDguVqhVYKY3IGs5AaKQRRZvAa7ch6s+bi8iXSZwzmYqCWGhrDixeXnGw2dPuBSGIpBEJrxr4npkQIE6au2W57pJRUUiCMpjLVIWNwkyOmyHq9RgjB0HWkEKhNRUjxwF+FEPDeIwR4OxK9Q1cNJFnCW4dSCm0Mjz75JZcvd/y13/k+i9UGITIPdjz2mfO5nuB/nUdKCZkyLIhl4cqUkCmRAUD2Y7EYiSi8C3xFdvQVBnJ9g5ANV6C+9/3f/2BOi6+zH0hJQjIIdDGeDAQ5YnqFKi42yWwY5Z8UCS2gFZ4NE9ptEdMLxsvPGZ885OrTX5LGHf3Yk4C2XVCZiuADIQR2ux1CCKZpwsfAru9Q2tC0C7zPE6YrQ9M2bK+2tOsVSMnLi5csmhaBwHuHqWqmaTqQl1prAJy1pJSwztM2Lc45UkqEEDJui9kwbDmu63sqnY3YO89ytczG5Rxt2x4mP8aQ0YoWpJioqpqu75AldPW7LY8ffc75+W3qxap47flHXi/aJDLuewU75znSBfPEcnwCKTNtAjlTEolILOSoyqFNFCw5W+fspf4KY4yplBlSPKANgUB973u/98ENZJ8UoIrZz+6rAKwSXJApczYH5jebukwBNe1RdsLEgQqLwqP8ROx6hBtJwbFatkit0cagdSbZjFF4b0klLZ6sxZTXrbWQEt57UkpcXl5BgugjxtTUVYUPgfVqRVPXGGMQUjJME4mEs44QAs65zL8UTyakZJpsNiYBTdPgvaeua5zPx7Zti9EG5z0xJky9YLfrSAkWi4a+H/Jql5meyMY2AQnnLVVt8MkTvCMGCN7x2aNPuHv3HnW7ODIaUcb44Ou/2mgoJRXgOJTFwtXkyY0kISEW4yhnVSVaZGN5FfC+QrAWAxFSIIXMobNgIPXdYjTXoEyCmEsLMXMwhaPOf8vrzygyhxNTwkiNiZFKR5aNRghFFJJxtAzDFX4c8LbHGEXTLBjHESklTdOw2awxRqGUxntHVdWYuiLGRN/3eXhCpG5qhjEDXqM1l5eX2Risw02WbhgYhoFxHLHWslouMVWFEAJrLdZanHMAjFOeWCVl/jtywCxCCNrFAu8d4zASQ6Cq63IfIWOjqqZpW7TWhBAJPhF8QilTOJMcGGJKKKUJwZNIaG0IzvH0i8958LV3UVV1GM+UwmwuZbKvJ/UQNmJZxHPd4EDuHWHSg0+IR0aZE/Bj0Hyd1X91SMpZsCzA//qhvvv93/vgGLvkg6/BrxDpyBHl1ZTmVVCelzERfUDJwKqRmQeJmkZapL1C2LGUSCN1XfPi4gLvPVOZRO89wzAwDD3b7Q47efqxzwNfwuY4WUKEabJsNhsANqcnNE0DCBaLBbqk0W3bIoTAx8BQQuCiXVA3DSEEjDF47wt2qmjamkRCSQUiZ4HTOJVz5/NppRiGAa0kdWWQYvYqDuccWmmMqZHSHAxmtJYEWOdx3iKkwPs4py28fH7B/a+9fZRsXIeLOUO6QRAeJvcIAKdrw5mN5jq2FQ5NSgQJURKSvNgLpDgs/lfqhoXJm38zZ80k1He//4cflATtyFiuPctscbMxXVvVHB4TYi7xxwA+4J0lTAPby8d4O6C1QAqYhoGXF8+x1uK9xxhD0zRcXl6y3V6WyZY4Gzk5O0VKyWKxyIATwW6/p6ornHcM41jCTcA5yzRZUoxUdUXXdTjvqZsagcBZB4gDlokxHoC2c5Zuv4cUUUqglUQKWLQN+/2Opl0cMM5h6ZQMqu97hJQopfDB44OjH/boRUOI4TD4MQakFDjnUDr7bYli7PdIJblz5w5ZATAXCmMuPCZIQUBUXNdsyhTkWMGcgl9b1uyFIKGQSSNFLv8IqUkIlNQIpRBKI0T+vDO9MHvaV0PVTDGkBOp7f/MPPjiA25QdQo7Rr5BzR3n3zCInEjLmCrVMkVYpVtoSxytU6FlXmpNNBqnJe5yzBBsQSCafccZ+v+fk5AQXI9Za6rpGKs3l1ZZhGIgxcHV1hTSatm1QUqKlYtEsc7yVkqqqmZxDIAjlPHaaEIArHi2EgNaaRduSgHEcMcZQa4OpzCEjMibXrJxzLBcLpnEiJrJnKvjLWosQAqUUUuZVO01TxmDGYJ1DFE8khMB7f1ALWGtBBELKKf9u+4LTs1s0y1Ue3eBIKRR/XupWIhRjKFySuPYqc8YX5yJlyiThXCqYwUVM4ggYq+JlJEIolJRf8jh/VflCCiVKPL1J+oSQCCEVsZU4+j2TSyVlkyVW+h7ZP8W9/Jzp5XNs32XjjwLXdbihL6GhQkjJerXGGMN6vSalxHq5wuiK588vef7ygn7oQQiutltOT89oTEWInn7oAMHV/gprHbMEAymRWuEL/lit1wD4kIHtcpm5l2maDqly3/dMdjqErLrIHYQQjOPIvtuzXC4ys1xV9MNA0zQsl0vGcWSuNu/3e4wxjHZicg6ZxLVnuuHuwRhN13dYOxDxdH3Pn//oX+LdRPQuY5aD4xAZ0ArJodZyoEUE6TiMIEEoKPKT/MjE3hxW8rwVoHwU0kgcoEAI15Xv459DJCGivvu3/uADkEjEDS3N7IoON3j4u7gqUk7vhj1pfIn0W3ADfujphrEgboGdLBBxztHtO/p+Bqt5ssZhZJxGBJmVbZoWUzeYqqJdtDgf2A89lTG0yyXee7wLmKrKOpyUsHYixUS37/DeYa096F2UVqTiKbJnkFRVhZTykDIvFguccwzDQFVVWGsPNShrJxaLhhQDSmmUlISSgeUBzimLlBKpVE4KqjpTA7JCIFFKF2yYvc6chVR1hZAKbye0EpzfunegL7JTyAmFlLKElxk/XANKAQdvJ68zlANuKfqGm5mSCMU4s2ZGEAt4z79fLSXMxkSpHOhZCCXlNRA6nJybHujagj0VgTZ1rDaKSq6xu+yZRLViuVpitEaWFPRyu2O73aKNQSpN0yyIKbto5zIgbduW3W7POE3YEAtYDVRNzenijJcvX3K136G1RiqNHTJTrLVGa0NSnlvL23jvuby8pOu6HC6UQhuFmyLee9q2zZ6hqlgulwgh6LqOGDNIV1Ieak1NAc5VVTFNI3W1yAC0hOa6rvEhHBR64sijN00DSWPtWKiEcGNReu8Zx46mySq9j3/+E97++rfQelHGuRCkUnJ84hjjgRgWQhFS9vSCBEoBgZQqKNXs7B2ma0+TjhObawc2z++rBebjx3xd9f2/9YcfpJhZxONDvlyRTqjkqUTHQkysTKCSgRQ9buxx04AdbXkvBF+ILiORIqvzgvcIKYghIpXEhcDoLME6QsjYYzeO+BgJKetmpZRcXV0x6zyqKocpJNRVQ1VV6JLSDkNOtZeLBUprnHMHJsN7i1IS790B7Cspcm1L5ZiulCbG7MlmAmLGMEpppqmnbZuSiYC1meOJIWCMZhjGjN9SzMB3GnPim66Ne8ZF1y4/lbTd07ZLTm/fIUmJkCrX8OTNyUyFjBHHk1yOp4QyqTXX5YE59Y4Hw5AiHy+EOOTDM+eXi5pfdhTHEglNzLWKnEGFGwenQkcLItHv0CWeOqVQKdCohAqRrusJ04QgoWrDyXpDCI7g/Y0Vo5TCe5icRUqBtQ6tNabRjIOjahacNy3bfZeNQWv6vqdt28Mqs9YePIBGMY7jTKhixzGHpRhpmobFYkHXdYQYaar6MDCTswdsI6U8gN+UJuo6lxO0ziSnlALvwgGjXBN5IdeWrKVtmuxJtcILcM4fgPE0jRhTZw8pDSnlBWKMKYxzwPs8Dr/42Y956+2vo6oaIXWBL19OSG54gAPDe3PB3zheKHK96SizKhmXYE6Gj7MkcYMtno1zlsJoZpB0eMPNmEbqiFNHbbLrNlrRaksrHSolklRUqwW+qfAxA0znJ5y17Ls9UgrGcSKlxDhOeA82JUyCpmnpxg7nHG4K2ELna5XxxuzOlVLF4HwZfInWmv12BwKqqsEHz3qdMxAhJd1+dwCFRmumaaKuc1mhrRuWiwXjONH1Ha5MvPe+gORQBshR1zWresl+3xWcl7DWoZQs0ohIiuW+tMJNI0ZrlFLsdzmlDsFh7URdtTifQ8Wcgc3YK4TANA18/tkj3n73WznvuSbLDhN4UNUdpvgV8u96qm+8Px0wTsYtB7Kf2ebSISOcwfXsDec5mP+fizFEwkw534xRyBipK0NVJ+rQEW3E9ZYphaydkRKRIpOd0HWFn0asy8C2aVu0gKEfmIorRwYWQdH3I0P0LJZtNgYRQUi6PgutpmFi6HoiibZty+rPJYVhGJBS0i4XAHjnmWtGSimC9xilENqU1x2LRctc3XXOYaeMVVxVIQoekWUSRRUxVc2u69jv97Rty3LR0u07QgHZM0cEEe8z/zLZqYi5IyEGlm1LPw6E4EtYnYoHDmijDys4ezGHEJKPf/oXvPPueyA0/9qP4m2OVZdzHStrZ778FllA8JxPXZ9KfOXf2RzyNfQBjOfDblxAxo5WOkIYSWMkpEQoCZsdLSIElosFRmtCFdnv94iYOG1bUoz44i2Sz5Na1zXb/Q4lBHWlqdHIlNP5yXlcyHXZ119/neViSQiRfd/hCt8xjuPBTXrvDxlR3dQ46xFC4VyuWaWUiD5nR04krLMgVDYYawux50haIpIgpkBVKbQUBB8hWJamplpv6PueMA6cLlus9wx9h27aQ21KKUXfD1Qqvx+pMFExTZZF05KkuOZ2hCHGjCS0UUzFC+cJDmy3L/j80SNef+vtm57ilWzmVcx5zOjm465fSynjmCRzr5QQgpjhEglHJBdIX/VYx6n3DaNJMadkQhwpupJARo/2eyIJ7y1Cy+J5AsJPKALGKIK3hADb/RapFG27JISZ/o7srWW12eBL01Zb1cSUcnEwRLpuYFbTU8oR3W6fJRPDQCgTs9vtUErRLhZEn7kElCrMaERLwTT0KK1RWqKipFY1rhBtRulDmq8LUCyYkrqpUEpw+9YtYgpM48i476namn4YwLmc4iuFdJ5KS5zN4vbKtPjgSldAxGjNMDqEkqi52KdV8XAeKUEZDSEw9EO5F4FSOeT6GPjs049488E7JFTWPJBuGMUcKuaJpPgKqYq0lBmH5PAefWQWtczvEYd35UwtG0TOtjIgDjcM6PhvfWCjCw+TwW8g+CukqLACVFNTC4uyOf6jNFJlIs25kdu3z2gWTSG8BF23Z7Va0tQNl5fbjGkEB0JsGAaMMVRVTWUMu2mPlJLVqmHoB168eMFisaBpGqZpIobAyWZzIOcksFouS0YzUlc1k/OcnW3wIeFDZLFYMk0Ti7Ylpsg4TbSLirWq8M6itaRtW05P1pyf3eKN+29wfucW3kc+f/SI7uUlnz78hKoyNE2d8VbIWZsbHXXV4K0lxYCIiaaqCDFgvUMrmetgMZLKZM/sMAISoYDPa0H3XCcypuHFs+dcXTxjc+s15oawmbFNKcsusqLyOgs7TOyc7RzzakIikNeLE1FoA5HbUlIkxesuzfl8r3q2A6aZmcDj0ObdSK0SUrZsVpIwbukvr1ApfygZcu6fFJA02+2Ous5xXgpFZQxD3zMMATsFgsupdt00aK1ZrVbFeEaUlJydnhAibLe7XBw0hjt376C1ZhyGTKKlxDD0LNqGGAJCSOq6AnLKqk3FOPS4cQQlSUQWbUOtDSFFFssFVWVotaRdtrzx5pvce+0ed9+4n8Oyyn3B0zjw1jtvMW53fPrxfSKCj3/xcx5+8ggbI7suFy2ryuT6UlmlPjggUZeallagFi2T93TDQF1X9P2Qq9wxSzxykTRXkbXWJfRCDBOPPvmIb9+6A2nGPmWykyyGFxEiZ3Rf1XKSEBmrzQXFw0tz+JrLDJBiNppjbPOrH3P2dETuEBKVyFilaQz9PuGHgIwZXKowEkMkJos2DauTFYKY5ZUh0o89ITiWyyVK5vBx6/Y5l5dXmQEex2wMRb4wa3O7fiogXNLUFfvt9lAovJYsRFKMNHWDlApnLUblDGnse7SSNJWhqrNsYeh72rqi63ecbNbcuXXKN7/xdZrFgttvvMX65JSrXcd6c8LzF89p2zZ3Pex7zOkJ3/yNb0OEW6dLFq3i8ePHuGGHryTj2FGZKks9SzZilMphQmThvR0nlNQs2wWTszRNXcoz2esEAaqQqnMYjTGANjx78jlu6qgWJ9fYJVUgfPFS15n2rwojiSNcM5uMkKQUECH3gFG6LY/f/6W0nmOPAzp7ousDpIjUdcXgIv3Q0wTLMvRIo4gRQoKmrVi0Gy6v9lm3sqgxShGUImhNSpFxmNBa4bzni88f0y7aw2rYbrc5+ykgMqXEZrMpXIem78ZchU0JJbJ4WyqBEPlYoxUgSFIc+JA7tzaM1iFFZNHWSAnt6QbnRu7cOmW9afn1b7/Pg7ffYbk6YRgHHn38Cf/ihz/iX/3oQz55+JA7p2sevHab979+l1//rd8mqYr16SlvvPU2t2+f88lHP+WPh/8bthNdb1FCEqXP1EHXl87SLIeNKWFMZq+dj9iQMFIhVaI2mpAgxSyt0EohRETrgi1i4mr/kkcPP+Wd9zcHObYQ9jBX8wTOC+uGsUSfPUdKWRBO4MbmA0BUChliTgKkgnDz9ZuGkg5hK6WE+p3v/d4H18RORKt80/gR7Xec1JJWS6JWbE5OaKqqsKuKum5QUtMPA/3Q5ypx4SiEFPiSqdR1jdYKWxR0czV5TqHHcQQoqawHUu5KkLnGNbfLzHyN1pq6zu25xmhWqyWQuYT1akldm8zypsR6s+DkZMXJes23fu2b6GZNjJH/+X/5X/nP/5v/gR9/4niyU/S25tGTS/7yo0f86Q9/zKOPf4GJPa+/cZ9bt++y2pxw6/YZRgm6y+cM+wmpBLrSKCVxpdhYm4rgPaFMwlwSyGRmzvgQAuuyjNLorAdSSpTPLdFGkxJ4a7n/4G1QpkwiBZfkZCWVlDodcGlClMJiiAFCIHlHxGUW/eCZisp7NoiUveV8v7/ay+SQpn7nu7//wUFMRcJPI9FuUXHL+dmauhQOIxCdJ4asDdFK8vLlJePYs9/vqKqi8A+xVKihqnP9JcaEsx6psuchQQieruvw3rNarZBS0vc91k4H9rgqGYvWmqqqisVn7a6UoJWiKnzHzIUATHaiaSqauqVpaqqq4uTkhJPTEz799DP+yf/+T/njP/kZza13eeP1t9nvruic5fPHHxPDwK6f8OYuP/vwQ5Tv+Ju/+7voquFkc8qdO/eY9pc8ffaClCJutKXTVOC8y4Yec4lCF7GXlFlv43zIJQVjcD7gvCeElAnA6LOO5kDjC6raYBScnt8moW7QITMdG+cm/TlSzIrBGKFkQyKW41PMHqUYzcHQZiPirwh1R2m++p3v/f4H85NhmhB+ZNN61ssl+ADWMuz7HJqcI8VMe3dDn+s1xnB6ckpT1wzDUFLCvFJeXrzE+0hV0mxrHburHeM4cnZ2CmRVnLWWaZyoq5rFYoFSiuVyiTEms8WFKZ4JPq11LjBqSQwe7yzr1ZJE5m/mmk6uZE903R6tNW1dYyrNf/Ff/iNO77xJGl/y/rtv8NOPPmZnJ5bLDfuuJwVHsHu+uLggeM/d0xVff/9bSK2JPvL0s4c8/OwzYmYJGJ1DKo3R+kAgEhOTdYQQsc5hXcDHwJ07d+m7npByO4332UhiCmitSJhdg3EAACAASURBVDGVkkakaSqmYeDB298AoY/ptK+Y2Ax2U5r7GbL8IaRY9FsBmQIi2UOGlUoN7aAlTnMN6pVC5VHt62A0h4NEYrkMLOrcEDUNPViLsxNuGvDeIUk4Z1kvV1nkZDMzGkM8TK53DqEMUqrsefqe4EMh3hSLZZOLiUXs3bYt+y6r4F48v8CYa6A8juNBUJ5SHlBrHUbP9aK80rb7DmOqA3nnnGO73TIMPU1Tc//+G+iqZrvb8z/9b/8HH338Ma2OfPbFQ37y0w8RySOSZXv1gpgczk+l53skTSPf/MZ73Lt9B+csn/zsx1xddWy3Pc5nvYoomx31Q+6atN4Tyf1aIWR8k0LAjhNJgg+BELPiXyl5kD+keTLJlfCYAqfnd9isT5mz5Vn68OqEZlcjiEKShCaikDKL5kQWPpGEPiqal/CUk/+D8X1V3en4Gvq4RTPX4RK6NvjdngUSITyOQFIc2kN0bXLqGAK1MTldLV5jt9vR9SPaROYdE6rKIKRg7EaCj5wuWyqjGfoeYspFRQT7fsip8+RwztL3A5v1mnGYsG5kuVxlZlVKtt3EojE0dY13DheKhLTS3Lp9CyUVp+fnvPv++3z96+/Qtg1JV/z4R3+B0jV72/Ozjx7mTQJI9FPPXPyXgEr5f9008eOHn/HRJ5/wG7/1HeResFpt8oSGnM0Zrdn1U8YDRKqqYb/fs1ytCNbhpcji9KpmnDxJ5GY2owTBJjQCtLrOoEJEqPz/i4sLPvzhn/LanQcIXWWjeoWhfdUrzJJNKSUpKmKYiNJDyoA7N0k5SA5ERIlYDN9B6aT8KpnMfE59fFEjJJXSCOdpJHg3UTcVUUR0YReTkoctSZRSSKWYppH91ZZ+GDCVoa5qXl5d0bQNXbenaVrskDW8y8WC4AO9tXgXGIaRuqkwuspEHpFgcwGwbVt2+332XFJweZnVesv1ikQgJYnWgvuvv0a7aDg/vcVrb7zJg7ffZrM5YXG6oWoWWaQEOG959/13+IPf/T7/3f/4j9kPIwnypJEOa61sp4RU+a9qsSYIiTIa7y0vLi7xPlfCfZ8Jv0pn9qKpc5dmVVVMYy5eTuOUz11AqxASpUpqbnT2QlIfMkHSEcGW4MXzxzz54pfce+sbWbZZxFKkuWZYeq1fKSsIIbKiUcosB83ySw6am5TLKcF7RKkMUGpwSbgbBjnX9lJK6BACSiqUEmw0nDeSBoGNgv2UmCaPVoZK5W04nj57itEGozVaZwwxDmNmRZsG6/KJT89OS6aUqfjZBacYsc5nFz45QoyEkLjcvmC5WBbNS2AYhkO7SSp6VlMp3nvvPe7cvs1iveD27ducnmxKJ4JmsVxw5+49mtUSbTS6XVDXDWM/ZElF23D3tXv8+3/33+Onv/glf/bjv2T0AZkS5qCGiwdyfb1Y8J3vfIe///f/A/6N73yb4D1XFy/4/PPH7HY9wziWvqlc7ZdClILqeKASZqWmkophHDFVmz+/iFjr8wSRcn/UZQ7ZuS8xSzZCCLzcXfIXP/kzbr/5NiHmLdHyqMyGVWBxumk4hwmXMstfRAJJuYYvBKHP2poks44nqiLw8l/yYvOjeJrIRidWemAaBjrnEMmj65q+G5iIXLoMztq6PrCX1g1F7ghBlCpxXTNZy36/Z5osUipSzB2Ay9UiV3lDpOs6ABaLBTF6TjcnB/lDLjFUB62LUopFW3Pv3uuc3b7N5uyc5XpFU9WsVgvatmWz2aC1wFS5jSWGvDGFGy2mqlEmp63L1Qnf+OY3+E//43/If/Vf/yP+yf/zJzx5flFEY9nPrDcr3n37Ae+8+Tr/4D/8B7z/rW9Q1w377ZZnj5+y2zsu9x1JgmlrlDLsuwHvI6GoDmfJaT8M+BARMRQBmqUyiv2QF2utDVVdsdqs+fTRo8IYR1K6lofEGPnoFz/lwTs/5u33fgtBBswhXGtpZlXdDc3El6wns4IyyVI+uIGoyRD6qyvbxyUGXRnN3bWmspdM/RbrI5WMJO/puz1KGZRU6LbJ4ajE3GEYICmkmLsjPc559vurvFWZ9xijcS5wdnZK3/fs9x0xZhH3nCVVlcGYnFrn1hJ9oNWzEVas1gvunt9mtT5heXqCNDrXUkJg7MeMaWzP2PUIAn3X50xsveLOvXusTk5p1mucT7gYOTm/zbealr/39/4uf/hHv8uf/cuf8POffcTLyyvOzs5YaM39+/f4o7/z73L3zddyiaTvefizD/mzH/6Qq75HVoZ91+NDQsWQM6jSAmttVjBO04SLeYfPEDwkQSARcssHTaVJIbJoDFUhO8/Ozpimie12iyeSrKMyFeM48vTzX/LuO98hSEUiHFnKMc453uUqkXBHz2WrksHn5CU6UnIQIyI4Ynn+1XrTzMrPPI2+tdS0/or9eIWWWZFH9Dg3ltWe3WwWX+d0drvbQcoa2SyuzpxDFlUbWlMjygrp+4F9tz98oOWyJcasoTXGYIymbWoWi5xij9N4sHJJoq4qzs/OOTk9JQl4/OlDnj59wtiPKKNzepliFpDHhBQS6yzaGFbLBee3zrj/4C3uP3ib1dltVF3hYkRWFV979z2++e1f5/vf/7d5/PgxDz/5hEXbcv/BAxASXeWi4+XTpzx//Ix/9s/+Ob98+Jh+CAjVIJWnVjm1Jgl88GSBN0zeIVTeC0eqChcCSmqcza02qUgwhIBFXaFk4vz8Vu7sdJ5xmjhZrelcfxCfXV1dkPyWpJfFUxwbzLwR41G3SIyIFIglnB2Ac/Kk5EllrpMPhOAKzxYO758xzDXjXKQRVbxksLldRHjLopKEaAhqBrtZ49t3PSFGdsVgNmVHBWttJvPIWtWszbW4ciFrLTEJ1ssWkmG9OUEgWa1alusFUojc1iIU4zixWa/p+v4ACiNweXnJp599ypMvnuEmi/U2lyhSIPiIErqEtpypzZxHWzd8/sUTHn36Bffufcw33n+fszuvo6oKYUwJIx4hJSen5/z1u/dQMrO71jr6vuflsye8ePI5H/3iIZ988ilXu579OJGUIElJCrFUkEFIlRsBY8THVKrZGaOFKHPIKllnjLEUPBPOTrz9zjuk6pKf/OgvWFc1bzRrzlZn9KqmGweQkjop3rp3hydPn7MTNTGKYgBF1lCMI6a8XRopIqMo3uZazhBTrjullCNKChMpFk8iSiHzqKVlzqRmbK6DsyhtqERuRR28AHTefABN8FmZlgrAWq/XCARGq4JbJmLwOJdjeT9mgq9pGrqi71VCcLJZU1UaqSTnp2esNits0aRkUXiHVJrdtkOpHL4kghfPn/P02XOss3TdWOSIidHmjEpIRYrXzXDGmQMHVNWGRVNztet4+vwFnz1+wmq15vadO5zfvs3J6SnaVKjSW55SzFmQiFw9f8mL58/57JOP+ezRF3R25MXFNtfFjMBNAa0Nu35fKty55dY6jzA673vjI1LlTY/slDNLRC6RILPgbb1acHp6QmUk0XrWpuEETbuoOa8WbNZnJK1Yrzec3r1HfPyEW67DyoahtMjkDt2Z4oCUAjL54mVytTuJ/LqMYd4D5pCFzT1ss119VRoPpayTQIPB2B3b7R6lJIiQXW0MhBRRUUCE2mhickjImz1PE+fntw5ib6013nsWbYsxZQOh9SbXnVQu35+s12gTWSwqgnUYqdl1PcMwYG1kcpmtlNLQ9T1XV5fYybEdRkSKmb/xuYwRU8IHjza5frVerBitZSz3khIMNmdp42RZtQu6bsLUT3j65PGhh+nOa69jdEUyimnf5b1oYmRyjqfPnvPycsfl1R5VGax32BhRLiKkphsmolC44JlcKL1P5rDVqw++fB5F0+aKuPWBuq5JwXF2vuT7v/3X+Nrr9/jRzz7h+eePWEvJvbrGTCMn3cCqmzCrhtOTlo17wfTxDqFbNu0Kf/s1fKpAhCJZLVRBUpDyZo0qxhLo897FzLuSp5wtxGQRZduSg9j8YCTywNnkUJUNXXs30tkxH5xiKahFpFSMw4Axmqoy9FOPdZax73lw/3Wuth2XLy9o6prNes12u6OqapSGRmWl3IHur2vqtiGmibZZoyUM48R+3xFCZBgHxsGy2/dY72nbhu2+Y7ff53qVzYDSaEMls3Gerjfsxj1CSbSu8VEiRDakWchdNS1VnQVU/XiFlLBaLnh5uUcJSWUMz15scW7CBU+lK7zzDNOUe8VjYBhzE390I857pKqwpXHMhUwN5HJC6U6Ewkhn/Ce1IsRAiqBVFp+nFFk2DbdP10RvCX6i3+8wPnISJWbsOZGCdtiTUsAs10x2wsYlt1bnjMFxWgtW7hGfmXuMos7NlAKuN2Esyj0RvuQ5DiD3hjeZN9686WkOXSnyei9k7aKjkgajsrZCyaxvGcYBrRSutIvGmMHmerVmf7VDBKiUJsWEdRMn6zXjNNKYPPBK5p0y2zanlFktVmHHkWePnzKOjnFyPHvxkv2+Y7ITZ+fnObXddcSQq90CiUgKJXIj/+Q8k7dY7xFaEByIJJFEnCvcghBImfuRrq6uqEy+vhKKfvSZzBKJmGKWN6TcsFZVBu9jPm6YGINndA4lJFJpJuvRiuLeYy44pswzebKcM3cZKbSROB9K54IqFW1Fih4lJMlHLl/u+cvp5+yudgyDZ1U33DECeXWJSREREqtNy7pNnGwkmzUY0THFkReXV9x58z5//eQZP7lsuZAniKSOJvd6f8N4hGeOH1JkXEYsrSyk0vM9e5tDGbP8zh5USwEqePzYZyUeKvMEMeGCpa01lZD0dkIk8N4RBflCSjIMPbfWa6RS7DvH1o7cvX2L87MTmrql63eEFNCywvnEfrRU7ZJnV8/5+OEjXl7tOF0teP2N15AFSO67DlkZppAQwbHt9oe+o3axYLFeMg0jPoa8E4IPJJlyJ0LZGybGSNdPaGMYh92hSl7XNeM4slxm4bokoY2hHzq6YcLGgAIma3Pbh9REwDubvV7wKNRhm5SMZRyhrNxQ2nhm9y5VrvIHHxFkjfBmveL1u3cZLq8YhwFdt5ydrRk+v2CZPIZILSXSGNqTJZsNnKtIEyaci2zUAiM9f/x//nP+nb/zt/ntNxM/evyUL/wd3NFmDvM0J1H2LT4SaGajkHmzS64NK5E5QJFubHxy8EYg0GqcmIY9SYBRLQKQ0tPUdWkJgf3LK5TSVLVm1/cHAm6xaqiqFVVd8/jxE9anp5kN3mxYLDKdXrc1tc5McT9YXl51XF3uudzumabAb//Wv4kicLnf4bxn1/W4mJj2A3n/oMByuaaua6y3+OCx44T3HmstIQ1IilxSKnyp7EIgeIcTidbUjMOAkPJgMLNQXQhBHIfcPVH4LufyBkQhBNw4UJkFIfqD65/3rTkWJnnv87ayZaXP4iiBZrlsC0eVgbFUkrHfcu/Oknt33qbdnPLhj37Oma5Y2ISnxiuB0TXNomZzplmpLbWBZGpSq6jbhjdPN0j/klX1Jn/jvuQHH3/O5/4MJ+ujNLxIKIQAMe9yFhAxZudPhZAl+8LmYnnKu17JYmXpUJ/MXJoO04hLgUW7RIlMSOUt4SeCy6lZvVhASZ8TUDcNUgr6fkTrrINZn25wbuL+/fvYoWO7tSyXS5btEjtFtvuOF5c7vnj8PGdaUvL2G6+xv7rEOcfoXMYtzQJVJdJgM94Z8ncgXFxcIJQgioQuLaVGaSqZw4BzjimVrUqkzFugFUzVjX3p69akyebdtYTAi5QLii63wkSR8DFSmczK5q8mklAyj7llZu4Tz5RC/t4F50ptR+WKstYqy2JjZLfrMxUQI0pmiuG1e3f5zW+9w/033+HP/9VPUbuRe8slYrtlXQmUVkgNm2XNgzuGZROoNpK4uo2rHiClRg2XTMOWtGuRpuL73/4aDy+2/PkXA1epIqIQSh7S7Ws1gySWbjlB9kICgUzl64qSvyaLYyIVDueAacYiWMp8Qk6B+z5vCFTprIAL3jMOA7vdLre69vu8EwIqV63PTtEqlwT2uy1aCU5Oz7GTo+8sNga6oWcYe6pKY1R2g9uhZxpHZDGCqqqxztMNPd0+a4ar0k5769at7P1E7mbQRuOJ7HZ7ZKUxskX4vDefklklOE1ZXrFYLLDOEZynaZrcbKck0zARfcBUmQ8KMR66LGftzjRNSOkPGwFkUZWkH0asc3kCYsz470gxkHcOz9uvCpFYrVa0i5ah2/Hg/hu89/YDmnpFbwPd5RUbozhbLOiToDYVqjWo1YKd1YjNA5b3lzy/7PnBDz5hGi546+373H3tDs2tO7hpII5bGq35+rrl3tLwJ48sj3pFFIq8/3NR7Il0aFO5DlaFTZ43e8RAKXAmkb87am6NSSmh/ui7f+MDrcqXwMQsGJoJvcxCXoHIO2LWde4j0lqxXi9p2xqtDSIlpn7LatmyWjRsTk6YRkcKkqt+5PJyxzBO9F1P32dibCjN8VJJfClL9GOeCFVpzk9vl87KUNji3Nfd7bvcnJcidVVTNzU+eJTMelwlZfn6mlwNrpuaGAIgWB3vK1PiuNKKGAIueIZxPIQspdSBSpgBYQjxgFdiTBkce5dxXkwHOSoFM8wC8vy8ZNzvUUpw784tvvOtb+K94yd/+TO2nz3h7tkpbrcn9gOqqmjOTtHrDZcTTL5iffst/q//9y/5b//xD7h/55w//flD/umf/gVutJyfv8H+xcjFF5+zqjwibHnv3gmVDOz7ASsWCGGQQs0QmLzzmUKIPG6H0FWMKzF/Y4sjeVfIvxzGdCJk14ok+oA2FVpJYgjsrq4wlcrpa2VKI1wou12OeVOhlOi7ibfeukfT1KQk2e16ttstbbvk+cWO7fYKpQ39ONENA4xQt21mUKeJyQY2p2cIkXf1nKzl8eMvCCGvduccp6enrNdrVqsl+27POI5cXl7iQ8j72CSPlLnPWyDw1hFc5pWICWctU9kSdtYQiXS99RtkTzkbxexRssZFkRClx1sUEVRiGAdiSmiZe4pmsD7zG3Ol2xjNerVgGgaUUnztnXc4f+0ucZz46Y9/it73pKam33WktkFUFa0wiKiQC8Mnw4R6+JjRefZK89//4EM2WmN84F+6X7Je32XNAvuyJtmXvHZf4PZ73js55+zNDT96suVp2IBUJEwWUrxSX5p3lrj+no+sNxblW1yOt8RW/9Zv/voHsdQejM67VPkh72ZAqV9IIWmalrzDwbxTEiyXS5y3fO3B/dyaOnT4ELna9Zye32LfDaWWJBmGESE1VVNzcnJyLX2QiuWyZZqmXFGfbN5torDK9+7dY7FYsN1uefbsGU+fP2d0llpVnJyecHpygkRycnLCYrHg6uIi0/VNdQBws974EJPLJgJZtQZ1lRdKcJ62XeQ6YAG489axdrIg0qEdGDhsYiSVysCxbEyQUjqI6+ft3VLIKfev/dqv8f6773D7/JRnnz/nwz/94f9X1Zv0ypGsaXqPuZnP7uExnvkcTpnMe2+N6KpCFyChJfRGAgRoJUBb/Qqt899p0dXVdfsOOZHJ4cwxR7iHz+6mhQV5u86GAEGQEUEPs2943+fFbnpUoxn6Aa50iKMBke8iXYesayksi33TsTtUDIKQvumxO8H5aMwsGSBsRb1MOaxLqoNFNJAI2aDrHF9ZTCcJfbunaBVaOH/x0P0ParwvPqwvCH7AsPrQXwbIX39UXTV4SuC5xn+dH0pcSxy1FgZ2JLGoDoWB+LStuZLoKYucOIrYZxs81zbFqevhNJrFYmE82lXDJj2ghEUQOGx2W/KDQYkkg+BoIFNYQQAcUFqjlIPvBXSdZrVasd3uzAjcsojDkGRo9ld1U9P2hu+73WxNbeG79EfJp5FdmPfxRZvTNg0IQdcbtL0tBEpZ1FVL5BuEidWDbxtFoBQWje5xbUXTdkjH+aqF7vovo3eNsMTx3jeFbte1OPIoalMC11aMhkN+85vf4LkO//Yv/8Yvv/8zdt3iuw6T8cR0capB2uZBzOuOsusRjsV2u6cuanzP55/+/j8Q+z5KVrRtia07stUjrh7QOIrl2kKFNk0raPYllmr5m6srzvOWH1Ylq1zTC/XvFp7mVDEI2+Nc2czIhDSMnOP767se5ToOjhLHex88V6H63hRQgC0tlLDoVUdZFdjCRPaVVcEkGeC5Nq7rH1tOyT47sN/l1FWFshXSkvieB2jW2zVRGIKlqIqS/W6PVIrVZk/TFOheE0YRfhCwXW/ZbHdYlsWLlxeIticrKvwgPC7bOBKqKqquZjgckmUZZVPjuS5N3XxdtFWlmeZ+sc00Xfu1zrAwaBM3Ds18is4ExPQ9lh/Q9ZosL1GOS96VZsrqGaG8kuYr2B5PGY3Z9zmWQAtF6Akup2MsaWG5AZPxjLrI+e8fP/PHf/lXAqG5CTySQEFX0NGDAsvW1FZHWYPj+fTSInJ97DgGrUm0TXa7wLZ6pmGAdHxW1sjAohyP7cGiedAI26G2XJ7fLRjPPC7enPH2xYx1anG73LPPN8cTtTN6H8yA7y8/ZkAoLImlpdlfyR5l6d74n7/QvI+qdmVb2LappruuQ9NSNw22Z6IFk3GI60rqqqCphTGMFRWbbWr+DkuS5jl127PfZ6YDmk3Y7g7stgvGowltb0JMLcsijocoZVPXDY+PjzjK4fLyEsdxSdM9fdcSDSIjGygr+l5THrGwnuexXBqH5DAeUDbGyPbFmekHAYljULFZlhH4RqPsHq8w3zHFsiGXtyghj4xhAwrYbFPKqsZRPUJaVKWm7fVX9l5R1nCUZ9B3RK6LshWeI/iHv/sbirIkqzsGkxldVfPDH36gOlScn864OJ0Qi+YIZ1J4ro/ruGAppO6JPZcSjWe7BK5HV1aossULYoTQaC1oshpp2bTS2Iec1iFb9JRWRSsb9tis93Pmh47Ji3OGoxlvLk9YpCn3tz+hKenb4uhmOC4w+yMq/0vX1H/ZkGtU3R4J3JbAUzaWAKE1nm8GWIfD4WteQRiG7A8F42FE4LjGJFY3BL5PVtZsdluEUMecghWWrdBaMBhECCF4ep7TdjCZnRgtjefSNDUDx6Wue9abLXVTMz2ZMAwHLBYL9rsdCIvBIKbrGorqQN12KCnRtIzHE0CTJAlIi+12S1mYHdYXM14QBF+hRmEY4iszuGz7FtsxUtdkEBF6rrlibUkchoYv3PeMosiY4Sxo6pbVdss+Na+j6yH2PbrOQABsKfFcG8/3iaOApi0ZjhP6fU1xyFk9PJOvt/zu5RumkyFSOnRNQTiIqTc5VWt6mMBVhL5EeiFDxwCtbcembzss0aMcRS/MhF64Np0UOOJIC6saWt2x14qDrtG2pmm3lE1N1gqewpTz6zOScIC6+Ws2mwfm83dICnr9Bdr4pUv89zAAAPH//j//t+7qhtBRSDqkNhPPNMvQCLI0w3hwXENCkBZSgO8owjCm1x1tJ2gQFGVBVdZflfpIgyndrtdUfYtGMEjGdO1fdlzrzdpEALZwfnqCEL3ZcG+NcMvzPSaTGYd8R1EUaASu77GcL5iOp+jeom4q8jwnr8oj4FmRDBOsY9v8xfoipcRxXXTd4toOtqMIAgcpDIV8MIjxXAdpYa5XpRglCa7nYTuS/X7Ler2iaSp2u9QY3SwLpQyuP00P+I6DrQxyTSmXsu34+PmBtlfMHx+4nJ5wPjsntBWBdIzZrzJGOixFUxZ4QhJ6Psp30NLMkKSSOMqmPeSIqsPrW4RukW5L1bTUjaLpoCwb6lbTtA0bYVEIm7aHWkq0kuB5eH7A6Oyct795he17DJIRYSD48OEHPnz8kf1+d3x4/j2b5ssDpHTTEjhGQKW1wWzts5K87qiqHEsIXMchjmPqpqFqW2xPMZtNKMsCSzl0ZUdTGcNb1/UGzOg4bLOcdL9jl6XMplNGyYi8aRBonhbPZmCmbPwgYDadUhUlaXqgqkq06BmPxwyiIdvdnkORH5k2LZvNFs+LqNuarvnLotLzPeI4Nskoume7230tWk9PT8nznN1ux+lkimvbHA4pQmqm0wlnJ6d4to3rKLabDVE8YHYyw/Ec3CMWTSQDBnFM1zfm5DkOEqN4QDIckqUZ2+UTuq1RysF2XH7/p1+5n69ZrDNcYBpX/ObNJWV6oNpk7PYrBGD7PlHo4nsDbG2CSzrpfI0GUkphdRrVCSxl0fQCXYPbSgSmlNBoel1TNS2tAGFLrFbjKoVwHPKmxepa6qpk/rRAWh7/8//yV2Bp8kLym+9+w1//7jU//fILP//yM7vdzrgY/ocWHUD+p7/9zfe+79O0LYeyMCGjh4yiyFHSUDk9z/0KEpoME6LIY7PdGm9O21FXtZEWOD6WJcgOOfPFnH16oCwKptMZ08mUum55eHxkv9kQBiFxFOPYNpEfkOUH9vvUEDKBk/MzLEuwWCzpdUvTt9RNTd0YNkwU+Cav27Kp68pcP8e2WgB5WXylkp+enpKmKV3bEg8GdE1DethjOzbJcIiUCtt1UK5DeshJ4gGeZ5AnCEHba1zXJ4gS/HjI+OSC6eyc0XhKHA8ZDsZIxyYIQ+I4ZHoy4/LygjD0KYoWNxqw2+2py5K+tWjrnCT2sGVP7Hkk8QAEHLKUvDhQVwWB0kSewPUl1Zd8dCO+RtPhOh69EFjC4OpbYdFq0FLSW5YRfB2R/bbQ0PcoqVBS0iGhszjkFfusYTIdsFin3N5mNFVOEPhEUcBut6NtGr72U18YiP/5P/z19z2wSQ8IKamKgqYosC2JrQRxECC0Wf23TUurW7I8ZTBIzKa3bEjioUFuWBaL5eLrAi8eREynU4QWLBZzyiInjHym4wnxYEBRFCTxgDIvKHKz0NPAxdUldV2z3e2o6orskBEfCeRJkuAHPmVVmhCO3PBrwjA0hC3XQ3c9zVEA9YUJLIRJVkFrdNcRDWJ8P4C2xw88lOPQIwiC0HxQQuCHIYNkQBTFuGGIG4ZGpO4HSMehsxSVBm07SNtB6OOQVIClbEbDa3M2iQAAIABJREFUIX7oMz2dcHNxjsorwr6kz3PqosSWEvoaTUvXVFgCPKWIQh+lJEp10OZQZ/iigTpDSTM3k9I+ShlMDdJ1DdpWdL6DVhbSVQhlfPCB5+E6YADYmrrVRx14xaE8kO0zZpMBurNJ98Uxb6tlt92jDwc0Ftr6S00j/6e/+933XWs2x0JrRNvgSWPAD1ybrm0NGgxwAx8lLabTsalZesByyPOctmt5XizQAkLXZ5AkuL5HVdVs1nuUlIzGI4aDAUo5LJdLvMAnr0zCreNKPN/j/OKSQ5axWq++brJPT0+/Du/atmW5XKLbnrZrcVzDlJHKomhrXGVTN2ZZmiRDbKUI/AB0b6ywXUcyHNI2LY7t4NouRV3hh6EhqVcVo9GI8di8R2nb9NIiiCLCOEIpGyyLqunphaDtMfIMS5qYGylpsHCUxyCOiQYRQeDhCsnPf/ozU8/jPImps5TNfEm2O9CUB5o0hfLA0JOGOFZs6WiNb0opOnpk4GCrDtmXoA9khy1VkRENPKKBb3QwysEJArA0tWVhKxstLOresIMD18aRPW3fIFSPUoJ9mjKf76maluFkhNAWD7e36Kog9D3S7RrkcWbcd6heHx12GHFS5Hu0ZXmUUTbYtrFW9MJC2jZKHlNNtMBRPlpaFBasFyuUsomiiKqpqbuWtjVd12gcG8jRkbw5n88RlqRrO/rWoNGapiUOYw67PfPnObZjY7s2l+fnxG5AbWnm8zmr1crg8H2X0XiM5/qs12u22Y4kGmBLG1vZ5gFpW4pjJoOU0uh/pdmnDQYDkmRIeeTKfKWV+z6e47NabgmCgFi6BFGEtI0zQ1mSsm3p0VR1ax4YIYxST1i0rUDZHvuqYYiiKEt2mx13d5/55rtXeI5DYEuS3YbHj3fs1ym7/IDddrRlw2K+wrcsaHtsRxK6Hso3+ZqtZeE6Lv4wxvZ9Ei/EEhZtXZDnW4pG48UJQTzk0Hn0RQudhe/Y1EXBKi3NoE6DZ7sI20U6PtpxafuW5eKRbbrn5YsbfD8BkfP0dE99yFFoesdBo1FO37EvcxxhrJsWGsdxKKsKZZlRfte2x29VhxsFhjVT1TS6pq0hLyuUtBkkEWma0nQtbdfTND3T6fQrl+aQF8xXGxxLk4wiqqpiMh7x+Ph0BBxZPD4/opRBiEzGE0bJiNVqw+enW/re5EpaQjBIEiI/5POnW/NabcUgGtCheZ4/YwtJ3XVIaTob3/fZpyl91xGFEYN4cPT+9MxOp9RNQ5EXhGHEZr8jGQ7xwgBsaa4eS1G1mlZ21H1HWdd0x1lNe+TRtI3hAeZ5hqTj9sM7xklCut3hOC43b1/T95p0s6SqFLdNQes7eMom8SSqNXORxjKU0OVuQ+506LZCH/Z4toPMNWwh8E0coxMEODJASIVyFVo3bLdPrA81JS1K+Bz2HVbTo7SBTvmOwhe28VX1FU3R0zUtdIq6svn004K//4e/I12tSbM9eZGjuhZ/OkYjUNkhNxpW6wgAOD6Jfd9TtR1urbBtB991ccMI0euvWDANpPsdyjGb7dV6xXK1ZTqb0LYVs8mIxWpzZKjAfP5MGMZMxgM6IIgi7u7u0WjGkwnPj48kYWQ6oDDADwKenh55fHwmDL7ojCFJRggpebh/oGkaojAysYG2zefPHxCWMPqgXtN1DUEQkGYGVO37PuPxEAGsN0tm0xMQUGQHJuOxkbQe//04SXC9AMtSICzjNqjNydx2+mvXVhQF8/mc/JDR1jXrzYbDbkMU+WTbHW1x4NuXV9Abb/duu2e9WjOMQoajGb4fEAQenz/fUtUdZWVkqlXp4Hbmun1xc4EWmrLIkV1P5yniaYxn2+y2G7rSRANoy6duodECrSSu7CjSjL6ssR0H7dkIy0HaPnXfIxqbuirRGtqqY5fneJ7P8tmslXzXI91vUEIjuh6UhcqqDNsy6/8oCmm7lrI0WJEoDGk7UMrMboTuqUVvOC1NzzbdI20bLWC337HZbhhPxxRFzmg4IktTNpsNcRyzXC45Oz0himKTbOJ6PM+XlEXF9fUVu+3OQIgch/F4BALe/foraXYgiiLGwwFd3yGVom9rsn1BLwy18wsJ9O7uDs92GCQJeV7QtgZ0VBSFaY19FyzJbr+jqSsuLy6PV45iNp7Stz1d1WDFAj8MsJRLXZvon7aGrhfUtdH9miVmT92UpPs9h0POfp/x/PTMp8+fubt9QEg4G/jMIsnKhcl0zE+ffmW/r7i8vMJzXT7cP1DkBYEf8uLmguvxmLpu+en9B54XCwazE66uzvl0+8B8/oy0JJM4pmp6ZBMx8kKqQcinhzmb3Zqm3tK2+uuyVQmB6Ht0b+E4kpgY322QlDS9ycNKswNtLxFCEtgus1lMc9ghtFn2xl6AY4NtaSohUHVdU3YVnudS1Saxtu073NDkEeR5boZeXmC6paKka802GGnheCGL5cKsAqIBFoKz01OKsuZpsT4a/42I6uzslOVyieM4PD0u2O72xHGELaCzNMqWuJ7D7pCx2+3Y7LbEUczJyYSqqb/mLW02G1OPVBauY1Rw68Wcqm04n55+RXQkiYkrLMsS3/coypwoHJBVDePBkEEY8fHTLZevXmArm7IrGU0nuI5NfshRto8lLLK0wHYjEIKy0TTaQfaSvGrBCggil7To2Nw+cv/4gGPB//af/gHRliwen0iGAxppc7fYodwBVy8Crq/O2GzMl2489kniAVIo9mnGv/7xJ7a7A6NhRDyIafuW1zfXpPmB5WKLrXwTPlKtmY40ZVXwtMwQwmE6OWU0CPjDj+/Y5pquqSnqilVa0WoQ1hOBrYg8h9dnY5Rl8bzcoBzF6eSEKPJAl9iU2G7EIPLpKx9P9khlUyFQRdEe44B9DnlJUeTEkY+tDbY9jgeMx2PKuqapzU7HUjZVWTGZTHh4esa2Jb5vwiuSZEBZtGy3exxH4fsuliWZzSas12ssy2I+n9PULdLSzEaJuR41DJIh2+2WrCiYzxeMRiNOTk4AWC6XWJZFURSMRiOkUuznOZdnZ2SHA2WnOT85BW0K5tFoRBxHFEVp5jVK4rsGpSZyxWw2Zj6fE42GWAKe1nOiODY09l4je0ma5qy2Oy5PZlQdNLWF59rMhkOWizmb50/s0jlKSv78ww94jsPAEVBrfv3xJ4bDhPW+4OP9kkESM51OkZbA8+DhYUmvITsUfPPqhouzU2wlWC42XJ+fEgUpQRTyy7sPHMoS23LZZLuvlC8hBGGSsNhnOEqxSXPaVrPcFURBCHbMdjvHlYLhcEharRCdoKwqdk1N1Qp2H5dEns1sGDFOhnjxkD/f3xP5DsnpBUL0hIOAOHTwZEXWw3rbIP/p7dX3JtHVMkTvpjFg6bYlDAJmsymOJ+l0iz6myvUalOez2W8RQhP4IbvdjvF4zH6/Z5elZIeUyWRM17WcTCfAcS90JFz1fc9oNEJY4thhjajqitV6RdXURGGM4zjYts1yuSTP/5KumyQJz8sFXdMzHPikWYqSDmVTs9luCMIA13XQuud5scCyJEkYmYdCC5R0EKJjudwYVqDuOVQFfhiwWK5p+57AD0jTHIFmNj3FdSUfPvzEw6ef+NN/+//4+OkHnu/eUx9StvMnHKFZLhforuLNq2t03/Pm9Wts15yMRVUipdEJrzd75qsNVdMwTMZIaZOmB3bZgafFmkGScH1zyfWLK97cXLHZZ1Rth7QlUjl4gY86DiN116Fcm5OzM/K6J81zirqjbFoGoxGTkzM2mx15YVC8jpL4nk/Z1ByqEktokjhE9y1v39zw4uY1P717z939E9FwyKtXr1EWjIchn24fefs3f4e8iMT3oWvj2TbNUf/StA1hGDEcDc2Ayvepi/aIaTeGqrTI0dpoX79MckejEavNmqpuGE9GfAlEn06mPD8/k2YZTdMQRzFxHOE4DovFgtlshpSS5XJppAeWMlkFnktVVmRZRt+b0PckiVmtVuy2B3zfZhDGbLcpQlikeUocRAR+QNebfEzpmInzZDyi681qQbcN+SFH2g7VkZWjLaMd6Xp4eXlJXVV0ukfQ83z7jj/823/l9sN7yjRls1nSdx3DYUKRH1iv1ghhUeQVj09Lnpdr0rxgvdtRViW247Dc7ljtdqxWG9JDznaf8jxfsN3vqdqKh/kzHfKIV3G4v39ksVgyiBOE6BmOJ/zmm9ecTBL2my1VXXN9esabN6/427//K04mY2YnM9quZbFZ4QfhUfusmcxOSI+Rib4foMHIYtuWutNs9hm7fU7fwXdvrhhPT/jp/Tssx8ywpOPx4WlDjeLTp8/I/+t//fvvB5HPelexy3LariOKQmbTMYNBROC7NHWP7o6iIyFo6xaNcQyWRcVyteLy8oLbu1vKsmQ6NRPfd+/e8/r1a4r8wHa7P2ZUS06mUyxp8fz8TNu2nJ2dUZZmwuv7AR2SdH/gZDIlz3Oa2hj+z8/P6HXPw8MTRVHw6uVLtts9TWu4fpPJGNu2uX+4x/e8I3FUczabYlmQpQfWuy3xIEELTYcgShIc22afHRCWwHMVTVGw2275fPuJ9eKZ7WbNbpeihAkC63vD4dmsNygNbd0wGQ05ORmSZTuasuL1i5e8uLogCj1evDhjvd0xTIZ89+1L8jyjaI2sIgwCvnn9kovTE05mJ8SDGD/w2R4K3n/4zHq1ZTCImE7G2K5DcShYrrZYts3D8zNV11LkOY506PuW0PeP+qTGACKPC9skSajanrrXdE2H6yhGoyGWJUhGQ+q243G9pigqXr++4er6hoeHR27v7knTAxYwGo+oqwr5n353873j2gyTBM+R+K6k7cH1Q7wgwJY2ZWUMa03T0LTGRRiEEVm2p21Lzs5OyA4HDlnOdDojjge8f/crySBhMhrzOH8i3WfMZjM8z8OyFQ8PD2SHnKvLF0D/FSYQxTG7NMNxFV3bkKVm2311dUXf99zePlCWNaenJ1gCVuuNWUckCcPhiM/3j9ie0bM0TYPrefiOR34oWe9ThC0ZDYeUVUGaF0wGI6q6Zj5fUh0KIt9hOV+wWC7pdI+yJFVZkBfl0aEg0HTkecHd/T3T8YhvvnnJaJgghGS3L5lvdvRdx4sX11xfXTAeJShLMB4OOJsNuTg74/ZxQa8NDSxLM05PZkRRTFVWJInpMPOyZDoeY1mS5WrD/cMDVdVAD1EUMZmMEQIeH5/ZbPcm4rHtcVwPz3NxXeeI2DVxj+PpjMl0Rl3VLFcbyrphdnLKdDZmNh1B17Pa7Hh8fibb7RmEMZPxlMVqSXYoGY0TvvvuW+T/+c+//T4IXULPga7FcxRKGFTG6XSKZUnyoqD7ovDXEPg+hyKnaWpTSxQlZVUzmUxxjjVIWVbcXL/g4fGR9WbLcDhiOExomobtdkuel1RlzeX5GVVd8bRYMZlMqKuKQ1ES+QG6N1j88XiMZVmsNxtWqy1xHBs/+S4lzXO0sLi5umSf7Vms18be65nw0ZPhmO1+z9NiTi8049GItumYb9a4jjJyg9a07rY04vqmM9h+3/NRwsTyFE1F6PsslnP6vqMocqq6JM8PNHVFHMVs9zuWyzVPiw3z9YrFekPgulS5iTP85s1rRqMxtu1TliUn0ym2rdjuCz7ePzNfLlBC0ZQlrhRcnJ0zHA357rdv2WzX/PDTe1a7jCSO+Lu/+StmJyNOZ1Our2/otOb+4ZE4Sbi8OGM2nTCbTViuVuz3GcPhiEGcGP+YME7UoiopcqPVHo4SAs83vGchzHonTTk7O+P1N99wd//A7d0DjrSRv7s8//7j45bf/3zHj7dL3t2v+Pi05fPTnNEgwfWMi+CL3xkhWK6W2EqaVcNXZt+XeJqGNE2J4oh9umezMeP46WREXlZsd1tjRVWK87MzXMeAjNJDwWAwoK4qtmmGJSx818NxHRzHYbUyuyjPdxmOhsfOIycvcmaTCa7r8+7jB1zfYzoeG7mnJXCU4vPjI03X4bgOSTQgPZSkeUESeVg9ZHlG1xlXZRSFKNuh63scJSnbiqauKIoS33OQ0mK5WLLd7VBK4npG7lrlBVEYEngu89WKsmno+o7b2weT5FtXKMch8F022y2z0xk3N5ecn59gK8U+3dFUHc+bFZ4Xcnl1RTKICeKIjx/v+O1vv6WqO7KqYbtNeXhe4CiFG3hIYXRHdWfcBEHgEkYBz89LytKgZGzbZjqdUBQZi+XSgJiOO8WiNOSt89MzTk6mRPHArBq8AM+xCQYDRtMpj/eP/PjTL0hXqO+fNwcOlfnQLClp+obz2Rmz8Zi+a6mr2hR6RU6vNXEUYVnSQBxd52t832KxMMHqdY2wBOv1islkzMnJDCEEv3z4SI9Zoo1GI2zbxlKKh/nccGKOMYSbYyf25T7+ov7/4sW2pCBLU3MiVCWz0Zg0O7AvKwZRdKRYGoF6VhRs9yl+GBP4PkJrNrs98SBmFMcc0hx1pGNJKVEagihkvV59JaLv9jvG4wkvri9IApeiNv6suqpxbY+L8wtubq6NP7zvGI1HrFZbmqbFsW3KqmKxXnP/9MRiueH0/IKu10zGU7J9Stca1s7JyZQoDPnx3a88Pi8Iw4Cr8wuiyRWjSQi659tv3/D89EyWV6w3K1bzJZvVBs/2cH2fl6/f0NOzXK/48OGO5XrDeDLh7GzKeJxwfXNF1xhA5s3Ni+O4Q4OQFEXJeDzi+vqasmy4e3giyzPOL2Yox6ZuWnb7PfJiPPq+PvqDjLBYUFQ1+yzl/OIUR1qoI650mIwo6pJOa+rjJNF2FBrNbrsnDGOzpu87np7mjEcTJpMJfdfz6faBVlgEns8wSRiORqw3G9Ca1XqDFwbYrsvzcsnZ2Rl5nuP5pl21hMUgjtnmOa7n0xw5NmVV0XcmlqeuW0aTEbozUTm271GVFU1vEmSnJxOiJGS9z7AdF8/zUFKQ5TnBMVtBa02R58xmU+6fHk1WU2+CzF69esHpyYjNak2a5zRtR54bansUOlxcnvDrp1taYSCWD/ePSGExPQ41R4OIF9cvOeQFf/zDD6RpwXz+xCAKWayWvHnzDaPxiPFwyO3dA2lRkGd7zk5iHFFw2O+4vnnBdp/hKslyvmAYRwgNRVuz2e95+eolddMShgMzsW4aFssVdd1RlhVBEJnNexRSVi1ZlvHq9VvquqFpaxxb4bkGdOm4xuaTZinz+YJBHDBMBmRphow99b3ue+PhQR/xX5q2F2y3KWjJeJyAhuVmg6U8yqoxG14MUFnairbvKfKKvGzIDhmnJyfEg4TdIWO93hh0qusgenNS3d8/4HsOab5DoxiNxqzXK8aTKeudEUw1bYclJdJ1WK33SKmOwRwtvh+x2GyJBjFSSYbJkNVqTVaVR7MaOK4LloUfuCgpKIrceLiCgKIsKA4HwjAysg/HhKJ6vofnutzdPxCHAdluBxacT6dsN2uqtmcwnBhPd5YRhQGzyZizswv8MOHu8Zn7pwW73Y5vXt5wcTpjmES8evWSxXzFIElYLFZmt1TVjJMBF9eXaA1RnND2PQ+PzyCMrNV1ffPQjSdUVU1V5uRHro+Ukn/4x7835YFl8e79e6IoZrvbIqWBHqy3G9QRTZemmZGrHpuZi/Mbut7UqW3T0LUN+SEzuu8gYDQemb3dPqPIK8qixHUdlMJAkPujfaPRPb023Ln1PsUNdnSfwepqTk/OKOvG7HKkZBjGtK1gnx7IDwWWEOZqGQ5wfZ+Hp2ejtHNdQFLnNbYjjq5Fi7rR6Fbh+4rtdoPtejw9PZPlBVeXl6ANMHq53aG1hW9ZWJaiqjLycom0XYTl0LSw3e9QtktT1gSRj+6MTLJrG5TnsdvtiQch4SDihx9+4vz8nM0+x3FcAunTd62ZHEufujHZD4vHFi9wmE7GrDdL8yEqh+V6S1c3JHHMyWxG2wvun5b0PRSHjLrImIwSwsjj2+++5ZCXzJc7/DDg9u4TyTBGa3hxdUN6yBlNO/bpgV2aU5Y1lu54c32O73poDWUj2D8ueXi4Z7vasFqnvP3uDRenE+qq5P/43/8z9w+PvP90y+//8AOtFljCOUpvHbquwfc8vv32DW1rvPLKlgirwZWSy7NLHFnT9wn6OFFf/nHJycmMt9+9Zb1eU9Y1g+GQX9+9R56G/vfaso7pbV8i6TgiXhvS/Z7H+QIlLUbjCYM4QiqF43o0XUfd9WyzA0oZnq7vOscitcQkhwgOeUFe1mgBySgyBjZLsTscWKcZnu/Sa9gdchzX5+zklLpt2aZ7Q8VUDp7vI6Tp5HrdU3Y9g+PpkgwH+H5IXtfstxnjUWIK7KLiCwuvaTqSZMj8ecFqtWY2m1I3Ld6x0N6le5St6GqT1XD3+TO2rZidnmALC9s1WpL3P7+nrgpmswkns3OatmV2dsJis+bTrx+wBFyeXxIENuPxmLbt+PXjZ5re+MnRPXHo8vLlFednM0bjIU3Tst2u+a//+gc+3T1zNhvzm7evGY0iw1R2XfKyomt7np8WpIcCKTQn56eczSb0fcf5+SnDZIglbTbbHfPVnl4bT37fNtiWxTSJePvdt4yHQ7bbPVIKfv31Iw+PzzRdyeuXV5yenDAZJUYHtNuz3RiqhzkZK87Oz5Bno8H3uteIrjOF8DE2x/xqIWixtaaoKw7FgX1+wPNCdG+R1TXz9YpkOMJ1XMqyoqoN48RxXJq+p+g0KvDRlmVsHUHMIa9Y7nY0fYfteQa6KK1j16L5/PBIUVa4nmvA0ZYkz3OKuqZsDGzI93y2u+yYgOdQdQ0PiwW+5zGdTthtd1Rdx2AwpMjNHOfh4YldbhwDbd3gegGKHkuau9sPQkbDIVVZslw9E3iSs/MrmqbGd21oG5Ik5vL8hCgI+fmX99ze3yGkRb5PSQYDXr56hWVJbNdhmAzp+44//PlPLJZLnp+fcVyXb759S9d2eEHwVQ3X9x2HoiaKIta7Hb0WXF6/5JAd6DtNGAYURcHzcsVwPKKujMC+ajuSUcI+PYCQ/Prp87GQ3xgwkzZBYRpY73ZoOmZnU85OZ+z3KUEQcfdwx2a/Y/68ZHYy4vrqnIuzM9rSgJzmiwUc/W9pmiFvJsn3Fj22Mp5dgTCYVimM+9Ay7LYOi6KsWawzPt498rzZ8PHTLbcPT3z48Ik//vlHfvzwgbLtkV7Iap9y+/REUTXs0gw/8Gj7nuflmqxpsRwPecSjagTrzY40PXDICxzPI44jqrJGYNLj2g78MMRxPFMMd4ZgpaQkiiLm8xX7rOTi3EyXF8u1KbZXa4RlgkifFnPKqmQ0SMgPGV1TEochQlnHVtXs0MqiYLvdEfsBD/cP5FnGm9cveHHzkjge4trGnfnHP/2ZujVpL9KyOBQVWJI4GXB3d8urq2s+f/pMGAaMR2Ncx9ial6u1sQx3GhuwsQhcnyzLeHFzQ9PVfP78wM/vPtD1Lev1hr6tqPMMWzq8/eYNri15+eqG//Iv/8J2cyAZxDzM58xXS4qyoihqNB3jsSHBZ0WBG4bQa25vH0iSAW+/fYPne+Rpzi7doxHc3T0xn6/xA5eXr244P3Z6u/0OpRRxHCOvZuPv7WOKW5wMaOrKWCocYxxzbI/ekthH/WtRFhzygrqpiTyXSeQxDn3CwCHxfXRbs99tqcqCpq5JswNpmmHErpKibtinGY9PT+zTjN1xB1M1DUEUoWwH13XNBFYbsLMlFY7v0bTG5VmUJWVu4AJhGJIXJfcPD4xGJvlusVyYB601e7TJaMj86dFoYxwbaZkcxyiIEMckveenxZFZvGO32Rj/EkdttFJcnJ2QVQ2Xr16RFyXz5ZyyMAl7k9GYMAx5XqzYrNc0TY1j2yhhVHzjyYTL62uiKKJuGu7vHnh+fDbhXF1NWZX4Ucjl9Qs836PKSx6enmm1ZrVeslqvoW/5q999x+XFCX4UMBhGDJKIMi9YLLd0ukNIgbIk680Gx3WZTAYkccj5xSm7NDOnBCbgbbVc4fou8SDGdT2KQwFdj+56o7B8nqPp+ad/+kcC32Oz3Rl4VRwhT6ez76HHdwN822YYB4S+b+5DS4GUVG2D7brYliBwHXxP4TkmW7ppGpCCommMBFIb7nDZghYCTymso+Isy/bU2dYIqcucpjigmxp0iyMFruejtYny2a3XBhRUV4RBaOY36w373Zb+KK/s+54oilgcj0/vyNNL05T8kFHVNb4fICXkB/N7ou+IQo/L8zMDYdrv8TyPxXzOdrOlrCtOp2O+e/sa2hbHBrSm61oe5ws6rXn/60d0p4lGY5TjEsUxdVVRlib/qW06rq8uGQ0Tvv3utxyqFuF4lGXFdr9nsVogLYvry3N++/YVNy9f0QnBoap4fHykaUzSixCSKPBNNFBW8Ljccf3iJW2vQUraviNwQ6RtkxYFL1++oqwrHMfh9HRGMojxXZvR8XWuV2ugZ5/tcTwXZTsEQcDNzRXj0Yjbu/tjDdjSdi2+H/L4eE8Q+nSd5vl5jmVJZOJ532vLIRpERIFL4Lhg+zS9RY9F0TQ4nkfbduRNS9321K2m08Ko5LVF22mEUkjHodWCL9EjTWuCOh1prK6GRdHhK5vQtfFciWMLXKVQQHnIqIscXddoNFVVEoYxaZqy3Zkn3QL6uiLPDybW7zh57Y/AIaUUeZbiuyYnM44H1E3NbrdDYETs1zfXCKWI4ohsZ06Wui4RumM0jPju7Vvzn54M2Kcpk/EYzw+4e3rm8fmZzXrDyewU23Pp0ERByHa9RkmLOIo5OzshCAxPuahqNpsdlpKsF0tWiwWB65IkA4ZRTOj7SGlTVzXb7Z7tbstyacRro3GM69qcncxYbze0bcf7Xz+yWq4pi4r1astquTb+s67l7v4R1w34+OEjgR9wfXXOaJRwej6jqhruH54oDoZlmOcFhyznxatr4jg6xjd6/PLLO2O9FYbwenPzgsfPt0dcnEHGqG9eXBL6Hp4ygZqrQ8Fys6esO3o0TaupC2NR6VtDdhJ5BxkcAAAFKklEQVRgmHKtpuuMEasrazNdPDJ2rWNH1jTNV5iQxgCXGhuszmy80YIvxBxhCeh7mq7jUJmCN8tLdN8eqU0WwnGOTlDFoThQVAUmn8KY5IoiR2BE7Y6ljPmsrI6Je5KmqanqhvVmzWQyQdqKQ56bb5C0mE2n5EWK7Vwyu7mm6BpGyZC72zuapiXdbQm9gIeHByYnM5Rt8/79ey7PTrEsQeD5tE3HcDjk9vMDXW80PXXfUmYHLk5PiIKAi/Nzg0lRLus0pWkb7m5vKcsa3/EZDwZMp0Mc1zfTWecBhOTm5obV84L9esshS7m8uuLDh18R0oSXbDYpeV7y4eNH3r59zYsXVyYZp2j5b0dTZOgHVGVL11n8y3/5PX/91zVB4DFMEs5OT5kvljRtR1nU/PzLr7z99iXr5ZLhKEbToV6cnnEoS27nS3ZVYVJc2566qtFNT69749LTFp0FUhwpT5aFsiXImrI0gai2UFhKo2xDo6obTdn2X1NH+r6ja7XxUiFwbNusKZpjCusRn9ofs6y0ACkM31foBrqOtjK2kVaDQn4VrX9JcSnKAmUJpABNRV5WtEfkm6skZVny808/EoQR6T5FHYE9fd/heUbu8M3rV0RBQDyMOb+4oCoq8ro2fBphI4TksFnhCBMYMp1N+ObNS7SGH395Tzgccjt/Rjg2D58+s9tt8X0X27aIwoB//o//kafVM3E4ou8s8kPGcrmlBy4uzmjqksuzGUoqeqFYrh4pm5ZDkTFYr/mb371BWhaT0RjhBAhH8m+//xP1Mbwt8gMcqfj0/gOOI7GUzelsyjCOWG5S4sGAyVRxyArCIOKXn3/l5uYajcQPfUZdTFkVNFXPcrGirir++Z//EcuSDAYJ0nfd7+8WWzZ5TltW9G1F35oaQ1lgW6AEuLb50JXUKGUBhgJpaVASbCWwhbmKpGW4No4t0cKk3CsBFj1Sm0LMloKubQxZSwssk8CJJQwwURz/vCW0iefTHa5SWH2PtEApC2kZ/L7QHUJogzoVZiES+D5db6ICbSWxLQNBatqOsqyxbZuqqg291FZfgc1KGSnoII7J0gP7XcrtwyPL9ZrlamtmNsri8uyMk9mIMPAIfY++bdCW4pdfP3Nxc0VRlPzp9/8dek0Shby4ukQe6V6e5xEnCfP1hqaFxWrD8+MD86dnPN/j6vSEYTLE8cxsCmHx+5/eG799V3NxOmM8HnFxfkGHwA0C/vzDL3/JBW1bJBZXl2f4foClFNvtjq7ryfKUu7sHLi/O+dvffmNCVKKIQ1Hw6fMtVV3RtBWvXl0zGo8oigP7dM92szu+dhc5GY6+F2hcofFtC18pAkdh0WMLcw2pY2aCiTM28cEI07fXXWOchZbBoFqWOYX0ER3m2g6OsvFcB9excRyFZ9tHOKBJvFdSIo8xxdKycG2JrSxsS2BbYEujNDPB7pK+a5BCHLMzzQlmCczpckSy1415MMypiCm2j0HzAjO8HI1GRIER0KM1vu8ThiHXV1dkh5ztLmWz3fP4+MRiuURZFl3XEIUBL64vOTs/ZTI54ezyisV6w9PznPu7Rx7v7nl6ekR3PdNpwuXlGTcvbwhCkypXVRXb9YbVasV6veH58ZksTRkPh9i2wnMczk5PUVLSNjVpfmC9WBk8q9b4rs9sOjKki/GIvMh59+snww9yHJRSSFuRlyW//e239Fqz3GwRlqJuena7vVFJdg1/9fYNlq0IopiiOPDzj++OgrYJ41HM+dkZu/WWoijJDkao9v8D2oPvsSFsImkAAAAASUVORK5CYII=","e":1},{"id":"image_8","w":405,"h":708,"u":"","p":"data:image/jpeg;base64,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","e":1}],"layers":[{"ddd":0,"ind":1,"ty":1,"nm":"Pale Orange Solid 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.075],"y":[0.996]},"t":135,"s":[0]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":139,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[1],"y":[0.013]},"t":150,"s":[100]},{"t":151.000006150356,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[160,90,0],"ix":2},"a":{"a":0,"k":[160,90,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.326,"y":1},"o":{"x":0.739,"y":0.739},"t":139,"s":[{"i":[[0,0],[2.625,-4.125],[0,0],[-8.289,-1.382],[-2.125,-2.625],[2.5,3.25]],"o":[[0,0],[-1.101,1.73],[0,0],[6.75,1.125],[0.957,1.182],[-2.5,-3.25]],"v":[[157.5,63],[146.125,67],[146.375,71.5],[158.375,65.625],[169.875,72.5],[169.5,68.25]],"c":true}]},{"i":{"x":0.302,"y":0.302},"o":{"x":0.752,"y":0},"t":144,"s":[{"i":[[0,0],[2.625,-4.125],[0,0],[-11.125,-1],[-0.75,4.375],[2.5,3.25]],"o":[[0,0],[-1.101,1.73],[0,0],[6.816,0.613],[0.275,-1.606],[-2.5,-3.25]],"v":[[157.5,63],[146.125,67],[145.125,76],[157.5,85.25],[171.125,76.5],[169.5,68.25]],"c":true}]},{"t":150.000006109625,"s":[{"i":[[0,0],[2.625,-4.125],[0,0],[-8.289,-1.382],[-2.125,-2.625],[2.5,3.25]],"o":[[0,0],[-1.101,1.73],[0,0],[6.75,1.125],[0.957,1.182],[-2.5,-3.25]],"v":[[157.5,63],[146.125,67],[146.375,71.5],[158.375,65.625],[169.875,72.5],[169.5,68.25]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"ef":[{"ty":20,"nm":"Tint","np":6,"mn":"ADBE Tint","ix":1,"en":1,"ef":[{"ty":2,"nm":"Map Black To","mn":"ADBE Tint-0001","ix":1,"v":{"a":0,"k":[0.65986520052,0.609302341938,0.562351107597,1],"ix":1}},{"ty":2,"nm":"Map White To","mn":"ADBE Tint-0002","ix":2,"v":{"a":0,"k":[1,0.939583837986,0.883195459843,1],"ix":2}},{"ty":0,"nm":"Amount to Tint","mn":"ADBE Tint-0003","ix":3,"v":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":139,"s":[0]},{"t":144.00000586524,"s":[100]}],"ix":3}},{"ty":6,"nm":"","mn":"ADBE Tint-0004","ix":4,"v":0}]}],"sw":320,"sh":180,"sc":"#d4beb0","ip":130.000005295009,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":161,"s":[100]},{"t":188.000007657397,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[134.5,59.25,0],"ix":2},"a":{"a":0,"k":[-14,-11,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,18.987]},"t":161,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":188,"s":[90,90,100]},{"t":297.000012097058,"s":[90,96.41,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":4,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":6,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":15.1,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.964307598039,0.948043763404,0.948043763404,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-14,-10.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":58.0000023623884,"op":958.00003902014,"st":58.0000023623884,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":144,"s":[100]},{"t":173.000007046434,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[136,76.5,0],"ix":2},"a":{"a":0,"k":[-14,-11,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,18.987]},"t":144,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":173,"s":[90,90,100]},{"t":297.000012097058,"s":[90,96.41,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":4,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":6,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":15.1,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.964307598039,0.948043763404,0.948043763404,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-14,-10.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":41.0000016699642,"op":941.000038327716,"st":41.0000016699642,"bm":0},{"ddd":0,"ind":4,"ty":2,"nm":"Channel.eps","cl":"eps","refId":"image_0","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":16,"s":[80]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47,"s":[80]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":59,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":349,"s":[0]},{"t":358.000014581639,"s":[80]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":47,"s":[218.75,47.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":59,"s":[262.25,47.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":349,"s":[262.25,47.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":358.000014581639,"s":[218.75,47.75,0]}],"ix":2},"a":{"a":0,"k":[10,10,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[5.523,0],[0,-5.523],[-5.523,0],[0,5.523]],"o":[[-5.523,0],[0,5.523],[5.523,0],[0,-5.523]],"v":[[10,0],[0,10],[10,20],[20,10]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":2,"nm":"Text Ad.eps","cl":"eps","refId":"image_1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":16,"s":[80]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47,"s":[80]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":59,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":349,"s":[0]},{"t":358.000014581639,"s":[80]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":47,"s":[118.75,43.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":59,"s":[59.25,43.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":349,"s":[59.25,43.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":358.000014581639,"s":[118.75,43.75,0]}],"ix":2},"a":{"a":0,"k":[21,3,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[1.657,0],[0,0],[0,-1.657],[-1.657,0],[0,0],[0,1.657]],"o":[[0,0],[-1.657,0],[0,1.657],[0,0],[1.657,0],[0,-1.657]],"v":[[39,0],[3,0],[0,3],[3,6],[39,6],[42,3]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":2,"nm":"Ad.eps","cl":"eps","refId":"image_2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":16,"s":[80]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47,"s":[80]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":59,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":349,"s":[0]},{"t":358.000014581639,"s":[80]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":47,"s":[132,95.875,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":59,"s":[48,95.875,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":349,"s":[48,95.875,0],"to":[0,0,0],"ti":[0,0,0]},{"t":358.000014581639,"s":[132,95.875,0]}],"ix":2},"a":{"a":0,"k":[33.5,8.5,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[4.694,0],[0,0],[0,-4.694],[-4.694,0],[0,0],[0,4.694]],"o":[[0,0],[-4.694,0],[0,4.694],[0,0],[4.694,0],[0,-4.694]],"v":[[58.5,0],[8.5,0],[0,8.5],[8.5,17],[58.5,17],[67,8.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":2,"nm":"Description","refId":"image_3","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":16,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47,"s":[80]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":59,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":349,"s":[0]},{"t":358.000014581639,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":47,"s":[163.167,143,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":59,"s":[162.667,204,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":349,"s":[162.667,204,0],"to":[0,0,0],"ti":[0,0,0]},{"t":358.000014581639,"s":[163.167,143,0]}],"ix":2},"a":{"a":0,"k":[70.5,24.5,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[0.046,0],[0,0],[0,-0.035],[0,0],[-4.418,0],[0,0],[0,4.119],[0,0]],"o":[[0,0],[-0.035,0],[0,0],[0,4.418],[0,0],[4.119,0],[0,0],[0,-0.046]],"v":[[141.208,-0.5],[-0.104,-0.5],[-0.167,-0.438],[-0.167,41.216],[7.833,49.216],[133.833,49.216],[141.292,41.757],[141.292,-0.416]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":2,"nm":"Top Bar.eps","cl":"eps","refId":"image_4","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":16,"s":[99]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":59,"s":[3]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":349,"s":[3]},{"t":358.000014581639,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":47,"s":[163.042,24.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":59,"s":[162.917,-14.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":349,"s":[162.917,-14.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":358.000014581639,"s":[163.042,24.75,0]}],"ix":2},"a":{"a":0,"k":[70.5,9,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[2.401,0],[0,0],[0,-2.209],[0,-2.557],[0,0],[0,2.402]],"o":[[0,0],[-2.209,0],[0,2.209],[0,0],[0,-3.466],[0,-2.402]],"v":[[136.039,0.5],[4.396,0.5],[0.396,4.5],[0.41,17.432],[140.311,17.432],[140.387,4.848]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":2,"nm":"Line 9.pdf","cl":"pdf","parent":12,"refId":"image_5","sr":1,"ks":{"o":{"a":0,"k":99,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[6.107,80.125,0],"ix":2},"a":{"a":0,"k":[0.25,2,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":59,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":346,"s":[161,100,100]},{"t":360.000014663101,"s":[100,100,100]}],"ix":6}},"ao":0,"ef":[{"ty":20,"nm":"Tint","np":6,"mn":"ADBE Tint","ix":1,"en":1,"ef":[{"ty":2,"nm":"Map Black To","mn":"ADBE Tint-0001","ix":1,"v":{"a":0,"k":[1,0.800000071526,0.200000017881,1],"ix":1}},{"ty":2,"nm":"Map White To","mn":"ADBE Tint-0002","ix":2,"v":{"a":0,"k":[0.93412989378,0.700597524643,0,1],"ix":2}},{"ty":0,"nm":"Amount to Tint","mn":"ADBE Tint-0003","ix":3,"v":{"a":1,"k":[{"i":{"x":[0.142],"y":[1]},"o":{"x":[0.951],"y":[0.71]},"t":47,"s":[100]},{"i":{"x":[0.142],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":59,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":346,"s":[0]},{"t":360.000014663101,"s":[100]}],"ix":3}},{"ty":6,"nm":"","mn":"ADBE Tint-0004","ix":4,"v":0}]}],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":2,"nm":"Line 10.pdf","cl":"pdf","parent":12,"refId":"image_6","sr":1,"ks":{"o":{"a":0,"k":99,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[135.838,80.125,0],"ix":2},"a":{"a":0,"k":[101.076,2,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":59,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":346,"s":[83,100,100]},{"t":360.000014663101,"s":[100,100,100]}],"ix":6}},"ao":0,"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":2,"nm":"Duck PLayer.eps","cl":"eps","refId":"image_7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":47,"s":[162.917,75.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0.333},"t":61,"s":[162.917,91.25,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":83,"s":[162.917,91.25,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":348,"s":[162.917,91.25,0],"to":[0,0,0],"ti":[0,0,0]},{"t":358.000014581639,"s":[162.917,75.75,0]}],"ix":2},"a":{"a":0,"k":[70.5,43.5,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0,0,0.667],"y":[0.987,0.987,1]},"o":{"x":[0.055,0.055,0.333],"y":[0.238,0.238,0]},"t":69,"s":[100,100,100]},{"i":{"x":[0.348,0.348,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":83,"s":[167.816,167.816,100]},{"i":{"x":[0.858,0.858,0.667],"y":[1.002,1.002,1]},"o":{"x":[0.527,0.527,0.167],"y":[0.276,0.276,0]},"t":348,"s":[167.816,167.816,100]},{"t":358.000014581639,"s":[100,100,100]}],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":47,"s":[{"i":[[-0.074,0],[0,0],[0,-0.029],[0,0],[0.006,0],[0,0],[0,-0.007],[0,0]],"o":[[0,0],[-0.055,0],[0,0],[0,-0.006],[0,0],[-0.007,0],[0,0],[0,0.074]],"v":[[140.892,0.447],[0.333,0.447],[0.235,0.5],[0.235,85.625],[0.223,85.614],[140.771,85.614],[140.758,85.627],[140.758,0.312]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":61,"s":[{"i":[[1.105,0],[0,0],[0,-1.105],[0,0],[-1.105,0],[0,0],[0,1.105],[0,0]],"o":[[0,0],[-1.105,0],[0,0],[0,1.105],[0,0],[1.105,0],[0,0],[0,-1.105]],"v":[[138.758,0.447],[2.235,0.447],[0.235,2.447],[0.235,83.614],[2.235,85.614],[138.758,85.614],[140.758,83.614],[140.758,2.447]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":348,"s":[{"i":[[1.105,0],[0,0],[0,-1.105],[0,0],[-1.105,0],[0,0],[0,1.105],[0,0]],"o":[[0,0],[-1.105,0],[0,0],[0,1.105],[0,0],[1.105,0],[0,0],[0,-1.105]],"v":[[138.758,0.447],[2.235,0.447],[0.235,2.447],[0.235,83.614],[2.235,85.614],[138.758,85.614],[140.758,83.614],[140.758,2.447]],"c":true}]},{"t":358.000014581639,"s":[{"i":[[-0.074,0],[0,0],[0,-0.029],[0,0],[0.006,0],[0,0],[0,-0.007],[0,0]],"o":[[0,0],[-0.055,0],[0,0],[0,-0.006],[0,0],[-0.007,0],[0,0],[0,0.074]],"v":[[140.892,0.447],[0.333,0.447],[0.235,0.5],[0.235,85.625],[0.223,85.614],[140.771,85.614],[140.758,85.627],[140.758,0.312]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":1,"nm":"Dark Gray Solid 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":0,"s":[7]},{"i":{"x":[0],"y":[1.014]},"o":{"x":[0.077],"y":[0.601]},"t":69,"s":[6]},{"i":{"x":[0],"y":[9.742]},"o":{"x":[0.167],"y":[0]},"t":83,"s":[39]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":348,"s":[40]},{"t":358.000014581639,"s":[7]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[162.125,87.75,0],"ix":2},"a":{"a":0,"k":[160,90,0],"ix":1},"s":{"a":0,"k":[105,108.889,100],"ix":6}},"ao":0,"sw":320,"sh":180,"sc":"#2d2d2d","ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":14,"ty":2,"nm":"Rectangle.png","cl":"png","refId":"image_8","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0],"y":[1.022]},"o":{"x":[0.027],"y":[0.608]},"t":69,"s":[0]},{"i":{"x":[0],"y":[43.204]},"o":{"x":[0.167],"y":[0]},"t":83,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":348,"s":[100]},{"t":358.000014581639,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":69,"s":[132.5,-168.5,0],"to":[0,0,0],"ti":[0,0,0]},{"t":358.000014581639,"s":[132.5,-105.5,0]}],"ix":2},"a":{"a":0,"k":[202.5,354,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":55.0000022401959,"op":955.000038897947,"st":55.0000022401959,"bm":0}],"markers":[{"tm":142.192505791619,"cm":"1","dr":0},{"tm":147.000005987433,"cm":"2","dr":0},{"tm":156.00000635401,"cm":"3","dr":0}]} \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-bg/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-bg/strings-duckplayer.xml new file mode 100644 index 000000000000..18d6960a88c0 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-bg/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Запази + Отмени + Duck Player + Duck Player + С Duck Player на DuckDuckGo можете да гледате YouTube без насочени реклами и вече гледаните видеоклипове няма да повлияят на препоръчаните.\nНаучете повече + Винаги + Никога + Питай всеки път + Отваряне на видеоклипове от YouTube в Duck Player? + Затваряне + Писна ли ви от рекламите в YouTube? Не и с Duck Player! + Писна ли Ви от рекламите в YouTube? Опитайте Duck Player! + С Duck Player на DuckDuckGo можете да гледате YouTube без насочени реклами и вече гледаните видеоклипове няма да повлияят на препоръчаните. + Разбрах! + Duck Player не е достъпен + Функционалността на Duck Player е засегната от последните промени в YouTube. Работим за отстраняване на тези проблеми и оценяваме вашето разбиране. + Научете повече + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-cs/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-cs/strings-duckplayer.xml new file mode 100644 index 000000000000..e19a895bb402 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-cs/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Uložit + Zrušit + Duck Player + Duck Player + Duck Player umožňuje dívat se na YouTube v prohlížeči DuckDuckGo bez cílených reklam. To, co sleduješ, nebude ovlivňovat tvoje doporučení.\nDalší informace + Vždy + Nikdy + Vždycky se ptát + Otevírat videa na YouTube v přehrávači Duck Player? + Zavřít + Topíš se v reklamách na YouTube? S přehrávačem Duck Player nebudeš! + Topíš se v reklamách na YouTube? Vyzkoušej přehrávač Duck Player! + Duck Player umožňuje dívat se na YouTube v prohlížeči DuckDuckGo bez cílených reklam. To, co sleduješ, nebude ovlivňovat tvoje doporučení. + Mám to! + Duck Player není dostupný + Funkčnost přehrávače Duck Player ovlivnily nedávné změny na YouTube. Problémy se snažíme opravit. Děkujeme za pochopení. + Více informací + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-da/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-da/strings-duckplayer.xml new file mode 100644 index 000000000000..7cf8ec1f79d4 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-da/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Gem + Annuller + Duck Player + Duck Player + Duck Player giver dig mulighed for at se YouTube uden målrettede annoncer i DuckDuckGo, og det, du ser, påvirker ikke dine anbefalinger.Få mere at vide + Altid + Aldrig + Spørg hver gang + Åbn YouTube-videoer i Duck Player? + Luk + Drukner du i annoncer på YouTube? Ikke med Duck Player! + Drukner du i annoncer på YouTube? Prøv Duck Player! + Duck Player giver dig mulighed for at se YouTube uden målrettede annoncer i DuckDuckGo, og det, du ser, påvirker ikke dine anbefalinger. + Forstået + Duck Player ikke tilgængelig + Duck Players funktionalitet er påvirket af de seneste ændringer på YouTube. Vi arbejder på at løse disse problemer og sætter pris på din forståelse. + Mere info + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-de/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-de/strings-duckplayer.xml new file mode 100644 index 000000000000..0ec129aaa019 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-de/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Speichern + Abbrechen + Duck Player + Duck Player + Mit dem Duck Player kannst du YouTube ohne gezielte Werbung in DuckDuckGo ansehen und was du dir ansiehst, hat keinen Einfluss auf deine Empfehlungen.\nMehr erfahren + Immer + Nie + Jedes Mal fragen + YouTube-Videos in Duck Player öffnen? + Schließen + Ertrinkst du in Werbung auf YouTube? Nicht mit Duck Player! + Ertrinkst du in Werbung auf YouTube? Teste Duck Player! + Mit dem Duck Player kannst du YouTube ohne gezielte Werbung in DuckDuckGo ansehen und was du dir ansiehst, hat keinen Einfluss auf deine Empfehlungen. + Verstanden. + Duck Player nicht verfügbar + Die Funktionalität des Duck Players wird durch die jüngsten Änderungen bei YouTube beeinträchtigt. Vielen Dank für dein Verständnis, während wir daran arbeiten, diese Probleme zu beheben. + Mehr erfahren + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-el/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-el/strings-duckplayer.xml new file mode 100644 index 000000000000..8c6ece74aeaf --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-el/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Αποθήκευση + Ακύρωση + Duck Player + Duck Player + Το Duck Player σάς επιτρέπει να παρακολουθείτε YouTube χωρίς στοχευμένες διαφημίσεις στο DuckDuckGo, ενώ το περιεχόμενο που παρακολουθείτε δεν θα επηρεάσει τις συστάσεις που θα λαμβάνετε.Μάθετε περισσότερα + Πάντα + Ποτέ + Να γίνεται ερώτηση κάθε φορά + Άνοιγμα βίντεο του YouTube στο Duck Player; + Κλείσιμο + Πνίγεστε από τις διαφημίσεις στο YouTube; Όχι με το Duck Player! + Πνίγεστε από τις διαφημίσεις στο YouTube; Δοκιμάστε το Duck Player! + Το Duck Player σάς επιτρέπει να παρακολουθείτε YouTube χωρίς στοχευμένες διαφημίσεις στο DuckDuckGo, ενώ το περιεχόμενο που παρακολουθείτε δεν θα επηρεάσει τις συστάσεις που θα λαμβάνετε. + Το κατάλαβα! + Το Duck Player δεν είναι διαθέσιμο + Η λειτουργία του Duck Player έχει επηρεαστεί από τις πρόσφατες αλλαγές στο YouTube. Προσπαθούμε να διορθώσουμε αυτά τα προβλήματα και εκτιμούμε την κατανόησή σας. + Μάθετε περισσότερα + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-es/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-es/strings-duckplayer.xml new file mode 100644 index 000000000000..6b6adfe694be --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-es/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Guardar + Cancelar + Duck Player + Duck Player + Duck Player te permite ver YouTube sin anuncios segmentados en DuckDuckGo y lo que veas no influirá en tus recomendaciones.Más información + Siempre + Nunca + Preguntar cada vez + ¿Abrir vídeos de Youtube en Duck Player? + Cerrar + ¿Te ahogas en anuncios en YouTube? ¡No con Duck Player! + ¿Te ahogas en anuncios en YouTube? ¡Prueba Duck Player! + Duck Player te permite ver YouTube sin anuncios segmentados en DuckDuckGo y lo que veas no influirá en tus recomendaciones. + Entendido + Duck Player no disponible + La funcionalidad de Duck Player se ha visto afectada por los cambios recientes en YouTube. Estamos trabajando para solucionar estos problemas y agradecemos tu comprensión. + Más información + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-et/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-et/strings-duckplayer.xml new file mode 100644 index 000000000000..dfaecc078718 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-et/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Salvesta + Loobu + Duck Player + Duck Player + Duck Player võimaldab sul DuckDuckGo-s YouTube\'i vaadata ilma sihitud reklaamideta ja see, mida vaatad, ei mõjuta sinu soovitusi.\nLisateave + Alati + Mitte kunagi + Küsi iga kord + Kas avada Youtube\'i videod Duck Playeris? + Sulge + Kas näed Youtube\'i kasutades massiliselt reklaame? Mitte Duck Playeriga! + Kas näed Youtube\'i kasutades massiliselt reklaame? Proovi Duck Playerit! + Duck Player võimaldab sul DuckDuckGo-s YouTube\'i vaadata ilma sihitud reklaamideta ja see, mida vaatad, ei mõjuta sinu soovitusi. + Sain aru! + Duck Player ei ole saadaval + Duck Playeri funktsionaalsust on mõjutanud hiljutised YouTube\'i muudatused. Töötame nende probleemide lahendamise nimel ja täname sind mõistva suhtumise eest. + Loe edasi + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-fi/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-fi/strings-duckplayer.xml new file mode 100644 index 000000000000..8955e2204a77 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-fi/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Tallenna + Peruuta + Duck Player + Duck Player + Duck Playerin avulla voit katsella YouTubea ilman kohdennettuja mainoksia DuckDuckGossa, eivätkä katsomasi videot vaikuta suosituksiisi.Lue lisää + Aina + Ei koskaan + Kysy joka kerta + Avataanko Youtube-videot Duck Playerissa? + Sulje + Hukutko mainoksiin YouTubessa? Duck Playerin avulla et huku! + Hukutko mainoksiin YouTubessa? Kokeile Duck Playeria! + Duck Playerin avulla voit katsella YouTubea ilman kohdennettuja mainoksia DuckDuckGossa, eivätkä katsomasi videot vaikuta suosituksiisi. + Selvä! + Duck Player ei ole käytettävissä + YouTuben viimeaikaiset muutokset ovat vaikuttaneet Duck Playerin toimintaan. Pyrimme korjaamaan nämä ongelmat. Kiitos ymmärryksestä. + Lue lisää + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-fr/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-fr/strings-duckplayer.xml new file mode 100644 index 000000000000..4a2360fbe2af --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-fr/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Enregistrer + Annuler + Duck Player + Duck Player + Duck Player vous permet de regarder YouTube sans publicités ciblées dans DuckDuckGo. De plus, ce que vous regardez n\'influence pas vos recommandations.\nEn savoir plus + Toujours + Jamais + Toujours demander + Ouvrir les vidéos YouTube dans Duck Player ? + Fermer + YouTube vous inonde de publicités ? Pas avec Duck Player ! + YouTube vous inonde de publicités ? Essayez Duck Player ! + Duck Player vous permet de regarder YouTube sans publicités ciblées dans DuckDuckGo. De plus, ce que vous regardez n\'influence pas vos recommandations. + J\'ai compris ! + Duck Player n\'est pas disponible + Les récents changements apportés à YouTube ont affecté la fonctionnalité de Duck Player. Nous nous efforçons de résoudre ces problèmes et vous remercions de votre compréhension. + En savoir plus + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-hr/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-hr/strings-duckplayer.xml new file mode 100644 index 000000000000..b368316cfc88 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-hr/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Spremi + Odustani + Duck Player + Duck Player + Duck Player ti omogućuje gledanje YouTubea bez ciljanih oglasa u DuckDuckGou, a ono što gledaš neće utjecati na tvoje preporuke.Saznaj više + Uvijek + Nikada + Pitaj svaki puta + Otvaraj YouTube videozapise u Duck Playeru? + Zatvori + Utapaš se u oglasima na YouTubeu? Ne s Duck Playerom! + Utapaš se u oglasima na YouTubeu? Isprobaj Duck Player! + Duck Player ti omogućuje gledanje YouTubea bez ciljanih oglasa u DuckDuckGou, a ono što gledaš neće utjecati na tvoje preporuke. + Shvaćam! + Duck Player je nedostupan + Na funkcionalnost Duck Playera utjecale su nedavne promjene na YouTubeu. Radimo na rješavanju ovih problema i cijenimo tvoje razumijevanje. + Saznajte više + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-hu/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-hu/strings-duckplayer.xml new file mode 100644 index 000000000000..cf534271aa8b --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-hu/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Mentés + Mégsem + Duck Player + Duck Player + A Duck Player segítségével a DuckDuckGóban célzott hirdetések nélkül nézheted a YouTube-ot, és a megtekintett tartalmak nem befolyásolják a neked szóló ajánlásokat.\nTovábbi tudnivalók + Mindig + Soha + Kérdezzen rá minden alkalommal + YouTube-videók megnyitása a Duck Playerben? + Bezárás + Elárasztanak a YouTube-hirdetések? A Duck Player használatakor nem! + Elárasztanak a YouTube-hirdetések? Próbáld ki a Duck Player! + A Duck Player segítségével a DuckDuckGóban célzott hirdetések nélkül nézheted a YouTube-ot, és a megtekintett tartalmak nem befolyásolják a neked szóló ajánlásokat. + Megvan! + A Duck Player nem érhető el + A YouTube legutóbbi módosításai érintik a Duck Player működését. Dolgozunk a problémák megoldásán, és köszönjük a megértésedet. + További részletek + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-it/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-it/strings-duckplayer.xml new file mode 100644 index 000000000000..b1dd072b3f2a --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-it/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Salva + Annulla + Duck Player + Duck Player + Duck Player ti consente di guardare YouTube senza annunci mirati in DuckDuckGo e ciò che guardi non inciderà sui consigli che ricevi.\nUlteriori informazioni + Sempre + Mai + Chiedi ogni volta + Aprire i video di YouTube in Duck Player? + Chiudi + YouTube ti inonda di annunci? Non con Duck Player! + YouTube ti inonda di annunci? Prova Duck Player! + Duck Player ti consente di guardare YouTube senza annunci mirati in DuckDuckGo e ciò che guardi non inciderà sui consigli che ricevi. + Ho capito! + Duck Player non disponibile + La funzionalità di Duck Player è stata influenzata dalle recenti modifiche a YouTube. Stiamo lavorando per risolvere questi problemi. Ti ringraziamo per la comprensione. + Ulteriori informazioni + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-lt/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-lt/strings-duckplayer.xml new file mode 100644 index 000000000000..c5907c3ccb8d --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-lt/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Išsaugoti + Atšaukti + Duck Player + Duck Player + „Duck Player“ leidžia sistemoje „DuckDuckGo“ žiūrėti „YouTube“ be taikomų reklamų, o tai, ką žiūrite, neturės įtakos jūsų rekomendacijoms.Sužinokite daugiau + Visada + Niekada + Klausti kiekvieną kartą + Ar atidaryti „YouTube“ vaizdo įrašus „Duck Player“? + Uždaryti + Matote per daug „YouTube“ skelbimų? Ne su „Duck Player“! + Matote per daug „YouTube“ skelbimų? Išbandykite „Duck Player“! + „Duck Player“ leidžia sistemoje „DuckDuckGo“ žiūrėti „YouTube“ be taikomų reklamų, o tai, ką žiūrite, neturės įtakos jūsų rekomendacijoms. + Supratau! + „Duck Player“ nepasiekiamas + Neseniai atlikti „YouTube“ pakeitimai turėjo įtakos „Duck Player“ funkcijoms. Stengiamės pašalinti šias triktis ir dėkojame už supratingumą. + Sužinoti daugiau + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-lv/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-lv/strings-duckplayer.xml new file mode 100644 index 000000000000..b9a8b6c72c72 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-lv/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Saglabāt + Atcelt + Duck Player + Duck Player + Duck Player ļauj skatīties YouTube bez mērķētām reklāmām DuckDuckGo vidē, un skatītais saturs neietekmē tev sniegtos ieteikumus.\nUzzināt vairāk + Vienmēr + Nekad + Jautāt katru reizi + Atvērt YouTube video ar Duck Player? + Aizvērt + Vai tevi nomāc YouTube reklāmas? Ne ar Duck Player! + Vai tevi nomāc YouTube reklāmas? Izmēģini Duck Player! + Duck Player ļauj skatīties YouTube bez mērķētām reklāmām DuckDuckGo vidē, un skatītais saturs neietekmē tev sniegtos ieteikumus. + Sapratu! + Duck Player nav pieejams + Nesen ieviestās YouTube izmaiņas ir ietekmējušas Duck Player funkcionalitāti. Mēs strādājam, lai novērstu šīs problēmas, un novērtējam tavu sapratni. + Uzzināt vairāk + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-nb/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-nb/strings-duckplayer.xml new file mode 100644 index 000000000000..71d5f17366ff --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-nb/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Lagre + Avbryt + Duck Player + Duck Player + Med Duck Player kan du se på YouTube uten målrettede annonser i DuckDuckGo. Det du ser på, påvirker ikke anbefalingene du får.\nLes mer + Alltid + Aldri + Spør hver gang + Vil du åpne YouTube-videoer i Duck Player? + Lukk + Drukner du i annonser på YouTube? Ikke med Duck Player! + Drukner du i annonser på YouTube? Prøv Duck Player! + Med Duck Player kan du se på YouTube uten målrettede annonser i DuckDuckGo. Det du ser på, påvirker ikke anbefalingene du får. + Skjønner! + Duck Player er utilgjengelig + Duck Players funksjonalitet har blitt påvirket av nylige endringer i YouTube. Vi jobber med å løse disse problemene og setter pris på forståelsen din. + Finn ut mer + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-nl/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-nl/strings-duckplayer.xml new file mode 100644 index 000000000000..91b0679534ca --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-nl/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Opslaan + Annuleren + Duck Player + Duck Player + Met Duck Player kun je YouTube bekijken in DuckDuckGo, zonder gerichte advertenties. Wat je bekijkt heeft geen invloed op je aanbevelingen.\nMeer informatie + Altijd + Nooit + Elke keer vragen + YouTube-video\'s openen in Duck Player? + Sluiten + Te veel advertenties op YouTube? Niet met Duck Player! + Te veel advertenties op YouTube? Probeer Duck Player! + Met Duck Player kun je YouTube bekijken in DuckDuckGo, zonder gerichte advertenties. Wat je bekijkt heeft geen invloed op je aanbevelingen. + Ik snap het! + Duck Player is niet beschikbaar + Recente wijzigingen in YouTube hebben invloed op de functionaliteit van Duck Player. We werken aan een oplossing voor deze problemen. Bedankt voor je begrip. + Meer informatie + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-pl/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-pl/strings-duckplayer.xml new file mode 100644 index 000000000000..0830424d93f8 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-pl/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Zapisz + Anuluj + Duck Player + Duck Player + Duck Player umożliwia oglądanie YouTube bez ukierunkowanych reklam w DuckDuckGo i wpływu oglądanych treści na rekomendacje.\nDowiedz się więcej + Zawsze + Nigdy + Pytaj za każdym razem + Otwierać filmy z YouTube w odtwarzaczu Duck Player? + Zamknij + Za dużo reklam na YouTube? Nie z odtwarzaczem Duck Player! + Za dużo reklam na YouTube? Wypróbuj Duck Player! + Duck Player umożliwia oglądanie YouTube bez ukierunkowanych reklam w DuckDuckGo i wpływu oglądanych treści na rekomendacje. + Rozumiem! + Duck Player jest niedostępny + Ostatnie zmiany w serwisie YouTube miały wpływ na działanie Duck Player. Pracujemy nad rozwiązaniem tych problemów i dziękujemy za zrozumienie. + Dowiedz się więcej + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-pt/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-pt/strings-duckplayer.xml new file mode 100644 index 000000000000..dcc9eec6c708 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-pt/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Guardar + Cancelar + Duck Player + Duck Player + O Duck Player permite-te ver o YouTube sem anúncios segmentados no DuckDuckGo, e o que vês não vai influenciar as tuas recomendações.\nSabe mais + Sempre + Nunca + Pergunte todas as vezes + Abrir vídeos do YouTube no Duck Player? + Fechar + Já não suportas ver anúncios no YouTube? Experimenta o Duck Player! + Já não suportas ver anúncios no YouTube? Experimenta o Duck Player! + O Duck Player permite-te ver o YouTube sem anúncios segmentados no DuckDuckGo, e o que vês não vai influenciar as tuas recomendações. + Entendi! + Duck Player indisponível + A funcionalidade do Duck Player foi afetada por alterações recentes ao YouTube. Estamos a trabalhar para resolver estes problemas e agradecemos a tua compreensão. + Saiba mais + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-ro/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-ro/strings-duckplayer.xml new file mode 100644 index 000000000000..226867e8407a --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-ro/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Salvează + Anulare + Duck Player + Duck Player + Duck Player îți permite să vizionezi YouTube fără reclame țintite în DuckDuckGo, iar ceea ce vizionezi nu îți va influența recomandările.\nAflă mai multe + Întotdeauna + Niciodată + Întreabă de fiecare dată + Deschizi videoclipurile YouTube în Duck Player? + Închidere + Te copleșesc reclamele pe YouTube? Nu și cu Duck Player! + Te copleșesc reclamele pe YouTube? Încearcă Duck Player! + Duck Player îți permite să vizionezi YouTube fără reclame țintite în DuckDuckGo, iar ceea ce vizionezi nu îți va influența recomandările. + Am înțeles! + Duck Player indisponibil + Funcționalitatea Duck Player a fost afectată de modificările recente ale YouTube. Lucrăm pentru a remedia aceste probleme și apreciem înțelegerea ta. + Află mai multe + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-ru/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-ru/strings-duckplayer.xml new file mode 100644 index 000000000000..3e6ee5590eda --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-ru/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Сохранить + Отменить + Проигрыватель Duck Player + Проигрыватель Duck Player + Проигрыватель Duck Player позволяет смотреть видео из YouTube в браузере DuckDuckGo без целевой рекламы. Просмотренные ролики не влияют на рекомендации.\nПодробнее... + Всегда + Никогда + Спрашивать каждый раз + Открывать видео из YouTube в Duck Player? + Закрыть + Шквал рекламы на YouTube? Только не с Duck Player! + Шквал рекламы на YouTube? Попробуйте Duck Player! + Проигрыватель Duck Player позволяет смотреть видео из YouTube в браузере DuckDuckGo без целевой рекламы. Просмотренные ролики не влияют на рекомендации. + Понятно + Duck Player недоступен + Недавние изменения YouTube приводят к некорректной работе проигрывателя Duck Player. Мы занимаемся решением этой проблемы и благодарим вас за понимание. + Узнать больше + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-sk/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-sk/strings-duckplayer.xml new file mode 100644 index 000000000000..18aea4c5c26c --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-sk/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Uložiť + Zrušiť + Duck Player + Duck Player + Duck Player vám umožňuje sledovať YouTube v DuckDuckGo bez cielených reklám a to, čo sledujete, nebude mať vplyv na vaše odporúčania.\nZistiť viac + Vždy + Nikdy + Vždy sa opýtať + Otvárať videá z YouTube s Duck Player? + Zatvoriť + Utápať sa v reklamách na YouTube? Nie s Duck Player! + Utápať sa v reklamách na YouTube? Vyskúšajte Duck Player! + Prehrávač Duck Player vám umožňuje sledovať YouTube v prehliadači DuckDuckGo bez cielených reklám a to, čo sledujete, nebude mať vplyv na vaše odporúčania. + Rozumiem! + Duck Player nie je dostupný + Funkčnosť prehrávača Duck Player bola ovplyvnená nedávnymi zmenami v službe YouTube. Na odstránení týchto problémov pracujeme. Ďakujeme za pochopenie. + Zistite viac + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-sl/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-sl/strings-duckplayer.xml new file mode 100644 index 000000000000..1126e66c525b --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-sl/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Shrani + Prekliči + Duck Player + Duck Player + Predvajalnik Duck Player vam s pomočjo DuckDuckGo omogoča gledanje YouTuba brez ciljanih oglasov, kar gledate pa ne bo vplivalo na vaša priporočila.\nVeč o tem + Vedno + Nikoli + Vprašaj vsakič + Želite videoposnetke v YouTubu odpreti s predvajalnikom Duck Player? + Zapri + Se utapljate v oglasih na YouTubu? S predvajalnikom Duck Player bo to preteklost! + Se utapljate v oglasih na YouTubu? Preizkusite Duck Player! + Predvajalnik Duck Player vam s pomočjo DuckDuckGo omogoča gledanje YouTuba brez ciljanih oglasov, kar gledate pa ne bo vplivalo na vaša priporočila. + Razumem! + Predvajalnik Duck Player ni na voljo + Nedavne spremembe v YouTubu so vplivale na delovanje predvajalnika Duck Player. Prizadevamo si za odpravljanje teh težav in cenimo vaše razumevanje. + Več + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-sv/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-sv/strings-duckplayer.xml new file mode 100644 index 000000000000..485f2956e6b6 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-sv/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Spara + Avbryt + Duck Player + Duck Player + Med Duck Player kan du titta på YouTube utan riktade annonser i DuckDuckGo, och dina rekommendationer påverkas inte av vad du tittar på.Läs mer + Alltid + Aldrig + Fråga varje gång + Öppna Youtube-videor i Duck Player? + Stäng + Drunknar du i annonser på YouTube? Inte med Duck Player! + Drunknar du i annonser på YouTube? Prova Duck Player! + Med Duck Player kan du titta på YouTube utan riktade annonser i DuckDuckGo, och dina rekommendationer påverkas inte av vad du tittar på. + Jag förstår! + Duck Player är inte tillgänglig + De senaste ändringarna på YouTube har påverkat funktionaliteten hos Duck Player. Vi arbetar för att åtgärda problemen och tackar för din förståelse. + Läs mer + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values-tr/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values-tr/strings-duckplayer.xml new file mode 100644 index 000000000000..9d514a0c411c --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values-tr/strings-duckplayer.xml @@ -0,0 +1,37 @@ + + + + + + Kaydet + Vazgeç + Duck Player + Duck Player + Duck Player, DuckDuckGo\'da hedefli reklamlar olmadan YouTube\'u izlemenizi sağlar ve izlediğiniz şeyler önerilerinizi etkilemez.\nDaha Fazla Bilgi + Her zaman + Hiçbir zaman + Her seferinde sor + YouTube videoları Duck Player\'da açılsın mı? + Kapat + YouTube\'da reklama mı boğuluyorsunuz? Duck Player ile bundan kurtulun! + YouTube\'da reklama mı boğuluyorsunuz? Duck Player\'ı deneyin! + Duck Player, DuckDuckGo\'da hedefli reklamlar olmadan YouTube\'u izlemenizi sağlar ve izlediğiniz şeyler önerilerinizi etkilemez. + Anladım! + Duck Player Kullanılamıyor + Duck Player\'ın işlevselliği YouTube\'da yapılan son değişikliklerden etkilendi. Bu sorunları çözmeye çalışıyoruz. Anlayışınız için teşekkür ederiz. + Daha Fazla Bilgi + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values/strings-duckplayer.xml b/duckplayer/duckplayer-impl/src/main/res/values/strings-duckplayer.xml new file mode 100644 index 000000000000..f2b63bfa437e --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values/strings-duckplayer.xml @@ -0,0 +1,36 @@ + + + + + Save + Cancel + Duck Player + Duck Player + Duck Player lets you watch YouTube without targeted ads in DuckDuckGo and what you watch won’t influence your recommendations.\nLearn More + Always + Never + Ask Every Time + Open Youtube videos in Duck Player? + Close + Drowning in ads on YouTube? Not with Duck Player! + Drowning in ads on YouTube? Try Duck Player! + Duck Player lets you watch YouTube without targeted ads in DuckDuckGo and what you watch won’t influence your recommendations. + Got it! + Duck Player Unavailable + Duck Player\'s functionality has been affected by recent changes to YouTube. We’re working to fix these issues and appreciate your understanding. + Learn More + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values/styles.xml b/duckplayer/duckplayer-impl/src/main/res/values/styles.xml new file mode 100644 index 000000000000..d2e7a947b4ab --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values/styles.xml @@ -0,0 +1,32 @@ + + + + + + + + + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/test/kotlin/com/duckduckgo/duckplayer/impl/RealDuckPlayerTest.kt b/duckplayer/duckplayer-impl/src/test/kotlin/com/duckduckgo/duckplayer/impl/RealDuckPlayerTest.kt new file mode 100644 index 000000000000..92fcfeb67247 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/test/kotlin/com/duckduckgo/duckplayer/impl/RealDuckPlayerTest.kt @@ -0,0 +1,771 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import android.content.Context +import android.content.res.AssetManager +import android.net.Uri +import android.webkit.MimeTypeMap +import android.webkit.WebResourceRequest +import android.webkit.WebView +import androidx.core.net.toUri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.common.utils.UrlScheme.Companion.duck +import com.duckduckgo.common.utils.UrlScheme.Companion.https +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.DISABLED_WIH_HELP_LINK +import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED +import com.duckduckgo.duckplayer.api.DuckPlayer.UserPreferences +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_DAILY_UNIQUE_VIEW +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_OVERLAY_YOUTUBE_IMPRESSIONS +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_OVERLAY_YOUTUBE_WATCH_HERE +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_VIEW_FROM_OTHER +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_VIEW_FROM_YOUTUBE_AUTOMATIC +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_VIEW_FROM_YOUTUBE_MAIN_OVERLAY +import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_WATCH_ON_YOUTUBE +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.any +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.kotlin.never +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class RealDuckPlayerTest { + + @get:org.junit.Rule + var coroutineRule = com.duckduckgo.common.test.CoroutineTestRule() + + private val mockDuckPlayerFeatureRepository: DuckPlayerFeatureRepository = + mock() + private val mockDuckPlayerFeature: DuckPlayerFeature = mock() + private val mockPixel: Pixel = mock() + private val mockDuckPlayerLocalFilesPath: DuckPlayerLocalFilesPath = mock() + private val mimeType: MimeTypeMap = mock() + private val dispatcherProvider = coroutineRule.testDispatcherProvider + + private val testee = RealDuckPlayer( + mockDuckPlayerFeatureRepository, + mockDuckPlayerFeature, + mockPixel, + mockDuckPlayerLocalFilesPath, + mimeType, + dispatcherProvider, + ) + + @Before + fun setup() = runTest { + mockFeatureToggle(true) + whenever(mockDuckPlayerFeatureRepository.getDuckPlayerDisabledHelpPageLink()) + .thenReturn(null) + whenever(mockDuckPlayerFeatureRepository.getVideoIDQueryParam()).thenReturn("v") + whenever(mockDuckPlayerFeatureRepository.getYouTubeWatchPath()).thenReturn("watch") + whenever(mockDuckPlayerFeatureRepository.getYouTubeUrl()).thenReturn("youtube.com") + whenever(mockDuckPlayerFeatureRepository.getYouTubeEmbedUrl()).thenReturn("youtube-nocookie.com") + whenever(mockDuckPlayerFeatureRepository.getYouTubeReferrerHeaders()).thenReturn(listOf("Referer")) + whenever(mockDuckPlayerFeatureRepository.getYouTubeReferrerQueryParams()).thenReturn(listOf("embeds_referring_euri")) + } + + // region getDuckPlayerState + + @Test + fun whenDuckPlayerStateIsEnabled_getDuckPlayerStateReturnsEnabled() = runTest { + mockFeatureToggle(true) + + val result = testee.getDuckPlayerState() + + assertEquals(ENABLED, result) + } + + @Test + fun whenDuckPlayerStateIsDisabled_getDuckPlayerStateReturnsDisabled() = runTest { + mockFeatureToggle(false) + + val result = testee.getDuckPlayerState() + + assertEquals(DISABLED, result) + } + + @Test + fun whenDuckPlayerStateIsDisabledWithHelpLink_getDuckPlayerStateReturnsDisabledWithHelpLink() = runTest { + mockFeatureToggle(false) + whenever(mockDuckPlayerFeatureRepository.getDuckPlayerDisabledHelpPageLink()).thenReturn("help_link") + + val result = testee.getDuckPlayerState() + + assertEquals(DISABLED_WIH_HELP_LINK, result) + } + + // endregion + + // region setUserPreferences + @Test + fun whenOverlayInteracted_setUserPreferencesUpdatesOverlayInteracted() = runTest { + testee.setUserPreferences(true, "disabled") + + verify(mockDuckPlayerFeatureRepository).setUserPreferences(UserPreferences(true, Disabled)) + } + + @Test + fun whenPrivatePlayerModeEnabled_setUserPreferencesUpdatesPrivatePlayerMode() = runTest { + testee.setUserPreferences(false, "enabled") + + verify(mockDuckPlayerFeatureRepository).setUserPreferences(UserPreferences(false, Enabled)) + } + + @Test + fun whenPrivatePlayerModeAlwaysAsk_setUserPreferencesUpdatesPrivatePlayerMode() = runTest { + testee.setUserPreferences(false, "always_ask") + + verify(mockDuckPlayerFeatureRepository).setUserPreferences(UserPreferences(false, AlwaysAsk)) + } + + //endregion + + //region getUserPreferences + + @Test + fun whenGetUserPreferencesCalled_returnsUserPreferencesFromRepository() = runTest { + val expectedUserPreferences = UserPreferences(true, Enabled) + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(expectedUserPreferences) + + val actualUserPreferences = testee.getUserPreferences() + + assertEquals(expectedUserPreferences, actualUserPreferences) + } + + @Test + fun whenGetUserPreferencesCalled_returnsUserPreferencesWithOverlayInteracted() = runTest { + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, Disabled)) + + val result = testee.getUserPreferences() + + assertTrue(result.overlayInteracted) + } + + @Test + fun whenGetUserPreferencesCalled_returnsUserPreferencesWithPrivatePlayerMode() = runTest { + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(false, AlwaysAsk)) + + val result = testee.getUserPreferences() + + assertEquals(AlwaysAsk, result.privatePlayerMode) + } + + // endregion + + // region shouldHideDuckPlayerOverlay + + @Test + fun whenOverlayHidden_shouldHideDuckPlayerOverlayReturnsFalse() { + testee.duckPlayerOverlayHidden() + + assertFalse(testee.shouldHideDuckPlayerOverlay()) + } + + @Test + fun whenOverlayNotHidden_shouldHideDuckPlayerOverlayReturnsFalse() { + assertFalse(testee.shouldHideDuckPlayerOverlay()) + } + + // endregion + + // region observeUserPreferences + @Test + fun observeUserPreferences_emitsUserPreferencesFromRepository() = runTest { + val expectedUserPreferences = UserPreferences(true, Enabled) + whenever(mockDuckPlayerFeatureRepository.observeUserPreferences()).thenReturn(flowOf(expectedUserPreferences)) + + val result = testee.observeUserPreferences().first() + + assertEquals(expectedUserPreferences, result) + } + + @Test + fun observeUserPreferences_emitsMultipleUserPreferencesFromRepository() = runTest { + val userPreferences1 = UserPreferences(true, Enabled) + val userPreferences2 = UserPreferences(false, Disabled) + whenever(mockDuckPlayerFeatureRepository.observeUserPreferences()).thenReturn(flowOf(userPreferences1, userPreferences2)) + + val results = testee.observeUserPreferences().take(2).toList() + + assertEquals(userPreferences1, results[0]) + assertEquals(userPreferences2, results[1]) + } + + // endregion + + // region sendDuckPlayerPixel + + @Test + fun sendDuckPlayerPixelWithOverlay_firesPixelWithCorrectNameAndData() = runTest { + val pixelName = "overlay" + val pixelData = mapOf("key" to "value") + + testee.sendDuckPlayerPixel(pixelName, pixelData) + + verify(mockPixel).fire(DUCK_PLAYER_OVERLAY_YOUTUBE_IMPRESSIONS, pixelData, emptyMap(), COUNT) + } + + @Test + fun sendDuckPlayerPixelWithOverlay_firesPixelWithEmptyDataWhenNoDataProvided() = runTest { + val pixelName = "overlay" + + testee.sendDuckPlayerPixel(pixelName, emptyMap()) + + verify(mockPixel).fire(DUCK_PLAYER_OVERLAY_YOUTUBE_IMPRESSIONS, emptyMap(), emptyMap(), COUNT) + } + + @Test + fun sendDuckPlayerPixelWithPlayUse_firesPixelWithCorrectNameAndData() = runTest { + val pixelName = "play.use" + val pixelData = mapOf("key" to "value") + + testee.sendDuckPlayerPixel(pixelName, pixelData) + + verify(mockPixel).fire(DUCK_PLAYER_VIEW_FROM_YOUTUBE_MAIN_OVERLAY, pixelData, emptyMap(), COUNT) + } + + @Test + fun sendDuckPlayerPixelWithPlayUse_firesPixelWithEmptyDataWhenNoDataProvided() = runTest { + val pixelName = "play.use" + + testee.sendDuckPlayerPixel(pixelName, emptyMap()) + + verify(mockPixel).fire(DUCK_PLAYER_VIEW_FROM_YOUTUBE_MAIN_OVERLAY, emptyMap(), emptyMap(), COUNT) + } + + @Test + fun sendDuckPlayerPixelWithPlayDoNotUse_firesPixelWithCorrectNameAndData() = runTest { + val pixelName = "play.do_not_use" + val pixelData = mapOf("key" to "value") + + testee.sendDuckPlayerPixel(pixelName, pixelData) + + verify(mockPixel).fire(DUCK_PLAYER_OVERLAY_YOUTUBE_WATCH_HERE, pixelData, emptyMap(), COUNT) + } + + @Test + fun sendDuckPlayerPixelWithPlayDoNotUse_firesPixelWithEmptyDataWhenNoDataProvided() = runTest { + val pixelName = "play.do_not_use" + + testee.sendDuckPlayerPixel(pixelName, emptyMap()) + + verify(mockPixel).fire(DUCK_PLAYER_OVERLAY_YOUTUBE_WATCH_HERE, emptyMap(), emptyMap(), COUNT) + } + + // endregion + + // region createYouTubeWatchFromDuckPlayer + + @Test + fun createYoutubeWatchUrlFromDuckPlayer_returnsCorrectUrl_whenVideoIdQueryParamExists() = runTest { + val youTubeWatchPath = "watch" + val youTubeHost = "youtube.com" + val videoID = "12345" + val uri = Uri.parse("$duck://player/$videoID") + + val result = testee.createYoutubeWatchUrlFromDuckPlayer(uri) + + assertEquals("$https://$youTubeHost/$youTubeWatchPath?v=$videoID", result) + } + + @Test + fun createYoutubeWatchUrlFromDuckPlayer_returnsNull_whenNoVideoIdFound() = runTest { + val youTubeWatchPath = "watch" + val youTubeHost = "youtube.com" + val uri = Uri.parse("$https://$youTubeHost/$youTubeWatchPath") + + val result = testee.createYoutubeWatchUrlFromDuckPlayer(uri) + + assertNull(result) + } + + //endregion + + // region isDuckPlayerUrl + + @Test + fun whenUriIsDuckPlayerUri_isDuckPlayerUriReturnsTrue() = runTest { + val uri = "duck://player/12345" + + val result = testee.isDuckPlayerUri(uri) + + assertTrue(result) + } + + @Test + fun whenUriIsNotDuckPlayerUri_isDuckPlayerUriReturnsFalse() = runTest { + val uri = "https://youtube.com/watch?v=12345" + + val result = testee.isDuckPlayerUri(uri) + + assertFalse(result) + } + + @Test + fun whenUriIsEmpty_isDuckPlayerUriReturnsFalse() = runTest { + val uri = "" + + val result = testee.isDuckPlayerUri(uri) + + assertFalse(result) + } + + // endregion + + // region isSimulatedYoutubeNoCookie + + @Test + fun whenUriHostYouTube_isSimulatedYoutubeNoCookieReturnsTrue() = runTest { + val uri = "https://www.youtube.com".toUri() + + val result = testee.isSimulatedYoutubeNoCookie(uri) + + assertFalse(result) + } + + @Test + fun whenUriHostIsEmbedUrlAndPathContainsValidPath_isSimulatedYoutubeNoCookieReturnsTrue() = runTest { + val uri = "https://www.youtube-nocookie.com/?videoID=1234".toUri() + + val result = testee.isSimulatedYoutubeNoCookie(uri) + + assertTrue(result) + } + + @Test + fun whenUrHostIsEmbedAndFileIsAvailableLocally_isSimulatedYoutubeNoCookieReturnsTrue() = runTest { + whenever(mockDuckPlayerLocalFilesPath.assetsPath).thenReturn(listOf("js/duckplayer.js")) + val uri = "https://www.youtube-nocookie.com/js/duckplayer.js".toUri() + + val result = testee.isSimulatedYoutubeNoCookie(uri) + + assertTrue(result) + } + + @Test + fun whenUrHostIsEmbedAndFileIsNotAvailableLocally_isSimulatedYoutubeNoCookieReturnsFalse() = runTest { + whenever(mockDuckPlayerLocalFilesPath.assetsPath).thenReturn(listOf("css/duckplayer.css")) + val uri = "https://www.youtube-nocookie.com/js/duckplayer.js".toUri() + + val result = testee.isSimulatedYoutubeNoCookie(uri) + + assertFalse(result) + } + + @Test + fun whenUrHostIsEmbedAndPathContainsEmbed_isSimulatedYoutubeNoCookieReturnsFalse() = runTest { + whenever(mockDuckPlayerLocalFilesPath.assetsPath).thenReturn(listOf()) + val uri = "https://www.youtube-nocookie.com/embed/js/duckplayer.js".toUri() + + val result = testee.isSimulatedYoutubeNoCookie(uri) + + assertFalse(result) + } + + @Test + fun whenUriHostIsNotEmbedUrl_isSimulatedYoutubeNoCookieReturnsFalse() = runTest { + val uri = "https://www.notyoutube.com".toUri() + + val result = testee.isSimulatedYoutubeNoCookie(uri) + + assertFalse(result) + } + + @Test + fun whenUriHostIsEmbedUrlAndPathContainsEmbed_isSimulatedYoutubeNoCookieReturnsFalse() = runTest { + val uri = "https://www.youtube.com/embed/12345".toUri() + + val result = testee.isSimulatedYoutubeNoCookie(uri) + + assertFalse(result) + } + + @Test + fun whenUriHostIsEmbedUrlAndPathDoesNotContainValidPath_isSimulatedYoutubeNoCookieReturnsFalse() = runTest { + val uri = "https://www.youtube.com/invalidPath".toUri() + + val result = testee.isSimulatedYoutubeNoCookie(uri) + + assertFalse(result) + } + + @Test + fun whenUriHostIsEmbedUrlAndPathDoesNotHaveVideoIdQueryParam_isSimulatedYoutubeNoCookieReturnsFalse() = runTest { + val uri = "https://www.youtube.com/watch".toUri() + + val result = testee.isSimulatedYoutubeNoCookie(uri) + + assertFalse(result) + } + + // endregion + + // region isYouTubeWatchUrl + + @Test + fun whenUriHostIsYoutubeAndPathIsWatch_isYoutubeWatchUrlReturnsTrue() = runTest { + val uri = "https://www.youtube.com/watch?v=12345".toUri() + + val result = testee.isYoutubeWatchUrl(uri) + + assertTrue(result) + } + + @Test + fun whenUriHostIsMobileYoutubeAndPathIsWatch_isYoutubeWatchUrlReturnsTrue() = runTest { + val uri = "https://m.youtube.com/watch?v=12345".toUri() + + val result = testee.isYoutubeWatchUrl(uri) + + assertTrue(result) + } + + @Test + fun whenUriHostIsNotYoutube_isYoutubeWatchUrlReturnsFalse() = runTest { + val uri = "https://www.notyoutube.com/watch?v=12345".toUri() + + val result = testee.isYoutubeWatchUrl(uri) + + assertFalse(result) + } + + @Test + fun whenUriPathIsNotWatch_isYoutubeWatchUrlReturnsFalse() = runTest { + val uri = "https://www.youtube.com/notwatch?v=12345".toUri() + + val result = testee.isYoutubeWatchUrl(uri) + + assertFalse(result) + } + + // endregion + + // region createDuckPlayerUriFromYoutubeNoCookie + + @Test + fun whenUriHasVideoIdQueryParam_createDuckPlayerUriFromYoutubeNoCookieReturnsCorrectUri() = runTest { + val uri = "https://www.youtube-nocookie.com/?videoID=12345".toUri() + + val result = testee.createDuckPlayerUriFromYoutubeNoCookie(uri) + + assertEquals("$duck://player/12345", result) + } + + @Test + fun whenUriDoesNotHaveVideoIdQueryParam_createDuckPlayerUriFromYoutubeNoCookieReturnsNull() = runTest { + val uri = "https://www.youtube-nocookie.com/".toUri() + + val result = testee.createDuckPlayerUriFromYoutubeNoCookie(uri) + + assertNull(result) + } + + @Test + fun whenUriIsEmpty_createDuckPlayerUriFromYoutubeNoCookieReturnsNull() = runTest { + val uri = "".toUri() + + val result = testee.createDuckPlayerUriFromYoutubeNoCookie(uri) + + assertNull(result) + } + + // endregion + + // region intercept + + @Test + fun whenDuckPlayerStateIsNotEnabled_interceptReturnsNull() = runTest { + val request: WebResourceRequest = mock() + val url: Uri = mock() + val webView: WebView = mock() + + mockFeatureToggle(false) + + val result = testee.intercept(request, url, webView) + + assertNull(result) + } + + @Test + fun whenUrlDoesNotMatchAnyCondition_interceptReturnsNull() = runTest { + val request: WebResourceRequest = mock() + val url: Uri = Uri.parse("https://www.notmatching.com") + val webView: WebView = mock() + + mockFeatureToggle(true) + + val result = testee.intercept(request, url, webView) + + assertNull(result) + } + + @Test + fun whenUriIsDuckPlayerUri_interceptProcessesDuckPlayerUri() = runTest { + val request: WebResourceRequest = mock() + val url: Uri = Uri.parse("duck://player/12345") + val webView: WebView = mock() + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, Enabled)) + + val result = testee.intercept(request, url, webView) + + verify(webView).loadUrl("https://www.youtube-nocookie.com?videoID=12345") + verify(mockPixel).fire(DUCK_PLAYER_VIEW_FROM_OTHER) + assertNotNull(result) + } + + @Test + fun whenUriIsDuckPlayerUriWithOpenInYouTube_interceptLoadsYouTubeUri() = runTest { + val request: WebResourceRequest = mock() + val url: Uri = Uri.parse("duck://player/openInYouTube?v=12345") + val webView: WebView = mock() + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, Enabled)) + + val result = testee.intercept(request, url, webView) + + verify(webView).loadUrl("https://youtube.com/watch?v=12345") + verify(mockPixel).fire(DUCK_PLAYER_WATCH_ON_YOUTUBE) + assertNotNull(result) + } + + @Test + fun whenUriIsDuckPlayerUriAndFeatureIsDisabled_InterceptLoadsYouTubeUri() = runTest { + mockFeatureToggle(false) + val request: WebResourceRequest = mock() + val url: Uri = Uri.parse("duck://player/12345") + val webView: WebView = mock() + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, Enabled)) + + val result = testee.intercept(request, url, webView) + + verify(webView).loadUrl("https://youtube.com/watch?v=12345") + verify(mockPixel, never()).fire(DUCK_PLAYER_WATCH_ON_YOUTUBE) + assertNotNull(result) + } + + @Test + fun whenUriIsDuckPlayerUriAndUserSettingsNever_InterceptLoadsYouTubeUri() = runTest { + mockFeatureToggle(true) + val request: WebResourceRequest = mock() + val url: Uri = Uri.parse("duck://player/12345") + val webView: WebView = mock() + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, Disabled)) + + val result = testee.intercept(request, url, webView) + + verify(webView).loadUrl("https://youtube.com/watch?v=12345") + verify(mockPixel, never()).fire(DUCK_PLAYER_WATCH_ON_YOUTUBE) + assertNotNull(result) + } + + @Test + fun whenUriIsYouTubeEmbed_interceptLoadsLocalFile() = runTest { + val request: WebResourceRequest = mock() + val url: Uri = Uri.parse("https://www.youtube-nocookie.com?videoID=12345") + val webView: WebView = mock() + val context: Context = mock() + val assets: AssetManager = mock() + whenever(webView.context).thenReturn(context) + whenever(context.assets).thenReturn(assets) + whenever(assets.open(any())).thenReturn(mock()) + + val result = testee.intercept(request, url, webView) + + verify(assets).open("duckplayer/index.html") + verify(mockPixel).fire(DUCK_PLAYER_DAILY_UNIQUE_VIEW, type = DAILY) + assertEquals("text/html", result?.mimeType) + } + + @Test + fun whenUriIsYouTubeEmbedAndFeatureDisabled_doNothing() = runTest { + mockFeatureToggle(false) + val request: WebResourceRequest = mock() + val url: Uri = Uri.parse("https://www.youtube-nocookie.com?videoID=12345") + val webView: WebView = mock() + + val result = testee.intercept(request, url, webView) + + assertNull(result) + } + + @Test + fun whenUriIsYoutubeWatchUrl_interceptProcessesYoutubeWatchUri() = runTest { + val request: WebResourceRequest = mock() + val url: Uri = Uri.parse("https://www.youtube.com/watch?v=12345") + val webView: WebView = mock() + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, Enabled)) + + val result = testee.intercept(request, url, webView) + + verify(webView).loadUrl("duck://player/12345?origin=auto") + verify(mockPixel).fire(DUCK_PLAYER_VIEW_FROM_YOUTUBE_AUTOMATIC) + assertNotNull(result) + } + + @Test + fun whenUriIsYoutubeWatchUrlAndPreviousUrlIsDuckPlayer_interceptReturnsNull() = runTest { + val request: WebResourceRequest = mock() + val url: Uri = Uri.parse("https://www.youtube.com/watch?v=12345") + val webView: WebView = mock() + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, Enabled)) + whenever(webView.url).thenReturn("https://www.youtube-nocookie.com?videoID=12345") + + val result = testee.intercept(request, url, webView) + + assertNull(result) + } + + @Test + fun whenUriIsYoutubeWatchUrlWithRefererAndPreviousUrlIsDuckPlayer_interceptReturnsNull() = runTest { + val request: WebResourceRequest = mock() + val url: Uri = Uri.parse("https://www.youtube.com/watch?v=12345") + whenever(request.requestHeaders).thenReturn(mapOf("Referer" to "https://www.youtube-nocookie.com?videoID=12345")) + whenever(mockDuckPlayerFeatureRepository.getYouTubeReferrerHeaders()).thenReturn(listOf("Referer")) + val webView: WebView = mock() + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, Enabled)) + whenever(webView.url).thenReturn("https://www.youtube-nocookie.com?videoID=12345") + + val result = testee.intercept(request, url, webView) + + assertNull(result) + } + + @Test + fun whenUriIsYoutubeWatchUrlAndPreviousUrlIsDuckPlayerWithDifferentId_interceptOpensDuckPlayer() = runTest { + val request: WebResourceRequest = mock() + val url: Uri = Uri.parse("https://www.youtube.com/watch?v=123456") + val webView: WebView = mock() + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, Enabled)) + whenever(webView.url).thenReturn("https://www.youtube-nocookie.com?videoID=12345") + + val result = testee.intercept(request, url, webView) + + verify(webView).loadUrl("duck://player/123456?origin=auto") + verify(mockPixel).fire(DUCK_PLAYER_VIEW_FROM_YOUTUBE_AUTOMATIC) + assertNotNull(result) + } + + @Test + fun whenUriIsYoutubeWatchUrlAndSettingsAlwaysAsk_interceptProcessesYoutubeWatchUri() = runTest { + val request: WebResourceRequest = mock() + val url: Uri = Uri.parse("https://www.youtube.com/watch?v=12345") + val webView: WebView = mock() + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, AlwaysAsk)) + + val result = testee.intercept(request, url, webView) + + verify(webView, never()).loadUrl(any()) + assertNull(result) + } + + // endregion + + // region willNavigateToDuckPlayer + + @Test + fun whenWillNavigateToDuckPlayerWithYouTubeWatchUrlSettingEnabledAndFeatureEnabled_returnTrue() = runTest { + val uri = "https://www.youtube.com/watch?v=12345".toUri() + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, Enabled)) + + val result = testee.willNavigateToDuckPlayer(uri) + + assertTrue(result) + } + + @Test + fun whenWillNavigateToDuckPlayerWithYouTubeWatchUrlSettingDisabledAndFeatureEnabled_returnFalse() = runTest { + val uri = "https://www.youtube.com/watch?v=12345".toUri() + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, Disabled)) + + val result = testee.willNavigateToDuckPlayer(uri) + + assertFalse(result) + } + + @Test + fun whenWillNavigateToDuckPlayerWithYouTubeRootUrlSettingEnableddAndFeatureEnabled_returnFalse() = runTest { + val uri = "https://www.youtube.com".toUri() + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, Enabled)) + + val result = testee.willNavigateToDuckPlayer(uri) + + assertFalse(result) + } + + @Test + fun whenWillNavigateToDuckPlayerWithNonYouTubeUrlSettingEnabledAndFeatureEnabled_returnFalse() = runTest { + val uri = "https://example.com".toUri() + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, Enabled)) + + val result = testee.willNavigateToDuckPlayer(uri) + + assertFalse(result) + } + + // endregion + + private fun mockFeatureToggle(enabled: Boolean) { + whenever(mockDuckPlayerFeature.self()).thenReturn( + object : Toggle { + override fun isEnabled() = enabled + + override fun setEnabled(state: State) { + TODO("Not yet implemented") + } + + override fun getRawStoredState(): State? { + TODO("Not yet implemented") + } + }, + ) + + whenever(mockDuckPlayerFeature.enableDuckPlayer()).thenReturn( + object : Toggle { + override fun isEnabled() = enabled + + override fun setEnabled(state: State) { + TODO("Not yet implemented") + } + + override fun getRawStoredState(): State? { + TODO("Not yet implemented") + } + }, + ) + } +} diff --git a/duckplayer/readme.md b/duckplayer/readme.md new file mode 100644 index 000000000000..2225b8802a25 --- /dev/null +++ b/duckplayer/readme.md @@ -0,0 +1,9 @@ +# Feature Name + +Describe the feature here. + +## Who can help you better understand this feature? +- Cris Barreiro + +## More information +- [Duck Player project](https://app.asana.com/0/1203249713006009/1203249713006009) \ No newline at end of file diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/cog.svg b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/cog.svg new file mode 100644 index 000000000000..c62b1b8e15c1 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/cog.svg @@ -0,0 +1,3 @@ + + + diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/dax.svg b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/dax.svg new file mode 100644 index 000000000000..faa1f060ef7e --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/dax.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/eyeball.svg b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/eyeball.svg new file mode 100644 index 000000000000..da9bd6ee74d5 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/eyeball.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/info-icon.svg b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/info-icon.svg new file mode 100644 index 000000000000..dee6a9b256f0 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/info-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/open.svg b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/open.svg new file mode 100644 index 000000000000..d5943118d7ae --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/player-bg.png b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/player-bg.png new file mode 100644 index 000000000000..83a4b8c3394e Binary files /dev/null and b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/img/player-bg.png differ diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/player.css b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/player.css new file mode 100644 index 000000000000..b516c95565c3 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/assets/player.css @@ -0,0 +1,263 @@ +:root { + --aspect-ratio: calc(9 / 16); + --toolbar-height: 56px; + + /* Set video to take up 80vw width */ + --video-width: 80vw; + + /* Calculate video height based on aspect ratio, but never exceed 80vh + * for the video height, for example when using short, wide screens. + */ + --video-height: min(calc(var(--video-width) * var(--aspect-ratio)), 80vh); +} + +@media screen and (max-width: 1080px) { + :root { + --video-width: 85vw; + } +} + +@media screen and (max-width: 740px) { + :root { + --video-width: 90vw; + } +} + +.bg { + background: url('img/player-bg.png'); +} + +.bg { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-size: cover; + opacity: 0.6; +} + +.bg::after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: -50%; + height: 100%; + background: linear-gradient(0deg, rgba(0, 0, 0, 0), #101010 70%); + transition: all 1s ease-out; +} + +body.faded .bg::after { + top: 30%; +} + +body { + color: rgba(255, 255, 255, 0.85); + font-family: system-ui; + font-size: 13px; + line-height: 16px; + letter-spacing: -0.08px; + margin: 0; + background: #101010; + + /* Make it feel more like something native */ + -webkit-user-select: none; + cursor: default; +} + +.player-container { + overflow: hidden; + z-index: 10; + position: relative; + background: black; +} + +.player-container, +#player { + width: var(--video-width); + height: var(--video-height); + max-width: 3840px; +} + +.player-error { + text-align: center; + line-height: var(--video-height); + background: #2f2f2f; +} + +.content-hover { + --content-padding: 1px; + padding: var(--content-padding); + width: var(--video-width); + + /* Set margin left to be half of the remaining vw - video width */ + margin-left: calc(((100vw - var(--video-width)) / 2) - var(--content-padding)); + + /* Set margin-top to be half of the remaaining vh - video and toolbar height, but never less than 0px. */ + margin-top: max(0px, calc((100vh - (var(--video-height) + var(--toolbar-height))) / 2)); + position: absolute; +} + +.toolbar { + background: rgba(0, 0, 0, 0.3); + border-radius: 0px 0px 12px 12px; + transition: all 0.5s linear; + opacity: 1; + margin-top: -12px; + padding: 12px; + padding-top: 24px; + height: 32px; + display: flex; +} + +@media (prefers-reduced-motion) { + .toolbar { + transition: none; + } +} + +body.faded .toolbar { + opacity: 0; + margin-top: -80px; +} + +.logo { + font-style: normal; + font-weight: 600; + color: #ffffff; + display: flex; + align-items: center; +} + +.dax-icon { + margin-right: 5px; +} + +.info-icon-container { + position: relative; + display: inline-block; + margin-left: 4px; +} + +.info-icon { + margin-bottom: -4px; +} + +.info-icon:hover path { + fill: rgba(255, 255, 255, 0.8); +} + +.info-icon-tooltip { + position: absolute; + background: linear-gradient(0deg, rgba(48, 48, 48, 0.35), rgba(48, 48, 48, 0.35)), rgba(33, 33, 33, 0.55); + background-blend-mode: normal, luminosity; + box-shadow: inset 0px 0px 1px #ffffff; + filter: drop-shadow(0px 0px 1px #000000) drop-shadow(0px 0px 1px #000000) drop-shadow(0px 0px 1px #000000) + drop-shadow(0px 8px 16px rgba(0, 0, 0, 0.2)); + backdrop-filter: blur(76px); + border-radius: 10px; + width: 300px; + font-weight: normal; + padding: 12px; + left: -162px; + top: 32px; + display: none; +} + +.info-icon-tooltip::after { + content: ''; + width: 15px; + height: 15px; + border: 1px solid #5f5f5f; + display: block; + position: absolute; + top: -8px; + border-right: none; + border-bottom: none; + transform: rotate(45deg); + background: #1d1d1d; + left: 162px; +} + +.info-icon-tooltip.above { + top: -105px; + z-index: 50; +} + +.info-icon-tooltip.above::after { + top: 80px; + transform: rotate(225deg); +} + +.info-icon-tooltip.visible { + display: block; +} + +.options { + margin-left: auto; + display: flex; + align-items: center; +} + +.setting-container { + overflow: hidden; + white-space: nowrap; + margin-right: 0; + width: 299px; +} + +.setting-container.animatable { + transition: 0.3s linear all; +} + +@media (prefers-reduced-motion) { + .setting-container.animatable { + transition: none + } +} + +.setting-container.invisible { + width: 0px; +} + +@media screen and (max-width: 760px) { + .setting-container { + width: calc(var(--video-width) - 370px); + margin-right: 8px; + text-overflow: ellipsis; + } +} + +.settings-label { + cursor: pointer; + display: flex; + align-items: center; +} +.settings-checkbox { + margin-right: 4px; + width: 14px; + height: 14px; +} + +.options-button { + height: 16px; + padding: 8px; + background: rgba(255, 255, 255, 0.18); + border-radius: 8px; + float: left; + color: white; + text-decoration: none; + margin-left: 8px; + font-weight: bold; + text-align: center; +} + +.options-button:hover, +.options-button.active { + background: rgba(255, 255, 255, 0.28); +} + +.play-on-youtube img { + margin-bottom: -3px; +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/index.html b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/index.html new file mode 100644 index 000000000000..f48bb6eef269 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/index.html @@ -0,0 +1,14 @@ + + + + Duck Player + + + + + + +
+ + + diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.css b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.css new file mode 100644 index 000000000000..de2906b8f108 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.css @@ -0,0 +1,954 @@ +/* pages/duckplayer/app/base.css */ +*, +*:after, +*:before { + box-sizing: border-box; +} +html[data-reduced-motion=true] *:not([data-allow-animation]) { + animation: none !important; + transition: none !important; +} +body { + font-family: system-ui, sans-serif; + font-size: 13px; + margin: 0; + height: 100vh; + width: 100%; + overflow-x: hidden; + user-select: none; + -webkit-user-select: none; + cursor: default; +} +button { + font-family: system-ui, sans-serif; + font-size: 13px; +} +ul { + margin: 0; + padding: 0; +} +li { + list-style: none; + margin: 0; + padding: 0; +} + +/* pages/duckplayer/app/index.css */ +html:has(body[data-display=app]) { + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} +body[data-display=app] { + color: rgba(242, 242, 242, 1); + background: #101010; + height: 100vh; + overflow: hidden; + padding: 16px; +} + +/* pages/duckplayer/app/components/Fallback.module.css */ +.Fallback_fallback { + height: 100%; + width: 100%; +} + +/* pages/duckplayer/app/components/Components.module.css */ +.Components_main { + color: white; + max-width: 3840px; + margin: 0 auto; + padding: 2rem 8px; + position: relative; + z-index: 1; +} +.Components_tube { + max-width: 750px; + margin: 0 auto; +} + +/* pages/duckplayer/app/components/PlayerContainer.module.css */ +.PlayerContainer_container { + border-radius: 14px; +} +.PlayerContainer_inset { + padding: 8px; + border-radius: var(--outer-radius); + background: rgba(0, 0, 0, 0.3); + transition: background 1s; +} +[data-focus-mode=on] .PlayerContainer_inset { + background: none; +} +.PlayerContainer_internals { + display: grid; + overflow: hidden; +} +.PlayerContainer_insetInternals { + gap: 8px; +} + +/* pages/duckplayer/app/components/Button.module.css */ +.Button_button { + border: none; + outline: none; + display: flex; + height: 44px; + line-height: 44px; + font-size: 16px; + padding: 0 20px; + flex-shrink: 0; + box-shadow: none; + background: rgba(255, 255, 255, 0.12); + border-radius: var(--inner-radius); + font-weight: bold; + color: rgba(255, 255, 255, 1); + text-decoration: none; +} +.Button_button:hover, +.Button_button:focus-visible { + cursor: pointer; + background: rgba(255, 255, 255, 0.2); +} +.Button_fill { + flex: 1; + text-align: center; + justify-content: center; + text-wrap: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} +.Button_desktop { + height: 32px; + line-height: 32px; + font-size: 13px; + font-weight: 600; + .Button_icon { + width: 16px; + height: 16px; + } +} +.Button_iconOnly { + width: 44px; + padding: 0 8px; + position: relative; +} +.Button_iconOnly .Button_icon { + position: absolute; + top: 50%; + left: 50%; + transform: translateY(-50%) translateX(-50%); +} +.Button_icon { +} +.Button_icon img { + display: block; + width: 100%; +} +.Button_desktop.Button_iconOnly { + width: 32px; +} +.Button_highlight { + transition: all .3s ease-in-out; + transition-delay: 2s; + background: rgba(255, 255, 255, 0.2); +} +.Button_highlight .Button_icon img { + transition: all .3s ease-in-out; + transition-delay: 2s; + transform: scale(1.1); +} + +/* pages/duckplayer/app/components/FloatingBar.module.css */ +.FloatingBar_floatingBar { + display: flex; + gap: 8px; +} +.FloatingBar_inset { + border-radius: var(--outer-radius); + padding: 8px; + background: rgba(0, 0, 0, 0.3); +} +.FloatingBar_topBar { + display: grid; + justify-content: center; + width: 100%; +} + +/* pages/duckplayer/app/components/SwitchBarMobile.module.css */ +.SwitchBarMobile_switchBar { + display: grid; + border-radius: 16px; + background: rgba(255, 255, 255, 0.03); + padding-inline: 16px; + height: 100%; + line-height: 1.1; +} +@media screen and (min-width: 900px) { + .SwitchBarMobile_switchBar { + border-radius: 8px; + } +} +@media screen and (min-width: 667px) and (max-height: 450px) { + .SwitchBarMobile_switchBar { + border-radius: 8px; + } +} +.SwitchBarMobile_stateExiting { + transition: all .3s ease-in-out; + transition-delay: 2s; + opacity: 0; + visibility: hidden; + transform: translateY(-50%); +} +.SwitchBarMobile_stateHidden { + display: none; +} +.SwitchBarMobile_label { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 16px; +} +.SwitchBarMobile_checkbox { +} +.SwitchBarMobile_text { + font-weight: bold; + font-size: 14px; +} +.SwitchBarMobile_placeholder { + height: 44px; + position: relative; + width: 100%; + > * { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } +} + +/* pages/duckplayer/app/components/Switch.module.css */ +.Switch_switch { + margin: 0; + padding: 0; + width: 52px; + height: 32px; + border: 0; + box-shadow: none; + background: rgba(136, 136, 136, 0.5); + border-radius: 32px; + position: relative; + transition: all .3s; +} +.Switch_switch:active .Switch_thumb { + scale: 1.15; +} +.Switch_thumb { + width: 24px; + height: 24px; + border-radius: 100%; + background: white; + position: absolute; + top: 4px; + left: 4px; + pointer-events: none; + transition: .2s left ease-in-out; +} +.Switch_switch[aria-checked=true] .Switch_thumb { + left: calc(100% - 32px + 4px); +} +.Switch_switch[aria-checked=true] { + background: rgba(57, 105, 239, 1); +} +.Switch_ios { + width: 51px; + height: 31px; +} +.Switch_ios .Switch_thumb { + top: 2px; + left: 2px; + width: 27px; + height: 27px; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.25); +} +.Switch_ios:active .Switch_thumb { + scale: 1; +} +.Switch_ios[aria-checked=true] .Switch_thumb { + left: calc(100% - 32px + 3px); +} + +/* pages/duckplayer/app/components/InfoBar.module.css */ +.InfoBar_infoBar { + display: flex; + align-items: center; + gap: 8px; + flex: 1; +} +.InfoBar_container { + padding: 12px; + background: rgba(0, 0, 0, 0.3); + position: relative; + z-index: 1; + border-bottom-left-radius: 14px; + border-bottom-right-radius: 14px; +} +.InfoBar_dax { + padding: 2px; +} +.InfoBar_img { + display: block; + width: 20px; + height: 20px; +} +.InfoBar_text { + margin-left: 5px; + margin-right: 8px; + font-size: 14px; + font-weight: bold; + white-space: nowrap; +} +.InfoBar_info { + width: 16px; + height: 16px; + appearance: none; + -webkit-appearance: none; + background: none; + border: 0; + padding: 0; + margin: 0; + position: relative; + &:focus-visible { + outline: none; + } + &:focus-visible[aria-expanded=true] { + outline: 1px solid white; + outline-offset: 2px; + border-radius: 50%; + } + img { + display: block; + width: 100%; + } +} +.InfoBar_lhs { + display: flex; + align-items: center; +} +.InfoBar_rhs { + margin-left: auto; + display: flex; + gap: 16px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} +.InfoBar_switch { + display: flex; + overflow: hidden; + white-space: nowrap; +} +.InfoBar_controls { + display: flex; + gap: 8px; +} + +/* pages/duckplayer/app/components/SwitchBarDesktop.module.css */ +.SwitchBarDesktop_switchBarDesktop { + display: flex; + align-items: center; +} +.SwitchBarDesktop_stateCompleted { + display: none; +} +.SwitchBarDesktop_stateExiting { + transition: all .5s ease-in-out; + transition-delay: 2s; + opacity: 0; + visibility: hidden; +} +.SwitchBarDesktop_label { + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; + overflow: hidden; +} +.SwitchBarDesktop_stateExiting .SwitchBarDesktop_label { + animation: SwitchBarDesktop_slide-out .5s forwards; + animation-delay: 2s; +} +@keyframes SwitchBarDesktop_slide-out { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(100%); + } +} +.SwitchBarDesktop_checkbox { + display: block; +} +.SwitchBarDesktop_stateExiting .SwitchBarDesktop_input { + pointer-events: none; +} +.SwitchBarDesktop_input { + display: block; + &:focus-visible { + outline: 1px solid white; + outline-offset: 2px; + } +} +.SwitchBarDesktop_input[disabled] { +} +.SwitchBarDesktop_text { + line-height: 1; + &:hover { + cursor: pointer; + } +} + +/* pages/duckplayer/app/components/Tooltip.module.css */ +.Tooltip_tooltip { + position: absolute; + background: linear-gradient(0deg, rgba(48, 48, 48, 0.35), rgba(48, 48, 48, 0.35)), rgba(33, 33, 33, 0.55); + background-blend-mode: normal, luminosity; + box-shadow: inset 0px 0px 1px #ffffff; + filter: drop-shadow(0px 0px 1px #000000) drop-shadow(0px 0px 1px #000000) drop-shadow(0px 0px 1px #000000) drop-shadow(0px 8px 16px rgba(0, 0, 0, 0.2)); + backdrop-filter: blur(76px); + border-radius: 10px; + width: 300px; + font-weight: normal; + padding: 12px; + left: -162px; + top: 32px; + color: white; + display: none; + z-index: 1; +} +.Tooltip_tooltip[aria-hidden=false] { + display: block; +} +.Tooltip_tooltip::after { + content: ""; + width: 15px; + height: 15px; + border: 1px solid #5f5f5f; + display: block; + position: absolute; + top: -8px; + border-right: none; + border-bottom: none; + transform: rotate(45deg); + background: #1d1d1d; + left: 162px; +} +.Tooltip_top { + top: -100px; + z-index: 50; +} +.Tooltip_top::after { + top: calc(100% - 7px); + transform: rotate(225deg); +} +.Tooltip_bottom { +} +.Tooltip_tooltip.Tooltip_visible { + display: block; +} + +/* pages/duckplayer/app/components/FocusMode.module.css */ +[data-focus-mode=on] .FocusMode_fade { + opacity: 0; + visibility: hidden; +} +[data-focus-mode=on] .FocusMode_slide { + opacity: 0; + visibility: hidden; + transform: translateY(-100%); +} +.FocusMode_hideInFocus { + opacity: 1; + visibility: visible; + top: 0; + transition: all .3s; +} + +/* pages/duckplayer/app/components/Wordmark.module.css */ +.Wordmark_wordmark { + display: flex; + white-space: nowrap; + align-items: center; + gap: 8px; +} +.Wordmark_logo { + width: 32px; + height: 32px; +} +.Wordmark_img { + display: block; + width: 100%; +} +.Wordmark_text { + font-size: 19px; + font-weight: bold; +} + +/* pages/duckplayer/app/components/Wordmark-mobile.module.css */ +.Wordmark_mobile_logo { + height: 44px; + display: grid; + width: 100%; + align-items: center; + grid-column-gap: 8px; + grid-template-columns: max-content max-content; +} +@media screen and (max-width: 500px) { + .Wordmark_mobile_logo { + height: 100px; + } +} +.Wordmark_mobile_logoSvg { + img { + display: block; + width: 32px; + height: 32px; + } +} +.Wordmark_mobile_text { + font-size: 17px; + font-weight: 600; +} + +/* pages/duckplayer/app/components/Background.module.css */ +.Background_bg { + background: url("./player-bg-F7QLKTXS.jpg"); + background-size: cover; +} +[data-layout=mobile] .Background_bg { + background: url("./mobile-bg-GCRU67TC.jpg"); + background-size: cover; +} +.Background_bg { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + width: 100%; + height: 100%; +} +.Background_bg::before { + content: ""; + position: absolute; + inset: 0; + height: 100%; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.00) 0%, rgba(0, 0, 0, 0.48) 32.23%, #000 93.87%); + transition: all .3s ease-in-out; +} +.Background_bg::after { + content: ""; + position: absolute; + inset: 0; + height: 100%; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.48) 0%, rgba(0, 0, 0, 0.90) 34.34%, #000 100%); + opacity: 0; + visibility: hidden; + transition: all .3s ease-in-out; +} +[data-focus-mode=on] .Background_bg::before { + transition-delay: .1s; + opacity: 0; +} +[data-focus-mode=off] .Background_bg::after { + transition-delay: .1s; +} +[data-focus-mode=on] .Background_bg::after { + opacity: 1; + visibility: visible; +} + +/* pages/duckplayer/app/components/Player.module.css */ +.Player_root.Player_desktop { + z-index: 1; + position: relative; + overflow: hidden; + height: var(--frame-height); +} +.Player_root.Player_mobile { + aspect-ratio: 16/9; + border-radius: var(--inner-radius); + height: auto; +} +.Player_root.Player_desktop { + height: var(--frame-height); +} +.Player_player { + font-size: 0; +} +.Player_iframe.Player_mobile { + height: 100%; + width: 100%; +} +.Player_iframe.Player_desktop { + height: var(--frame-height); + width: 100%; + z-index: 1; + position: relative; +} +.Player_desktop { + border-top-left-radius: var(--outer-radius); + border-top-right-radius: var(--outer-radius); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.Player_error { + height: 100%; + display: grid; + align-items: center; + justify-items: center; + background: #2f2f2f; +} + +/* pages/duckplayer/app/components/MobileApp.module.css */ +html[data-focus-mode=on]:root .MobileApp_main { + --bg-color: transparent; +} +.MobileApp_hideInFocus { + opacity: 1; + visibility: visible; + transition: opacity .3s, visibility .3s; +} +html[data-focus-mode=on] .MobileApp_hideInFocus { + opacity: 0; + visibility: hidden; +} +@keyframes MobileApp_fadeout { + from { + opacity: 1; + visibility: visible; + } + to { + opacity: 0; + visibility: visible; + } +} +.MobileApp_filler { + display: none; +} +.MobileApp_main { + --bg-color: rgba(0, 0, 0, 0.3); + --logo-spacing: 185px; + --ui-control-height: calc(44px + 12px + 12px); + --additional-ui: calc(44px * 3); + --gutter-width: 8px; + --gutter-combined: calc(var(--gutter-width) * 2); + --outer-radius: 16px; + --inner-radius: 8px; + --logo-width: 157px; + --inner-padding: 12px; + position: relative; + max-width: 100vh; + margin: 0 auto; + height: 100%; + display: grid; + grid-template-columns: auto; + --row-1: 0; + --row-2: auto; + --row-3: max-content; + --row-4: max-content; + --row-5: 12px; + --row-6: max-content; + --row-7: auto; + grid-template-rows: var(--row-1) var(--row-2) var(--row-3) var(--row-4) var(--row-5) var(--row-6) var(--row-7); + grid-template-areas: "logo" "gap1" "embed" "buttons" "button-gap" "switch" "gap2"; +} +body:has([data-state=completed] [aria-checked=true]) .MobileApp_main { + --row-1: 0; + --row-2: auto; + --row-3: max-content; + --row-4: max-content; + --row-5: 0; + --row-6: 0; + --row-7: auto; +} +body:has([data-state=completed] [aria-checked=true]) .MobileApp_switch { + background: transparent; +} +.MobileApp_embed { + background: var(--bg-color); + grid-area: embed; + padding: var(--inner-padding); + padding-bottom: 0; + border-top-left-radius: var(--outer-radius); + border-top-right-radius: var(--outer-radius); +} +.MobileApp_logo { + justify-self: center; + grid-area: logo; +} +.MobileApp_buttons { + grid-area: buttons; + padding: var(--inner-padding); + background: var(--bg-color); + border-bottom-left-radius: var(--outer-radius); + border-bottom-right-radius: var(--outer-radius); +} +.MobileApp_switch { + grid-area: switch; + height: 50px; + background: rgba(255, 255, 255, 0.03); + border-radius: 16px; +} +@media screen and (min-width: 425px) and (max-height: 600px) { + .MobileApp_main { + grid-template-rows: max-content auto max-content max-content 12px max-content auto; + } +} +@media screen and (min-width: 768px) and (min-height: 600px) { + .MobileApp_logo { + justify-self: unset; + background: var(--bg-color); + border-bottom-left-radius: var(--outer-radius); + display: grid; + align-items: center; + padding-left: var(--inner-padding); + } + .MobileApp_buttons { + border-bottom-left-radius: unset; + } + .MobileApp_main { + grid-template-columns: auto minmax(384px, max-content); + grid-template-rows: max-content max-content 12px max-content; + grid-template-areas: "embed embed" "logo buttons" "button-gap button-gap" "switch switch"; + align-content: center; + max-width: calc(100vh * 1.3); + } + body:has([data-state=completed] [aria-checked=true]) .MobileApp_main { + grid-template-rows: max-content max-content 0 0; + } +} +@media screen and (min-width: 900px) and (min-height: 660px) { + .MobileApp_logo { + justify-self: unset; + padding-right: 34px; + } + .MobileApp_switch { + background: var(--bg-color); + border-radius: unset; + display: grid; + padding-top: 12px; + padding-bottom: 12px; + height: 100%; + } + .MobileApp_buttons { + padding-left: 8px; + } + .MobileApp_main { + grid-template-columns: max-content auto minmax(384px, max-content); + grid-template-rows: max-content max-content; + grid-template-areas: "embed embed embed" "logo switch buttons"; + align-content: center; + max-width: calc(100vh * 1.3); + } + body:has([data-state=completed] [aria-checked=true]) .MobileApp_switch { + background: var(--bg-color); + } +} +@media screen and (min-width: 600px) and (max-height: 450px) { + .MobileApp_main { + grid-template-columns: 1fr 1fr; + grid-template-rows: calc(44px + 24px) 44px auto calc(44px + 24px); + grid-template-areas: "embed logo" "embed buttons" "embed filler" "embed switch"; + align-content: center; + max-width: 100%; + max-height: 90vh; + } + body:has([data-state=completed] [aria-checked=true]) .MobileApp_main { + grid-template-rows: max-content max-content 0 0; + } + body:has([data-state=completed] [aria-checked=true]) .MobileApp_logo { + padding-top: 0; + align-items: end; + } + body:has([data-state=completed] [aria-checked=true]) .MobileApp_buttons { + border-bottom-right-radius: var(--outer-radius); + padding-bottom: 12px; + } + body:has([data-state=completed] [aria-checked=true]) .MobileApp_switch { + display: none; + } + .MobileApp_filler { + display: block; + height: 100%; + grid-area: filler; + background: var(--bg-color); + } + .MobileApp_embed { + padding: var(--inner-padding); + border-bottom-left-radius: var(--outer-radius); + border-top-right-radius: 0; + } + .MobileApp_logo { + display: grid; + width: 100%; + background: var(--bg-color); + justify-content: center; + border-top-right-radius: var(--outer-radius); + padding: var(--inner-padding); + padding-left: 0; + } + .MobileApp_buttons { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + padding: 0; + padding-right: var(--inner-padding); + } + .MobileApp_switch { + background: var(--bg-color); + border-top-right-radius: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: var(--outer-radius); + align-self: end; + padding: var(--inner-padding); + padding-left: 0; + height: 100%; + } +} +@media screen and (min-width: 1100px) { + .MobileApp_switch { + justify-content: end; + } + .MobileApp_switch > * { + min-width: 400px; + } +} + +/* pages/duckplayer/app/components/MobileButtons.module.css */ +.MobileButtons_buttons { + display: grid; + grid-template-columns: max-content max-content auto; + grid-column-gap: 8px; +} + +/* pages/duckplayer/app/components/DesktopApp.module.css */ +:root { + --video-width: 80vw; + --outer-radius: 16px; + --inner-radius: 8px; +} +@media screen and (max-width: 1080px) { + :root { + --video-width: 85vw; + } +} +@media screen and (max-width: 740px) { + :root { + --video-width: 90vw; + } +} +:root [data-layout=desktop] { + --frame-height: min( calc(var(--video-width) * calc(9 / 16)), 80vh ) ; +} +:root [data-layout=mobile][data-orientation=portrait] { + --video-width: calc(100vw - 32px) ; +} +:root [data-layout=mobile][data-orientation=landscape] { + --video-width: calc(calc(100vw - 32px) * 0.6) ; +} +@media screen and (max-width: 700px) { + :root [data-layout=mobile][data-orientation=landscape] { + --video-width: calc(calc(100vw - 32px) * 0.5) ; + } +} +:root [data-layout=mobile] { + --frame-height: min( calc(var(--video-width) * calc(9 / 16)), calc(100vh - 32px) ) ; +} +.DesktopApp_app { + margin: 0 auto; + position: relative; + z-index: 1; + height: 100%; + width: 100%; + max-width: 3840px; + color: rgba(255, 255, 255, 0.85); +} +.DesktopApp_portrait { + height: 100%; + display: grid; + align-self: center; + grid-template-areas: "header" "main"; + grid-template-rows: max-content 1fr; +} +.DesktopApp_landscape { + height: 100%; + display: grid; + align-self: center; + align-items: center; + align-content: center; +} +.DesktopApp_wrapper { +} +.DesktopApp_portrait .DesktopApp_wrapper { + grid-area: main; + display: grid; + grid-template-areas: "main" "controls"; + grid-template-rows: auto max-content; +} +.DesktopApp_landscape .DesktopApp_wrapper { + display: grid; + grid-template-columns: 60% 1fr; + grid-column-gap: 8px; + background: rgba(0, 0, 0, 0.3); + border-radius: var(--outer-radius); + padding: 8px; + @media screen and (max-width: 700px) { + grid-template-columns: 50% 1fr; + } +} +.DesktopApp_desktop { + height: 100%; + width: var(--video-width); + margin: 0 auto; + display: flex; + flex-direction: column; + justify-content: center; +} +.DesktopApp_rhs { + display: grid; + height: 100%; + grid-template-areas: "header" "controls" "switch"; + grid-template-rows: max-content max-content auto; + grid-template-columns: 1fr; + grid-row-gap: 12px; +} +.DesktopApp_rhs:has([data-state=completed] [aria-checked=true]) { + align-content: center; +} +.DesktopApp_header { + grid-area: header; + padding-top: 48px; + @media screen and (max-height: 500px) { + padding-top: 32px; + } +} +.DesktopApp_main { + align-self: center; +} +.DesktopApp_controls { + grid-area: controls; +} +.DesktopApp_switch { + grid-area: switch; +} +.DesktopApp_landscape .DesktopApp_header { + padding-top: 8px; +} +.DesktopApp_landscape .DesktopApp_switch { + align-self: end; +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.js b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.js new file mode 100644 index 000000000000..59dac3b2756d --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/index.js @@ -0,0 +1,3603 @@ +"use strict"; +(() => { + var __create = Object.create; + var __defProp = Object.defineProperty; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropNames = Object.getOwnPropertyNames; + var __getProtoOf = Object.getPrototypeOf; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; + }; + var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; + }; + var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod + )); + + // ../../node_modules/classnames/index.js + var require_classnames = __commonJS({ + "../../node_modules/classnames/index.js"(exports, module) { + (function() { + "use strict"; + var hasOwn = {}.hasOwnProperty; + var nativeCodeString = "[native code]"; + function classNames() { + var classes = []; + for (var i3 = 0; i3 < arguments.length; i3++) { + var arg = arguments[i3]; + if (!arg) + continue; + var argType = typeof arg; + if (argType === "string" || argType === "number") { + classes.push(arg); + } else if (Array.isArray(arg)) { + if (arg.length) { + var inner = classNames.apply(null, arg); + if (inner) { + classes.push(inner); + } + } + } else if (argType === "object") { + if (arg.toString !== Object.prototype.toString && !arg.toString.toString().includes("[native code]")) { + classes.push(arg.toString()); + continue; + } + for (var key in arg) { + if (hasOwn.call(arg, key) && arg[key]) { + classes.push(key); + } + } + } + } + return classes.join(" "); + } + if (typeof module !== "undefined" && module.exports) { + classNames.default = classNames; + module.exports = classNames; + } else if (typeof define === "function" && typeof define.amd === "object" && define.amd) { + define("classnames", [], function() { + return classNames; + }); + } else { + window.classNames = classNames; + } + })(); + } + }); + + // ../messaging/lib/windows.js + var WindowsMessagingTransport = class { + /** + * @param {WindowsMessagingConfig} config + * @param {import('../index.js').MessagingContext} messagingContext + * @internal + */ + constructor(config, messagingContext) { + this.messagingContext = messagingContext; + this.config = config; + this.globals = { + window, + JSONparse: window.JSON.parse, + JSONstringify: window.JSON.stringify, + Promise: window.Promise, + Error: window.Error, + String: window.String + }; + for (const [methodName, fn] of Object.entries(this.config.methods)) { + if (typeof fn !== "function") { + throw new Error("cannot create WindowsMessagingTransport, missing the method: " + methodName); + } + } + } + /** + * @param {import('../index.js').NotificationMessage} msg + */ + notify(msg) { + const data = this.globals.JSONparse(this.globals.JSONstringify(msg.params || {})); + const notification = WindowsNotification.fromNotification(msg, data); + this.config.methods.postMessage(notification); + } + /** + * @param {import('../index.js').RequestMessage} msg + * @param {{signal?: AbortSignal}} opts + * @return {Promise} + */ + request(msg, opts = {}) { + const data = this.globals.JSONparse(this.globals.JSONstringify(msg.params || {})); + const outgoing = WindowsRequestMessage.fromRequest(msg, data); + this.config.methods.postMessage(outgoing); + const comparator = (eventData) => { + return eventData.featureName === msg.featureName && eventData.context === msg.context && eventData.id === msg.id; + }; + function isMessageResponse(data2) { + if ("result" in data2) + return true; + if ("error" in data2) + return true; + return false; + } + return new this.globals.Promise((resolve, reject) => { + try { + this._subscribe(comparator, opts, (value, unsubscribe) => { + unsubscribe(); + if (!isMessageResponse(value)) { + console.warn("unknown response type", value); + return reject(new this.globals.Error("unknown response")); + } + if (value.result) { + return resolve(value.result); + } + const message = this.globals.String(value.error?.message || "unknown error"); + reject(new this.globals.Error(message)); + }); + } catch (e3) { + reject(e3); + } + }); + } + /** + * @param {import('../index.js').Subscription} msg + * @param {(value: unknown | undefined) => void} callback + */ + subscribe(msg, callback) { + const comparator = (eventData) => { + return eventData.featureName === msg.featureName && eventData.context === msg.context && eventData.subscriptionName === msg.subscriptionName; + }; + const cb = (eventData) => { + return callback(eventData.params); + }; + return this._subscribe(comparator, {}, cb); + } + /** + * @typedef {import('../index.js').MessageResponse | import('../index.js').SubscriptionEvent} Incoming + */ + /** + * @param {(eventData: any) => boolean} comparator + * @param {{signal?: AbortSignal}} options + * @param {(value: Incoming, unsubscribe: (()=>void)) => void} callback + * @internal + */ + _subscribe(comparator, options, callback) { + if (options?.signal?.aborted) { + throw new DOMException("Aborted", "AbortError"); + } + let teardown; + const idHandler = (event) => { + if (this.messagingContext.env === "production") { + if (event.origin !== null && event.origin !== void 0) { + console.warn("ignoring because evt.origin is not `null` or `undefined`"); + return; + } + } + if (!event.data) { + console.warn("data absent from message"); + return; + } + if (comparator(event.data)) { + if (!teardown) + throw new Error("unreachable"); + callback(event.data, teardown); + } + }; + const abortHandler = () => { + teardown?.(); + throw new DOMException("Aborted", "AbortError"); + }; + this.config.methods.addEventListener("message", idHandler); + options?.signal?.addEventListener("abort", abortHandler); + teardown = () => { + this.config.methods.removeEventListener("message", idHandler); + options?.signal?.removeEventListener("abort", abortHandler); + }; + return () => { + teardown?.(); + }; + } + }; + var WindowsMessagingConfig = class { + /** + * @param {object} params + * @param {WindowsInteropMethods} params.methods + * @internal + */ + constructor(params) { + this.methods = params.methods; + this.platform = "windows"; + } + }; + var WindowsNotification = class { + /** + * @param {object} params + * @param {string} params.Feature + * @param {string} params.SubFeatureName + * @param {string} params.Name + * @param {Record} [params.Data] + * @internal + */ + constructor(params) { + this.Feature = params.Feature; + this.SubFeatureName = params.SubFeatureName; + this.Name = params.Name; + this.Data = params.Data; + } + /** + * Helper to convert a {@link NotificationMessage} to a format that Windows can support + * @param {NotificationMessage} notification + * @returns {WindowsNotification} + */ + static fromNotification(notification, data) { + const output = { + Data: data, + Feature: notification.context, + SubFeatureName: notification.featureName, + Name: notification.method + }; + return output; + } + }; + var WindowsRequestMessage = class { + /** + * @param {object} params + * @param {string} params.Feature + * @param {string} params.SubFeatureName + * @param {string} params.Name + * @param {Record} [params.Data] + * @param {string} [params.Id] + * @internal + */ + constructor(params) { + this.Feature = params.Feature; + this.SubFeatureName = params.SubFeatureName; + this.Name = params.Name; + this.Data = params.Data; + this.Id = params.Id; + } + /** + * Helper to convert a {@link RequestMessage} to a format that Windows can support + * @param {RequestMessage} msg + * @param {Record} data + * @returns {WindowsRequestMessage} + */ + static fromRequest(msg, data) { + const output = { + Data: data, + Feature: msg.context, + SubFeatureName: msg.featureName, + Name: msg.method, + Id: msg.id + }; + return output; + } + }; + + // ../messaging/schema.js + var RequestMessage = class { + /** + * @param {object} params + * @param {string} params.context + * @param {string} params.featureName + * @param {string} params.method + * @param {string} params.id + * @param {Record} [params.params] + * @internal + */ + constructor(params) { + this.context = params.context; + this.featureName = params.featureName; + this.method = params.method; + this.id = params.id; + this.params = params.params; + } + }; + var NotificationMessage = class { + /** + * @param {object} params + * @param {string} params.context + * @param {string} params.featureName + * @param {string} params.method + * @param {Record} [params.params] + * @internal + */ + constructor(params) { + this.context = params.context; + this.featureName = params.featureName; + this.method = params.method; + this.params = params.params; + } + }; + var Subscription = class { + /** + * @param {object} params + * @param {string} params.context + * @param {string} params.featureName + * @param {string} params.subscriptionName + * @internal + */ + constructor(params) { + this.context = params.context; + this.featureName = params.featureName; + this.subscriptionName = params.subscriptionName; + } + }; + function isResponseFor(request, data) { + if ("result" in data) { + return data.featureName === request.featureName && data.context === request.context && data.id === request.id; + } + if ("error" in data) { + if ("message" in data.error) { + return true; + } + } + return false; + } + function isSubscriptionEventFor(sub, data) { + if ("subscriptionName" in data) { + return data.featureName === sub.featureName && data.context === sub.context && data.subscriptionName === sub.subscriptionName; + } + return false; + } + + // ../messaging/lib/webkit.js + var WebkitMessagingTransport = class { + /** + * @param {WebkitMessagingConfig} config + * @param {import('../index.js').MessagingContext} messagingContext + */ + constructor(config, messagingContext) { + this.messagingContext = messagingContext; + this.config = config; + this.globals = captureGlobals(); + if (!this.config.hasModernWebkitAPI) { + this.captureWebkitHandlers(this.config.webkitMessageHandlerNames); + } + } + /** + * Sends message to the webkit layer (fire and forget) + * @param {String} handler + * @param {*} data + * @internal + */ + wkSend(handler, data = {}) { + if (!(handler in this.globals.window.webkit.messageHandlers)) { + throw new MissingHandler(`Missing webkit handler: '${handler}'`, handler); + } + if (!this.config.hasModernWebkitAPI) { + const outgoing = { + ...data, + messageHandling: { + ...data.messageHandling, + secret: this.config.secret + } + }; + if (!(handler in this.globals.capturedWebkitHandlers)) { + throw new MissingHandler(`cannot continue, method ${handler} not captured on macos < 11`, handler); + } else { + return this.globals.capturedWebkitHandlers[handler](outgoing); + } + } + return this.globals.window.webkit.messageHandlers[handler].postMessage?.(data); + } + /** + * Sends message to the webkit layer and waits for the specified response + * @param {String} handler + * @param {import('../index.js').RequestMessage} data + * @returns {Promise<*>} + * @internal + */ + async wkSendAndWait(handler, data) { + if (this.config.hasModernWebkitAPI) { + const response = await this.wkSend(handler, data); + return this.globals.JSONparse(response || "{}"); + } + try { + const randMethodName = this.createRandMethodName(); + const key = await this.createRandKey(); + const iv = this.createRandIv(); + const { + ciphertext, + tag + } = await new this.globals.Promise((resolve) => { + this.generateRandomMethod(randMethodName, resolve); + data.messageHandling = new SecureMessagingParams({ + methodName: randMethodName, + secret: this.config.secret, + key: this.globals.Arrayfrom(key), + iv: this.globals.Arrayfrom(iv) + }); + this.wkSend(handler, data); + }); + const cipher = new this.globals.Uint8Array([...ciphertext, ...tag]); + const decrypted = await this.decrypt(cipher, key, iv); + return this.globals.JSONparse(decrypted || "{}"); + } catch (e3) { + if (e3 instanceof MissingHandler) { + throw e3; + } else { + console.error("decryption failed", e3); + console.error(e3); + return { error: e3 }; + } + } + } + /** + * @param {import('../index.js').NotificationMessage} msg + */ + notify(msg) { + this.wkSend(msg.context, msg); + } + /** + * @param {import('../index.js').RequestMessage} msg + */ + async request(msg) { + const data = await this.wkSendAndWait(msg.context, msg); + if (isResponseFor(msg, data)) { + if (data.result) { + return data.result || {}; + } + if (data.error) { + throw new Error(data.error.message); + } + } + throw new Error("an unknown error occurred"); + } + /** + * Generate a random method name and adds it to the global scope + * The native layer will use this method to send the response + * @param {string | number} randomMethodName + * @param {Function} callback + * @internal + */ + generateRandomMethod(randomMethodName, callback) { + this.globals.ObjectDefineProperty(this.globals.window, randomMethodName, { + enumerable: false, + // configurable, To allow for deletion later + configurable: true, + writable: false, + /** + * @param {any[]} args + */ + value: (...args) => { + callback(...args); + delete this.globals.window[randomMethodName]; + } + }); + } + /** + * @internal + * @return {string} + */ + randomString() { + return "" + this.globals.getRandomValues(new this.globals.Uint32Array(1))[0]; + } + /** + * @internal + * @return {string} + */ + createRandMethodName() { + return "_" + this.randomString(); + } + /** + * @type {{name: string, length: number}} + * @internal + */ + algoObj = { + name: "AES-GCM", + length: 256 + }; + /** + * @returns {Promise} + * @internal + */ + async createRandKey() { + const key = await this.globals.generateKey(this.algoObj, true, ["encrypt", "decrypt"]); + const exportedKey = await this.globals.exportKey("raw", key); + return new this.globals.Uint8Array(exportedKey); + } + /** + * @returns {Uint8Array} + * @internal + */ + createRandIv() { + return this.globals.getRandomValues(new this.globals.Uint8Array(12)); + } + /** + * @param {BufferSource} ciphertext + * @param {BufferSource} key + * @param {Uint8Array} iv + * @returns {Promise} + * @internal + */ + async decrypt(ciphertext, key, iv) { + const cryptoKey = await this.globals.importKey("raw", key, "AES-GCM", false, ["decrypt"]); + const algo = { + name: "AES-GCM", + iv + }; + const decrypted = await this.globals.decrypt(algo, cryptoKey, ciphertext); + const dec = new this.globals.TextDecoder(); + return dec.decode(decrypted); + } + /** + * When required (such as on macos 10.x), capture the `postMessage` method on + * each webkit messageHandler + * + * @param {string[]} handlerNames + */ + captureWebkitHandlers(handlerNames) { + const handlers = window.webkit.messageHandlers; + if (!handlers) + throw new MissingHandler("window.webkit.messageHandlers was absent", "all"); + for (const webkitMessageHandlerName of handlerNames) { + if (typeof handlers[webkitMessageHandlerName]?.postMessage === "function") { + const original = handlers[webkitMessageHandlerName]; + const bound = handlers[webkitMessageHandlerName].postMessage?.bind(original); + this.globals.capturedWebkitHandlers[webkitMessageHandlerName] = bound; + delete handlers[webkitMessageHandlerName].postMessage; + } + } + } + /** + * @param {import('../index.js').Subscription} msg + * @param {(value: unknown) => void} callback + */ + subscribe(msg, callback) { + if (msg.subscriptionName in this.globals.window) { + throw new this.globals.Error(`A subscription with the name ${msg.subscriptionName} already exists`); + } + this.globals.ObjectDefineProperty(this.globals.window, msg.subscriptionName, { + enumerable: false, + configurable: true, + writable: false, + value: (data) => { + if (data && isSubscriptionEventFor(msg, data)) { + callback(data.params); + } else { + console.warn("Received a message that did not match the subscription", data); + } + } + }); + return () => { + this.globals.ReflectDeleteProperty(this.globals.window, msg.subscriptionName); + }; + } + }; + var WebkitMessagingConfig = class { + /** + * @param {object} params + * @param {boolean} params.hasModernWebkitAPI + * @param {string[]} params.webkitMessageHandlerNames + * @param {string} params.secret + * @internal + */ + constructor(params) { + this.hasModernWebkitAPI = params.hasModernWebkitAPI; + this.webkitMessageHandlerNames = params.webkitMessageHandlerNames; + this.secret = params.secret; + } + }; + var SecureMessagingParams = class { + /** + * @param {object} params + * @param {string} params.methodName + * @param {string} params.secret + * @param {number[]} params.key + * @param {number[]} params.iv + */ + constructor(params) { + this.methodName = params.methodName; + this.secret = params.secret; + this.key = params.key; + this.iv = params.iv; + } + }; + function captureGlobals() { + const globals = { + window, + getRandomValues: window.crypto.getRandomValues.bind(window.crypto), + TextEncoder, + TextDecoder, + Uint8Array, + Uint16Array, + Uint32Array, + JSONstringify: window.JSON.stringify, + JSONparse: window.JSON.parse, + Arrayfrom: window.Array.from, + Promise: window.Promise, + Error: window.Error, + ReflectDeleteProperty: window.Reflect.deleteProperty.bind(window.Reflect), + ObjectDefineProperty: window.Object.defineProperty, + addEventListener: window.addEventListener.bind(window), + /** @type {Record} */ + capturedWebkitHandlers: {} + }; + if (isSecureContext) { + globals.generateKey = window.crypto.subtle.generateKey.bind(window.crypto.subtle); + globals.exportKey = window.crypto.subtle.exportKey.bind(window.crypto.subtle); + globals.importKey = window.crypto.subtle.importKey.bind(window.crypto.subtle); + globals.encrypt = window.crypto.subtle.encrypt.bind(window.crypto.subtle); + globals.decrypt = window.crypto.subtle.decrypt.bind(window.crypto.subtle); + } + return globals; + } + + // ../messaging/lib/android.js + var AndroidMessagingTransport = class { + /** + * @param {AndroidMessagingConfig} config + * @param {MessagingContext} messagingContext + * @internal + */ + constructor(config, messagingContext) { + this.messagingContext = messagingContext; + this.config = config; + } + /** + * @param {NotificationMessage} msg + */ + notify(msg) { + try { + this.config.sendMessageThrows?.(JSON.stringify(msg)); + } catch (e3) { + console.error(".notify failed", e3); + } + } + /** + * @param {RequestMessage} msg + * @return {Promise} + */ + request(msg) { + return new Promise((resolve, reject) => { + const unsub = this.config.subscribe(msg.id, handler); + try { + this.config.sendMessageThrows?.(JSON.stringify(msg)); + } catch (e3) { + unsub(); + reject(new Error("request failed to send: " + e3.message || "unknown error")); + } + function handler(data) { + if (isResponseFor(msg, data)) { + if (data.result) { + resolve(data.result || {}); + return unsub(); + } + if (data.error) { + reject(new Error(data.error.message)); + return unsub(); + } + unsub(); + throw new Error("unreachable: must have `result` or `error` key by this point"); + } + } + }); + } + /** + * @param {Subscription} msg + * @param {(value: unknown | undefined) => void} callback + */ + subscribe(msg, callback) { + const unsub = this.config.subscribe(msg.subscriptionName, (data) => { + if (isSubscriptionEventFor(msg, data)) { + callback(data.params || {}); + } + }); + return () => { + unsub(); + }; + } + }; + var AndroidMessagingConfig = class { + /** @type {(json: string, secret: string) => void} */ + _capturedHandler; + /** + * @param {object} params + * @param {Record} params.target + * @param {boolean} params.debug + * @param {string} params.messageSecret - a secret to ensure that messages are only + * processed by the correct handler + * @param {string} params.javascriptInterface - the name of the javascript interface + * registered on the native side + * @param {string} params.messageCallback - the name of the callback that the native + * side will use to send messages back to the javascript side + */ + constructor(params) { + this.target = params.target; + this.debug = params.debug; + this.javascriptInterface = params.javascriptInterface; + this.messageSecret = params.messageSecret; + this.messageCallback = params.messageCallback; + this.listeners = new globalThis.Map(); + this._captureGlobalHandler(); + this._assignHandlerMethod(); + } + /** + * The transport can call this to transmit a JSON payload along with a secret + * to the native Android handler. + * + * Note: This can throw - it's up to the transport to handle the error. + * + * @type {(json: string) => void} + * @throws + * @internal + */ + sendMessageThrows(json) { + this._capturedHandler(json, this.messageSecret); + } + /** + * A subscription on Android is just a named listener. All messages from + * android -> are delivered through a single function, and this mapping is used + * to route the messages to the correct listener. + * + * Note: Use this to implement request->response by unsubscribing after the first + * response. + * + * @param {string} id + * @param {(msg: MessageResponse | SubscriptionEvent) => void} callback + * @returns {() => void} + * @internal + */ + subscribe(id, callback) { + this.listeners.set(id, callback); + return () => { + this.listeners.delete(id); + }; + } + /** + * Accept incoming messages and try to deliver it to a registered listener. + * + * This code is defensive to prevent any single handler from affecting another if + * it throws (producer interference). + * + * @param {MessageResponse | SubscriptionEvent} payload + * @internal + */ + _dispatch(payload) { + if (!payload) + return this._log("no response"); + if ("id" in payload) { + if (this.listeners.has(payload.id)) { + this._tryCatch(() => this.listeners.get(payload.id)?.(payload)); + } else { + this._log("no listeners for ", payload); + } + } + if ("subscriptionName" in payload) { + if (this.listeners.has(payload.subscriptionName)) { + this._tryCatch(() => this.listeners.get(payload.subscriptionName)?.(payload)); + } else { + this._log("no subscription listeners for ", payload); + } + } + } + /** + * + * @param {(...args: any[]) => any} fn + * @param {string} [context] + */ + _tryCatch(fn, context = "none") { + try { + return fn(); + } catch (e3) { + if (this.debug) { + console.error("AndroidMessagingConfig error:", context); + console.error(e3); + } + } + } + /** + * @param {...any} args + */ + _log(...args) { + if (this.debug) { + console.log("AndroidMessagingConfig", ...args); + } + } + /** + * Capture the global handler and remove it from the global object. + */ + _captureGlobalHandler() { + const { target, javascriptInterface } = this; + if (Object.prototype.hasOwnProperty.call(target, javascriptInterface)) { + this._capturedHandler = target[javascriptInterface].process.bind(target[javascriptInterface]); + delete target[javascriptInterface]; + } else { + this._capturedHandler = () => { + this._log("Android messaging interface not available", javascriptInterface); + }; + } + } + /** + * Assign the incoming handler method to the global object. + * This is the method that Android will call to deliver messages. + */ + _assignHandlerMethod() { + const responseHandler = (providedSecret, response) => { + if (providedSecret === this.messageSecret) { + this._dispatch(response); + } + }; + Object.defineProperty(this.target, this.messageCallback, { + value: responseHandler + }); + } + }; + + // ../messaging/lib/typed-messages.js + function createTypedMessages(base, messaging2) { + const asAny = ( + /** @type {any} */ + messaging2 + ); + return ( + /** @type {BaseClass} */ + asAny + ); + } + + // ../messaging/index.js + var MessagingContext = class { + /** + * @param {object} params + * @param {string} params.context + * @param {string} params.featureName + * @param {"production" | "development"} params.env + * @internal + */ + constructor(params) { + this.context = params.context; + this.featureName = params.featureName; + this.env = params.env; + } + }; + var Messaging = class { + /** + * @param {MessagingContext} messagingContext + * @param {MessagingConfig} config + */ + constructor(messagingContext, config) { + this.messagingContext = messagingContext; + this.transport = getTransport(config, this.messagingContext); + } + /** + * Send a 'fire-and-forget' message. + * @throws {MissingHandler} + * + * @example + * + * ```ts + * const messaging = new Messaging(config) + * messaging.notify("foo", {bar: "baz"}) + * ``` + * @param {string} name + * @param {Record} [data] + */ + notify(name, data = {}) { + const message = new NotificationMessage({ + context: this.messagingContext.context, + featureName: this.messagingContext.featureName, + method: name, + params: data + }); + this.transport.notify(message); + } + /** + * Send a request, and wait for a response + * @throws {MissingHandler} + * + * @example + * ``` + * const messaging = new Messaging(config) + * const response = await messaging.request("foo", {bar: "baz"}) + * ``` + * + * @param {string} name + * @param {Record} [data] + * @return {Promise} + */ + request(name, data = {}) { + const id = globalThis?.crypto?.randomUUID?.() || name + ".response"; + const message = new RequestMessage({ + context: this.messagingContext.context, + featureName: this.messagingContext.featureName, + method: name, + params: data, + id + }); + return this.transport.request(message); + } + /** + * @param {string} name + * @param {(value: unknown) => void} callback + * @return {() => void} + */ + subscribe(name, callback) { + const msg = new Subscription({ + context: this.messagingContext.context, + featureName: this.messagingContext.featureName, + subscriptionName: name + }); + return this.transport.subscribe(msg, callback); + } + }; + var TestTransportConfig = class { + /** + * @param {MessagingTransport} impl + */ + constructor(impl) { + this.impl = impl; + } + }; + var TestTransport = class { + /** + * @param {TestTransportConfig} config + * @param {MessagingContext} messagingContext + */ + constructor(config, messagingContext) { + this.config = config; + this.messagingContext = messagingContext; + } + notify(msg) { + return this.config.impl.notify(msg); + } + request(msg) { + return this.config.impl.request(msg); + } + subscribe(msg, callback) { + return this.config.impl.subscribe(msg, callback); + } + }; + function getTransport(config, messagingContext) { + if (config instanceof WebkitMessagingConfig) { + return new WebkitMessagingTransport(config, messagingContext); + } + if (config instanceof WindowsMessagingConfig) { + return new WindowsMessagingTransport(config, messagingContext); + } + if (config instanceof AndroidMessagingConfig) { + return new AndroidMessagingTransport(config, messagingContext); + } + if (config instanceof TestTransportConfig) { + return new TestTransport(config, messagingContext); + } + throw new Error("unreachable"); + } + var MissingHandler = class extends Error { + /** + * @param {string} message + * @param {string} handlerName + */ + constructor(message, handlerName) { + super(message); + this.handlerName = handlerName; + } + }; + + // shared/environment.js + var Environment = class _Environment { + /** + * @param {object} params + * @param {'app' | 'components'} [params.display] - whether to show the application or component list + * @param {'production' | 'development'} [params.env] - application environment + * @param {URLSearchParams} [params.urlParams] - URL params passed into the page + * @param {ImportMeta['injectName']} [params.injectName] - application platform + * @param {boolean} [params.willThrow] - whether the application will simulate an error + * @param {boolean} [params.debugState] - whether to show debugging UI + * @param {string} [params.locale] - for applications strings + * @param {number} [params.textLength] - what ratio of text should be used. Set a number higher than 1 to have longer strings for testing + */ + constructor({ + env = "production", + urlParams = new URLSearchParams(location.search), + injectName = "windows", + willThrow = urlParams.get("willThrow") === "true", + debugState = urlParams.has("debugState"), + display = "app", + locale = "en", + textLength = 1 + } = {}) { + this.display = display; + this.urlParams = urlParams; + this.injectName = injectName; + this.willThrow = willThrow; + this.debugState = debugState; + this.env = env; + this.locale = locale; + this.textLength = textLength; + } + /** + * @param {string|null|undefined} injectName + * @returns {Environment} + */ + withInjectName(injectName) { + if (!injectName) + return this; + if (!isInjectName(injectName)) + return this; + return new _Environment({ + ...this, + injectName + }); + } + /** + * @param {string|null|undefined} env + * @returns {Environment} + */ + withEnv(env) { + if (!env) + return this; + if (env !== "production" && env !== "development") + return this; + return new _Environment({ + ...this, + env + }); + } + /** + * @param {string|null|undefined} display + * @returns {Environment} + */ + withDisplay(display) { + if (!display) + return this; + if (display !== "app" && display !== "components") + return this; + return new _Environment({ + ...this, + display + }); + } + /** + * @param {string|null|undefined} locale + * @returns {Environment} + */ + withLocale(locale) { + if (!locale) + return this; + if (typeof locale !== "string") + return this; + if (locale.length !== 2) + return this; + return new _Environment({ + ...this, + locale + }); + } + /** + * @param {string|number|null|undefined} length + * @returns {Environment} + */ + withTextLength(length) { + if (!length) + return this; + const num = Number(length); + if (num >= 1 && num <= 2) { + return new _Environment({ + ...this, + textLength: num + }); + } + return this; + } + }; + function isInjectName(input) { + const allowed = ["windows", "apple", "integration", "android"]; + return allowed.includes(input); + } + + // shared/create-special-page-messaging.js + function createSpecialPageMessaging(opts) { + const messageContext = new MessagingContext({ + context: "specialPages", + featureName: opts.pageName, + env: opts.env + }); + try { + if (opts.injectName === "windows") { + const opts2 = new WindowsMessagingConfig({ + methods: { + // @ts-expect-error - not in @types/chrome + postMessage: window.chrome.webview.postMessage, + // @ts-expect-error - not in @types/chrome + addEventListener: window.chrome.webview.addEventListener, + // @ts-expect-error - not in @types/chrome + removeEventListener: window.chrome.webview.removeEventListener + } + }); + return new Messaging(messageContext, opts2); + } else if (opts.injectName === "apple") { + const opts2 = new WebkitMessagingConfig({ + hasModernWebkitAPI: true, + secret: "", + webkitMessageHandlerNames: ["specialPages"] + }); + return new Messaging(messageContext, opts2); + } else if (opts.injectName === "android") { + const opts2 = new AndroidMessagingConfig({ + messageSecret: "duckduckgo-android-messaging-secret", + messageCallback: "messageCallback", + javascriptInterface: messageContext.context, + target: globalThis, + debug: true + }); + return new Messaging(messageContext, opts2); + } + } catch (e3) { + console.error("could not access handlers for %s, falling back to mock interface", opts.injectName); + } + const fallback = new TestTransportConfig({ + /** + * @param {import('@duckduckgo/messaging').NotificationMessage} msg + */ + notify(msg) { + console.log(msg); + }, + /** + * @param {import('@duckduckgo/messaging').RequestMessage} msg + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + request: (msg) => { + console.log(msg); + if (msg.method === "initialSetup") { + return Promise.resolve({ + locale: "en", + env: opts.env + }); + } + return Promise.resolve(null); + }, + /** + * @param {import('@duckduckgo/messaging').SubscriptionEvent} msg + */ + subscribe(msg) { + console.log(msg); + return () => { + console.log("teardown"); + }; + } + }); + return new Messaging(messageContext, fallback); + } + + // shared/call-with-retry.js + async function callWithRetry(fn, params = {}) { + const { maxAttempts = 10, intervalMs = 300 } = params; + let attempt = 1; + while (attempt <= maxAttempts) { + try { + return { value: await fn(), attempt }; + } catch (error) { + if (attempt === maxAttempts) { + return { error: `Max attempts reached: ${error}` }; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + attempt++; + } + } + return { error: "Unreachable: value not retrieved" }; + } + + // ../../node_modules/preact/dist/preact.module.js + var n; + var l; + var u; + var t; + var i; + var o; + var r; + var f; + var e; + var c = {}; + var s = []; + var a = /acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i; + var h = Array.isArray; + function v(n2, l3) { + for (var u3 in l3) + n2[u3] = l3[u3]; + return n2; + } + function p(n2) { + var l3 = n2.parentNode; + l3 && l3.removeChild(n2); + } + function y(l3, u3, t3) { + var i3, o3, r3, f3 = {}; + for (r3 in u3) + "key" == r3 ? i3 = u3[r3] : "ref" == r3 ? o3 = u3[r3] : f3[r3] = u3[r3]; + if (arguments.length > 2 && (f3.children = arguments.length > 3 ? n.call(arguments, 2) : t3), "function" == typeof l3 && null != l3.defaultProps) + for (r3 in l3.defaultProps) + void 0 === f3[r3] && (f3[r3] = l3.defaultProps[r3]); + return d(l3, f3, i3, o3, null); + } + function d(n2, t3, i3, o3, r3) { + var f3 = { type: n2, props: t3, key: i3, ref: o3, __k: null, __: null, __b: 0, __e: null, __d: void 0, __c: null, constructor: void 0, __v: null == r3 ? ++u : r3, __i: -1, __u: 0 }; + return null == r3 && null != l.vnode && l.vnode(f3), f3; + } + function g(n2) { + return n2.children; + } + function b(n2, l3) { + this.props = n2, this.context = l3; + } + function m(n2, l3) { + if (null == l3) + return n2.__ ? m(n2.__, n2.__i + 1) : null; + for (var u3; l3 < n2.__k.length; l3++) + if (null != (u3 = n2.__k[l3]) && null != u3.__e) + return u3.__e; + return "function" == typeof n2.type ? m(n2) : null; + } + function k(n2) { + var l3, u3; + if (null != (n2 = n2.__) && null != n2.__c) { + for (n2.__e = n2.__c.base = null, l3 = 0; l3 < n2.__k.length; l3++) + if (null != (u3 = n2.__k[l3]) && null != u3.__e) { + n2.__e = n2.__c.base = u3.__e; + break; + } + return k(n2); + } + } + function w(n2) { + (!n2.__d && (n2.__d = true) && i.push(n2) && !x.__r++ || o !== l.debounceRendering) && ((o = l.debounceRendering) || r)(x); + } + function x() { + var n2, u3, t3, o3, r3, e3, c3, s3, a3; + for (i.sort(f); n2 = i.shift(); ) + n2.__d && (u3 = i.length, o3 = void 0, e3 = (r3 = (t3 = n2).__v).__e, s3 = [], a3 = [], (c3 = t3.__P) && ((o3 = v({}, r3)).__v = r3.__v + 1, l.vnode && l.vnode(o3), L(c3, o3, r3, t3.__n, void 0 !== c3.ownerSVGElement, 32 & r3.__u ? [e3] : null, s3, null == e3 ? m(r3) : e3, !!(32 & r3.__u), a3), o3.__.__k[o3.__i] = o3, M(s3, o3, a3), o3.__e != e3 && k(o3)), i.length > u3 && i.sort(f)); + x.__r = 0; + } + function C(n2, l3, u3, t3, i3, o3, r3, f3, e3, a3, h3) { + var v3, p3, y3, d3, _2, g3 = t3 && t3.__k || s, b3 = l3.length; + for (u3.__d = e3, P(u3, l3, g3), e3 = u3.__d, v3 = 0; v3 < b3; v3++) + null != (y3 = u3.__k[v3]) && "boolean" != typeof y3 && "function" != typeof y3 && (p3 = -1 === y3.__i ? c : g3[y3.__i] || c, y3.__i = v3, L(n2, y3, p3, i3, o3, r3, f3, e3, a3, h3), d3 = y3.__e, y3.ref && p3.ref != y3.ref && (p3.ref && z(p3.ref, null, y3), h3.push(y3.ref, y3.__c || d3, y3)), null == _2 && null != d3 && (_2 = d3), 65536 & y3.__u || p3.__k === y3.__k ? e3 = S(y3, e3, n2) : "function" == typeof y3.type && void 0 !== y3.__d ? e3 = y3.__d : d3 && (e3 = d3.nextSibling), y3.__d = void 0, y3.__u &= -196609); + u3.__d = e3, u3.__e = _2; + } + function P(n2, l3, u3) { + var t3, i3, o3, r3, f3, e3 = l3.length, c3 = u3.length, s3 = c3, a3 = 0; + for (n2.__k = [], t3 = 0; t3 < e3; t3++) + null != (i3 = n2.__k[t3] = null == (i3 = l3[t3]) || "boolean" == typeof i3 || "function" == typeof i3 ? null : "string" == typeof i3 || "number" == typeof i3 || "bigint" == typeof i3 || i3.constructor == String ? d(null, i3, null, null, i3) : h(i3) ? d(g, { children: i3 }, null, null, null) : void 0 === i3.constructor && i3.__b > 0 ? d(i3.type, i3.props, i3.key, i3.ref ? i3.ref : null, i3.__v) : i3) ? (i3.__ = n2, i3.__b = n2.__b + 1, f3 = H(i3, u3, r3 = t3 + a3, s3), i3.__i = f3, o3 = null, -1 !== f3 && (s3--, (o3 = u3[f3]) && (o3.__u |= 131072)), null == o3 || null === o3.__v ? (-1 == f3 && a3--, "function" != typeof i3.type && (i3.__u |= 65536)) : f3 !== r3 && (f3 === r3 + 1 ? a3++ : f3 > r3 ? s3 > e3 - r3 ? a3 += f3 - r3 : a3-- : a3 = f3 < r3 && f3 == r3 - 1 ? f3 - r3 : 0, f3 !== t3 + a3 && (i3.__u |= 65536))) : (o3 = u3[t3]) && null == o3.key && o3.__e && (o3.__e == n2.__d && (n2.__d = m(o3)), N(o3, o3, false), u3[t3] = null, s3--); + if (s3) + for (t3 = 0; t3 < c3; t3++) + null != (o3 = u3[t3]) && 0 == (131072 & o3.__u) && (o3.__e == n2.__d && (n2.__d = m(o3)), N(o3, o3)); + } + function S(n2, l3, u3) { + var t3, i3; + if ("function" == typeof n2.type) { + for (t3 = n2.__k, i3 = 0; t3 && i3 < t3.length; i3++) + t3[i3] && (t3[i3].__ = n2, l3 = S(t3[i3], l3, u3)); + return l3; + } + return n2.__e != l3 && (u3.insertBefore(n2.__e, l3 || null), l3 = n2.__e), l3 && l3.nextSibling; + } + function H(n2, l3, u3, t3) { + var i3 = n2.key, o3 = n2.type, r3 = u3 - 1, f3 = u3 + 1, e3 = l3[u3]; + if (null === e3 || e3 && i3 == e3.key && o3 === e3.type) + return u3; + if (t3 > (null != e3 && 0 == (131072 & e3.__u) ? 1 : 0)) + for (; r3 >= 0 || f3 < l3.length; ) { + if (r3 >= 0) { + if ((e3 = l3[r3]) && 0 == (131072 & e3.__u) && i3 == e3.key && o3 === e3.type) + return r3; + r3--; + } + if (f3 < l3.length) { + if ((e3 = l3[f3]) && 0 == (131072 & e3.__u) && i3 == e3.key && o3 === e3.type) + return f3; + f3++; + } + } + return -1; + } + function I(n2, l3, u3) { + "-" === l3[0] ? n2.setProperty(l3, null == u3 ? "" : u3) : n2[l3] = null == u3 ? "" : "number" != typeof u3 || a.test(l3) ? u3 : u3 + "px"; + } + function T(n2, l3, u3, t3, i3) { + var o3; + n: + if ("style" === l3) + if ("string" == typeof u3) + n2.style.cssText = u3; + else { + if ("string" == typeof t3 && (n2.style.cssText = t3 = ""), t3) + for (l3 in t3) + u3 && l3 in u3 || I(n2.style, l3, ""); + if (u3) + for (l3 in u3) + t3 && u3[l3] === t3[l3] || I(n2.style, l3, u3[l3]); + } + else if ("o" === l3[0] && "n" === l3[1]) + o3 = l3 !== (l3 = l3.replace(/(PointerCapture)$|Capture$/, "$1")), l3 = l3.toLowerCase() in n2 ? l3.toLowerCase().slice(2) : l3.slice(2), n2.l || (n2.l = {}), n2.l[l3 + o3] = u3, u3 ? t3 ? u3.u = t3.u : (u3.u = Date.now(), n2.addEventListener(l3, o3 ? D : A, o3)) : n2.removeEventListener(l3, o3 ? D : A, o3); + else { + if (i3) + l3 = l3.replace(/xlink(H|:h)/, "h").replace(/sName$/, "s"); + else if ("width" !== l3 && "height" !== l3 && "href" !== l3 && "list" !== l3 && "form" !== l3 && "tabIndex" !== l3 && "download" !== l3 && "rowSpan" !== l3 && "colSpan" !== l3 && "role" !== l3 && l3 in n2) + try { + n2[l3] = null == u3 ? "" : u3; + break n; + } catch (n3) { + } + "function" == typeof u3 || (null == u3 || false === u3 && "-" !== l3[4] ? n2.removeAttribute(l3) : n2.setAttribute(l3, u3)); + } + } + function A(n2) { + var u3 = this.l[n2.type + false]; + if (n2.t) { + if (n2.t <= u3.u) + return; + } else + n2.t = Date.now(); + return u3(l.event ? l.event(n2) : n2); + } + function D(n2) { + return this.l[n2.type + true](l.event ? l.event(n2) : n2); + } + function L(n2, u3, t3, i3, o3, r3, f3, e3, c3, s3) { + var a3, p3, y3, d3, _2, m3, k3, w3, x2, P2, S2, $, H2, I2, T3, A2 = u3.type; + if (void 0 !== u3.constructor) + return null; + 128 & t3.__u && (c3 = !!(32 & t3.__u), r3 = [e3 = u3.__e = t3.__e]), (a3 = l.__b) && a3(u3); + n: + if ("function" == typeof A2) + try { + if (w3 = u3.props, x2 = (a3 = A2.contextType) && i3[a3.__c], P2 = a3 ? x2 ? x2.props.value : a3.__ : i3, t3.__c ? k3 = (p3 = u3.__c = t3.__c).__ = p3.__E : ("prototype" in A2 && A2.prototype.render ? u3.__c = p3 = new A2(w3, P2) : (u3.__c = p3 = new b(w3, P2), p3.constructor = A2, p3.render = O), x2 && x2.sub(p3), p3.props = w3, p3.state || (p3.state = {}), p3.context = P2, p3.__n = i3, y3 = p3.__d = true, p3.__h = [], p3._sb = []), null == p3.__s && (p3.__s = p3.state), null != A2.getDerivedStateFromProps && (p3.__s == p3.state && (p3.__s = v({}, p3.__s)), v(p3.__s, A2.getDerivedStateFromProps(w3, p3.__s))), d3 = p3.props, _2 = p3.state, p3.__v = u3, y3) + null == A2.getDerivedStateFromProps && null != p3.componentWillMount && p3.componentWillMount(), null != p3.componentDidMount && p3.__h.push(p3.componentDidMount); + else { + if (null == A2.getDerivedStateFromProps && w3 !== d3 && null != p3.componentWillReceiveProps && p3.componentWillReceiveProps(w3, P2), !p3.__e && (null != p3.shouldComponentUpdate && false === p3.shouldComponentUpdate(w3, p3.__s, P2) || u3.__v === t3.__v)) { + for (u3.__v !== t3.__v && (p3.props = w3, p3.state = p3.__s, p3.__d = false), u3.__e = t3.__e, u3.__k = t3.__k, u3.__k.forEach(function(n3) { + n3 && (n3.__ = u3); + }), S2 = 0; S2 < p3._sb.length; S2++) + p3.__h.push(p3._sb[S2]); + p3._sb = [], p3.__h.length && f3.push(p3); + break n; + } + null != p3.componentWillUpdate && p3.componentWillUpdate(w3, p3.__s, P2), null != p3.componentDidUpdate && p3.__h.push(function() { + p3.componentDidUpdate(d3, _2, m3); + }); + } + if (p3.context = P2, p3.props = w3, p3.__P = n2, p3.__e = false, $ = l.__r, H2 = 0, "prototype" in A2 && A2.prototype.render) { + for (p3.state = p3.__s, p3.__d = false, $ && $(u3), a3 = p3.render(p3.props, p3.state, p3.context), I2 = 0; I2 < p3._sb.length; I2++) + p3.__h.push(p3._sb[I2]); + p3._sb = []; + } else + do { + p3.__d = false, $ && $(u3), a3 = p3.render(p3.props, p3.state, p3.context), p3.state = p3.__s; + } while (p3.__d && ++H2 < 25); + p3.state = p3.__s, null != p3.getChildContext && (i3 = v(v({}, i3), p3.getChildContext())), y3 || null == p3.getSnapshotBeforeUpdate || (m3 = p3.getSnapshotBeforeUpdate(d3, _2)), C(n2, h(T3 = null != a3 && a3.type === g && null == a3.key ? a3.props.children : a3) ? T3 : [T3], u3, t3, i3, o3, r3, f3, e3, c3, s3), p3.base = u3.__e, u3.__u &= -161, p3.__h.length && f3.push(p3), k3 && (p3.__E = p3.__ = null); + } catch (n3) { + u3.__v = null, c3 || null != r3 ? (u3.__e = e3, u3.__u |= c3 ? 160 : 32, r3[r3.indexOf(e3)] = null) : (u3.__e = t3.__e, u3.__k = t3.__k), l.__e(n3, u3, t3); + } + else + null == r3 && u3.__v === t3.__v ? (u3.__k = t3.__k, u3.__e = t3.__e) : u3.__e = j(t3.__e, u3, t3, i3, o3, r3, f3, c3, s3); + (a3 = l.diffed) && a3(u3); + } + function M(n2, u3, t3) { + u3.__d = void 0; + for (var i3 = 0; i3 < t3.length; i3++) + z(t3[i3], t3[++i3], t3[++i3]); + l.__c && l.__c(u3, n2), n2.some(function(u4) { + try { + n2 = u4.__h, u4.__h = [], n2.some(function(n3) { + n3.call(u4); + }); + } catch (n3) { + l.__e(n3, u4.__v); + } + }); + } + function j(l3, u3, t3, i3, o3, r3, f3, e3, s3) { + var a3, v3, y3, d3, _2, g3, b3, k3 = t3.props, w3 = u3.props, x2 = u3.type; + if ("svg" === x2 && (o3 = true), null != r3) { + for (a3 = 0; a3 < r3.length; a3++) + if ((_2 = r3[a3]) && "setAttribute" in _2 == !!x2 && (x2 ? _2.localName === x2 : 3 === _2.nodeType)) { + l3 = _2, r3[a3] = null; + break; + } + } + if (null == l3) { + if (null === x2) + return document.createTextNode(w3); + l3 = o3 ? document.createElementNS("http://www.w3.org/2000/svg", x2) : document.createElement(x2, w3.is && w3), r3 = null, e3 = false; + } + if (null === x2) + k3 === w3 || e3 && l3.data === w3 || (l3.data = w3); + else { + if (r3 = r3 && n.call(l3.childNodes), k3 = t3.props || c, !e3 && null != r3) + for (k3 = {}, a3 = 0; a3 < l3.attributes.length; a3++) + k3[(_2 = l3.attributes[a3]).name] = _2.value; + for (a3 in k3) + _2 = k3[a3], "children" == a3 || ("dangerouslySetInnerHTML" == a3 ? y3 = _2 : "key" === a3 || a3 in w3 || T(l3, a3, null, _2, o3)); + for (a3 in w3) + _2 = w3[a3], "children" == a3 ? d3 = _2 : "dangerouslySetInnerHTML" == a3 ? v3 = _2 : "value" == a3 ? g3 = _2 : "checked" == a3 ? b3 = _2 : "key" === a3 || e3 && "function" != typeof _2 || k3[a3] === _2 || T(l3, a3, _2, k3[a3], o3); + if (v3) + e3 || y3 && (v3.__html === y3.__html || v3.__html === l3.innerHTML) || (l3.innerHTML = v3.__html), u3.__k = []; + else if (y3 && (l3.innerHTML = ""), C(l3, h(d3) ? d3 : [d3], u3, t3, i3, o3 && "foreignObject" !== x2, r3, f3, r3 ? r3[0] : t3.__k && m(t3, 0), e3, s3), null != r3) + for (a3 = r3.length; a3--; ) + null != r3[a3] && p(r3[a3]); + e3 || (a3 = "value", void 0 !== g3 && (g3 !== l3[a3] || "progress" === x2 && !g3 || "option" === x2 && g3 !== k3[a3]) && T(l3, a3, g3, k3[a3], false), a3 = "checked", void 0 !== b3 && b3 !== l3[a3] && T(l3, a3, b3, k3[a3], false)); + } + return l3; + } + function z(n2, u3, t3) { + try { + "function" == typeof n2 ? n2(u3) : n2.current = u3; + } catch (n3) { + l.__e(n3, t3); + } + } + function N(n2, u3, t3) { + var i3, o3; + if (l.unmount && l.unmount(n2), (i3 = n2.ref) && (i3.current && i3.current !== n2.__e || z(i3, null, u3)), null != (i3 = n2.__c)) { + if (i3.componentWillUnmount) + try { + i3.componentWillUnmount(); + } catch (n3) { + l.__e(n3, u3); + } + i3.base = i3.__P = null, n2.__c = void 0; + } + if (i3 = n2.__k) + for (o3 = 0; o3 < i3.length; o3++) + i3[o3] && N(i3[o3], u3, t3 || "function" != typeof n2.type); + t3 || null == n2.__e || p(n2.__e), n2.__ = n2.__e = n2.__d = void 0; + } + function O(n2, l3, u3) { + return this.constructor(n2, u3); + } + function q(u3, t3, i3) { + var o3, r3, f3, e3; + l.__ && l.__(u3, t3), r3 = (o3 = "function" == typeof i3) ? null : i3 && i3.__k || t3.__k, f3 = [], e3 = [], L(t3, u3 = (!o3 && i3 || t3).__k = y(g, null, [u3]), r3 || c, c, void 0 !== t3.ownerSVGElement, !o3 && i3 ? [i3] : r3 ? null : t3.firstChild ? n.call(t3.childNodes) : null, f3, !o3 && i3 ? i3 : r3 ? r3.__e : t3.firstChild, o3, e3), M(f3, u3, e3); + } + function F(n2, l3) { + var u3 = { __c: l3 = "__cC" + e++, __: n2, Consumer: function(n3, l4) { + return n3.children(l4); + }, Provider: function(n3) { + var u4, t3; + return this.getChildContext || (u4 = [], (t3 = {})[l3] = this, this.getChildContext = function() { + return t3; + }, this.shouldComponentUpdate = function(n4) { + this.props.value !== n4.value && u4.some(function(n5) { + n5.__e = true, w(n5); + }); + }, this.sub = function(n4) { + u4.push(n4); + var l4 = n4.componentWillUnmount; + n4.componentWillUnmount = function() { + u4.splice(u4.indexOf(n4), 1), l4 && l4.call(n4); + }; + }), n3.children; + } }; + return u3.Provider.__ = u3.Consumer.contextType = u3; + } + n = s.slice, l = { __e: function(n2, l3, u3, t3) { + for (var i3, o3, r3; l3 = l3.__; ) + if ((i3 = l3.__c) && !i3.__) + try { + if ((o3 = i3.constructor) && null != o3.getDerivedStateFromError && (i3.setState(o3.getDerivedStateFromError(n2)), r3 = i3.__d), null != i3.componentDidCatch && (i3.componentDidCatch(n2, t3 || {}), r3 = i3.__d), r3) + return i3.__E = i3; + } catch (l4) { + n2 = l4; + } + throw n2; + } }, u = 0, t = function(n2) { + return null != n2 && null == n2.constructor; + }, b.prototype.setState = function(n2, l3) { + var u3; + u3 = null != this.__s && this.__s !== this.state ? this.__s : this.__s = v({}, this.state), "function" == typeof n2 && (n2 = n2(v({}, u3), this.props)), n2 && v(u3, n2), null != n2 && this.__v && (l3 && this._sb.push(l3), w(this)); + }, b.prototype.forceUpdate = function(n2) { + this.__v && (this.__e = true, n2 && this.__h.push(n2), w(this)); + }, b.prototype.render = g, i = [], r = "function" == typeof Promise ? Promise.prototype.then.bind(Promise.resolve()) : setTimeout, f = function(n2, l3) { + return n2.__v.__b - l3.__v.__b; + }, x.__r = 0, e = 0; + + // ../../node_modules/preact/hooks/dist/hooks.module.js + var t2; + var r2; + var u2; + var i2; + var o2 = 0; + var f2 = []; + var c2 = []; + var e2 = l.__b; + var a2 = l.__r; + var v2 = l.diffed; + var l2 = l.__c; + var m2 = l.unmount; + function d2(t3, u3) { + l.__h && l.__h(r2, t3, o2 || u3), o2 = 0; + var i3 = r2.__H || (r2.__H = { __: [], __h: [] }); + return t3 >= i3.__.length && i3.__.push({ __V: c2 }), i3.__[t3]; + } + function h2(n2) { + return o2 = 1, s2(B, n2); + } + function s2(n2, u3, i3) { + var o3 = d2(t2++, 2); + if (o3.t = n2, !o3.__c && (o3.__ = [i3 ? i3(u3) : B(void 0, u3), function(n3) { + var t3 = o3.__N ? o3.__N[0] : o3.__[0], r3 = o3.t(t3, n3); + t3 !== r3 && (o3.__N = [r3, o3.__[1]], o3.__c.setState({})); + }], o3.__c = r2, !r2.u)) { + var f3 = function(n3, t3, r3) { + if (!o3.__c.__H) + return true; + var u4 = o3.__c.__H.__.filter(function(n4) { + return n4.__c; + }); + if (u4.every(function(n4) { + return !n4.__N; + })) + return !c3 || c3.call(this, n3, t3, r3); + var i4 = false; + return u4.forEach(function(n4) { + if (n4.__N) { + var t4 = n4.__[0]; + n4.__ = n4.__N, n4.__N = void 0, t4 !== n4.__[0] && (i4 = true); + } + }), !(!i4 && o3.__c.props === n3) && (!c3 || c3.call(this, n3, t3, r3)); + }; + r2.u = true; + var c3 = r2.shouldComponentUpdate, e3 = r2.componentWillUpdate; + r2.componentWillUpdate = function(n3, t3, r3) { + if (this.__e) { + var u4 = c3; + c3 = void 0, f3(n3, t3, r3), c3 = u4; + } + e3 && e3.call(this, n3, t3, r3); + }, r2.shouldComponentUpdate = f3; + } + return o3.__N || o3.__; + } + function p2(u3, i3) { + var o3 = d2(t2++, 3); + !l.__s && z2(o3.__H, i3) && (o3.__ = u3, o3.i = i3, r2.__H.__h.push(o3)); + } + function y2(u3, i3) { + var o3 = d2(t2++, 4); + !l.__s && z2(o3.__H, i3) && (o3.__ = u3, o3.i = i3, r2.__h.push(o3)); + } + function _(n2) { + return o2 = 5, F2(function() { + return { current: n2 }; + }, []); + } + function F2(n2, r3) { + var u3 = d2(t2++, 7); + return z2(u3.__H, r3) ? (u3.__V = n2(), u3.i = r3, u3.__h = n2, u3.__V) : u3.__; + } + function T2(n2, t3) { + return o2 = 8, F2(function() { + return n2; + }, t3); + } + function q2(n2) { + var u3 = r2.context[n2.__c], i3 = d2(t2++, 9); + return i3.c = n2, u3 ? (null == i3.__ && (i3.__ = true, u3.sub(r2)), u3.props.value) : n2.__; + } + function b2() { + for (var t3; t3 = f2.shift(); ) + if (t3.__P && t3.__H) + try { + t3.__H.__h.forEach(k2), t3.__H.__h.forEach(w2), t3.__H.__h = []; + } catch (r3) { + t3.__H.__h = [], l.__e(r3, t3.__v); + } + } + l.__b = function(n2) { + r2 = null, e2 && e2(n2); + }, l.__r = function(n2) { + a2 && a2(n2), t2 = 0; + var i3 = (r2 = n2.__c).__H; + i3 && (u2 === r2 ? (i3.__h = [], r2.__h = [], i3.__.forEach(function(n3) { + n3.__N && (n3.__ = n3.__N), n3.__V = c2, n3.__N = n3.i = void 0; + })) : (i3.__h.forEach(k2), i3.__h.forEach(w2), i3.__h = [], t2 = 0)), u2 = r2; + }, l.diffed = function(t3) { + v2 && v2(t3); + var o3 = t3.__c; + o3 && o3.__H && (o3.__H.__h.length && (1 !== f2.push(o3) && i2 === l.requestAnimationFrame || ((i2 = l.requestAnimationFrame) || j2)(b2)), o3.__H.__.forEach(function(n2) { + n2.i && (n2.__H = n2.i), n2.__V !== c2 && (n2.__ = n2.__V), n2.i = void 0, n2.__V = c2; + })), u2 = r2 = null; + }, l.__c = function(t3, r3) { + r3.some(function(t4) { + try { + t4.__h.forEach(k2), t4.__h = t4.__h.filter(function(n2) { + return !n2.__ || w2(n2); + }); + } catch (u3) { + r3.some(function(n2) { + n2.__h && (n2.__h = []); + }), r3 = [], l.__e(u3, t4.__v); + } + }), l2 && l2(t3, r3); + }, l.unmount = function(t3) { + m2 && m2(t3); + var r3, u3 = t3.__c; + u3 && u3.__H && (u3.__H.__.forEach(function(n2) { + try { + k2(n2); + } catch (n3) { + r3 = n3; + } + }), u3.__H = void 0, r3 && l.__e(r3, u3.__v)); + }; + var g2 = "function" == typeof requestAnimationFrame; + function j2(n2) { + var t3, r3 = function() { + clearTimeout(u3), g2 && cancelAnimationFrame(t3), setTimeout(n2); + }, u3 = setTimeout(r3, 100); + g2 && (t3 = requestAnimationFrame(r3)); + } + function k2(n2) { + var t3 = r2, u3 = n2.__c; + "function" == typeof u3 && (n2.__c = void 0, u3()), r2 = t3; + } + function w2(n2) { + var t3 = r2; + n2.__c = n2.__(), r2 = t3; + } + function z2(n2, t3) { + return !n2 || n2.length !== t3.length || t3.some(function(t4, r3) { + return t4 !== n2[r3]; + }); + } + function B(n2, t3) { + return "function" == typeof t3 ? t3(n2) : t3; + } + + // shared/components/EnvironmentProvider.js + var EnvironmentContext = F({ + isReducedMotion: false, + isDarkMode: false, + debugState: false, + injectName: ( + /** @type {import('../environment').Environment['injectName']} */ + "windows" + ), + willThrow: false + }); + var THEME_QUERY = "(prefers-color-scheme: dark)"; + var REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; + function EnvironmentProvider({ children, debugState, willThrow = false, injectName = "windows" }) { + const [theme, setTheme] = h2(window.matchMedia(THEME_QUERY).matches ? "dark" : "light"); + const [isReducedMotion, setReducedMotion] = h2(window.matchMedia(REDUCED_MOTION_QUERY).matches); + p2(() => { + const mediaQueryList = window.matchMedia(THEME_QUERY); + const listener = (e3) => setTheme(e3.matches ? "dark" : "light"); + mediaQueryList.addEventListener("change", listener); + return () => mediaQueryList.removeEventListener("change", listener); + }, []); + p2(() => { + const mediaQueryList = window.matchMedia(REDUCED_MOTION_QUERY); + const listener = (e3) => setter(e3.matches); + mediaQueryList.addEventListener("change", listener); + setter(mediaQueryList.matches); + function setter(value) { + document.documentElement.dataset.reducedMotion = String(value); + setReducedMotion(value); + } + window.addEventListener("toggle-reduced-motion", () => { + setter(true); + }); + return () => mediaQueryList.removeEventListener("change", listener); + }, []); + return /* @__PURE__ */ y(EnvironmentContext.Provider, { value: { + isReducedMotion, + debugState, + isDarkMode: theme === "dark", + injectName, + willThrow + } }, children); + } + function UpdateEnvironment({ search }) { + p2(() => { + const params = new URLSearchParams(search); + if (params.has("reduced-motion")) { + setTimeout(() => { + window.dispatchEvent(new CustomEvent("toggle-reduced-motion")); + }, 0); + } + }, [search]); + return null; + } + function useEnv() { + return q2(EnvironmentContext); + } + function WillThrow() { + const env = useEnv(); + if (env.willThrow) { + throw new Error("Simulated Exception"); + } + return null; + } + + // shared/translations.js + function apply(subject, replacements, textLength = 1) { + if (typeof subject !== "string" || subject.length === 0) + return ""; + let out = subject; + if (replacements) { + for (let [name, value] of Object.entries(replacements)) { + if (typeof value !== "string") + value = ""; + out = out.replaceAll(`{${name}}`, value); + } + } + if (textLength !== 1 && textLength > 0 && textLength <= 2) { + const targetLen = Math.ceil(out.length * textLength); + const target = Math.ceil(textLength); + const combined = out.repeat(target); + return combined.slice(0, targetLen); + } + return out; + } + + // shared/components/TranslationsProvider.js + var TranslationContext = F({ + /** @type {LocalTranslationFn} */ + t: () => { + throw new Error("must implement"); + } + }); + function TranslationProvider({ children, translationObject, fallback, textLength = 1 }) { + function t3(inputKey, replacements) { + const subject = translationObject?.[inputKey]?.title || fallback?.[inputKey]?.title; + return apply(subject, replacements, textLength); + } + return /* @__PURE__ */ y(TranslationContext.Provider, { value: { t: t3 } }, children); + } + + // shared/components/ErrorBoundary.js + var ErrorBoundary = class extends b { + /** + * @param {{didCatch: (params: {error: Error; info: any}) => void}} props + */ + constructor(props) { + super(props); + this.state = { hasError: false }; + } + static getDerivedStateFromError() { + return { hasError: true }; + } + componentDidCatch(error, info) { + console.error(error); + console.log(info); + this.props.didCatch({ error, info }); + } + render() { + if (this.state.hasError) { + return this.props.fallback; + } + return this.props.children; + } + }; + + // pages/duckplayer/app/embed-settings.js + var EmbedSettings = class _EmbedSettings { + /** + * @param {object} params + * @param {VideoId} params.videoId - videoID is required + * @param {Timestamp|null|undefined} params.timestamp - optional timestamp + * @param {boolean} [params.autoplay] - optional timestamp + * @param {boolean} [params.muted] - optionally start muted + */ + constructor({ + videoId, + timestamp, + autoplay = true, + muted = false + }) { + this.videoId = videoId; + this.timestamp = timestamp; + this.autoplay = autoplay; + this.muted = muted; + } + /** + * @param {boolean|null|undefined} autoplay + * @return {EmbedSettings} + */ + withAutoplay(autoplay) { + if (typeof autoplay !== "boolean") + return this; + return new _EmbedSettings({ + ...this, + autoplay + }); + } + /** + * @param {boolean|null|undefined} muted + * @return {EmbedSettings} + */ + withMuted(muted) { + if (typeof muted !== "boolean") + return this; + return new _EmbedSettings({ + ...this, + muted + }); + } + /** + * @param {string|null|undefined} href + * @returns {EmbedSettings|null} + */ + static fromHref(href) { + try { + return new _EmbedSettings({ + videoId: VideoId.fromHref(href), + timestamp: Timestamp.fromHref(href) + }); + } catch (e3) { + console.error(e3); + return null; + } + } + /** + * @return {string} + */ + toEmbedUrl() { + const url = new URL(`/embed/${this.videoId.id}`, "https://www.youtube-nocookie.com"); + url.searchParams.set("iv_load_policy", "1"); + if (this.autoplay) { + url.searchParams.set("autoplay", "1"); + if (this.muted) { + url.searchParams.set("muted", "1"); + } + } + url.searchParams.set("rel", "0"); + url.searchParams.set("modestbranding", "1"); + if (this.timestamp && this.timestamp.seconds > 0) { + url.searchParams.set("start", String(this.timestamp.seconds)); + } + return url.href; + } + /** + * @param {URL} base + * @return {string} + */ + intoYoutubeUrl(base) { + const url = new URL(base); + url.searchParams.set("v", this.videoId.id); + if (this.timestamp && this.timestamp.seconds > 0) { + url.searchParams.set("t", `${this.timestamp.seconds}s`); + } + return url.toString(); + } + }; + var VideoId = class _VideoId { + /** + * @param {string|null|undefined} input + * @throws {Error} + */ + constructor(input) { + if (typeof input !== "string") + throw new Error("string required, got: " + input); + const sanitized = sanitizeYoutubeId(input); + if (sanitized === null) + throw new Error("invalid ID from: " + input); + this.id = sanitized; + } + /** + * @param {string|null|undefined} href + */ + static fromHref(href) { + return new _VideoId(idFromHref(href)); + } + }; + var Timestamp = class _Timestamp { + /** + * @param {string|null|undefined} input + * @throws {Error} + */ + constructor(input) { + if (typeof input !== "string") + throw new Error("string required for timestamp"); + const seconds = timestampInSeconds(input); + if (seconds === null) + throw new Error("invalid input for timestamp: " + input); + this.seconds = seconds; + } + /** + * @param {string|null|undefined} href + * @return {Timestamp|null} + */ + static fromHref(href) { + if (typeof href !== "string") + return null; + const param = timestampFromHref(href); + if (param) { + try { + return new _Timestamp(param); + } catch (e3) { + return null; + } + } + return null; + } + }; + function idFromHref(href) { + if (typeof href !== "string") + return null; + let url; + try { + url = new URL(href); + } catch (e3) { + return null; + } + const fromParam = url.searchParams.get("videoID"); + if (fromParam) + return fromParam; + if (url.protocol === "duck:") { + return url.pathname.slice(1); + } + if (url.pathname.includes("/embed/")) { + return url.pathname.replace("/embed/", ""); + } + return null; + } + function timestampFromHref(href) { + if (typeof href !== "string") + return null; + let url; + try { + url = new URL(href); + } catch (e3) { + console.error(e3); + return null; + } + const timeParameter = url.searchParams.get("t"); + if (timeParameter) { + return timeParameter; + } + return null; + } + function timestampInSeconds(timestamp) { + const units = { + h: 3600, + m: 60, + s: 1 + }; + const parts = timestamp.split(/(\d+[hms]?)/); + const totalSeconds = parts.reduce((total, part) => { + if (!part) + return total; + for (const unit in units) { + if (part.includes(unit)) { + return total + parseInt(part) * units[unit]; + } + } + return total; + }, 0); + if (totalSeconds > 0) { + return totalSeconds; + } + return null; + } + function sanitizeYoutubeId(input) { + const subject = input.slice(0, 11); + if (/^[a-zA-Z0-9-_]+$/.test(subject)) { + return subject; + } + return null; + } + + // pages/duckplayer/src/locales/en/duckplayer.json + var duckplayer_default = { + smartling: { + string_format: "icu", + translate_paths: [ + { + path: "*/title", + key: "{*}/title", + instruction: "*/note" + } + ] + }, + alwaysWatchHere: { + title: "Always open YouTube videos here", + note: "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + keepEnabled: { + title: "Keep Duck Player turned on", + note: "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + openInfoButton: { + title: "Open Info", + note: "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + openSettingsButton: { + title: "Open Settings", + note: "aria label text on a button, opens a screen where the user can change settings" + }, + watchOnYoutube: { + title: "Watch on YouTube", + note: "text on a link that takes the user from the current page back onto YouTube.com" + }, + invalidIdError: { + title: "ERROR: Invalid video id", + note: "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + tooltipInfo: { + title: "Duck Player provides a clean viewing experience without personalized ads and prevents viewing activity from influencing your YouTube recommendations." + } + }; + + // pages/duckplayer/app/settings.js + var Settings = class _Settings { + /** + * @param {object} params + * @param {{name: ImportMeta['platform']}} [params.platform] + * @param {{state: 'enabled' | 'disabled'}} [params.pip] + * @param {{state: 'enabled' | 'disabled'}} [params.autoplay] + * @param {{state: 'enabled' | 'disabled'}} [params.focusMode] + */ + constructor({ + platform = { name: "macos" }, + pip = { state: "disabled" }, + autoplay = { state: "enabled" }, + focusMode = { state: "enabled" } + }) { + this.platform = platform; + this.pip = pip; + this.autoplay = autoplay; + this.focusMode = focusMode; + } + /** + * @param {keyof import("../../../types/duckplayer").DuckPlayerPageSettings} named + * @param {{state: 'enabled' | 'disabled'} | null | undefined} settings + * @return {Settings} + */ + withFeatureState(named, settings) { + if (!settings) + return this; + const valid = ["pip", "autoplay", "focusMode"]; + if (!valid.includes(named)) { + console.warn(`Excluding invalid feature key ${named}`); + return this; + } + if (settings.state === "enabled" || settings.state === "disabled") { + return new _Settings({ + ...this, + [named]: settings + }); + } + return this; + } + withPlatformName(name) { + const valid = ["windows", "macos", "ios", "android"]; + if (valid.includes( + /** @type {any} */ + name + )) { + return new _Settings({ + ...this, + platform: { name } + }); + } + return this; + } + /** + * @param {string|null|undefined} newState + * @return {Settings} + */ + withDisabledFocusMode(newState) { + if (newState === "disabled" || newState === "enabled") { + return new _Settings({ + ...this, + focusMode: { state: newState } + }); + } + return this; + } + /** + * @return {string} + */ + get youtubeBase() { + switch (this.platform.name) { + case "windows": + case "ios": + case "android": { + return "duck://player/openInYoutube"; + } + case "macos": { + return "https://www.youtube.com/watch"; + } + default: + throw new Error("unreachable"); + } + } + /** + * @return {'desktop' | 'mobile'} + */ + get layout() { + switch (this.platform.name) { + case "windows": + case "macos": { + return "desktop"; + } + case "ios": + case "android": { + return "mobile"; + } + default: + return "desktop"; + } + } + }; + + // pages/duckplayer/app/types.js + function useTypedTranslation() { + return { + t: q2(TranslationContext).t + }; + } + var MessagingContext2 = F( + /** @type {import("../src/js/index.js").DuckplayerPage} */ + {} + ); + var useMessaging = () => q2(MessagingContext2); + + // pages/duckplayer/app/providers/SettingsProvider.jsx + var SettingsContext = F( + /** @type {{settings: Settings}} */ + {} + ); + function SettingsProvider({ settings, children }) { + return /* @__PURE__ */ y(SettingsContext.Provider, { value: { settings } }, children); + } + function usePlatformName() { + return q2(SettingsContext).settings.platform.name; + } + function useOpenSettingsHandler() { + const settings = q2(SettingsContext).settings; + const messaging2 = useMessaging(); + return () => { + switch (settings.platform.name) { + case "ios": + case "android": { + messaging2.openSettings(); + break; + } + default: { + console.warn("unreachable!"); + } + } + }; + } + function useSettingsUrl() { + return "duck://settings/duckplayer"; + } + function useSettings() { + return q2(SettingsContext).settings; + } + function useOpenInfoHandler() { + const settings = q2(SettingsContext).settings; + const messaging2 = useMessaging(); + return () => { + switch (settings.platform.name) { + case "android": + case "ios": { + messaging2.openInfo(); + break; + } + default: { + console.warn("unreachable!"); + } + } + }; + } + function useOpenOnYoutubeHandler() { + const settings = q2(SettingsContext).settings; + return (embed) => { + if (!embed) + return console.warn("unreachable, settings.embed must be present"); + try { + const base = new URL(settings.youtubeBase); + window.location.href = embed.intoYoutubeUrl(base); + } catch (e3) { + console.error("could not form a URL to open in Youtube", e3); + } + }; + } + + // pages/duckplayer/app/providers/UserValuesProvider.jsx + var UserValuesContext = F({ + /** @type {UserValues} */ + value: { + privatePlayerMode: { alwaysAsk: {} }, + overlayInteracted: false + }, + /** + * @type {() => void} + */ + setEnabled: () => { + } + }); + function UserValuesProvider({ initial, children }) { + const [value, setValue] = h2(initial); + const messaging2 = useMessaging(); + p2(() => { + window.addEventListener("toggle-user-values-enabled", () => { + setValue({ privatePlayerMode: { enabled: {} }, overlayInteracted: false }); + }); + window.addEventListener("toggle-user-values-ask", () => { + setValue({ privatePlayerMode: { alwaysAsk: {} }, overlayInteracted: false }); + }); + const unsubscribe = messaging2.onUserValuesChanged((userValues) => { + setValue(userValues); + }); + return () => unsubscribe(); + }, [messaging2]); + function setEnabled() { + const values = { + privatePlayerMode: { enabled: {} }, + overlayInteracted: false + }; + messaging2.setUserValues(values).then((next) => { + console.log("response after setUserValues...", next); + console.log("will set", values); + setValue(values); + }).catch((err) => { + console.error("could not set the enabled flag", err); + messaging2.reportPageException({ message: "could not set the enabled flag: " + err.toString() }); + }); + } + return /* @__PURE__ */ y(UserValuesContext.Provider, { value: { value, setEnabled } }, children); + } + function useUserValues() { + return q2(UserValuesContext).value; + } + function useSetEnabled() { + return q2(UserValuesContext).setEnabled; + } + + // pages/duckplayer/app/components/Fallback.module.css + var Fallback_default = { + fallback: "Fallback_fallback" + }; + + // pages/duckplayer/app/components/Fallback.jsx + function Fallback({ showDetails }) { + return /* @__PURE__ */ y("div", { class: Fallback_default.fallback }, /* @__PURE__ */ y("div", null, /* @__PURE__ */ y("p", null, "Something went wrong!"), showDetails && /* @__PURE__ */ y("p", null, "Please check logs for a message called ", /* @__PURE__ */ y("code", null, "reportPageException")))); + } + + // pages/duckplayer/app/components/Components.module.css + var Components_default = { + main: "Components_main", + tube: "Components_tube" + }; + + // pages/duckplayer/app/components/PlayerContainer.jsx + var import_classnames = __toESM(require_classnames(), 1); + + // pages/duckplayer/app/components/PlayerContainer.module.css + var PlayerContainer_default = { + container: "PlayerContainer_container", + inset: "PlayerContainer_inset", + internals: "PlayerContainer_internals", + insetInternals: "PlayerContainer_insetInternals" + }; + + // pages/duckplayer/app/components/PlayerContainer.jsx + function PlayerContainer({ children, inset }) { + return /* @__PURE__ */ y("div", { class: (0, import_classnames.default)(PlayerContainer_default.container, { + [PlayerContainer_default.inset]: inset + }) }, children); + } + function PlayerInternal({ children, inset }) { + return /* @__PURE__ */ y("div", { class: (0, import_classnames.default)(PlayerContainer_default.internals, { [PlayerContainer_default.insetInternals]: inset }) }, children); + } + + // pages/duckplayer/app/img/info.data.svg + var info_data_default = 'data:image/svg+xml,%0A %0A %0A %0A%0A'; + + // pages/duckplayer/app/img/cog.data.svg + var cog_data_default = 'data:image/svg+xml,%0A %0A %0A%0A'; + + // pages/duckplayer/app/components/Button.jsx + var import_classnames2 = __toESM(require_classnames(), 1); + + // pages/duckplayer/app/components/Button.module.css + var Button_default = { + button: "Button_button", + fill: "Button_fill", + desktop: "Button_desktop", + icon: "Button_icon", + iconOnly: "Button_iconOnly", + highlight: "Button_highlight" + }; + + // pages/duckplayer/app/components/Button.jsx + function Button({ + children, + formfactor = "mobile", + icon = false, + fill = false, + highlight = false, + buttonProps = {} + }) { + const classes = (0, import_classnames2.default)({ + [Button_default.button]: true, + [Button_default.desktop]: formfactor === "desktop", + [Button_default.highlight]: highlight === true, + [Button_default.fill]: fill === true, + [Button_default.iconOnly]: icon === true + }); + return /* @__PURE__ */ y( + "button", + { + class: classes, + type: "button", + ...buttonProps + }, + children + ); + } + function ButtonLink({ + children, + formfactor = "mobile", + icon = false, + fill = false, + highlight = false, + anchorProps = {} + }) { + const classes = (0, import_classnames2.default)({ + [Button_default.button]: true, + [Button_default.desktop]: formfactor === "desktop", + [Button_default.highlight]: highlight === true, + [Button_default.fill]: fill === true, + [Button_default.iconOnly]: icon === true + }); + return /* @__PURE__ */ y( + "a", + { + class: classes, + type: "button", + ...anchorProps + }, + children + ); + } + function Icon({ src }) { + return /* @__PURE__ */ y("span", { class: Button_default.icon }, /* @__PURE__ */ y("img", { src, alt: "" })); + } + + // pages/duckplayer/app/components/FloatingBar.module.css + var FloatingBar_default = { + floatingBar: "FloatingBar_floatingBar", + inset: "FloatingBar_inset", + topBar: "FloatingBar_topBar" + }; + + // pages/duckplayer/app/components/FloatingBar.jsx + var import_classnames3 = __toESM(require_classnames(), 1); + function FloatingBar({ children, inset = false }) { + return /* @__PURE__ */ y("div", { class: (0, import_classnames3.default)(FloatingBar_default.floatingBar, { [FloatingBar_default.inset]: inset }) }, children); + } + + // pages/duckplayer/app/components/SwitchBarMobile.jsx + var import_classnames5 = __toESM(require_classnames(), 1); + + // pages/duckplayer/app/components/SwitchBarMobile.module.css + var SwitchBarMobile_default = { + switchBar: "SwitchBarMobile_switchBar", + stateExiting: "SwitchBarMobile_stateExiting", + stateHidden: "SwitchBarMobile_stateHidden", + label: "SwitchBarMobile_label", + checkbox: "SwitchBarMobile_checkbox", + text: "SwitchBarMobile_text", + placeholder: "SwitchBarMobile_placeholder" + }; + + // pages/duckplayer/app/providers/SwitchProvider.jsx + var SwitchContext = F({ + /** @type {SwitchState} */ + state: "showing", + /** @type {() => void} */ + onChange: () => { + throw new Error("must implement"); + }, + /** @type {() => void} */ + onDone: () => { + throw new Error("must implement"); + } + }); + function SwitchProvider({ children }) { + const userValues = useUserValues(); + const setEnabled = useSetEnabled(); + const initialState = "enabled" in userValues.privatePlayerMode ? "completed" : "showing"; + const [state, dispatch] = s2((state2, event) => { + console.log("\u{1F4E9}", { state: state2, event }); + switch (state2) { + case "showing": { + if (event === "change") { + return "exiting"; + } + if (event === "enabled") { + return "completed"; + } + if (event === "done") { + return "completed"; + } + break; + } + case "exiting": { + if (event === "done") { + return "completed"; + } + break; + } + case "completed": { + if (event === "ask") { + return "showing"; + } + } + } + return state2; + }, initialState); + function onChange() { + dispatch("change"); + setEnabled(); + } + p2(() => { + const evt = "enabled" in userValues.privatePlayerMode ? "enabled" : "ask"; + dispatch(evt); + }, [initialState]); + function onDone() { + dispatch("done"); + } + return /* @__PURE__ */ y(SwitchContext.Provider, { value: { state, onChange, onDone } }, children); + } + + // pages/duckplayer/app/components/Switch.module.css + var Switch_default = { + switch: "Switch_switch", + thumb: "Switch_thumb", + ios: "Switch_ios" + }; + + // pages/duckplayer/app/components/Switch.jsx + var import_classnames4 = __toESM(require_classnames(), 1); + function Switch({ checked, onChange, platformName = "ios" }) { + return /* @__PURE__ */ y( + "button", + { + role: "switch", + "aria-checked": checked, + onClick: onChange, + className: (0, import_classnames4.default)(Switch_default.switch, { + [Switch_default.ios]: platformName === "ios", + [Switch_default.android]: platformName === "android" + }) + }, + /* @__PURE__ */ y("span", { className: Switch_default.thumb }) + ); + } + + // pages/duckplayer/app/components/SwitchBarMobile.jsx + function SwitchBarMobile({ platformName }) { + const { onChange, onDone, state } = q2(SwitchContext); + const { t: t3 } = useTypedTranslation(); + function blockClick(e3) { + if (state === "exiting") { + return e3.preventDefault(); + } + } + function onTransitionEnd(e3) { + if (e3.target?.dataset?.state === "exiting") { + onDone(); + } + } + const classes = (0, import_classnames5.default)({ + [SwitchBarMobile_default.switchBar]: true, + [SwitchBarMobile_default.stateExiting]: state === "exiting", + [SwitchBarMobile_default.stateHidden]: state === "completed" + }); + return /* @__PURE__ */ y("div", { class: classes, "data-state": state, onTransitionEnd }, /* @__PURE__ */ y("label", { onClick: blockClick, class: SwitchBarMobile_default.label }, /* @__PURE__ */ y("span", { className: SwitchBarMobile_default.text }, t3("keepEnabled")), /* @__PURE__ */ y( + Switch, + { + checked: state !== "showing", + onChange, + platformName + } + ))); + } + + // pages/duckplayer/app/components/InfoBar.module.css + var InfoBar_default = { + infoBar: "InfoBar_infoBar", + container: "InfoBar_container", + dax: "InfoBar_dax", + img: "InfoBar_img", + text: "InfoBar_text", + info: "InfoBar_info", + lhs: "InfoBar_lhs", + rhs: "InfoBar_rhs", + switch: "InfoBar_switch", + controls: "InfoBar_controls" + }; + + // pages/duckplayer/app/img/dax.data.svg + var dax_data_default = 'data:image/svg+xml,%0A %0A %0A %0A %0A %0A %0A %0A %0A %0A %0A%0A'; + + // pages/duckplayer/app/components/SwitchBarDesktop.module.css + var SwitchBarDesktop_default = { + switchBarDesktop: "SwitchBarDesktop_switchBarDesktop", + stateCompleted: "SwitchBarDesktop_stateCompleted", + stateExiting: "SwitchBarDesktop_stateExiting", + label: "SwitchBarDesktop_label", + "slide-out": "SwitchBarDesktop_slide-out", + checkbox: "SwitchBarDesktop_checkbox", + input: "SwitchBarDesktop_input", + text: "SwitchBarDesktop_text" + }; + + // pages/duckplayer/app/components/SwitchBarDesktop.jsx + var import_classnames6 = __toESM(require_classnames(), 1); + function SwitchBarDesktop() { + const { onChange, onDone, state } = q2(SwitchContext); + const { t: t3 } = useTypedTranslation(); + function blockClick(e3) { + if (state === "exiting") { + return e3.preventDefault(); + } + } + const classes = (0, import_classnames6.default)({ + [SwitchBarDesktop_default.switchBarDesktop]: true, + [SwitchBarDesktop_default.stateExiting]: state === "exiting", + [SwitchBarDesktop_default.stateCompleted]: state === "completed" + }); + return /* @__PURE__ */ y( + "div", + { + class: classes, + "data-state": state, + "data-allow-animation": true, + onTransitionEnd: onDone + }, + /* @__PURE__ */ y("label", { class: SwitchBarDesktop_default.label, onClick: blockClick }, /* @__PURE__ */ y("span", { class: SwitchBarDesktop_default.checkbox }, /* @__PURE__ */ y( + "input", + { + class: SwitchBarDesktop_default.input, + onChange, + name: "enabled", + type: "checkbox", + checked: state !== "showing" + } + )), /* @__PURE__ */ y("span", { class: SwitchBarDesktop_default.text }, t3("alwaysWatchHere"))) + ); + } + + // pages/duckplayer/app/components/Tooltip.jsx + var import_classnames7 = __toESM(require_classnames(), 1); + + // pages/duckplayer/app/components/Tooltip.module.css + var Tooltip_default = { + tooltip: "Tooltip_tooltip", + top: "Tooltip_top", + bottom: "Tooltip_bottom", + visible: "Tooltip_visible" + }; + + // pages/duckplayer/app/components/Tooltip.jsx + function Tooltip({ id, isVisible, position }) { + const { t: t3 } = useTypedTranslation(); + return /* @__PURE__ */ y( + "div", + { + class: (0, import_classnames7.default)(Tooltip_default.tooltip, { + [Tooltip_default.top]: position === "top", + [Tooltip_default.bottom]: position === "bottom" + }), + role: "tooltip", + "aria-hidden": !isVisible, + id + }, + t3("tooltipInfo") + ); + } + + // pages/duckplayer/app/components/FocusMode.jsx + var import_classnames8 = __toESM(require_classnames(), 1); + + // pages/duckplayer/app/components/FocusMode.module.css + var FocusMode_default = { + fade: "FocusMode_fade", + slide: "FocusMode_slide", + hideInFocus: "FocusMode_hideInFocus" + }; + + // pages/duckplayer/app/components/FocusMode.jsx + var EVENT_ON = "ddg-duckplayer-focusmode-on"; + var EVENT_OFF = "ddg-duckplayer-focusmode-off"; + function FocusMode() { + p2(() => { + let enabled = true; + let timerId; + const on = () => { + if (document.documentElement.dataset.focusModeState === "paused") { + wait(); + } else { + if (!enabled) { + return console.warn("ignoring focusMode because it was disabled"); + } + document.documentElement.dataset.focusMode = "on"; + } + }; + const off = () => document.documentElement.dataset.focusMode = "off"; + const cancel = () => { + clearTimeout(timerId); + off(); + wait(); + }; + const wait = () => { + clearTimeout(timerId); + timerId = setTimeout(on, 2e3); + }; + wait(); + document.addEventListener("mousemove", cancel); + document.addEventListener("pointerdown", cancel); + window.addEventListener("frame-mousemove", cancel); + window.addEventListener(EVENT_OFF, () => { + enabled = false; + off(); + }); + window.addEventListener(EVENT_ON, () => { + if (enabled === true) + return; + enabled = true; + on(); + }); + return () => { + clearTimeout(timerId); + }; + }, []); + return null; + } + FocusMode.disable = () => setTimeout(() => window.dispatchEvent(new Event(EVENT_OFF)), 0); + FocusMode.enable = () => setTimeout(() => window.dispatchEvent(new Event(EVENT_ON)), 0); + function HideInFocusMode({ children, style = "fade" }) { + const classes = (0, import_classnames8.default)({ + [FocusMode_default.hideInFocus]: true, + [FocusMode_default.fade]: style === "fade", + [FocusMode_default.slide]: style === "slide" + }); + return /* @__PURE__ */ y("div", { class: classes, "data-style": style }, children); + } + function useSetFocusMode() { + return T2((action) => { + document.documentElement.dataset.focusModeState = action; + }, []); + } + + // pages/duckplayer/app/components/InfoBar.jsx + function InfoBar({ embed }) { + return /* @__PURE__ */ y("div", { class: InfoBar_default.infoBar }, /* @__PURE__ */ y("div", { class: InfoBar_default.lhs }, /* @__PURE__ */ y("div", { class: InfoBar_default.dax }, /* @__PURE__ */ y("img", { src: dax_data_default, class: InfoBar_default.img })), /* @__PURE__ */ y("div", { class: InfoBar_default.text }, "Duck Player"), /* @__PURE__ */ y(InfoIcon, null)), /* @__PURE__ */ y("div", { class: InfoBar_default.rhs }, /* @__PURE__ */ y(SwitchProvider, null, /* @__PURE__ */ y("div", { class: InfoBar_default.switch }, /* @__PURE__ */ y(SwitchBarDesktop, null)), /* @__PURE__ */ y(ControlBarDesktop, { embed })))); + } + function InfoIcon({ debugStyles = false }) { + const setFocusMode = useSetFocusMode(); + const [isVisible, setIsVisible] = h2(debugStyles); + const [isBottom, setIsBottom] = h2(false); + const tooltipRef = _(null); + function show() { + setIsVisible(true); + setFocusMode("paused"); + } + function hide() { + setIsVisible(false); + setFocusMode("enabled"); + } + y2(() => { + if (!tooltipRef.current) + return; + const icon = tooltipRef.current; + const rect = icon.getBoundingClientRect(); + const iconTop = rect.top + window.scrollY; + const spaceBelowIcon = window.innerHeight - iconTop; + if (spaceBelowIcon < 125) { + return setIsBottom(false); + } + return setIsBottom(true); + }, [isVisible]); + return /* @__PURE__ */ y( + "button", + { + className: InfoBar_default.info, + "aria-describedby": "tooltip1", + "aria-expanded": isVisible, + "aria-label": "Info", + onMouseEnter: show, + onMouseLeave: hide, + onFocus: show, + onBlur: hide, + ref: tooltipRef + }, + /* @__PURE__ */ y(Icon, { src: info_data_default }), + /* @__PURE__ */ y( + Tooltip, + { + id: "tooltip1", + isVisible, + position: isBottom ? "bottom" : "top" + } + ) + ); + } + function ControlBarDesktop({ embed }) { + const settingsUrl = useSettingsUrl(); + const openOnYoutube = useOpenOnYoutubeHandler(); + const { t: t3 } = useTypedTranslation(); + const { state } = q2(SwitchContext); + return /* @__PURE__ */ y("div", { className: InfoBar_default.controls }, /* @__PURE__ */ y( + ButtonLink, + { + formfactor: "desktop", + icon: true, + highlight: state === "exiting", + anchorProps: { + "href": settingsUrl, + target: "_blank", + "aria-label": t3("openSettingsButton") + } + }, + /* @__PURE__ */ y(Icon, { src: cog_data_default }) + ), /* @__PURE__ */ y( + Button, + { + formfactor: "desktop", + buttonProps: { + onClick: () => { + if (embed) + openOnYoutube(embed); + } + } + }, + t3("watchOnYoutube") + )); + } + function InfoBarContainer({ children }) { + return /* @__PURE__ */ y("div", { class: InfoBar_default.container }, children); + } + + // pages/duckplayer/app/components/Wordmark.module.css + var Wordmark_default = { + wordmark: "Wordmark_wordmark", + logo: "Wordmark_logo", + img: "Wordmark_img", + text: "Wordmark_text" + }; + + // pages/duckplayer/app/components/Wordmark-mobile.module.css + var Wordmark_mobile_default = { + logo: "Wordmark_mobile_logo", + logoSvg: "Wordmark_mobile_logoSvg", + text: "Wordmark_mobile_text" + }; + + // pages/duckplayer/app/components/Wordmark.jsx + function Wordmark() { + return /* @__PURE__ */ y("div", { class: Wordmark_default.wordmark }, /* @__PURE__ */ y("div", { className: Wordmark_default.logo }, /* @__PURE__ */ y("img", { src: dax_data_default, className: Wordmark_default.img, alt: "DuckDuckGo logo" })), /* @__PURE__ */ y("div", { className: Wordmark_default.text }, "Duck Player")); + } + function MobileWordmark() { + return /* @__PURE__ */ y("div", { class: Wordmark_mobile_default.logo }, /* @__PURE__ */ y("span", { class: Wordmark_mobile_default.logoSvg }, /* @__PURE__ */ y("img", { src: dax_data_default, className: Wordmark_mobile_default.img, alt: "DuckDuckGo logo" })), /* @__PURE__ */ y("span", { class: Wordmark_mobile_default.text }, "Duck Player")); + } + + // pages/duckplayer/app/components/Background.module.css + var Background_default = { + bg: "Background_bg" + }; + + // pages/duckplayer/app/components/Background.jsx + function Background() { + return /* @__PURE__ */ y("div", { class: Background_default.bg }); + } + + // pages/duckplayer/app/components/Player.jsx + var import_classnames9 = __toESM(require_classnames(), 1); + + // pages/duckplayer/app/components/Player.module.css + var Player_default = { + root: "Player_root", + desktop: "Player_desktop", + mobile: "Player_mobile", + player: "Player_player", + iframe: "Player_iframe", + error: "Player_error" + }; + + // pages/duckplayer/app/features/pip.js + var PIP = class { + /** + * @param {HTMLIFrameElement} iframe + */ + iframeDidLoad(iframe) { + try { + const iframeDocument = iframe.contentDocument; + const iframeWindow = iframe.contentWindow; + if (iframeDocument && iframeWindow) { + const CSSStyleSheet = ( + /** @type {any} */ + iframeWindow.CSSStyleSheet + ); + const styleSheet = new CSSStyleSheet(); + styleSheet.replaceSync("button.ytp-pip-button { display: inline-block !important; }"); + iframeDocument.adoptedStyleSheets = [...iframeDocument.adoptedStyleSheets, styleSheet]; + } + } catch (e3) { + console.warn(e3); + } + return null; + } + }; + + // pages/duckplayer/app/features/autofocus.js + var AutoFocus = class { + /** + * @param {HTMLIFrameElement} iframe + */ + iframeDidLoad(iframe) { + const maxAttempts = 1e3; + let attempt = 0; + let id; + function check() { + if (!iframe.contentDocument) + return; + if (attempt > maxAttempts) + return; + attempt += 1; + const selector = "#player video"; + const video = ( + /** @type {HTMLIFrameElement | null} */ + iframe.contentDocument?.body.querySelector(selector) + ); + if (!video) { + id = requestAnimationFrame(check); + return; + } + video.focus(); + document.body.dataset.videoState = "loaded+focussed"; + } + id = requestAnimationFrame(check); + return () => { + cancelAnimationFrame(id); + }; + } + }; + + // ../../src/features/duckplayer/util.js + var VideoParams = class _VideoParams { + /** + * @param {string} id - the YouTube video ID + * @param {string|null|undefined} time - an optional time + */ + constructor(id, time) { + this.id = id; + this.time = time; + } + static validVideoId = /^[a-zA-Z0-9-_]+$/; + static validTimestamp = /^[0-9hms]+$/; + /** + * @returns {string} + */ + toPrivatePlayerUrl() { + const duckUrl = new URL(`duck://player/${this.id}`); + if (this.time) { + duckUrl.searchParams.set("t", this.time); + } + return duckUrl.href; + } + /** + * Create a VideoParams instance from a href, only if it's on the watch page + * + * @param {string} href + * @returns {VideoParams|null} + */ + static forWatchPage(href) { + let url; + try { + url = new URL(href); + } catch (e3) { + return null; + } + if (!url.pathname.startsWith("/watch")) { + return null; + } + return _VideoParams.fromHref(url.href); + } + /** + * Convert a relative pathname into VideoParams + * + * @param pathname + * @returns {VideoParams|null} + */ + static fromPathname(pathname) { + let url; + try { + url = new URL(pathname, window.location.origin); + } catch (e3) { + return null; + } + return _VideoParams.fromHref(url.href); + } + /** + * Convert a href into valid video params. Those can then be converted into a private player + * link when needed + * + * @param href + * @returns {VideoParams|null} + */ + static fromHref(href) { + let url; + try { + url = new URL(href); + } catch (e3) { + return null; + } + let id = null; + const vParam = url.searchParams.get("v"); + const tParam = url.searchParams.get("t"); + if (url.searchParams.has("list") && !url.searchParams.has("index")) { + return null; + } + let time = null; + if (vParam && _VideoParams.validVideoId.test(vParam)) { + id = vParam; + } else { + return null; + } + if (tParam && _VideoParams.validTimestamp.test(tParam)) { + time = tParam; + } + return new _VideoParams(id, time); + } + }; + + // pages/duckplayer/src/js/utils.js + function createYoutubeURLForError(href, urlBase) { + const valid = VideoParams.forWatchPage(href); + if (!valid) + return null; + const original = new URL(href); + if (original.searchParams.get("feature") !== "emb_err_woyt") + return null; + const url = new URL(urlBase); + url.searchParams.set("v", valid.id); + if (typeof valid.time === "string") { + url.searchParams.set("t", valid.time); + } + return url.toString(); + } + function getValidVideoTitle(iframeTitle) { + if (typeof iframeTitle !== "string") + return null; + if (iframeTitle === "YouTube") + return null; + return iframeTitle.replace(/ - YouTube$/g, ""); + } + + // pages/duckplayer/app/features/click-capture.js + var ClickCapture = class { + /** + * @param {object} params + * @param {string} params.baseUrl + */ + constructor({ baseUrl }) { + this.baseUrl = baseUrl; + } + /** + * @param {HTMLIFrameElement} iframe + */ + iframeDidLoad(iframe) { + const handler = (e3) => { + if (!e3.target) + return; + const target = ( + /** @type {Element} */ + e3.target + ); + if (!("href" in target) || typeof target.href !== "string") + return; + const next = createYoutubeURLForError(target.href, this.baseUrl); + if (!next) + return; + e3.preventDefault(); + e3.stopImmediatePropagation(); + window.location.href = next; + }; + iframe.contentDocument?.addEventListener("click", handler); + return () => { + iframe.contentDocument?.removeEventListener("click", handler); + }; + } + }; + + // pages/duckplayer/app/features/title-capture.js + var TitleCapture = class { + /** + * @param {HTMLIFrameElement} iframe + */ + iframeDidLoad(iframe) { + const setter = (title) => { + const validTitle = getValidVideoTitle(title); + if (validTitle) { + document.title = "Duck Player - " + validTitle; + } + }; + const doc = iframe.contentDocument; + const win = iframe.contentWindow; + if (!doc) { + console.log("could not access contentDocument"); + return () => { + }; + } + if (doc.title) { + setter(doc.title); + } + if (win && doc) { + const titleElem = doc.querySelector("title"); + if (titleElem) { + const observer = new win.MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + setter(mutation.target.textContent); + }); + }); + observer.observe(titleElem, { childList: true }); + } else { + console.warn("could not access title in iframe"); + } + } else { + console.warn("could not access iframe?.contentWindow && iframe?.contentDocument"); + } + return null; + } + }; + + // pages/duckplayer/app/features/mouse-capture.js + var MouseCapture = class { + /** + * @param {HTMLIFrameElement} iframe + */ + iframeDidLoad(iframe) { + iframe.contentDocument?.addEventListener("mousemove", () => { + window.dispatchEvent(new Event("iframe-mousemove")); + }); + return null; + } + }; + + // pages/duckplayer/app/features/iframe.js + var IframeFeature = class { + /** + * @param {HTMLIFrameElement} iframe + * @returns {(() => void) | null} + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + iframeDidLoad(iframe) { + return () => { + console.log("teardown"); + }; + } + static noop() { + return { + iframeDidLoad: () => { + return () => { + }; + } + }; + } + }; + function createIframeFeatures(settings) { + return { + /** + * @return {IframeFeature} + */ + pip: () => { + if (settings.pip.state === "enabled") { + return new PIP(); + } + return IframeFeature.noop(); + }, + /** + * @return {IframeFeature} + */ + autofocus: () => { + return new AutoFocus(); + }, + /** + * @return {IframeFeature} + */ + clickCapture: () => { + return new ClickCapture({ + baseUrl: settings.youtubeBase + }); + }, + /** + * @return {IframeFeature} + */ + titleCapture: () => { + return new TitleCapture(); + }, + /** + * @return {IframeFeature} + */ + mouseCapture: () => { + return new MouseCapture(); + } + }; + } + + // pages/duckplayer/app/components/Player.jsx + function Player({ src, layout }) { + const { ref, didLoad } = useIframeEffects(src); + const wrapperClasses = (0, import_classnames9.default)({ + [Player_default.root]: true, + [Player_default.player]: true, + [Player_default.desktop]: layout === "desktop", + [Player_default.mobile]: layout === "mobile" + }); + const iframeClasses = (0, import_classnames9.default)({ + [Player_default.iframe]: true, + [Player_default.desktop]: layout === "desktop", + [Player_default.mobile]: layout === "mobile" + }); + return /* @__PURE__ */ y("div", { class: wrapperClasses }, /* @__PURE__ */ y( + "iframe", + { + class: iframeClasses, + frameBorder: "0", + id: "player", + allow: "autoplay; encrypted-media; fullscreen", + sandbox: "allow-popups allow-scripts allow-same-origin allow-popups-to-escape-sandbox", + src, + ref, + onLoad: didLoad + } + )); + } + function PlayerError({ kind, layout }) { + const { t: t3 } = useTypedTranslation(); + const errors = { + ["invalid-id"]: /* @__PURE__ */ y("span", { dangerouslySetInnerHTML: { __html: t3("invalidIdError") } }) + }; + const text = errors[kind] || errors["invalid-id"]; + return /* @__PURE__ */ y("div", { class: (0, import_classnames9.default)(Player_default.root, { + [Player_default.desktop]: layout === "desktop", + [Player_default.mobile]: layout === "mobile" + }) }, /* @__PURE__ */ y("div", { className: Player_default.error }, /* @__PURE__ */ y("p", null, text))); + } + function useIframeEffects(src) { + const ref = _( + /** @type {HTMLIFrameElement|null} */ + null + ); + const didLoad = _( + /** @type {boolean} */ + false + ); + const settings = useSettings(); + p2(() => { + if (!ref.current) + return; + const iframe = ref.current; + const features = createIframeFeatures(settings); + const iframeFeatures = [ + features.autofocus(), + features.pip(), + features.clickCapture(), + features.titleCapture(), + features.mouseCapture() + ]; + const cleanups = []; + const loadHandler = () => { + for (let feature of iframeFeatures) { + try { + cleanups.push(feature.iframeDidLoad(iframe)); + } catch (e3) { + console.error(e3); + } + } + }; + if (didLoad.current === true) { + loadHandler(); + } else { + iframe.addEventListener("load", loadHandler); + } + return () => { + for (let cleanup of cleanups) { + cleanup?.(); + } + iframe.removeEventListener("load", loadHandler); + }; + }, [src, settings]); + return { ref, didLoad: () => didLoad.current = true }; + } + + // pages/duckplayer/app/components/Components.jsx + function Components() { + const settings = new Settings({ + platform: { name: "macos" } + }); + let embed = EmbedSettings.fromHref("https://localhost?videoID=123"); + let url = embed?.toEmbedUrl(); + if (!url) + throw new Error("unreachable"); + return /* @__PURE__ */ y(g, null, /* @__PURE__ */ y("div", { "data-layout": "mobile" }, /* @__PURE__ */ y(Background, null)), /* @__PURE__ */ y("main", { class: Components_default.main }, /* @__PURE__ */ y("div", { class: Components_default.tube }, /* @__PURE__ */ y(Wordmark, null), /* @__PURE__ */ y("h2", null, "Floating Bar"), /* @__PURE__ */ y("div", { style: "position: relative; padding-left: 10em; min-height: 150px;" }, /* @__PURE__ */ y(InfoIcon, { debugStyles: true })), /* @__PURE__ */ y("h2", null, "Info Tooltip"), /* @__PURE__ */ y(FloatingBar, null, /* @__PURE__ */ y(Button, { icon: true }, /* @__PURE__ */ y(Icon, { src: info_data_default })), /* @__PURE__ */ y(Button, { icon: true }, /* @__PURE__ */ y(Icon, { src: cog_data_default })), /* @__PURE__ */ y(Button, { fill: true }, "Open in YouTube")), /* @__PURE__ */ y("h2", null, "Info Bar"), /* @__PURE__ */ y(SettingsProvider, { settings }, /* @__PURE__ */ y(SwitchProvider, null, /* @__PURE__ */ y(InfoBar, { embed }))), /* @__PURE__ */ y("br", null), /* @__PURE__ */ y("h2", null, "Mobile Switch Bar (ios)"), /* @__PURE__ */ y(SwitchProvider, null, /* @__PURE__ */ y(SwitchBarMobile, { platformName: "ios" })), /* @__PURE__ */ y("h2", null, "Mobile Switch Bar (android)"), /* @__PURE__ */ y(SwitchProvider, null, /* @__PURE__ */ y(SwitchBarMobile, { platformName: "android" })), /* @__PURE__ */ y("h2", null, "Desktop Switch bar"), /* @__PURE__ */ y("h3", null, "idle"), /* @__PURE__ */ y(SwitchProvider, null, /* @__PURE__ */ y(SwitchBarDesktop, null))), /* @__PURE__ */ y("h2", null, /* @__PURE__ */ y("code", null, "inset=false (desktop)")), /* @__PURE__ */ y(SettingsProvider, { settings }, /* @__PURE__ */ y(PlayerContainer, null, /* @__PURE__ */ y(Player, { src: url, layout: "desktop" }), /* @__PURE__ */ y(InfoBarContainer, null, /* @__PURE__ */ y(InfoBar, { embed })))), /* @__PURE__ */ y("br", null), /* @__PURE__ */ y("h2", null, /* @__PURE__ */ y("code", null, "inset=true (mobile)")), /* @__PURE__ */ y(PlayerContainer, { inset: true }, /* @__PURE__ */ y(PlayerInternal, { inset: true }, /* @__PURE__ */ y(PlayerError, { layout: "mobile", kind: "invalid-id" }), /* @__PURE__ */ y(SwitchBarMobile, { platformName: "ios" }))), /* @__PURE__ */ y("br", null))); + } + + // pages/duckplayer/app/components/MobileApp.jsx + var import_classnames10 = __toESM(require_classnames(), 1); + + // pages/duckplayer/app/components/MobileApp.module.css + var MobileApp_default = { + main: "MobileApp_main", + hideInFocus: "MobileApp_hideInFocus", + fadeout: "MobileApp_fadeout", + filler: "MobileApp_filler", + switch: "MobileApp_switch", + embed: "MobileApp_embed", + logo: "MobileApp_logo", + buttons: "MobileApp_buttons" + }; + + // pages/duckplayer/app/features/app.js + function createAppFeaturesFrom(settings) { + return { + focusMode: () => { + if (settings.focusMode.state === "enabled") { + return /* @__PURE__ */ y(FocusMode, null); + } else { + return null; + } + } + }; + } + + // pages/duckplayer/app/components/MobileButtons.module.css + var MobileButtons_default = { + buttons: "MobileButtons_buttons" + }; + + // pages/duckplayer/app/components/MobileButtons.jsx + function MobileButtons({ embed }) { + const openSettings = useOpenSettingsHandler(); + const openInfo = useOpenInfoHandler(); + const openOnYoutube = useOpenOnYoutubeHandler(); + const { t: t3 } = useTypedTranslation(); + return /* @__PURE__ */ y("div", { class: MobileButtons_default.buttons }, /* @__PURE__ */ y( + Button, + { + icon: true, + buttonProps: { + "aria-label": t3("openInfoButton"), + onClick: openInfo + } + }, + /* @__PURE__ */ y(Icon, { src: info_data_default }) + ), /* @__PURE__ */ y( + Button, + { + icon: true, + buttonProps: { + "aria-label": t3("openSettingsButton"), + onClick: openSettings + } + }, + /* @__PURE__ */ y(Icon, { src: cog_data_default }) + ), /* @__PURE__ */ y( + Button, + { + fill: true, + buttonProps: { + onClick: () => { + if (embed) + openOnYoutube(embed); + } + } + }, + t3("watchOnYoutube") + )); + } + + // pages/duckplayer/app/providers/OrientationProvider.jsx + function OrientationProvider({ onChange }) { + p2(() => { + if (!screen.orientation?.type) + return; + onChange(getOrientationFromScreen()); + const handleOrientationChange = () => { + onChange(getOrientationFromScreen()); + }; + screen.orientation.addEventListener("change", handleOrientationChange); + return () => screen.orientation.removeEventListener("change", handleOrientationChange); + }, []); + p2(() => { + let timer; + const listener = () => { + clearTimeout(timer); + timer = setTimeout(() => onChange(getOrientationFromWidth()), 300); + }; + window.addEventListener("resize", listener); + return () => window.removeEventListener("resize", listener); + }, []); + return null; + } + function getOrientationFromWidth() { + return window.innerWidth > window.innerHeight ? "landscape" : "portrait"; + } + function getOrientationFromScreen() { + return screen.orientation.type.includes("landscape") ? "landscape" : "portrait"; + } + + // pages/duckplayer/app/components/MobileApp.jsx + var DISABLED_HEIGHT = 450; + function MobileApp({ embed }) { + const settings = useSettings(); + const features = createAppFeaturesFrom(settings); + return /* @__PURE__ */ y(g, null, /* @__PURE__ */ y(Background, null), features.focusMode(), /* @__PURE__ */ y(OrientationProvider, { onChange: (orientation) => { + if (orientation === "portrait") { + return FocusMode.enable(); + } + if (window.innerHeight < DISABLED_HEIGHT) { + return FocusMode.disable(); + } + return FocusMode.enable(); + } }), /* @__PURE__ */ y(MobileLayout, { embed })); + } + function MobileLayout({ embed }) { + const platformName = usePlatformName(); + return /* @__PURE__ */ y("main", { class: MobileApp_default.main }, /* @__PURE__ */ y("div", { class: (0, import_classnames10.default)(MobileApp_default.filler, MobileApp_default.hideInFocus) }), /* @__PURE__ */ y("div", { class: MobileApp_default.embed }, embed === null && /* @__PURE__ */ y(PlayerError, { layout: "mobile", kind: "invalid-id" }), embed !== null && /* @__PURE__ */ y(Player, { src: embed.toEmbedUrl(), layout: "mobile" })), /* @__PURE__ */ y("div", { class: (0, import_classnames10.default)(MobileApp_default.logo, MobileApp_default.hideInFocus) }, /* @__PURE__ */ y(MobileWordmark, null)), /* @__PURE__ */ y("div", { class: (0, import_classnames10.default)(MobileApp_default.switch, MobileApp_default.hideInFocus) }, /* @__PURE__ */ y(SwitchProvider, null, /* @__PURE__ */ y(SwitchBarMobile, { platformName }))), /* @__PURE__ */ y("div", { class: (0, import_classnames10.default)(MobileApp_default.buttons, MobileApp_default.hideInFocus) }, /* @__PURE__ */ y(MobileButtons, { embed }))); + } + + // pages/duckplayer/app/components/DesktopApp.module.css + var DesktopApp_default = { + app: "DesktopApp_app", + portrait: "DesktopApp_portrait", + landscape: "DesktopApp_landscape", + wrapper: "DesktopApp_wrapper", + desktop: "DesktopApp_desktop", + rhs: "DesktopApp_rhs", + header: "DesktopApp_header", + main: "DesktopApp_main", + controls: "DesktopApp_controls", + switch: "DesktopApp_switch" + }; + + // pages/duckplayer/app/components/DesktopApp.jsx + function DesktopApp({ embed }) { + const settings = useSettings(); + const features = createAppFeaturesFrom(settings); + return /* @__PURE__ */ y(g, null, /* @__PURE__ */ y(Background, null), features.focusMode(), /* @__PURE__ */ y("main", { class: DesktopApp_default.app }, /* @__PURE__ */ y(DesktopLayout, { embed }))); + } + function DesktopLayout({ embed }) { + return /* @__PURE__ */ y("div", { class: DesktopApp_default.desktop }, /* @__PURE__ */ y(PlayerContainer, null, embed === null && /* @__PURE__ */ y(PlayerError, { layout: "desktop", kind: "invalid-id" }), embed !== null && /* @__PURE__ */ y(Player, { src: embed.toEmbedUrl(), layout: "desktop" }), /* @__PURE__ */ y(HideInFocusMode, { style: "slide" }, /* @__PURE__ */ y(InfoBarContainer, null, /* @__PURE__ */ y(InfoBar, { embed }))))); + } + + // pages/duckplayer/app/index.js + async function init(messaging2, baseEnvironment2) { + const result = await callWithRetry(() => messaging2.initialSetup()); + if ("error" in result) { + throw new Error(result.error); + } + const init2 = result.value; + console.log("INITIAL DATA", init2); + const environment = baseEnvironment2.withEnv(init2.env).withLocale(init2.locale).withLocale(baseEnvironment2.urlParams.get("locale")).withTextLength(baseEnvironment2.urlParams.get("textLength")).withDisplay(baseEnvironment2.urlParams.get("display")); + console.log("environment:", environment); + console.log("locale:", environment.locale); + document.body.dataset.display = environment.display; + const strings = environment.locale === "en" ? duckplayer_default : await getTranslationsFromStringOrLoadDynamically(init2.localeStrings, environment.locale) || duckplayer_default; + const settings = new Settings({}).withPlatformName(baseEnvironment2.injectName).withPlatformName(init2.platform?.name).withPlatformName(baseEnvironment2.urlParams.get("platform")).withFeatureState("pip", init2.settings.pip).withFeatureState("autoplay", init2.settings.autoplay).withFeatureState("focusMode", init2.settings.focusMode).withDisabledFocusMode(baseEnvironment2.urlParams.get("focusMode")); + console.log(settings); + const embed = createEmbedSettings(window.location.href, settings); + const didCatch = (error) => { + const message = error?.message || "unknown"; + messaging2.reportPageException({ message }); + }; + document.body.dataset.layout = settings.layout; + const root = document.querySelector("body"); + if (!root) + throw new Error("could not render, root element missing"); + if (environment.display === "app") { + q( + /* @__PURE__ */ y( + EnvironmentProvider, + { + debugState: environment.debugState, + injectName: environment.injectName, + willThrow: environment.willThrow + }, + /* @__PURE__ */ y(ErrorBoundary, { didCatch, fallback: /* @__PURE__ */ y(Fallback, { showDetails: environment.env === "development" }) }, /* @__PURE__ */ y(UpdateEnvironment, { search: window.location.search }), /* @__PURE__ */ y(MessagingContext2.Provider, { value: messaging2 }, /* @__PURE__ */ y(SettingsProvider, { settings }, /* @__PURE__ */ y(UserValuesProvider, { initial: init2.userValues }, settings.layout === "desktop" && /* @__PURE__ */ y(TranslationProvider, { translationObject: duckplayer_default, fallback: duckplayer_default, textLength: environment.textLength }, /* @__PURE__ */ y(DesktopApp, { embed })), settings.layout === "mobile" && /* @__PURE__ */ y(TranslationProvider, { translationObject: strings, fallback: duckplayer_default, textLength: environment.textLength }, /* @__PURE__ */ y(MobileApp, { embed })), /* @__PURE__ */ y(WillThrow, null))))) + ), + root + ); + } else if (environment.display === "components") { + q( + /* @__PURE__ */ y(EnvironmentProvider, { debugState: false, injectName: environment.injectName }, /* @__PURE__ */ y(MessagingContext2.Provider, { value: messaging2 }, /* @__PURE__ */ y(TranslationProvider, { translationObject: duckplayer_default, fallback: duckplayer_default, textLength: environment.textLength }, /* @__PURE__ */ y(Components, null)))), + root + ); + } + } + function createEmbedSettings(href, settings) { + const embed = EmbedSettings.fromHref(href); + if (!embed) + return null; + return embed.withAutoplay(settings.autoplay.state === "enabled").withMuted(settings.platform.name === "ios"); + } + async function getTranslationsFromStringOrLoadDynamically(stringInput, locale) { + if (stringInput) { + try { + return JSON.parse(stringInput); + } catch (e3) { + console.warn("String could not be parsed. Falling back to fetch..."); + } + } + try { + const response = await fetch(`./locales/${locale}/duckplayer.json`); + if (!response.ok) { + console.error("Network response was not ok"); + return null; + } + return await response.json(); + } catch (e3) { + console.error("Failed to fetch or parse JSON from the network:", e3); + return null; + } + } + + // pages/duckplayer/src/js/storage.js + function deleteStorage(subject) { + Object.keys(subject).forEach((key) => { + if (key.indexOf("yt-player") === 0) { + return; + } + subject.removeItem(key); + }); + } + function deleteAllCookies() { + const cookies = document.cookie.split(";"); + for (let i3 = 0; i3 < cookies.length; i3++) { + const cookie = cookies[i3]; + const eqPos = cookie.indexOf("="); + const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie; + document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;domain=youtube-nocookie.com;path=/;"; + } + } + function initStorage() { + window.addEventListener("unload", () => { + deleteStorage(localStorage); + deleteStorage(sessionStorage); + deleteAllCookies(); + }); + window.addEventListener("load", () => { + deleteStorage(localStorage); + deleteStorage(sessionStorage); + deleteAllCookies(); + }); + } + + // pages/duckplayer/src/js/index.js + var DuckplayerPage = class { + /** + * @param {import("@duckduckgo/messaging").Messaging} messaging + */ + constructor(messaging2, injectName) { + this.messaging = createTypedMessages(this, messaging2); + this.injectName = injectName; + } + /** + * This will be sent if the application has loaded, but a client-side error + * has occurred that cannot be recovered from + * @returns {Promise} + */ + initialSetup() { + if (this.injectName === "integration") { + return Promise.resolve({ + platform: { name: "ios" }, + env: "development", + userValues: { privatePlayerMode: { alwaysAsk: {} }, overlayInteracted: false }, + settings: { + pip: { + state: "enabled" + }, + autoplay: { + state: "enabled" + } + }, + locale: "en" + }); + } + return this.messaging.request("initialSetup"); + } + /** + * This is sent when the user wants to set Duck Player as the default. + * + * @param {import("../../../../types/duckplayer").UserValues} userValues + */ + setUserValues(userValues) { + return this.messaging.request("setUserValues", userValues); + } + /** + * For platforms that require a message to open settings + */ + openSettings() { + return this.messaging.notify("openSettings"); + } + /** + * For platforms that require a message to open info modal + */ + openInfo() { + return this.messaging.notify("openInfo"); + } + /** + * This is a subscription that we set up when the page loads. + * We use this value to show/hide the checkboxes. + * + * **Integration NOTE**: Native platforms should always send this at least once on initial page load. + * + * - See {@link Messaging.SubscriptionEvent} for details on each value of this message + * + * ```json + * // the payload that we receive should look like this + * { + * "context": "specialPages", + * "featureName": "duckPlayerPage", + * "subscriptionName": "onUserValuesChanged", + * "params": { + * "overlayInteracted": false, + * "privatePlayerMode": { + * "enabled": {} + * } + * } + * } + * ``` + * + * @param {(value: import("../../../../types/duckplayer").UserValues) => void} cb + */ + onUserValuesChanged(cb) { + return this.messaging.subscribe("onUserValuesChanged", cb); + } + /** + * This will be sent if the application has loaded, but a client-side error + * has occurred that cannot be recovered from + * @param {{message: string}} params + */ + reportPageException(params) { + this.messaging.notify("reportPageException", params); + } + /** + * This will be sent if the application fails to load. + * @param {{message: string}} params + */ + reportInitException(params) { + this.messaging.notify("reportInitException", params); + } + }; + var baseEnvironment = new Environment().withInjectName(document.documentElement.dataset.platform).withEnv("production"); + var messaging = createSpecialPageMessaging({ + injectName: baseEnvironment.injectName, + env: baseEnvironment.env, + pageName: "duckPlayerPage" + }); + var example = new DuckplayerPage(messaging, "android"); + init(example, baseEnvironment).catch((e3) => { + console.error(e3); + const msg = typeof e3?.message === "string" ? e3.message : "unknown init error"; + example.reportInitException({ message: msg }); + }); + initStorage(); +})(); +/*! Bundled license information: + +classnames/index.js: + (*! + Copyright (c) 2018 Jed Watson. + Licensed under the MIT License (MIT), see + http://jedwatson.github.io/classnames + *) +*/ diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/inline.js b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/inline.js new file mode 100644 index 000000000000..6f18bb2bfd2e --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/inline.js @@ -0,0 +1,14 @@ +"use strict"; +(() => { + // pages/duckplayer/src/js/inline.js + var param = new URLSearchParams(window.location.search).get("platform"); + if (isAllowed(param)) { + document.documentElement.dataset.platform = String(param); + } else { + document.documentElement.dataset.platform = "android"; + } + function isAllowed(input) { + const allowed = ["windows", "apple", "integration"]; + return allowed.includes(input); + } +})(); diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/messages.example.js b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/messages.example.js new file mode 100644 index 000000000000..1ce11d984b14 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/messages.example.js @@ -0,0 +1,43 @@ +/** + * The user wishes to enable DuckPlayer + * @satisfies {import("./messages").UserValues} + */ +const enabled = { + privatePlayerMode: { enabled: {} }, + overlayInteracted: false +} + +console.log(enabled) + +/** + * The user wishes to disable DuckPlayer + * @satisfies {import("./messages").UserValues} + */ +const disabled = { + privatePlayerMode: { disabled: {} }, + overlayInteracted: false +} + +console.log(disabled) + +/** + * The user wishes for overlays to always show + * @satisfies {import("./messages").UserValues} + */ +const alwaysAsk = { + privatePlayerMode: { alwaysAsk: {} }, + overlayInteracted: false +} + +console.log(alwaysAsk) + +/** + * The user wishes only for small overlays to show, not the blocking video ones + * @satisfies {import("./messages").UserValues} + */ +const alwaysAskRemembered = { + privatePlayerMode: { alwaysAsk: {} }, + overlayInteracted: true +} + +console.log(alwaysAskRemembered) diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/messages.js b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/messages.js new file mode 100644 index 000000000000..60b35fcbd077 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/messages.js @@ -0,0 +1,150 @@ +/** + * @typedef {object} InitialSetup - The initial payload used to communicate render-blocking information + * @property {UserValues} userValues - The state of the user values + * @property {DuckPlayerPageSettings} settings - Additional settings + */ + +/** + * Notifications or requests that the Duck Player Page will + * send to the native side + */ +export class DuckPlayerPageMessages { + /** + * @param {import("@duckduckgo/messaging").Messaging} messaging + * @param {ImportMeta["injectName"]} injectName + * @internal + */ + constructor (messaging, injectName) { + /** + * @internal + */ + this.messaging = messaging + this.injectName = injectName + } + + /** + * This is sent when the user wants to set Duck Player as the default. + * + * @returns {Promise} params + */ + initialSetup () { + if (this.injectName === 'integration') { + return Promise.resolve({ + settings: { + pip: { + state: 'enabled' + } + }, + userValues: new UserValues({ + overlayInteracted: false, + privatePlayerMode: { alwaysAsk: {} } + }) + }) + } + return this.messaging.request('initialSetup') + } + + /** + * This is sent when the user wants to set Duck Player as the default. + * + * @param {UserValues} userValues + */ + setUserValues (userValues) { + return this.messaging.request('setUserValues', userValues) + } + + /** + * This is sent when the user wants to set Duck Player as the default. + * @return {Promise} + */ + getUserValues () { + if (this.injectName === 'integration') { + return Promise.resolve(new UserValues({ + overlayInteracted: false, + privatePlayerMode: { alwaysAsk: {} } + })) + } + return this.messaging.request('getUserValues') + } + + /** + * This is a subscription that we set up when the page loads. + * We use this value to show/hide the checkboxes. + * + * **Integration NOTE**: Native platforms should always send this at least once on initial page load. + * + * - See {@link Messaging.SubscriptionEvent} for details on each value of this message + * - See {@link UserValues} for details on the `params` + * + * ```json + * // the payload that we receive should look like this + * { + * "context": "specialPages", + * "featureName": "duckPlayerPage", + * "subscriptionName": "onUserValuesChanged", + * "params": { + * "overlayInteracted": false, + * "privatePlayerMode": { + * "enabled": {} + * } + * } + * } + * ``` + * + * @param {(value: UserValues) => void} cb + */ + onUserValuesChanged (cb) { + return this.messaging.subscribe('onUserValuesChanged', cb) + } +} + +/** + * This data structure is sent to enable user settings to be updated + * + * ```js + * [[include:packages/special-pages/pages/duckplayer/src/js/messages.example.js]]``` + */ +export class UserValues { + /** + * @param {object} params + * @param {{enabled: {}} | {disabled: {}} | {alwaysAsk: {}}} params.privatePlayerMode + * @param {boolean} params.overlayInteracted + */ + constructor (params) { + /** + * 'enabled' means 'always play in duck player' + * 'disabled' means 'never play in duck player' + * 'alwaysAsk' means 'show overlay prompts for using duck player' + * @type {{enabled: {}}|{disabled: {}}|{alwaysAsk: {}}} + */ + this.privatePlayerMode = params.privatePlayerMode + /** + * `true` when the user has asked to remember a previous choice + * + * `false` if they have never used the checkbox + * @type {boolean} + */ + this.overlayInteracted = params.overlayInteracted + } +} + +/** + * Sent in the initial page load request. Used to provide features toggles + * and other none-user-specific settings. + * + * Note: This will be improved soon with better remote config integration. + */ +export class DuckPlayerPageSettings { + /** + * @param {object} params + * @param {object} params.pip + * @param {"enabled" | "disabled"} params.pip.state + */ + constructor (params) { + /** + * 'enabled' means that the FE should show the PIP button + * 'disabled' means that the FE should never show it + */ + this.pip = params.pip + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/mobile-bg-GCRU67TC.jpg b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/mobile-bg-GCRU67TC.jpg new file mode 100644 index 000000000000..aefa07635090 Binary files /dev/null and b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/mobile-bg-GCRU67TC.jpg differ diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/player-bg-F7QLKTXS.jpg b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/player-bg-F7QLKTXS.jpg new file mode 100644 index 000000000000..bfb3a127ed61 Binary files /dev/null and b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/player-bg-F7QLKTXS.jpg differ diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/storage.js b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/storage.js new file mode 100644 index 000000000000..e32740f51d9b --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/storage.js @@ -0,0 +1,32 @@ +function deleteStorage (subject) { + Object.keys(subject).forEach((key) => { + if (key.indexOf('yt-player') === 0) { + return + } + subject.removeItem(key) + }) +} + +function deleteAllCookies () { + const cookies = document.cookie.split(';') + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i] + const eqPos = cookie.indexOf('=') + const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie + document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT;domain=youtube-nocookie.com;path=/;' + } +} + +export function initStorage () { + window.addEventListener('unload', () => { + deleteStorage(localStorage) + deleteStorage(sessionStorage) + deleteAllCookies() + }) + + window.addEventListener('load', () => { + deleteStorage(localStorage) + deleteStorage(sessionStorage) + deleteAllCookies() + }) +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/utils.js b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/utils.js new file mode 100644 index 000000000000..4a88871a9234 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/js/utils.js @@ -0,0 +1,40 @@ +import { VideoParams } from '../../../../../../src/features/duckplayer/util' + +/** + * @param {string} href + * @param {string} urlBase + * @return {null | string} + */ +export function createYoutubeURLForError (href, urlBase) { + const valid = VideoParams.forWatchPage(href) + if (!valid) return null + + // this will not throw, since it was guarded above + const original = new URL(href) + + // for now, we're only intercepting clicks when `emb_err_woyt` is present + // this may not be enough to cover all situations, but it solves our immediate + // problems whilst keeping the blast radius low + if (original.searchParams.get('feature') !== 'emb_err_woyt') return null + + // if we get this far, we think a click is occurring that would cause a navigation loop + // construct the 'next' url + const url = new URL(urlBase) + url.searchParams.set('v', valid.id) + + if (typeof valid.time === 'string') { + url.searchParams.set('t', valid.time) + } + + return url.toString() +} + +/** + * @param {string|null|undefined} iframeTitle + * @return {string | null} + */ +export function getValidVideoTitle (iframeTitle) { + if (typeof iframeTitle !== 'string') return null + if (iframeTitle === 'YouTube') return null + return iframeTitle.replace(/ - YouTube$/g, '') +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/bg/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/bg/duckplayer.json new file mode 100644 index 000000000000..d7f3d20ef74c --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/bg/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Видеоклиповете в YouTube винаги да се отварят тук", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Duck Player да остане включен", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Отваряне на информацията", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Отваряне на настройки", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Гледане в YouTube", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "ГРЕШКА: невалиден идентификатор на видеоклипа", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Duck Player осигурява чисто изживяване без персонализирани реклами в YouTube и предотвратява влиянието на вече гледаните видеоклипове върху препоръките на YouTube." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/cs/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/cs/duckplayer.json new file mode 100644 index 000000000000..8d24c5726549 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/cs/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Vždy otevírat videa YouTube tady", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Nechat zapnutý přehrávač Duck Player", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Otevřít informace", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Otevřené nastavení", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Sledovat na YouTube", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "CHYBA: Neplatné ID videa", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Přehrávač Duck Player nabízí sledování v minimalistickém prostředí bez personalizovaných reklam a brání tomu, aby sledovaná videa ovlivňovala tvoje doporučení na YouTube." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/da/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/da/duckplayer.json new file mode 100644 index 000000000000..f99ab298e425 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/da/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Åbn altid YouTube-videoer her", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Hold Duck Player slået til", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Åbn info", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Åbn Indstillinger", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Se på YouTube", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "FEJL: Ugyldigt video-ID", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Duck Player giver en ren seeroplevelse uden målrettede annoncer og forhindrer, at visningsaktivitet påvirker dine YouTube-anbefalinger." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/de/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/de/duckplayer.json new file mode 100644 index 000000000000..0ddca103a2f9 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/de/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "YouTube-Videos immer hier öffnen", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Duck Player aktiviert lassen", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Info öffnen", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Einstellungen öffnen", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Auf YouTube ansehen", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "FEHLER: Ungültige Video-ID", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Mit Duck Player kannst du dir ungestört und ohne personalisierte Werbung Inhalte ansehen. Er verhindert, dass das, was du dir ansiehst, deine YouTube-Empfehlungen beeinflussen." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/el/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/el/duckplayer.json new file mode 100644 index 000000000000..d00195f5e350 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/el/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Ανοίγετε πάντα τα βίντεο του YouTube εδώ", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Διατηρήστε το Duck Player ενεργοποιημένο", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Ανοίξτε για πληροφορίες", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Άνοιγμα ρυθμίσεων", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Παρακολούθηση στο YouTube", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "ΣΦΑΛΜΑ: Μη έγκυρο αναγνωριστικό βίντεο", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Το Duck Player παρέχει μια καθαρή εμπειρία προβολής χωρίς εξατομικευμένες διαφημίσεις, ενώ εμποδίζει τη δραστηριότητα προβολής να επηρεάσει τις συστάσεις που θα λαμβάνετε στο YouTube." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/en/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/en/duckplayer.json new file mode 100644 index 000000000000..c2b5683b9f0e --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/en/duckplayer.json @@ -0,0 +1,39 @@ +{ + "smartling": { + "string_format": "icu", + "translate_paths": [ + { + "path": "*/title", + "key": "{*}/title", + "instruction": "*/note" + } + ] + }, + "alwaysWatchHere": { + "title": "Always open YouTube videos here", + "note": "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled": { + "title": "Keep Duck Player turned on", + "note": "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton": { + "title": "Open Info", + "note": "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton": { + "title": "Open Settings", + "note": "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube": { + "title": "Watch on YouTube", + "note": "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError": { + "title": "ERROR: Invalid video id", + "note": "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo": { + "title": "Duck Player provides a clean viewing experience without personalized ads and prevents viewing activity from influencing your YouTube recommendations." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/es/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/es/duckplayer.json new file mode 100644 index 000000000000..1b5d8b9585e9 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/es/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Abrir siempre los vídeos de YouTube aquí", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Mantener Duck Player activado", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Abrir información", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Abrir ajustes", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Ver en YouTube", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "ERROR: ID de vídeo no válida", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Duck Player ofrece una experiencia de visualización limpia sin anuncios personalizados e impide que la actividad de visualización influya en tus recomendaciones de YouTube." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/et/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/et/duckplayer.json new file mode 100644 index 000000000000..c9863f4f50c4 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/et/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Ava YouTube'i videod alati siin", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Hoia Duck Player sisse lülitatud", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Ava teave", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Ava seaded", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Vaata YouTube'is", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "VIGA: vale video ID", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Duck Player pakub isikupärastatud reklaamidest vaba vaatamiskogemust ja takistab, et vaatamisaktiivsus mõjutaks sinu YouTube'i soovitusi." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/fi/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/fi/duckplayer.json new file mode 100644 index 000000000000..e73022b8fa9a --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/fi/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Avaa YouTube-videot aina täällä", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Pidä Duck Player käytössä", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Avaa tiedot", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Avaa asetukset", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Katso YouTubessa", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "VIRHE: virheellinen videotunnus", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Duck Player tarjoaa puhtaan katselukokemuksen ilman kohdennettuja mainoksia ja estää katseluhistoriaa vaikuttamasta YouTube-suosituksiisi." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/fr/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/fr/duckplayer.json new file mode 100644 index 000000000000..716f0c071011 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/fr/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Toujours ouvrir les vidéos YouTube ici", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Laisser Duck Player activé", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Ouvrir les infos", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Ouvrez les Paramètres", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Regarder sur YouTube", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "ERREUR : identifiant vidéo non valide", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Duck Player offre une expérience de visionnage épurée, sans publicités personnalisées, et empêche l'activité de visionnage d'influencer vos recommandations YouTube." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/hr/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/hr/duckplayer.json new file mode 100644 index 000000000000..3f0e8aeaee03 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/hr/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "YouTube videozapise uvijek otvaraj ovdje", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Drži Duck Player uključen", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Više informacija", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Otvori Postavke", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Pogledaj na YouTubeu", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "POGREŠKA: Nevažeći ID videozapisa", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Duck Player pruža čisti doživljaj gledanja bez personaliziranih oglasa i sprječava da aktivnosti gledanja utječu na tvoje preporuke na YouTubeu." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/hu/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/hu/duckplayer.json new file mode 100644 index 000000000000..3bbe06210f10 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/hu/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Mindig itt nyissa meg a YouTube-videókat", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Maradjon bekapcsolva a Duck Player", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Információk megtekintése", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Beállítások megnyitása", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Lejátszás YouTube-on", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "HIBA: Érvénytelen videoazonosító", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "A Duck Player személyre szabott hirdetések nélküli, letisztult megtekintési élményt nyújt, és megakadályozza, hogy a megtekintési tevékenységed befolyásolja a neked szóló YouTube-ajánlásokat." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/it/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/it/duckplayer.json new file mode 100644 index 000000000000..9ce21667708e --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/it/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Apri sempre i video di YouTube qui", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Tieni Duck Player attivo", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Apri le informazioni", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Apri Impostazioni", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Guarda su YouTube", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "ERRORE: ID video non valido", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Duck Player offre un'esperienza di visualizzazione pulita, senza annunci personalizzati, e impedisce che l'attività di visualizzazione incida sulle raccomandazioni di YouTube." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/lt/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/lt/duckplayer.json new file mode 100644 index 000000000000..1b282dd2ccaa --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/lt/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Visada atidaryti „YouTube“ vaizdo įrašus čia", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Laikyti „Duck Player“ įjungtą", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Atidaryti informaciją", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Atidaryti Nustatymus", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Žiūrėti „YouTube“", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "KLAIDA: netinkamas vaizdo įrašo ID", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "„Duck Player“ užtikrina nepriekaištingą žiūrėjimo patirtį be suasmenintų reklamų ir neleidžia žiūrėjimo veiklai daryti įtakos „YouTube“ rekomendacijoms." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/lv/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/lv/duckplayer.json new file mode 100644 index 000000000000..46d0bee8e660 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/lv/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Vienmēr atvērt YouTube videoklipus šeit", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Paturēt Duck Player ieslēgtu", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Atvērt informāciju", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Atvērt iestatījumus", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Skatīties pie YouTube", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "KĻŪDA: Nederīgs video ID", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Duck Player nodrošina netraucētu skatīšanās pieredzi bez personalizētām reklāmām un neļauj skatīšanās darbībām ietekmēt tavus YouTube ieteikumus." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/nb/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/nb/duckplayer.json new file mode 100644 index 000000000000..4c1d826f666f --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/nb/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Åpne alltid YouTube-videoer her", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "La Duck Player være på", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Åpne informasjon", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Åpne innstillingene", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Se på YouTube", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "FEIL: Ugyldig video-ID", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Duck Player tilbyr en ren seeropplevelse uten tilpassede annonser og forhindrer at seeraktiviteten din påvirker YouTube-anbefalingene dine." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/nl/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/nl/duckplayer.json new file mode 100644 index 000000000000..a1be8669e254 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/nl/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "YouTube-video's altijd hier openen", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Houd Duck Player ingeschakeld", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Meer info", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Open Instellingen", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Kijken op YouTube", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "FOUT: ongeldige video-id", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Duck Player biedt puur kijkplezier zonder gepersonaliseerde advertenties en voorkomt dat de dingen die je bekijkt je YouTube-aanbevelingen beïnvloeden." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/pl/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/pl/duckplayer.json new file mode 100644 index 000000000000..accd3fde5d50 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/pl/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Zawsze otwieraj filmy z YouTube tutaj", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Nie wyłączaj odtwarzacza Duck Player", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Otwórz informacje", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Otwórz ustawienia", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Obejrzyj na YouTube", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "BŁĄD: nieprawidłowy identyfikator filmu", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Duck Player zapewnia czyste środowisko oglądania bez spersonalizowanych reklam i sprawia, że aktywność związana z oglądaniem filmów nie wpływa na rekomendacje YouTube'a." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/pt/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/pt/duckplayer.json new file mode 100644 index 000000000000..a5bfca188192 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/pt/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Abrir sempre os vídeos do YouTube aqui", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Manter o Duck Player ligado", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Abrir Informações", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Abre Definições", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Ver no YouTube", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "ERRO: ID de vídeo inválido", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "O Duck Player oferece uma experiência de visualização limpa sem anúncios personalizados e evita que as atividades de visualização influenciem as recomendações do YouTube." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/ro/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/ro/duckplayer.json new file mode 100644 index 000000000000..bfafec70e054 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/ro/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Deschide întotdeauna videoclipurile YouTube aici", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Menține Duck Player activat", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Deschide Informațiile", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Deschide Setări", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Vizionează pe YouTube", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "EROARE: ID video incorect", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Duck Player oferă o experiență de vizionare fără perturbări, fără reclame personalizate și împiedică activitatea de vizionare să îți influențeze recomandările YouTube." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/ru/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/ru/duckplayer.json new file mode 100644 index 000000000000..4bf5cc0c1f99 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/ru/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Всегда открывайте видео на YouTube здесь", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Держите Duck Player включенным", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Открыть информацию", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Открыть Настройки", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Смотреть на YouTube", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "ОШИБКА: Неверный идентификатор видео", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Проигрыватель Duck Player обеспечивает беспрепятственный просмотр без персонализированной рекламы и влияния просмотренных роликов на рекомендации в YouTube." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/sk/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/sk/duckplayer.json new file mode 100644 index 000000000000..88a2cc3a57d8 --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/sk/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Vždy otvárajte videá YouTube tu", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Nechajte zapnutý prehrávač Duck Player", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Otvorené informácie", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Otvoriť nastavenia", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Pozrieť na YouTube", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "CHYBA: Neplatný identifikátor videa", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Duck Player poskytuje čisté zobrazenie bez personalizovaných reklám a zabraňuje tomu, aby aktivita pri sledovaní ovplyvňovala vaše odporúčania v službe YouTube." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/sl/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/sl/duckplayer.json new file mode 100644 index 000000000000..7d4a89155afd --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/sl/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Videoposnetke iz YouTuba vedno odpri tukaj", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Predvajalnik Duck Player naj ostane vklopljen", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Prikaži informacije", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Odpri nastavitve", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Glej na YouTubu", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "NAPAKA: Neveljaven ID videoposnetka", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Predvajalnik Duck Player zagotavlja čisto izkušnjo gledanja brez prilagojenih oglasov in preprečuje, da bi dejavnost gledanja vplivala na vaša priporočila v YouTubu." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/sv/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/sv/duckplayer.json new file mode 100644 index 000000000000..cb5b75e4caef --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/sv/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "Öppna alltid YouTube-videor här", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Låt Duck Player vara aktiverat", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Öppna info", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Öppna inställningar", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Se på Youtube", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "FEL: Ogiltigt video-ID", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Duck Player ger en störningsfri visningsupplevelse utan personliga annonser och förhindrar att din tittaraktivitet påverkar YouTube-rekommendationer." + } +} diff --git a/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/tr/duckplayer.json b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/tr/duckplayer.json new file mode 100644 index 000000000000..c23cab2bbd0a --- /dev/null +++ b/node_modules/@duckduckgo/content-scope-scripts/build/android/pages/duckplayer/locales/tr/duckplayer.json @@ -0,0 +1,38 @@ +{ + "smartling" : { + "string_format" : "icu", + "translate_paths" : [ + { + "path" : "*/title", + "key" : "{*}/title", + "instruction" : "*/note" + }] + }, + "alwaysWatchHere" : { + "title" : "YouTube videolarını her zaman burada aç", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "keepEnabled" : { + "title" : "Duck Player'ı açık tut", + "note" : "label text for a checkbox that enables this feature for all videos, not just the current one" + }, + "openInfoButton" : { + "title" : "Bilgileri Aç", + "note" : "aria label text on a button, to indicate there's more information to be shown if clicked" + }, + "openSettingsButton" : { + "title" : "Ayarları Aç", + "note" : "aria label text on a button, opens a screen where the user can change settings" + }, + "watchOnYoutube" : { + "title" : "Youtube'da İzle", + "note" : "text on a link that takes the user from the current page back onto YouTube.com" + }, + "invalidIdError" : { + "title" : "HATA: Geçersiz video kimliği", + "note" : "Shown when the page URL doesn't match a known video ID. Note for translators: The tag makes the word 'ERROR:' bold. Depending on the grammar of the target language, you might need to move it so that the correct word is emphasized." + }, + "tooltipInfo" : { + "title" : "Duck Player, kişiselleştirilmiş reklamlar olmadan temiz bir görüntüleme deneyimi sağlar ve görüntüleme etkinliğinin YouTube önerilerinizi etkilemesini önler." + } +} diff --git a/settings/settings-api/src/main/java/com/duckduckgo/settings/api/ProSettingsPlugin.kt b/settings/settings-api/src/main/java/com/duckduckgo/settings/api/ProSettingsPlugin.kt index 485c124f5adc..bfb6db7462ba 100644 --- a/settings/settings-api/src/main/java/com/duckduckgo/settings/api/ProSettingsPlugin.kt +++ b/settings/settings-api/src/main/java/com/duckduckgo/settings/api/ProSettingsPlugin.kt @@ -34,3 +34,8 @@ interface SettingsPlugin { * This is the plugin for the subs settings */ interface ProSettingsPlugin : SettingsPlugin + +/** + * This is the plugin for Duck Player settings + */ +interface DuckPlayerSettingsPlugin : SettingsPlugin diff --git a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/DuckPlayerSettingModule.kt b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/DuckPlayerSettingModule.kt new file mode 100644 index 000000000000..97266b059c35 --- /dev/null +++ b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/DuckPlayerSettingModule.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.settings.impl + +import com.duckduckgo.anvil.annotations.ContributesPluginPoint +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.settings.api.DuckPlayerSettingsPlugin + +@ContributesPluginPoint( + scope = ActivityScope::class, + boundType = DuckPlayerSettingsPlugin::class, +) +private interface DuckPlayerSettingsPluginTrigger diff --git a/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/Subscriptions.kt b/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/Subscriptions.kt index fe34602cc5f4..9b13ea9144a3 100644 --- a/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/Subscriptions.kt +++ b/subscriptions/subscriptions-api/src/main/java/com/duckduckgo/subscriptions/api/Subscriptions.kt @@ -56,9 +56,9 @@ interface Subscriptions { fun launchPrivacyPro(context: Context, uri: Uri?) /** - * @return `true` if the given URL leads to the Privacy Pro page, or `false` otherwise + * @return `true` if the given Uri leads to the Privacy Pro page, or `false` otherwise */ - fun isPrivacyProUrl(url: String): Boolean + fun isPrivacyProUrl(uri: Uri): Boolean } enum class Product(val value: String) { diff --git a/subscriptions/subscriptions-dummy-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsDummy.kt b/subscriptions/subscriptions-dummy-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsDummy.kt index 97fb40123fb0..ce28bcf8b04a 100644 --- a/subscriptions/subscriptions-dummy-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsDummy.kt +++ b/subscriptions/subscriptions-dummy-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsDummy.kt @@ -47,5 +47,5 @@ class SubscriptionsDummy @Inject constructor() : Subscriptions { // no-op } - override fun isPrivacyProUrl(url: String): Boolean = false + override fun isPrivacyProUrl(uri: Uri): Boolean = false } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt index 68921212ac7e..086a0a4657fd 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt @@ -112,7 +112,7 @@ class RealSubscriptions @Inject constructor( } override fun shouldLaunchPrivacyProForUrl(url: String): Boolean { - return if (isPrivacyProUrl(url)) { + return if (isPrivacyProUrl(url.toUri())) { runBlocking { isEligible() } @@ -121,8 +121,7 @@ class RealSubscriptions @Inject constructor( } } - override fun isPrivacyProUrl(url: String): Boolean { - val uri = url.toUri() + override fun isPrivacyProUrl(uri: Uri): Boolean { val eTld = uri.host?.toTldPlusOne() ?: return false val size = uri.pathSegments.size val path = uri.pathSegments.firstOrNull()