diff --git a/course/src/main/java/in/testpress/course/fragments/BaseVideoWidgetFragment.kt b/course/src/main/java/in/testpress/course/fragments/BaseVideoWidgetFragment.kt index ffb35e4cc..c8523cf0d 100644 --- a/course/src/main/java/in/testpress/course/fragments/BaseVideoWidgetFragment.kt +++ b/course/src/main/java/in/testpress/course/fragments/BaseVideoWidgetFragment.kt @@ -4,6 +4,7 @@ import `in`.testpress.course.TestpressCourse import `in`.testpress.course.di.InjectorUtils import `in`.testpress.course.viewmodels.ContentViewModel import android.os.Bundle +import android.os.Handler import androidx.annotation.VisibleForTesting import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModel @@ -27,4 +28,12 @@ open class BaseVideoWidgetFragment : Fragment() { } open fun seekTo(milliSeconds: Long?) {} + + open fun setupQuiz( + positions: List, + positionsMs: LongArray, + callbackHandler: Handler + ) {} + open fun pauseVideo() {} + open fun playVideo() {} } \ No newline at end of file diff --git a/course/src/main/java/in/testpress/course/fragments/NativeVideoWidgetFragment.kt b/course/src/main/java/in/testpress/course/fragments/NativeVideoWidgetFragment.kt index 3252a5e42..0ea9034f8 100644 --- a/course/src/main/java/in/testpress/course/fragments/NativeVideoWidgetFragment.kt +++ b/course/src/main/java/in/testpress/course/fragments/NativeVideoWidgetFragment.kt @@ -9,6 +9,7 @@ import `in`.testpress.course.ui.ContentActivity.CONTENT_ID import `in`.testpress.course.util.ExoPlayerUtil import `in`.testpress.course.util.ExoplayerFullscreenHelper import android.os.Bundle +import android.os.Handler import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -148,4 +149,20 @@ class NativeVideoWidgetFragment : BaseVideoWidgetFragment() { super.onDestroy() exoplayerFullscreenHelper?.disableOrientationListener() } + + override fun setupQuiz( + positions: List, + positionsMs: LongArray, + callbackHandler: Handler + ) { + exoPlayerUtil?.setupQuiz(positions, positionsMs, callbackHandler) + } + + override fun pauseVideo() { + exoPlayerUtil?.pauseVideo() + } + + override fun playVideo() { + exoPlayerUtil?.playVideo() + } } \ No newline at end of file diff --git a/course/src/main/java/in/testpress/course/fragments/VideoContentFragment.kt b/course/src/main/java/in/testpress/course/fragments/VideoContentFragment.kt index 4236e546e..ea1420c52 100644 --- a/course/src/main/java/in/testpress/course/fragments/VideoContentFragment.kt +++ b/course/src/main/java/in/testpress/course/fragments/VideoContentFragment.kt @@ -18,6 +18,8 @@ import android.app.AlertDialog import android.content.Intent import android.graphics.Color import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -30,10 +32,15 @@ import androidx.core.text.HtmlCompat import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import `in`.testpress.course.repository.ContentRepository +import `in`.testpress.course.viewmodels.VideoQuizViewModel +import `in`.testpress.course.network.NetworkVideoQuestion +import `in`.testpress.enums.Status import io.netopen.hotbitmapgg.library.view.RingProgressBar import java.util.regex.Pattern -open class VideoContentFragment : BaseContentDetailFragment() { +open class VideoContentFragment : BaseContentDetailFragment(), VideoQuizDialogFragment.OnQuizCompleteListener { + protected lateinit var titleView: TextView protected lateinit var description: TextView protected lateinit var titleLayout: LinearLayout @@ -44,6 +51,15 @@ open class VideoContentFragment : BaseContentDetailFragment() { protected lateinit var instituteSettings: InstituteSettings; protected var remainingDownloadCount :Int? = null + private lateinit var contentRepository: ContentRepository + private lateinit var videoQuizViewModel: VideoQuizViewModel + + private val quizCallbackHandler = Handler(Looper.getMainLooper()) { message -> + val positionInSeconds = message.what.toLong() + handleQuizTrigger(positionInSeconds) + true + } + override var isBookmarkEnabled: Boolean get() = false set(value) {} @@ -56,6 +72,9 @@ open class VideoContentFragment : BaseContentDetailFragment() { return OfflineVideoViewModel(OfflineVideoRepository(requireContext())) as T } }).get(OfflineVideoViewModel::class.java) + + videoQuizViewModel = ViewModelProvider(this).get(VideoQuizViewModel::class.java) + contentRepository = ContentRepository(requireContext()) } override fun onCreateView( @@ -211,6 +230,7 @@ open class VideoContentFragment : BaseContentDetailFragment() { val transaction = childFragmentManager.beginTransaction() transaction.replace(R.id.video_widget_fragment, videoWidgetFragment) transaction.commit() + fetchVideoQuestions() } override fun onResume() { @@ -276,6 +296,69 @@ open class VideoContentFragment : BaseContentDetailFragment() { videoWidgetFragment.onActivityResult(requestCode, resultCode, data) } } + + + private fun fetchVideoQuestions() { + content.video?.id?.let { videoId -> + contentRepository.loadVideoQuestions(videoContentId = videoId).observe(viewLifecycleOwner, Observer { resource -> + when (resource.status) { + Status.SUCCESS -> { + resource.data?.let { questions -> + if (questions.isNotEmpty()) { + setupQuizLogic(questions) + } + } + } + Status.ERROR -> { + // Handle or log error + } + Status.LOADING -> { + // Handle or log error + } + } + }) + } + } + + private fun setupQuizLogic(questions: List) { + val validQuestions = questions.filter { q -> + !(q.question.type == "G" && q.question.answers.isNullOrEmpty()) + } + + videoQuizViewModel.setQuestions(validQuestions) + val positions = videoQuizViewModel.getUniquePositions() + val positionsMs = videoQuizViewModel.getUniquePositionMs() + videoWidgetFragment.setupQuiz( + positions, + positionsMs, + quizCallbackHandler + ) + } + + private fun handleQuizTrigger(position: Long) { + val question = videoQuizViewModel.getNextQuestionForPosition(position.toInt()) + + if (question == null) { + return + } + + videoWidgetFragment.pauseVideo() + + VideoQuizDialogFragment.newInstance(question) + .show(childFragmentManager, "VideoQuizDialogFragment") + } + + override fun onQuizCompleted(questionId: Long) { + val position = videoQuizViewModel.markQuestionAsCompleted(questionId) + val nextQuestion = videoQuizViewModel.getNextQuestionForPosition(position) + + if (nextQuestion != null) { + VideoQuizDialogFragment.newInstance(nextQuestion) + .show(childFragmentManager, "VideoQuizDialogFragment") + } else { + videoWidgetFragment.playVideo() + } + } } class VideoWidgetFragmentFactory { diff --git a/course/src/main/java/in/testpress/course/fragments/VideoQuizDialogFragment.kt b/course/src/main/java/in/testpress/course/fragments/VideoQuizDialogFragment.kt new file mode 100644 index 000000000..398239d06 --- /dev/null +++ b/course/src/main/java/in/testpress/course/fragments/VideoQuizDialogFragment.kt @@ -0,0 +1,355 @@ +package `in`.testpress.course.fragments + +import android.app.Dialog +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.os.Bundle +import android.text.InputType +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.widget.Button +import android.widget.CheckBox +import android.widget.EditText +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.RadioButton +import android.widget.RadioGroup +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import androidx.core.text.HtmlCompat +import androidx.fragment.app.DialogFragment +import com.google.android.flexbox.FlexWrap +import com.google.android.flexbox.FlexboxLayout +import com.google.gson.Gson +import `in`.testpress.course.R +import `in`.testpress.course.network.NetworkAnswer +import `in`.testpress.course.network.NetworkVideoQuestion +import java.util.regex.Pattern + +class VideoQuizDialogFragment : DialogFragment() { + + private lateinit var question: NetworkVideoQuestion + private var listener: OnQuizCompleteListener? = null + + private var answerViews = mutableListOf() + private var selectedAnswerIds = mutableListOf() + private var isCorrect = false + private var gapFillResults = mutableListOf() + private var checkButton: Button? = null + + interface OnQuizCompleteListener { + fun onQuizCompleted(questionId: Long) + } + + companion object { + private const val ARG_QUESTION = "ARG_QUESTION" + + fun newInstance(question: NetworkVideoQuestion): VideoQuizDialogFragment { + val fragment = VideoQuizDialogFragment() + val args = Bundle() + args.putString(ARG_QUESTION, Gson().toJson(question)) + fragment.arguments = args + return fragment + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + question = Gson().fromJson( + arguments?.getString(ARG_QUESTION), + NetworkVideoQuestion::class.java + ) + if (parentFragment is OnQuizCompleteListener) { + listener = parentFragment as OnQuizCompleteListener + } else { + throw RuntimeException("$parentFragment must implement OnQuizCompleteListener") + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = AlertDialog.Builder(requireActivity(), R.style.TestpressAppCompatAlertDialogStyle) + val inflater = requireActivity().layoutInflater + val view = inflater.inflate(R.layout.fragment_quiz_dialog, null) + + val questionText: TextView = view.findViewById(R.id.quiz_question_text) + val optionsContainer: LinearLayout = view.findViewById(R.id.quiz_options_container) + checkButton = view.findViewById(R.id.quiz_check_button) + val continueButton: Button = view.findViewById(R.id.quiz_continue_button) + val feedbackText: TextView = view.findViewById(R.id.quiz_feedback_text) + + if (question.question.type == "G") { + questionText.visibility = View.GONE + } else { + questionText.text = HtmlCompat.fromHtml( + question.question.questionHtml, + HtmlCompat.FROM_HTML_MODE_LEGACY + ) + } + + answerViews.clear() + buildQuestionUI(inflater.context, optionsContainer, question) + + checkButton?.isEnabled = false + checkButton?.setOnClickListener { + isCorrect = checkAnswers() + showFeedback(feedbackText, isCorrect) + feedbackText.visibility = View.VISIBLE + disableOptions() + checkButton?.visibility = View.GONE + continueButton.visibility = View.VISIBLE + } + + continueButton.setOnClickListener { + listener?.onQuizCompleted(question.id) + dismiss() + } + + builder.setView(view) + val dialog = builder.create() + dialog.setCancelable(false) + dialog.setCanceledOnTouchOutside(false) + return dialog + } + + private fun buildQuestionUI( + context: Context, + container: LinearLayout, + question: NetworkVideoQuestion + ) { + val inflater = LayoutInflater.from(context) + when (question.question.type) { + "R" -> { + val radioGroup = RadioGroup(context) + radioGroup.orientation = LinearLayout.VERTICAL + radioGroup.id = View.generateViewId() + + val radioButtons = mutableListOf() + + question.question.answers?.forEach { answer -> + val optionView = inflater.inflate(R.layout.list_item_quiz_radio, radioGroup, false) + val radioButton = optionView.findViewById(R.id.quiz_radio_button) + + radioButton.tag = answer + radioButton.text = HtmlCompat.fromHtml( + answer.textHtml, + HtmlCompat.FROM_HTML_MODE_LEGACY + ) + + radioButton.setOnCheckedChangeListener { buttonView, isChecked -> + if (isChecked) { + radioButtons.forEach { rb -> + if (rb != buttonView) { + rb.isChecked = false + } + } + } + checkButton?.isEnabled = radioButtons.any { it.isChecked } + } + + radioButtons.add(radioButton) + radioGroup.addView(optionView) + answerViews.add(optionView) + } + container.addView(radioGroup) + answerViews.add(radioGroup) + } + "C" -> { + question.question.answers?.forEach { answer -> + val optionView = inflater.inflate(R.layout.list_item_quiz_checkbox, container, false) + val checkBox = optionView.findViewById(R.id.quiz_check_box) + + checkBox.tag = answer + checkBox.text = HtmlCompat.fromHtml( + answer.textHtml, + HtmlCompat.FROM_HTML_MODE_LEGACY + ) + + checkBox.setOnCheckedChangeListener { _, _ -> + val hasSelection = answerViews.any { view -> + view.findViewById(R.id.quiz_check_box)?.isChecked == true + } + checkButton?.isEnabled = hasSelection + } + + container.addView(optionView) + answerViews.add(optionView) + } + } + "G" -> { + val flexboxLayout = FlexboxLayout(context) + flexboxLayout.flexWrap = FlexWrap.WRAP + flexboxLayout.alignItems = com.google.android.flexbox.AlignItems.CENTER + + val html = question.question.questionHtml + val cleanText = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY).toString().trim() + + val pattern = Pattern.compile("\\[(.*?)\\]") + val matcher = pattern.matcher(cleanText) + + val gapFillEditTexts = mutableListOf() + var lastEnd = 0 + while (matcher.find()) { + val textBefore = cleanText.substring(lastEnd, matcher.start()) + if (textBefore.isNotEmpty()) { + val textView = TextView(context) + textView.text = textBefore + textView.textSize = 18f + textView.setTextColor(ContextCompat.getColor(context, R.color.testpress_table_text)) + flexboxLayout.addView(textView) + } + + val editText = EditText(context) + editText.setSingleLine(true) + editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + editText.minEms = 3 + editText.setTextColor(ContextCompat.getColor(context, R.color.testpress_table_text)) + editText.setBackgroundResource(R.drawable.quiz_gap_border_normal) + + gapFillEditTexts.add(editText) + editText.addTextChangedListener(object : android.text.TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: android.text.Editable?) { + val allFilled = gapFillEditTexts.all { it.text.toString().trim().isNotEmpty() } + checkButton?.isEnabled = allFilled + } + }) + + flexboxLayout.addView(editText) + answerViews.add(editText) + + lastEnd = matcher.end() + } + + val textAfter = cleanText.substring(lastEnd) + if (textAfter.isNotEmpty()) { + val textView = TextView(context) + textView.text = textAfter + textView.textSize = 18f + textView.setTextColor(ContextCompat.getColor(context, R.color.testpress_table_text)) + flexboxLayout.addView(textView) + } + + container.addView(flexboxLayout) + } + } + } + + private fun checkAnswers(): Boolean { + selectedAnswerIds.clear() + gapFillResults.clear() + + val correctAnswers = question.question.answers + ?.filter { it.isCorrect } + ?.map { it.id } + ?.toSet() ?: emptySet() + + + when (question.question.type) { + "R" -> { + answerViews.forEach { view -> + val radioButton = view.findViewById(R.id.quiz_radio_button) + if (radioButton != null && radioButton.isChecked) { + val selectedAnswer = radioButton.tag as NetworkAnswer + selectedAnswerIds.add(selectedAnswer.id) + } + } + } + "C" -> { + answerViews.forEach { view -> + val checkBox = view.findViewById(R.id.quiz_check_box) + if (checkBox != null && checkBox.isChecked) { + val selectedAnswer = checkBox.tag as NetworkAnswer + selectedAnswerIds.add(selectedAnswer.id) + } + } + } + "G" -> { + val key = question.question.answers?.map { it.textHtml.trim() } ?: emptyList() + val userAnswers = answerViews.filterIsInstance().map { it.text.toString().trim() } + + var allCorrect = true + for (i in key.indices) { + val isBoxCorrect = key[i].equals(userAnswers.getOrNull(i), ignoreCase = true) + gapFillResults.add(isBoxCorrect) + if (!isBoxCorrect) { + allCorrect = false + } + } + return allCorrect + } + } + + val isMatch = correctAnswers.isNotEmpty() && correctAnswers == selectedAnswerIds.toSet() + return isMatch + } + + private fun disableOptions() { + answerViews.forEach { view -> + view.findViewById(R.id.quiz_radio_button)?.isEnabled = false + view.findViewById(R.id.quiz_check_box)?.isEnabled = false + if (view is EditText) { + view.isEnabled = false + } + if (view is RadioGroup) { + view.isEnabled = false + } + view.findViewById(R.id.option_root)?.isEnabled = false + } + } + + private fun showFeedback(feedbackText: TextView, isCorrect: Boolean) { + val context = feedbackText.context + if (isCorrect) { + feedbackText.text = "Correct!" + feedbackText.setTextColor(ContextCompat.getColor(context, R.color.testpress_green)) + } else { + feedbackText.text = "Incorrect." + feedbackText.setTextColor(ContextCompat.getColor(context, R.color.testpress_red_incorrect)) + } + + when (question.question.type) { + "R", "C" -> { + answerViews.forEach { view -> + val optionRoot = view.findViewById(R.id.option_root) + val icon = view.findViewById(R.id.quiz_icon) + + val button = view.findViewById(R.id.quiz_radio_button) + ?: view.findViewById(R.id.quiz_check_box) + + if (button == null || optionRoot == null) return@forEach + + val answer = button.tag as? NetworkAnswer ?: return@forEach + + if (answer.isCorrect) { + optionRoot.setBackgroundResource(R.drawable.quiz_option_border_correct) + icon.setImageResource(R.drawable.ic_quiz_check_green) + icon.visibility = View.VISIBLE + } else if (selectedAnswerIds.contains(answer.id)) { + optionRoot.setBackgroundResource(R.drawable.quiz_option_border_incorrect) + icon.setImageResource(R.drawable.ic_quiz_cancel_red) + icon.visibility = View.VISIBLE + } + } + } + "G" -> { + val editTexts = answerViews.filterIsInstance() + editTexts.forEachIndexed { index, editText -> + val isBoxCorrect = gapFillResults.getOrNull(index) ?: false + if (isBoxCorrect) { + editText.setBackgroundResource(R.drawable.quiz_gap_correct_border) + } else { + editText.setBackgroundResource(R.drawable.quiz_gap_incorrect_border) + } + } + + val correctAnswerString = "Correct answer is: " + + (question.question.answers?.joinToString(" , ") { it.textHtml } ?: "") + feedbackText.append("\n" + correctAnswerString) + } + } + } +} diff --git a/course/src/main/java/in/testpress/course/network/CourseService.kt b/course/src/main/java/in/testpress/course/network/CourseService.kt index 2281a7edc..1ec98c119 100644 --- a/course/src/main/java/in/testpress/course/network/CourseService.kt +++ b/course/src/main/java/in/testpress/course/network/CourseService.kt @@ -11,6 +11,7 @@ import `in`.testpress.exam.network.NetworkLanguage import `in`.testpress.models.TestpressApiResponse import `in`.testpress.models.greendao.Course import `in`.testpress.network.RetrofitCall +import `in`.testpress.course.network.NetworkVideoQuestionResponse import `in`.testpress.network.TestpressApiClient import `in`.testpress.v2_4.models.ApiResponse import `in`.testpress.v2_4.models.ContentsListResponse @@ -102,6 +103,11 @@ interface CourseService { @Path(value = "exam_id", encoded = true) examId: Long, @Body arguments: HashMap ): RetrofitCall> + + @GET("api/v2.5/video_contents/{video_content_id}/questions/") + fun getVideoQuestions( + @Path(value = "video_content_id", encoded = true) videoContentId: Long + ): RetrofitCall } @@ -119,6 +125,10 @@ class CourseNetwork(context: Context) : TestpressApiClient(context, TestpressSdk return getCourseService().createContentAttempt(contentId, queryParams) } + fun getVideoQuestions(videoContentId: Long): RetrofitCall { + return getCourseService().getVideoQuestions(videoContentId) + } + fun getContentAttempts(url: String): RetrofitCall> { return getCourseService().getContentAttempts(url) } diff --git a/course/src/main/java/in/testpress/course/network/NetworkVideoQuestion.kt b/course/src/main/java/in/testpress/course/network/NetworkVideoQuestion.kt new file mode 100644 index 000000000..6ba085531 --- /dev/null +++ b/course/src/main/java/in/testpress/course/network/NetworkVideoQuestion.kt @@ -0,0 +1,48 @@ +package `in`.testpress.course.network + +import com.google.gson.annotations.SerializedName + + +data class NetworkVideoQuestionResponse( + @SerializedName("results") + val results: List +) + +data class NetworkVideoQuestion( + @SerializedName("id") + val id: Long, + + @SerializedName("position") + val position: Int, + + @SerializedName("order") + val order: Int, + + @SerializedName("question") + val question: NetworkQuestion +) + +data class NetworkQuestion( + @SerializedName("id") + val id: Long, + + @SerializedName("type") + val type: String, // "R", "C", or "G" + + @SerializedName("question_html") + val questionHtml: String, + + @SerializedName("answers") + val answers: List? // Nullable for Gap-Fill (G) +) + +data class NetworkAnswer( + @SerializedName("id") + val id: Long, + + @SerializedName("is_correct") + val isCorrect: Boolean, + + @SerializedName("text_html") + val textHtml: String +) diff --git a/course/src/main/java/in/testpress/course/repository/ContentRepository.kt b/course/src/main/java/in/testpress/course/repository/ContentRepository.kt index 7b8062828..d93e1fd17 100644 --- a/course/src/main/java/in/testpress/course/repository/ContentRepository.kt +++ b/course/src/main/java/in/testpress/course/repository/ContentRepository.kt @@ -23,6 +23,8 @@ import `in`.testpress.network.RetrofitCall import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import `in`.testpress.course.network.NetworkVideoQuestion +import `in`.testpress.course.network.NetworkVideoQuestionResponse open class ContentRepository( val context: Context @@ -32,6 +34,7 @@ open class ContentRepository( val courseNetwork = CourseNetwork(context) private var contentAttempt: MutableLiveData> = MutableLiveData() + private val videoQuestions = MutableLiveData>>() fun loadContent( contentId: Long, @@ -108,6 +111,21 @@ open class ContentRepository( return contentAttempt } + fun loadVideoQuestions(videoContentId: Long): LiveData>> { + videoQuestions.value = Resource.loading(null) + courseNetwork.getVideoQuestions(videoContentId) + .enqueue(object : TestpressCallback() { + override fun onSuccess(result: NetworkVideoQuestionResponse) { + videoQuestions.value = Resource.success(result.results) + } + + override fun onException(exception: TestpressException) { + videoQuestions.value = Resource.error(exception, null) + } + }) + return videoQuestions + } + fun storeBookmarkIdToContent(bookmarkId: Long?, contentId: Long) { val content = getContentFromDB(contentId) content?.bookmarkId = bookmarkId diff --git a/course/src/main/java/in/testpress/course/util/ExoPlayerUtil.java b/course/src/main/java/in/testpress/course/util/ExoPlayerUtil.java index f1ba1322a..b1b4f3e08 100644 --- a/course/src/main/java/in/testpress/course/util/ExoPlayerUtil.java +++ b/course/src/main/java/in/testpress/course/util/ExoPlayerUtil.java @@ -46,6 +46,7 @@ import com.google.android.exoplayer2.PlaybackException; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.PlayerMessage; import com.google.android.exoplayer2.audio.AudioAttributes; import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; import com.google.android.exoplayer2.drm.DrmSession; @@ -60,6 +61,7 @@ import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.ExoTrackSelection; import com.google.android.exoplayer2.trackselection.MappingTrackSelector; +import com.google.android.exoplayer2.ui.PlayerControlView; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; @@ -155,6 +157,9 @@ public class ExoPlayerUtil implements VideoTimeRangeListener, DrmSessionManagerP private long lastApiCallTime = System.currentTimeMillis() / 1000; long throttleTimeRemaining = 0; private ProfileDetails profileDetails = null; + private long[] quizPositionMs = null; + private Handler quizCallbackHandler = null; + private List quizPositions = new ArrayList<>(); public ExoPlayerUtil(Activity activity, FrameLayout exoPlayerMainFrame, String url, float startPosition, LiveStreamCallbackListener liveStreamCallbackListener) { @@ -431,6 +436,11 @@ private void buildPlayer() { player.addAnalyticsListener(new ExoplayerAnalyticsListener(this)); player.setAudioAttributes(AudioAttributes.DEFAULT,true); playerView.setPlayer(player); + + if (quizCallbackHandler != null) { + scheduleQuizTriggers(); + } + player.setPlayWhenReady(playWhenReady); player.setPlaybackParameters(new PlaybackParameters(speedRate)); player.setMediaItem(mediaItem); @@ -849,6 +859,52 @@ public DrmSessionManager get(MediaItem mediaItem) { return new DefaultDrmSessionManager.Builder().build(new CustomHttpDrmMediaCallback(activity, content.getId())); } + public void setupQuiz(List positions, long[] positionsMs, Handler callbackHandler) { + this.quizPositions = positions; + this.quizCallbackHandler = callbackHandler; + this.quizPositionMs = positionsMs; + + addTimelineMarkers(positionsMs); + + if (player != null) { + scheduleQuizTriggers(); + } + } + + private void scheduleQuizTriggers() { + if (player == null || quizCallbackHandler == null || quizPositions.isEmpty()) { + return; + } + + PlayerMessage.Target target = new PlayerMessage.Target() { + public void handleMessage(int messageType, Object payload) { + int positionInSeconds = messageType; + + quizCallbackHandler.obtainMessage(positionInSeconds).sendToTarget(); + } + }; + + for (int position : quizPositions) { + player.createMessage(target) + .setPosition(position * 1000L) + .setType(position) + .setPayload(null) + .send(); + } + } + + public void pauseVideo() { + if (player != null) { + player.setPlayWhenReady(false); + } + } + + public void playVideo() { + if (player != null) { + player.setPlayWhenReady(true); + } + } + private class PlayerEventListener implements Player.Listener, DRMLicenseFetchCallback { @Override @@ -987,6 +1043,28 @@ private boolean isDRMException(Throwable cause) { return cause instanceof DrmSession.DrmSessionException || cause instanceof MediaCodec.CryptoException || cause instanceof MediaDrmCallbackException; } + public void addTimelineMarkers(long[] positionsMs) { + if (playerView == null) { + return; + } + + if (positionsMs == null || positionsMs.length == 0) { + return; + } + + PlayerControlView playerControlView = + playerView.findViewById(R.id.exo_controller); + + if (playerControlView != null) { + boolean[] playedMarkers = new boolean[positionsMs.length]; + for (int i = 0; i < playedMarkers.length; i++) { + playedMarkers[i] = false; + } + + playerControlView.setExtraAdGroupMarkers(positionsMs, playedMarkers); + } + } + public static int getRendererIndex(int trackType, MappingTrackSelector.MappedTrackInfo mappedTrackInfo) { for (int i=0; i < mappedTrackInfo.getRendererCount(); i++) { if (mappedTrackInfo.getRendererType(i) == trackType) { diff --git a/course/src/main/java/in/testpress/course/viewmodels/VideoQuizViewModel.kt b/course/src/main/java/in/testpress/course/viewmodels/VideoQuizViewModel.kt new file mode 100644 index 000000000..2767001a0 --- /dev/null +++ b/course/src/main/java/in/testpress/course/viewmodels/VideoQuizViewModel.kt @@ -0,0 +1,35 @@ +package `in`.testpress.course.viewmodels + +import androidx.lifecycle.ViewModel +import `in`.testpress.course.network.NetworkVideoQuestion + + +class VideoQuizViewModel : ViewModel() { + + private var allQuestions: List = emptyList() + private val completedQuestionIds = mutableSetOf() + fun setQuestions(questions: List) { + allQuestions = questions + completedQuestionIds.clear() + } + + fun getNextQuestionForPosition(position: Int): NetworkVideoQuestion? { + return allQuestions + .filter { it.position == position } + .sortedBy { it.order } + .firstOrNull { !completedQuestionIds.contains(it.id) } + } + + fun markQuestionAsCompleted(questionId: Long): Int { + completedQuestionIds.add(questionId) + return allQuestions.first { it.id == questionId }.position + } + + fun getUniquePositions(): List { + return allQuestions.map { it.position }.distinct() + } + + fun getUniquePositionMs(): LongArray { + return allQuestions.map { it.position * 1000L }.distinct().toLongArray() + } +} diff --git a/course/src/main/res/drawable/ic_quiz_cancel_red.xml b/course/src/main/res/drawable/ic_quiz_cancel_red.xml new file mode 100644 index 000000000..113d3b903 --- /dev/null +++ b/course/src/main/res/drawable/ic_quiz_cancel_red.xml @@ -0,0 +1,10 @@ + + + diff --git a/course/src/main/res/drawable/ic_quiz_check_green.xml b/course/src/main/res/drawable/ic_quiz_check_green.xml new file mode 100644 index 000000000..85a05c31b --- /dev/null +++ b/course/src/main/res/drawable/ic_quiz_check_green.xml @@ -0,0 +1,11 @@ + + + + diff --git a/course/src/main/res/drawable/quiz_gap_border_normal.xml b/course/src/main/res/drawable/quiz_gap_border_normal.xml new file mode 100644 index 000000000..4f04e3b8f --- /dev/null +++ b/course/src/main/res/drawable/quiz_gap_border_normal.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/course/src/main/res/drawable/quiz_gap_correct_border.xml b/course/src/main/res/drawable/quiz_gap_correct_border.xml new file mode 100644 index 000000000..97ca754d4 --- /dev/null +++ b/course/src/main/res/drawable/quiz_gap_correct_border.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/course/src/main/res/drawable/quiz_gap_incorrect_border.xml b/course/src/main/res/drawable/quiz_gap_incorrect_border.xml new file mode 100644 index 000000000..828b47114 --- /dev/null +++ b/course/src/main/res/drawable/quiz_gap_incorrect_border.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/course/src/main/res/drawable/quiz_option_border_correct.xml b/course/src/main/res/drawable/quiz_option_border_correct.xml new file mode 100644 index 000000000..0b3c35e27 --- /dev/null +++ b/course/src/main/res/drawable/quiz_option_border_correct.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/course/src/main/res/drawable/quiz_option_border_incorrect.xml b/course/src/main/res/drawable/quiz_option_border_incorrect.xml new file mode 100644 index 000000000..7ea4bf3f0 --- /dev/null +++ b/course/src/main/res/drawable/quiz_option_border_incorrect.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/course/src/main/res/drawable/quiz_option_border_normal.xml b/course/src/main/res/drawable/quiz_option_border_normal.xml new file mode 100644 index 000000000..7c3bb976b --- /dev/null +++ b/course/src/main/res/drawable/quiz_option_border_normal.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/course/src/main/res/layout/fragment_quiz_dialog.xml b/course/src/main/res/layout/fragment_quiz_dialog.xml new file mode 100644 index 000000000..0c923e456 --- /dev/null +++ b/course/src/main/res/layout/fragment_quiz_dialog.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + +