From 14fae9c2b8e047fb03eb54aec47b06a704b79cfa Mon Sep 17 00:00:00 2001 From: Yogesh Bhagat Date: Fri, 31 Oct 2025 19:27:23 +0530 Subject: [PATCH] implement webview for youtube video --- build.gradle | 4 +- .../video/YoutubeVideoFullScreenFragment.kt | 90 +++- .../unit/video/YoutubeVideoUnitFragment.kt | 421 +++++++++++++++++- .../layout/fragment_youtube_video_unit.xml | 6 + gradle/wrapper/gradle-wrapper.properties | 2 +- 5 files changed, 496 insertions(+), 27 deletions(-) diff --git a/build.gradle b/build.gradle index 390d02699..472733ccb 100644 --- a/build.gradle +++ b/build.gradle @@ -14,8 +14,8 @@ buildscript { } plugins { - id 'com.android.application' version '8.5.2' apply false - id 'com.android.library' version '8.5.2' apply false + id 'com.android.application' version '8.11.2' apply false + id 'com.android.library' version '8.11.2' apply false id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false id 'com.google.gms.google-services' version '4.4.2' apply false id "com.google.firebase.crashlytics" version "3.0.2" apply false diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt index 03f8b906a..542c00f32 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt @@ -2,6 +2,7 @@ package org.openedx.course.presentation.unit.video import android.os.Bundle import android.view.View +import android.webkit.WebView import android.widget.FrameLayout import androidx.core.os.bundleOf import androidx.core.view.WindowInsetsCompat @@ -12,7 +13,6 @@ import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.options.IFramePlayerOptions import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils.YouTubePlayerTracker -import com.pierfrancescosoffritti.androidyoutubeplayer.core.ui.DefaultPlayerUiController import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf @@ -39,8 +39,8 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - viewModel.videoUrl = requireArguments().getString(ARG_BLOCK_VIDEO_URL, "") - blockId = requireArguments().getString(ARG_BLOCK_ID, "") + // Enable WebView debugging for YouTube player + WebView.setWebContentsDebuggingEnabled(true) if (viewModel.currentVideoTime == 0L) { viewModel.currentVideoTime = requireArguments().getLong(ARG_VIDEO_TIME, 0) } @@ -67,8 +67,10 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ lifecycle.addObserver(binding.youtubePlayerView) val options = IFramePlayerOptions.Builder() - .controls(0) - .rel(0) + .controls(0) // Hide default controls (using custom UI) + .rel(0) // Don't show related videos + .ivLoadPolicy(3) // Critical: Disable video annotations + .ccLoadPolicy(1) // Show closed captions .build() binding.youtubePlayerView.initialize( @@ -80,12 +82,20 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ state: PlayerConstants.PlayerState, ) { super.onStateChange(youTubePlayer, state) + android.util.Log.d("YoutubePlayerFS", "Player state: $state") + if (state == PlayerConstants.PlayerState.ENDED) { viewModel.markBlockCompleted(blockId, CourseAnalyticsKey.YOUTUBE.key) } viewModel.isPlaying = when (state) { - PlayerConstants.PlayerState.PLAYING -> true - PlayerConstants.PlayerState.PAUSED -> false + PlayerConstants.PlayerState.PLAYING -> { + android.util.Log.d("YoutubePlayerFS", "✅ Fullscreen video PLAYING") + true + } + PlayerConstants.PlayerState.PAUSED -> { + android.util.Log.d("YoutubePlayerFS", "⏸️ Fullscreen video PAUSED") + false + } else -> return } } @@ -108,21 +118,23 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ override fun onReady(youTubePlayer: YouTubePlayer) { super.onReady(youTubePlayer) binding.youtubePlayerView.isVisible = true - val defPlayerUiController = - DefaultPlayerUiController(binding.youtubePlayerView, youTubePlayer) - defPlayerUiController.setFullScreenButtonClickListener { - parentFragmentManager.popBackStack() - } - binding.youtubePlayerView.setCustomPlayerUi(defPlayerUiController.rootView) + android.util.Log.d("YoutubePlayerFS", "=== Fullscreen onReady ===") + android.util.Log.d("YoutubePlayerFS", "Video URL: ${viewModel.videoUrl}") - val videoId = viewModel.videoUrl.split("watch?v=")[1] - if (viewModel.isPlaying == true) { - youTubePlayer.loadVideo(videoId, viewModel.currentVideoTime.toFloat() / 1000) + val videoId = extractYouTubeVideoId(viewModel.videoUrl) + android.util.Log.d("YoutubePlayerFS", "Extracted video ID: '$videoId'") + + if (videoId != null) { + val startTime = viewModel.currentVideoTime.toFloat() / 1000 + // Always use cueVideo - avoids autoplay policy issues + android.util.Log.d("YoutubePlayerFS", "Action: cueVideo, Start time: $startTime sec") + + youTubePlayer.cueVideo(videoId, startTime) + youTubePlayer.addListener(youtubeTrackerListener) } else { - youTubePlayer.cueVideo(videoId, viewModel.currentVideoTime.toFloat() / 1000) + android.util.Log.e("YoutubePlayerFS", "❌ FAILED to extract video ID from: ${viewModel.videoUrl}") } - youTubePlayer.addListener(youtubeTrackerListener) } }, options @@ -134,6 +146,48 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ super.onDestroyView() } + private fun extractYouTubeVideoId(url: String): String? { + return try { + when { + // Standard watch URL: https://www.youtube.com/watch?v=VIDEO_ID + url.contains("watch?v=") -> { + val parts = url.split("watch?v=") + if (parts.size > 1) { + parts[1].split("&").firstOrNull() + } else null + } + // Short URL: https://youtu.be/VIDEO_ID + url.contains("youtu.be/") -> { + val parts = url.split("youtu.be/") + if (parts.size > 1) { + parts[1].split("?").firstOrNull()?.split("&")?.firstOrNull() + } else null + } + // Embed URL: https://www.youtube.com/embed/VIDEO_ID + url.contains("/embed/") -> { + val parts = url.split("/embed/") + if (parts.size > 1) { + parts[1].split("?").firstOrNull()?.split("&")?.firstOrNull() + } else null + } + // v parameter: https://youtube.com/v/VIDEO_ID + url.contains("/v/") -> { + val parts = url.split("/v/") + if (parts.size > 1) { + parts[1].split("?").firstOrNull()?.split("&")?.firstOrNull() + } else null + } + else -> { + android.util.Log.w("YoutubePlayer", "Unknown YouTube URL format: $url") + null + } + } + } catch (e: Exception) { + android.util.Log.e("YoutubePlayer", "Error extracting video ID from: $url", e) + null + } + } + companion object { private const val ARG_BLOCK_VIDEO_URL = "blockVideoUrl" private const val ARG_VIDEO_TIME = "videoTime" diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt index c1cd33aa3..088d51553 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt @@ -4,6 +4,10 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.webkit.WebSettings +import android.webkit.WebChromeClient +import androidx.appcompat.app.AppCompatActivity + import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -52,7 +56,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) private var blockId = "" - private var isPlayerInitialized = false + private val youtubeTrackerListener = YouTubePlayerTracker() @@ -137,6 +141,15 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) binding.connectionError.isVisible = !viewModel.hasInternetConnection + // Hide WebView initially - it's only a fallback + binding.fallbackWebview?.visibility = View.GONE + binding.youtubePlayerView.visibility = View.VISIBLE + + // Initialize YouTube player library (original approach) + initializeYoutubePlayer() + } + + private fun initializeYoutubePlayer() { lifecycle.addObserver(binding.youtubePlayerView) val options = IFramePlayerOptions.Builder() @@ -146,6 +159,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) val listener = object : AbstractYouTubePlayerListener() { var isMarkBlockCompletedCalled = false + var hasError = false override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) { super.onCurrentSecond(youTubePlayer, second) @@ -178,8 +192,21 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) ) } + override fun onError(youTubePlayer: YouTubePlayer, error: PlayerConstants.PlayerError) { + super.onError(youTubePlayer, error) + // If YouTube player fails, switch to WebView fallback + if (!hasError) { + hasError = true + android.util.Log.w("YoutubePlayer", "YouTube player error: $error - switching to WebView fallback") + switchToWebViewFallback() + } + } + override fun onReady(youTubePlayer: YouTubePlayer) { super.onReady(youTubePlayer) + android.util.Log.d("YoutubePlayer", "=== onReady called ===") + android.util.Log.d("YoutubePlayer", "Video URL: ${viewModel.videoUrl}") + _youTubePlayer = youTubePlayer if (_binding != null) { val defPlayerUiController = DefaultPlayerUiController( @@ -200,12 +227,17 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) } viewModel.videoUrl.split("watch?v=").getOrNull(1)?.let { videoId -> + android.util.Log.d("YoutubePlayer", "Extracted video ID: '$videoId'") if (viewModel.isPlaying && isResumed) { + android.util.Log.d("YoutubePlayer", "Action: loadVideo (autoplay)") + android.util.Log.d("YoutubePlayer", "Video ID: '$videoId', Start time: ${viewModel.getCurrentVideoTime() / 1000f} sec, isResumed: $isResumed") youTubePlayer.loadVideo( videoId, viewModel.getCurrentVideoTime().toFloat() / 1000 ) } else { + android.util.Log.d("YoutubePlayer", "Action: cueVideo (user must click play)") + android.util.Log.d("YoutubePlayer", "Video ID: '$videoId', Start time: ${viewModel.getCurrentVideoTime() / 1000f} sec") youTubePlayer.cueVideo( videoId, viewModel.getCurrentVideoTime().toFloat() / 1000 @@ -222,10 +254,18 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) } } - if (!isPlayerInitialized) { - binding.youtubePlayerView.initialize(listener, options) - isPlayerInitialized = true - } + binding.youtubePlayerView.initialize(listener, options) + } + + private fun switchToWebViewFallback() { + android.util.Log.w("YoutubePlayer", "Switching to WebView fallback") + + // Hide YouTube player, show WebView + binding.youtubePlayerView.visibility = View.GONE + binding.fallbackWebview?.visibility = View.VISIBLE + + // Setup and load WebView + setupWebViewPlayer() } override fun onPause() { @@ -234,12 +274,381 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) } override fun onDestroyView() { - isPlayerInitialized = false _youTubePlayer = null super.onDestroyView() _binding = null } + private fun setupWebViewPlayer() { + val videoId = extractYouTubeVideoId(viewModel.videoUrl) ?: run { + android.util.Log.e("YoutubePlayer", "Failed to extract video ID from: ${viewModel.videoUrl}") + return + } + + android.util.Log.d("YoutubePlayer", "Setting up WebView for video: $videoId") + android.util.Log.d("YoutubePlayer", "Full URL: ${viewModel.videoUrl}") + + // Hide YouTube player library view, show WebView + binding.youtubePlayerView.visibility = View.GONE + binding.fallbackWebview?.visibility = View.VISIBLE + + // Configure WebView with better settings for video playback + binding.fallbackWebview?.apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.mediaPlaybackRequiresUserGesture = false + settings.setSupportZoom(false) + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + settings.allowFileAccess = true + settings.allowContentAccess = true + settings.cacheMode = WebSettings.LOAD_DEFAULT + settings.databaseEnabled = true + + // Enable fullscreen support + settings.javaScriptCanOpenWindowsAutomatically = true + + // Set black background like native player + setBackgroundColor(android.graphics.Color.BLACK) + + // Add WebChromeClient for fullscreen support + webChromeClient = object : WebChromeClient() { + private var customView: View? = null + private var customViewCallback: CustomViewCallback? = null + + override fun onShowCustomView(view: View?, callback: CustomViewCallback?) { + if (customView != null) { + callback?.onCustomViewHidden() + return + } + + customView = view + customViewCallback = callback + + // Enter fullscreen + (activity as? AppCompatActivity)?.let { activity -> + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { + activity.window.insetsController?.let { controller -> + controller.hide(android.view.WindowInsets.Type.statusBars() or android.view.WindowInsets.Type.navigationBars()) + controller.systemBarsBehavior = android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } else { + @Suppress("DEPRECATION") + activity.window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_FULLSCREEN + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + ) + } + + val contentView = activity.findViewById(android.R.id.content) + contentView.addView(view, ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )) + } + } + + override fun onHideCustomView() { + (activity as? AppCompatActivity)?.let { activity -> + val contentView = activity.findViewById(android.R.id.content) + contentView.removeView(customView) + + // Exit fullscreen + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { + activity.window.insetsController?.show(android.view.WindowInsets.Type.statusBars() or android.view.WindowInsets.Type.navigationBars()) + } else { + @Suppress("DEPRECATION") + activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE + } + } + + customView = null + customViewCallback?.onCustomViewHidden() + customViewCallback = null + } + } + + // Add WebViewClient to handle errors and block external navigation + webViewClient = object : android.webkit.WebViewClient() { + @Deprecated("Deprecated in Java") + override fun onReceivedError( + view: android.webkit.WebView?, + errorCode: Int, + description: String?, + failingUrl: String? + ) { + android.util.Log.e("YoutubePlayer", "WebView error: $errorCode - $description") + // Let YouTube's own error handling show in the iframe + } + + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading( + view: android.webkit.WebView?, + url: String? + ): Boolean { + // Block all navigation attempts - keep user in the video player + if (url != null) { + android.util.Log.d("YoutubePlayer", "Blocked navigation to: $url") + + // Check if it's trying to navigate to YouTube website, channel, or external links + when { + url.contains("youtube.com/channel") || + url.contains("youtube.com/user") || + url.contains("youtube.com/c/") || + url.contains("youtube.com/@") || + url.contains("youtube.com/watch") || + url.contains("youtube.com/playlist") || + url.contains("youtu.be") -> { + android.util.Log.w("YoutubePlayer", "Blocked YouTube navigation attempt") + return true // Block navigation + } + url.startsWith("http") && !url.contains("staging.sherab.org") -> { + android.util.Log.w("YoutubePlayer", "Blocked external navigation attempt") + return true // Block any external navigation + } + } + } + return false // Allow internal navigation (shouldn't happen in our case) + } + + override fun shouldOverrideUrlLoading( + view: android.webkit.WebView?, + request: android.webkit.WebResourceRequest? + ): Boolean { + // Modern API version - also block navigation + val url = request?.url?.toString() + if (url != null) { + android.util.Log.d("YoutubePlayer", "Blocked navigation to: $url") + + when { + url.contains("youtube.com/channel") || + url.contains("youtube.com/user") || + url.contains("youtube.com/c/") || + url.contains("youtube.com/@") || + url.contains("youtube.com/watch") || + url.contains("youtube.com/playlist") || + url.contains("youtu.be") -> { + android.util.Log.w("YoutubePlayer", "Blocked YouTube navigation attempt") + return true + } + url.startsWith("http") && !url.contains("staging.sherab.org") -> { + android.util.Log.w("YoutubePlayer", "Blocked external navigation attempt") + return true + } + } + } + return false + } + } + } + + // Try primary embed approach first + loadYouTubeEmbed(videoId) + } + + private fun loadYouTubeEmbed(videoId: String) { + // Calculate start time in seconds + val startTime = (viewModel.getCurrentVideoTime() / 1000).toInt() + + // Create HTML with YouTube IFrame Player API for native-like experience + val embedHtml = """ + + + + + + + +
+
+
+ + + + + + """.trimIndent() + + // Load the HTML + binding.fallbackWebview?.loadDataWithBaseURL( + "https://staging.sherab.org", + embedHtml, + "text/html", + "UTF-8", + null + ) + + android.util.Log.d("YoutubePlayer", "✅ WebView player loaded with YouTube IFrame API") + } + + + private fun extractYouTubeVideoId(url: String): String? { + return try { + when { + // Standard watch URL: https://www.youtube.com/watch?v=VIDEO_ID + url.contains("watch?v=") -> { + val parts = url.split("watch?v=") + if (parts.size > 1) { + parts[1].split("&").firstOrNull() + } else null + } + // Short URL: https://youtu.be/VIDEO_ID + url.contains("youtu.be/") -> { + val parts = url.split("youtu.be/") + if (parts.size > 1) { + parts[1].split("?").firstOrNull()?.split("&")?.firstOrNull() + } else null + } + // Embed URL: https://www.youtube.com/embed/VIDEO_ID + url.contains("/embed/") -> { + val parts = url.split("/embed/") + if (parts.size > 1) { + parts[1].split("?").firstOrNull()?.split("&")?.firstOrNull() + } else null + } + // v parameter: https://youtube.com/v/VIDEO_ID + url.contains("/v/") -> { + val parts = url.split("/v/") + if (parts.size > 1) { + parts[1].split("?").firstOrNull()?.split("&")?.firstOrNull() + } else null + } + else -> { + android.util.Log.w("YoutubePlayer", "Unknown YouTube URL format: $url") + null + } + } + } catch (e: Exception) { + android.util.Log.e("YoutubePlayer", "Error extracting video ID from: $url", e) + null + } + } + companion object { private const val ARG_VIDEO_URL = "videoUrl" diff --git a/course/src/main/res/layout/fragment_youtube_video_unit.xml b/course/src/main/res/layout/fragment_youtube_video_unit.xml index 6af485d49..fa5e7c29b 100644 --- a/course/src/main/res/layout/fragment_youtube_video_unit.xml +++ b/course/src/main/res/layout/fragment_youtube_video_unit.xml @@ -33,6 +33,12 @@ android:layout_height="match_parent" app:enableAutomaticInitialization="false" /> + +