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 f61cf393f..979b9589c 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -88,9 +88,9 @@ val screenModule = module { viewModel { (courseId: String) -> CourseSectionViewModel(get(), get(), get(), get(), get(), get(), get(), get(), courseId) } viewModel { (courseId: String) -> CourseUnitContainerViewModel(get(), get(), get(), courseId) } viewModel { (courseId: String) -> CourseVideoViewModel(courseId, get(), get(), get(), get(), get(), get(), get()) } - viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get()) } + viewModel { (courseId: String) -> VideoViewModel(courseId, get(), 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, blockId: String) -> EncodedVideoUnitViewModel(courseId, blockId, get(), 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/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt b/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt index 9e2b84ddb..07241824b 100644 --- a/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt +++ b/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt @@ -11,11 +11,11 @@ data class VideoSettings( } } -enum class VideoQuality(val titleResId: Int) { - AUTO(R.string.auto_recommended_text), - OPTION_360P(R.string.video_quality_p360), - OPTION_540P(R.string.video_quality_p540), - OPTION_720P(R.string.video_quality_p720); +enum class VideoQuality(val titleResId: Int, val width: Int, val height: Int) { + AUTO(R.string.auto_recommended_text, 0, 0), + OPTION_360P(R.string.video_quality_p360, 640, 360), + OPTION_540P(R.string.video_quality_p540, 960, 540), + OPTION_720P(R.string.video_quality_p720, 1280, 720); val value: String = this.name.replace("OPTION_", "").lowercase() } diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml index 581b19366..d5623089b 100644 --- a/core/src/main/res/values-uk/strings.xml +++ b/core/src/main/res/values-uk/strings.xml @@ -21,7 +21,7 @@ Пароль незабаром Авто (Рекомендовано) - 360p (Найменший розмір) + 360p (Менше використання трафіку) 540p 720p (Найкраща якість) Офлайн diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 975d96869..f2584d60a 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -27,7 +27,7 @@ Reload Downloading in progress Auto (Recommended) - 360p (Smallest file size) + 360p (Lower data usage) 540p 720p (Best quality) User account is not activated. Please activate your account first. diff --git a/course/build.gradle b/course/build.gradle index d7b0f2cab..fdf14b83d 100644 --- a/course/build.gradle +++ b/course/build.gradle @@ -62,6 +62,7 @@ dependencies { implementation project(path: ':discussion') implementation "com.pierfrancescosoffritti.androidyoutubeplayer:core:$youtubeplayer_version" implementation "androidx.media3:media3-exoplayer:$media3_version" + implementation "androidx.media3:media3-exoplayer-hls:$media3_version" implementation "androidx.media3:media3-ui:$media3_version" implementation "androidx.media3:media3-cast:$media3_version" 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 index dbb6b385e..5f613bc83 100644 --- 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 @@ -4,8 +4,19 @@ import android.content.Context import androidx.lifecycle.LifecycleOwner import androidx.media3.cast.CastPlayer import androidx.media3.common.Player +import androidx.media3.common.util.Clock +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.DefaultAnalyticsCollector +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.trackselection.AdaptiveTrackSelection +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter +import androidx.media3.extractor.DefaultExtractorsFactory import com.google.android.gms.cast.framework.CastContext +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.VideoQuality import org.openedx.core.module.TranscriptManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier @@ -19,6 +30,7 @@ class EncodedVideoUnitViewModel( notifier: CourseNotifier, networkConnection: NetworkConnection, transcriptManager: TranscriptManager, + val preferencesManager: CorePreferences, private val context: Context, ) : VideoUnitViewModel( courseId, @@ -55,13 +67,10 @@ class EncodedVideoUnitViewModel( @androidx.media3.common.util.UnstableApi override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - if (exoPlayer != null) { return } - - exoPlayer = ExoPlayer.Builder(context) - .build() + initPlayer() val executor = Executors.newSingleThreadExecutor() castContext = CastContext.getSharedInstance(context, executor).result @@ -73,6 +82,7 @@ class EncodedVideoUnitViewModel( override fun onResume(owner: LifecycleOwner) { super.onResume(owner) exoPlayer?.addListener(exoPlayerListener) + getActivePlayer()?.playWhenReady = isPlaying } override fun onPause(owner: LifecycleOwner) { @@ -96,4 +106,33 @@ class EncodedVideoUnitViewModel( exoPlayer = null castPlayer = null } + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + fun initPlayer() { + val videoQuality = getVideoQuality() + val params = DefaultTrackSelector.Parameters.Builder(context) + .apply { + if (videoQuality != VideoQuality.AUTO) { + setMaxVideoSize(videoQuality.width, videoQuality.height) + setViewportSize(videoQuality.width, videoQuality.height, false) + } + } + .build() + + val factory = AdaptiveTrackSelection.Factory() + val selector = DefaultTrackSelector(context, factory) + selector.parameters = params + + exoPlayer = ExoPlayer.Builder( + context, + DefaultRenderersFactory(context), + DefaultMediaSourceFactory(context, DefaultExtractorsFactory()), + selector, + DefaultLoadControl(), + DefaultBandwidthMeter.getSingletonInstance(context), + DefaultAnalyticsCollector(Clock.DEFAULT) + ).build() + } + + private fun getVideoQuality() = preferencesManager.videoSettings.videoQuality } \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt index 20ef5629c..321f8ad8f 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt @@ -10,9 +10,21 @@ import androidx.fragment.app.Fragment import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.common.util.Clock +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.DefaultAnalyticsCollector +import androidx.media3.exoplayer.hls.HlsMediaSource +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.trackselection.AdaptiveTrackSelection +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter +import androidx.media3.extractor.DefaultExtractorsFactory import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.core.domain.model.VideoQuality import org.openedx.core.extension.requestApplyInsetsWhenAttached import org.openedx.core.presentation.global.viewBinding import org.openedx.course.R @@ -32,6 +44,7 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { super.onPlayWhenReadyChanged(playWhenReady, reason) viewModel.isPlaying = playWhenReady } + override fun onPlaybackStateChanged(playbackState: Int) { super.onPlaybackStateChanged(playbackState) if (playbackState == Player.STATE_ENDED) { @@ -74,14 +87,35 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { private fun initPlayer() { with(binding) { if (exoPlayer == null) { - exoPlayer = ExoPlayer.Builder(requireContext()) + val videoQuality = viewModel.getVideoQuality() + val params = DefaultTrackSelector.Parameters.Builder(requireContext()) + .apply { + if (videoQuality != VideoQuality.AUTO) { + setMaxVideoSize(videoQuality.width, videoQuality.height) + setViewportSize(videoQuality.width, videoQuality.height, false) + } + } .build() + + val factory = AdaptiveTrackSelection.Factory() + val selector = DefaultTrackSelector(requireContext(), factory) + selector.parameters = params + + exoPlayer = ExoPlayer.Builder( + requireContext(), + DefaultRenderersFactory(requireContext()), + DefaultMediaSourceFactory(requireContext(), DefaultExtractorsFactory()), + selector, + DefaultLoadControl(), + DefaultBandwidthMeter.getSingletonInstance(requireContext()), + DefaultAnalyticsCollector(Clock.DEFAULT) + ).build() } playerView.player = exoPlayer playerView.setShowNextButton(false) playerView.setShowPreviousButton(false) val mediaItem = MediaItem.fromUri(viewModel.videoUrl) - exoPlayer?.setMediaItem(mediaItem, viewModel.currentVideoTime) + setPlayerMedia(mediaItem) exoPlayer?.prepare() exoPlayer?.playWhenReady = viewModel.isPlaying ?: false @@ -100,6 +134,20 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { } } + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun setPlayerMedia(mediaItem: MediaItem) { + if (viewModel.videoUrl.endsWith(".m3u8")) { + val factory = DefaultDataSource.Factory(requireContext()) + val mediaSource: HlsMediaSource = HlsMediaSource.Factory(factory).createMediaSource(mediaItem) + exoPlayer?.setMediaSource(mediaSource, viewModel.currentVideoTime) + } else { + exoPlayer?.setMediaItem( + mediaItem, + viewModel.currentVideoTime + ) + } + } + private fun releasePlayer() { exoPlayer?.stop() exoPlayer?.release() 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 8077c537e..4218bc7e4 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 @@ -21,6 +21,8 @@ import androidx.media3.cast.SessionAvailabilityListener import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.window.layout.WindowMetricsCalculator import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -187,13 +189,9 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { .build() if (!viewModel.isPlayerSetUp) { - viewModel.getActivePlayer()?.setMediaItem( - mediaItem, - viewModel.getCurrentVideoTime() - ) + setPlayerMedia(mediaItem) viewModel.getActivePlayer()?.prepare() viewModel.getActivePlayer()?.playWhenReady = viewModel.isPlaying - viewModel.isPlayerSetUp = true } @@ -239,14 +237,11 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { } @UnstableApi - override fun onDestroyView() { - super.onDestroyView() + override fun onDestroy() { if (!requireActivity().isChangingConfigurations) { viewModel.releasePlayers() + viewModel.isPlayerSetUp = false } - } - - override fun onDestroy() { handler.removeCallbacks(videoTimeRunnable) super.onDestroy() } @@ -263,6 +258,20 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { } } + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun setPlayerMedia(mediaItem: MediaItem) { + if (viewModel.videoUrl.endsWith(".m3u8")) { + val factory = DefaultDataSource.Factory(requireContext()) + val mediaSource: HlsMediaSource = HlsMediaSource.Factory(factory).createMediaSource(mediaItem) + viewModel.exoPlayer?.setMediaSource(mediaSource, viewModel.getCurrentVideoTime()) + } else { + viewModel.getActivePlayer()?.setMediaItem( + mediaItem, + viewModel.getCurrentVideoTime() + ) + } + } + companion object { private const val ARG_BLOCK_ID = "blockId" private const val ARG_VIDEO_URL = "videoUrl" diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt index 79f4e8acc..bfcda93c8 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt @@ -7,11 +7,13 @@ import org.openedx.course.data.repository.CourseRepository import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseVideoPositionChanged import kotlinx.coroutines.launch +import org.openedx.core.data.storage.CorePreferences class VideoViewModel( private val courseId: String, private val courseRepository: CourseRepository, - private val notifier: CourseNotifier + private val notifier: CourseNotifier, + private val preferencesManager: CorePreferences ) : BaseViewModel() { var videoUrl = "" @@ -45,4 +47,5 @@ class VideoViewModel( } } + fun getVideoQuality() = preferencesManager.videoSettings.videoQuality } \ No newline at end of file diff --git a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt index 144698ec5..ce1799432 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt @@ -15,6 +15,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.data.storage.CorePreferences @OptIn(ExperimentalCoroutinesApi::class) class VideoViewModelTest { @@ -26,6 +27,7 @@ class VideoViewModelTest { private val courseRepository = mockk() private val notifier = mockk() + private val preferenceManager = mockk() @Before fun setUp() { @@ -39,7 +41,7 @@ class VideoViewModelTest { @Test fun `sendTime test`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier) + val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager) coEvery { notifier.send(CourseVideoPositionChanged("", 0, false)) } returns Unit viewModel.sendTime() advanceUntilIdle() @@ -49,7 +51,7 @@ class VideoViewModelTest { @Test fun `markBlockCompleted exception`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier) + val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager) coEvery { courseRepository.markBlocksCompletion( any(), @@ -69,7 +71,7 @@ class VideoViewModelTest { @Test fun `markBlockCompleted success`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier) + val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager) coEvery { courseRepository.markBlocksCompletion( any(), diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt index 7acb2a9c4..46c645a76 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt @@ -121,7 +121,7 @@ private fun VideoQualityScreen( Text( modifier = Modifier .fillMaxWidth(), - text = stringResource(id = profileR.string.profile_video_download_quality), + text = stringResource(id = profileR.string.profile_video_streaming_quality), color = MaterialTheme.appColors.textPrimary, textAlign = TextAlign.Center, style = MaterialTheme.appTypography.titleMedium diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt index ca86d48ec..747df792a 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt @@ -198,7 +198,7 @@ private fun VideoSettingsScreen( ) { Column(Modifier.weight(1f)) { Text( - text = stringResource(id = profileR.string.profile_video_download_quality), + text = stringResource(id = profileR.string.profile_video_streaming_quality), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium ) diff --git a/profile/src/main/res/values-uk/strings.xml b/profile/src/main/res/values-uk/strings.xml index 123d4aee6..1cbb0a60a 100644 --- a/profile/src/main/res/values-uk/strings.xml +++ b/profile/src/main/res/values-uk/strings.xml @@ -30,7 +30,7 @@ Налаштування відео Завантаження тільки через Wi-Fi Завантажуйте вміст лише тоді, коли ввімкнено wi-fi - Якість завантаження відео + Якість транслювання відео Видалити акаунт Ви впевнені, що бажаєте видалити свій акаунт? diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 9ad0c47c9..03f82fa8a 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -38,7 +38,7 @@ Video settings Wi-fi only download Only download content when wi-fi is turned on - Video download quality + Video streaming quality Leave profile? Leave Keep editing