diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 74c1b0761..94d175e04 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -52,6 +52,10 @@
android:authorities="${applicationId}.firebaseinitprovider"
android:exported="false"
tools:node="remove" />
+
+
\ No newline at end of file
diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt
index b074d3165..29181da88 100644
--- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt
+++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt
@@ -17,7 +17,6 @@ import org.openedx.course.presentation.outline.CourseOutlineViewModel
import org.openedx.discovery.presentation.search.CourseSearchViewModel
import org.openedx.course.presentation.section.CourseSectionViewModel
import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel
-import org.openedx.course.presentation.unit.video.VideoUnitViewModel
import org.openedx.course.presentation.unit.video.VideoViewModel
import org.openedx.course.presentation.videos.CourseVideoViewModel
import org.openedx.dashboard.data.repository.DashboardRepository
@@ -46,6 +45,8 @@ import org.openedx.profile.presentation.settings.video.VideoSettingsViewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
+import org.openedx.course.presentation.unit.video.EncodedVideoUnitViewModel
+import org.openedx.course.presentation.unit.video.VideoUnitViewModel
import org.openedx.profile.presentation.anothers_account.AnothersProfileViewModel
val screenModule = module {
@@ -86,6 +87,7 @@ val screenModule = module {
viewModel { (courseId: String) -> CourseVideoViewModel(courseId, get(), get(), get(), get(), get(), get(), get()) }
viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get()) }
viewModel { (courseId: String) -> VideoUnitViewModel(courseId, get(), get(), get(), get()) }
+ viewModel { (courseId: String, blockId: String) -> EncodedVideoUnitViewModel(courseId, blockId, get(), get(), get(), get(), get()) }
viewModel { (courseId:String, handoutsType: String) -> HandoutsViewModel(courseId, handoutsType, get()) }
viewModel { CourseSearchViewModel(get(), get(), get()) }
viewModel { SelectDialogViewModel(get()) }
diff --git a/build.gradle b/build.gradle
index b4ce2e34a..81792e08f 100644
--- a/build.gradle
+++ b/build.gradle
@@ -27,7 +27,7 @@ ext {
fragment_version = "1.6.1"
constraintlayout_version = "2.1.4"
viewpager2_version = "1.0.0"
- media3 = "1.1.1"
+ media3_version = "1.1.1"
youtubeplayer_version = "11.1.0"
firebase_version = "32.1.0"
diff --git a/core/src/main/java/org/openedx/core/domain/model/Block.kt b/core/src/main/java/org/openedx/core/domain/model/Block.kt
index 36163e1c1..81b030db0 100644
--- a/core/src/main/java/org/openedx/core/domain/model/Block.kt
+++ b/core/src/main/java/org/openedx/core/domain/model/Block.kt
@@ -24,7 +24,7 @@ data class Block(
val descendants: List,
val descendantsType: BlockType,
val completion: Double,
- val downloadModel: DownloadModel? = null
+ val downloadModel: DownloadModel? = null,
) {
val isDownloadable: Boolean
get() {
@@ -36,6 +36,7 @@ data class Block(
BlockType.VIDEO -> {
FileType.VIDEO
}
+
else -> {
FileType.UNKNOWN
}
@@ -55,7 +56,7 @@ data class StudentViewData(
val duration: Any,
val transcripts: HashMap?,
val encodedVideos: EncodedVideos?,
- val topicId: String
+ val topicId: String,
)
data class EncodedVideos(
@@ -64,7 +65,7 @@ data class EncodedVideos(
var fallback: VideoInfo?,
var desktopMp4: VideoInfo?,
var mobileHigh: VideoInfo?,
- var mobileLow: VideoInfo?
+ var mobileLow: VideoInfo?,
) {
val hasDownloadableVideo: Boolean
get() = isPreferredVideoInfo(hls) ||
@@ -73,6 +74,21 @@ data class EncodedVideos(
isPreferredVideoInfo(mobileHigh) ||
isPreferredVideoInfo(mobileLow)
+ val hasNonYoutubeVideo: Boolean
+ get() = mobileHigh?.url != null
+ || mobileLow?.url != null
+ || desktopMp4?.url != null
+ || hls?.url != null
+ || fallback?.url != null
+
+ val videoUrl: String
+ get() = mobileHigh?.url
+ ?: mobileLow?.url
+ ?: desktopMp4?.url
+ ?: hls?.url
+ ?: fallback?.url
+ ?: ""
+
fun getPreferredVideoInfoForDownloading(preferredVideoQuality: VideoQuality): VideoInfo? {
var preferredVideoInfo = when (preferredVideoQuality) {
VideoQuality.OPTION_360P -> mobileLow
@@ -126,9 +142,9 @@ data class EncodedVideos(
data class VideoInfo(
val url: String,
- val fileSize: Int
+ val fileSize: Int,
)
data class BlockCounts(
- val video: Int
+ val video: Int,
)
diff --git a/course/build.gradle b/course/build.gradle
index f42807f81..d7b0f2cab 100644
--- a/course/build.gradle
+++ b/course/build.gradle
@@ -61,8 +61,9 @@ dependencies {
implementation project(path: ':core')
implementation project(path: ':discussion')
implementation "com.pierfrancescosoffritti.androidyoutubeplayer:core:$youtubeplayer_version"
- implementation "androidx.media3:media3-exoplayer:$media3"
- implementation "androidx.media3:media3-ui:$media3"
+ implementation "androidx.media3:media3-exoplayer:$media3_version"
+ implementation "androidx.media3:media3-ui:$media3_version"
+ implementation "androidx.media3:media3-cast:$media3_version"
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt
index b0ac5a952..ee7ea2363 100644
--- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt
+++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt
@@ -14,8 +14,8 @@ import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment
class CourseUnitContainerAdapter(
fragment: Fragment,
+ val blocks: List,
private val viewModel: CourseUnitContainerViewModel,
- private var blocks: List
) : FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = blocks.size
diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt
index 0b4391892..43aaa384d 100644
--- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt
+++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt
@@ -3,8 +3,10 @@ package org.openedx.course.presentation.unit.container
import android.content.res.Configuration
import android.os.Bundle
import android.os.SystemClock
+import android.view.LayoutInflater
import android.view.View
import androidx.compose.foundation.layout.statusBarsPadding
+import android.view.ViewGroup
import androidx.compose.foundation.layout.width
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.getValue
@@ -16,8 +18,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.os.bundleOf
+import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.viewpager2.widget.ViewPager2
+import com.google.android.gms.cast.framework.CastButtonFactory
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
@@ -25,7 +29,6 @@ import org.openedx.core.BlockType
import org.openedx.core.extension.serializable
import org.openedx.core.presentation.course.CourseViewMode
import org.openedx.core.presentation.global.InsetHolder
-import org.openedx.core.presentation.global.viewBinding
import org.openedx.core.ui.BackBtn
import org.openedx.core.ui.rememberWindowSize
import org.openedx.core.ui.theme.OpenEdXTheme
@@ -41,7 +44,9 @@ import org.openedx.course.presentation.ui.VideoTitle
class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_container) {
- private val binding by viewBinding(FragmentCourseUnitContainerBinding::bind)
+ private val binding: FragmentCourseUnitContainerBinding
+ get() = _binding!!
+ private var _binding: FragmentCourseUnitContainerBinding? = null
private val viewModel by viewModel {
parametersOf(requireArguments().getString(ARG_COURSE_ID, ""))
@@ -55,6 +60,19 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta
private var lastClickTime = 0L
+ private val onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {
+ override fun onPageSelected(position: Int) {
+ super.onPageSelected(position)
+ val blocks = viewModel.getUnitBlocks()
+ blocks.getOrNull(position)?.let { currentBlock ->
+ val encodedVideo = currentBlock.studentViewData?.encodedVideos
+ binding.mediaRouteButton.isVisible = currentBlock.type == BlockType.VIDEO
+ && encodedVideo?.hasNonYoutubeVideo == true
+ && !encodedVideo.videoUrl.endsWith(".m3u8")
+ }
+ }
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycle.addObserver(viewModel)
@@ -63,6 +81,15 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta
viewModel.setupCurrentIndex(blockId)
}
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ _binding = FragmentCourseUnitContainerBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -77,6 +104,9 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta
binding.cvCount.layoutParams = countParams
}
+ binding.mediaRouteButton.setAlwaysVisible(true)
+ CastButtonFactory.setUpMediaRouteButton(requireContext(), binding.mediaRouteButton)
+
initViewPager()
if (savedInstanceState == null) {
val currentBlockIndex = viewModel.getUnitBlocks().indexOfFirst {
@@ -166,7 +196,12 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta
}
}
}
+ }
+ override fun onDestroyView() {
+ binding.viewPager.unregisterOnPageChangeCallback(onPageChangeCallback)
+ super.onDestroyView()
+ _binding = null
}
private fun updateNavigationButtons(updatedData: (String, Boolean, Boolean) -> Unit) {
@@ -193,9 +228,10 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta
private fun initViewPager() {
binding.viewPager.orientation = ViewPager2.ORIENTATION_VERTICAL
binding.viewPager.offscreenPageLimit = 1
- adapter = CourseUnitContainerAdapter(this, viewModel, viewModel.getUnitBlocks())
+ adapter = CourseUnitContainerAdapter(this, viewModel.getUnitBlocks(), viewModel)
binding.viewPager.adapter = adapter
binding.viewPager.isUserInputEnabled = false
+ binding.viewPager.registerOnPageChangeCallback(onPageChangeCallback)
}
private fun handlePrevClick(buttonChanged: (String, Boolean, Boolean) -> Unit) {
diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt
new file mode 100644
index 000000000..dbb6b385e
--- /dev/null
+++ b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt
@@ -0,0 +1,99 @@
+package org.openedx.course.presentation.unit.video
+
+import android.content.Context
+import androidx.lifecycle.LifecycleOwner
+import androidx.media3.cast.CastPlayer
+import androidx.media3.common.Player
+import androidx.media3.exoplayer.ExoPlayer
+import com.google.android.gms.cast.framework.CastContext
+import org.openedx.core.module.TranscriptManager
+import org.openedx.core.system.connection.NetworkConnection
+import org.openedx.core.system.notifier.CourseNotifier
+import org.openedx.course.data.repository.CourseRepository
+import java.util.concurrent.Executors
+
+class EncodedVideoUnitViewModel(
+ courseId: String,
+ val blockId: String,
+ courseRepository: CourseRepository,
+ notifier: CourseNotifier,
+ networkConnection: NetworkConnection,
+ transcriptManager: TranscriptManager,
+ private val context: Context,
+) : VideoUnitViewModel(
+ courseId,
+ courseRepository,
+ notifier,
+ networkConnection,
+ transcriptManager
+) {
+
+ var exoPlayer: ExoPlayer? = null
+ private set
+ var castPlayer: CastPlayer? = null
+ private set
+ private var castContext: CastContext? = null
+
+ var isCastActive = false
+
+ var isPlayerSetUp = false
+
+ private val exoPlayerListener = object : Player.Listener {
+ override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
+ super.onPlayWhenReadyChanged(playWhenReady, reason)
+ isPlaying = playWhenReady
+ }
+
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ super.onPlaybackStateChanged(playbackState)
+ if (playbackState == Player.STATE_ENDED) {
+ markBlockCompleted(blockId)
+ }
+ }
+ }
+
+ @androidx.media3.common.util.UnstableApi
+ override fun onCreate(owner: LifecycleOwner) {
+ super.onCreate(owner)
+
+ if (exoPlayer != null) {
+ return
+ }
+
+ exoPlayer = ExoPlayer.Builder(context)
+ .build()
+
+ val executor = Executors.newSingleThreadExecutor()
+ castContext = CastContext.getSharedInstance(context, executor).result
+ castContext?.let {
+ castPlayer = CastPlayer(it)
+ }
+ }
+
+ override fun onResume(owner: LifecycleOwner) {
+ super.onResume(owner)
+ exoPlayer?.addListener(exoPlayerListener)
+ }
+
+ override fun onPause(owner: LifecycleOwner) {
+ super.onPause(owner)
+ exoPlayer?.removeListener(exoPlayerListener)
+ exoPlayer?.pause()
+ }
+
+ fun getActivePlayer(): Player? {
+ return if (isCastActive) {
+ castPlayer
+ } else {
+ exoPlayer
+ }
+ }
+
+ @androidx.media3.common.util.UnstableApi
+ fun releasePlayers() {
+ exoPlayer?.release()
+ castPlayer?.release()
+ exoPlayer = null
+ castPlayer = null
+ }
+}
\ No newline at end of file
diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt
index 65f94eba0..2d82c3f5b 100644
--- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt
+++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt
@@ -17,10 +17,15 @@ import androidx.compose.ui.Modifier
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
+import androidx.media3.cast.CastPlayer
+import androidx.media3.cast.SessionAvailabilityListener
import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.window.layout.WindowMetricsCalculator
+import com.google.android.gms.cast.framework.CastContext
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
@@ -40,50 +45,38 @@ import org.openedx.course.presentation.CourseRouter
import org.openedx.course.presentation.ui.ConnectionErrorView
import org.openedx.course.presentation.ui.VideoSubtitles
import org.openedx.course.presentation.ui.VideoTitle
+import java.util.concurrent.Executors
import kotlin.math.roundToInt
class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) {
private val binding by viewBinding(FragmentVideoUnitBinding::bind)
- private val viewModel by viewModel {
- parametersOf(requireArguments().getString(ARG_COURSE_ID, ""))
+ private val viewModel by viewModel {
+ parametersOf(
+ requireArguments().getString(ARG_COURSE_ID, ""),
+ requireArguments().getString(ARG_BLOCK_ID, ""),
+ )
}
private val router by inject()
- private var exoPlayer: ExoPlayer? = null
private var windowSize: WindowSize? = null
- private var blockId = ""
-
private val handler = Handler(Looper.getMainLooper())
private var videoTimeRunnable: Runnable = object : Runnable {
override fun run() {
- exoPlayer?.let {
+ viewModel.getActivePlayer()?.let {
if (it.isPlaying) {
viewModel.setCurrentVideoTime(it.currentPosition)
}
val completePercentage = it.currentPosition.toDouble() / it.duration.toDouble()
if (completePercentage >= 0.8f) {
- viewModel.markBlockCompleted(blockId)
+ viewModel.markBlockCompleted(viewModel.blockId)
}
}
handler.postDelayed(this, 200)
}
}
- private val exoPlayerListener = object : Player.Listener {
- override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
- super.onPlayWhenReadyChanged(playWhenReady, reason)
- viewModel.isPlaying = playWhenReady
- }
- override fun onPlaybackStateChanged(playbackState: Int) {
- super.onPlaybackStateChanged(playbackState)
- if (playbackState == Player.STATE_ENDED) {
- viewModel.markBlockCompleted(blockId)
- }
- }
- }
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
windowSize = computeWindowSizeClasses()
@@ -91,12 +84,10 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) {
handler.post(videoTimeRunnable)
requireArguments().apply {
viewModel.videoUrl = getString(ARG_VIDEO_URL, "")
- viewModel.transcripts =
- stringToObject