diff --git a/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/PlayerActivity.kt b/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/PlayerActivity.kt index c4604709c..c9730060d 100644 --- a/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/PlayerActivity.kt +++ b/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/PlayerActivity.kt @@ -37,13 +37,17 @@ import android.widget.ImageButton import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast + import androidx.activity.addCallback +import androidx.activity.result.contract.ActivityResultContracts + import androidx.activity.result.contract.ActivityResultContracts.OpenDocument import androidx.activity.viewModels import androidx.annotation.DrawableRes import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope import androidx.media3.common.C @@ -60,6 +64,7 @@ import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView import androidx.media3.ui.TimeBar +import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.color.DynamicColors import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.common.util.concurrent.ListenableFuture @@ -67,6 +72,9 @@ import dagger.hilt.android.AndroidEntryPoint import dev.anilbeesetti.nextplayer.core.common.Utils import dev.anilbeesetti.nextplayer.core.common.extensions.getMediaContentUri import dev.anilbeesetti.nextplayer.core.common.extensions.isDeviceTvBox +import dev.anilbeesetti.nextplayer.core.common.extensions.subtitleCacheDir +import dev.anilbeesetti.nextplayer.core.model.DecoderPriority +import dev.anilbeesetti.nextplayer.core.model.ScreenOrientation import dev.anilbeesetti.nextplayer.core.model.ControlButtonsPosition import dev.anilbeesetti.nextplayer.core.model.ThemeConfig import dev.anilbeesetti.nextplayer.core.model.VideoZoom @@ -78,8 +86,10 @@ import dev.anilbeesetti.nextplayer.feature.player.dialogs.VideoZoomOptionsDialog import dev.anilbeesetti.nextplayer.feature.player.dialogs.nameRes import dev.anilbeesetti.nextplayer.feature.player.extensions.audioSessionId import dev.anilbeesetti.nextplayer.feature.player.extensions.isPortrait +import dev.anilbeesetti.nextplayer.feature.player.extensions.jumpToTimestamp import dev.anilbeesetti.nextplayer.feature.player.extensions.next import dev.anilbeesetti.nextplayer.feature.player.extensions.seekBack +import dev.anilbeesetti.nextplayer.feature.player.extensions.jumpToTimestamp import dev.anilbeesetti.nextplayer.feature.player.extensions.seekForward import dev.anilbeesetti.nextplayer.feature.player.extensions.setImageDrawable import dev.anilbeesetti.nextplayer.feature.player.extensions.shouldFastSeek @@ -104,7 +114,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.guava.await import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject import timber.log.Timber +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader @SuppressLint("UnsafeOptInUsageError") @AndroidEntryPoint @@ -137,6 +152,9 @@ class PlayerActivity : AppCompatActivity() { /** * Player */ + private lateinit var player: Player + private lateinit var btnShowSections: ImageButton + private var isJsonFileLoaded = false private var controllerFuture: ListenableFuture? = null private var mediaController: MediaController? = null private lateinit var playerGestureHelper: PlayerGestureHelper @@ -180,13 +198,123 @@ class PlayerActivity : AppCompatActivity() { private lateinit var unlockControlsButton: ImageButton private lateinit var videoTitleTextView: TextView private lateinit var videoZoomButton: ImageButton + private lateinit var sectionsBottomSheet: BottomSheetDialog + private lateinit var sectionsList: LinearLayout + private lateinit var exportBookmarksButton: ImageButton + + + + private val filePickerLauncher = registerForActivityResult(OpenDocument()) { uri: Uri? -> + uri?.let { + try { + if (!::sectionsBottomSheet.isInitialized) { + setupSectionsBottomSheet() + } + val sectionsJson = readJsonFromUri(it) + populateSectionsList(sectionsJson) + isJsonFileLoaded = true + sectionsBottomSheet.show() + } catch (e: Exception) { + e.printStackTrace() + // Log the error message + Timber.e(e, "Failed to load JSON file: ${e.message}") + // Show a detailed error message to the user + Toast.makeText(this, "Failed to load JSON file: ${e.message}", Toast.LENGTH_LONG).show() + } + } ?: run { + // Handle the case where the URI is null + Timber.e("File picker returned a null URI") + Toast.makeText(this, "No file selected", Toast.LENGTH_SHORT).show() + } +} + + private fun readJsonFromUri(uri: Uri): String { + val inputStream = contentResolver.openInputStream(uri) + val bufferedReader = BufferedReader(InputStreamReader(inputStream)) + return bufferedReader.use { it.readText() } + } + + private fun populateSectionsList(sectionsJson: String? = null) { + sectionsList.removeAllViews() + val sectionsArray = sectionsJson?.let { JSONArray(it) } ?: JSONArray() + + // Add imported sections + for (i in 0 until sectionsArray.length()) { + val section = sectionsArray.getJSONObject(i) + val title = section.getString("title") + val timestamp = section.getLong("timestamp") + + val sectionItem = TextView(this).apply { + text = title + setPadding(16, 16, 16, 16) + setOnClickListener { + mediaController?.jumpToTimestamp(timestamp) + sectionsBottomSheet.dismiss() + } + } + sectionsList.addView(sectionItem) + } + + // Add newly added bookmarks + for (bookmark in bookmarks) { + val title = bookmark.getString("title") + val timestamp = bookmark.getLong("timestamp") + + val sectionItem = TextView(this).apply { + text = title + setPadding(16, 16, 16, 16) + + setOnClickListener { + mediaController?.jumpToTimestamp(timestamp) + sectionsBottomSheet.dismiss() + } + } + sectionsList.addView(sectionItem) + } +} + private lateinit var playInBackgroundButton: ImageButton private lateinit var extraControls: LinearLayout + private val bookmarks = mutableListOf() + private val isPipSupported: Boolean by lazy { Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) } + private val createDocumentLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> + uri?.let { + try { + val bookmarksJsonArray = JSONArray(bookmarks) + val bookmarksJsonString = bookmarksJsonArray.toString() + contentResolver.openOutputStream(it)?.use { outputStream -> + outputStream.write(bookmarksJsonString.toByteArray()) + } + Toast.makeText(this, "Bookmarks exported successfully", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + e.printStackTrace() + Timber.e(e, "Failed to export JSON file: ${e.message}") + Toast.makeText(this, "Failed to export JSON file: ${e.message}", Toast.LENGTH_LONG).show() + } + } ?: run { + Timber.e("Document creation returned a null URI") + Toast.makeText(this, "Export cancelled", Toast.LENGTH_SHORT).show() + } +} + +private fun setupSectionsBottomSheet() { + val view = layoutInflater.inflate(R.layout.bottom_sheet_sections, null) + sectionsList = view.findViewById(R.id.sections_list) + exportBookmarksButton = view.findViewById(R.id.btn_export_bookmarks) + + exportBookmarksButton.setOnClickListener { + createDocumentLauncher.launch("${mediaController?.currentMediaItem?.mediaMetadata?.title}.json") + } + sectionsBottomSheet = BottomSheetDialog(this).apply { + setContentView(view) + } +} + private val isPipEnabled: Boolean get() { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -203,117 +331,130 @@ class PlayerActivity : AppCompatActivity() { } override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + super.onCreate(savedInstanceState) + prettyPrintIntent() + + AppCompatDelegate.setDefaultNightMode( + when (applicationPreferences.themeConfig) { + ThemeConfig.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + ThemeConfig.OFF -> AppCompatDelegate.MODE_NIGHT_NO + ThemeConfig.ON -> AppCompatDelegate.MODE_NIGHT_YES + }, + ) - AppCompatDelegate.setDefaultNightMode( - when (applicationPreferences.themeConfig) { - ThemeConfig.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - ThemeConfig.OFF -> AppCompatDelegate.MODE_NIGHT_NO - ThemeConfig.ON -> AppCompatDelegate.MODE_NIGHT_YES - }, - ) + if (applicationPreferences.useDynamicColors) { + DynamicColors.applyToActivityIfAvailable(this) + } - if (applicationPreferences.useDynamicColors) { - DynamicColors.applyToActivityIfAvailable(this) - } - // The window is always allowed to extend into the DisplayCutout areas on the short edges of the screen - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES - } + // The window is always allowed to extend into the DisplayCutout areas on the short edges of the screen + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + + WindowCompat.setDecorFitsSystemWindows(window, false) + + binding = ActivityPlayerBinding.inflate(layoutInflater) + setContentView(binding.root) + + // Initializing views + audioTrackButton = binding.playerView.findViewById(R.id.btn_audio_track) + backButton = binding.playerView.findViewById(R.id.back_button) + exoContentFrameLayout = binding.playerView.findViewById(R.id.exo_content_frame) + lockControlsButton = binding.playerView.findViewById(R.id.btn_lock_controls) + playbackSpeedButton = binding.playerView.findViewById(R.id.btn_playback_speed) + playerLockControls = binding.playerView.findViewById(R.id.player_lock_controls) + playerUnlockControls = binding.playerView.findViewById(R.id.player_unlock_controls) + playerCenterControls = binding.playerView.findViewById(R.id.player_center_controls) + screenRotateButton = binding.playerView.findViewById(R.id.screen_rotate) + pipButton = binding.playerView.findViewById(R.id.btn_pip) + seekBar = binding.playerView.findViewById(R.id.exo_progress) + subtitleTrackButton = binding.playerView.findViewById(R.id.btn_subtitle_track) + unlockControlsButton = binding.playerView.findViewById(R.id.btn_unlock_controls) + videoTitleTextView = binding.playerView.findViewById(R.id.video_name) + videoZoomButton = binding.playerView.findViewById(R.id.btn_video_zoom) + - WindowCompat.setDecorFitsSystemWindows(window, false) - - binding = ActivityPlayerBinding.inflate(layoutInflater) - setContentView(binding.root) - - // Initializing views - audioTrackButton = binding.playerView.findViewById(R.id.btn_audio_track) - backButton = binding.playerView.findViewById(R.id.back_button) - exoContentFrameLayout = binding.playerView.findViewById(R.id.exo_content_frame) - lockControlsButton = binding.playerView.findViewById(R.id.btn_lock_controls) - playbackSpeedButton = binding.playerView.findViewById(R.id.btn_playback_speed) - playerLockControls = binding.playerView.findViewById(R.id.player_lock_controls) - playerUnlockControls = binding.playerView.findViewById(R.id.player_unlock_controls) - playerCenterControls = binding.playerView.findViewById(R.id.player_center_controls) - screenRotateButton = binding.playerView.findViewById(R.id.screen_rotate) - pipButton = binding.playerView.findViewById(R.id.btn_pip) - seekBar = binding.playerView.findViewById(R.id.exo_progress) - subtitleTrackButton = binding.playerView.findViewById(R.id.btn_subtitle_track) - unlockControlsButton = binding.playerView.findViewById(R.id.btn_unlock_controls) - videoTitleTextView = binding.playerView.findViewById(R.id.video_name) - videoZoomButton = binding.playerView.findViewById(R.id.btn_video_zoom) - playInBackgroundButton = binding.playerView.findViewById(R.id.btn_background) - extraControls = binding.playerView.findViewById(R.id.extra_controls) - - if (playerPreferences.controlButtonsPosition == ControlButtonsPosition.RIGHT) { - extraControls.gravity = Gravity.END + + // Initialize my views + btnShowSections = findViewById(R.id.btn_show_sections) + + // Set up sections bottom sheet + setupSectionsBottomSheet() + + // Set up button click listener + btnShowSections.setOnClickListener { + if (!isJsonFileLoaded && bookmarks.isEmpty()) { + filePickerLauncher.launch(arrayOf("application/json")) + } else { + sectionsBottomSheet.show() } + } - if (!isPipSupported) { - pipButton.visibility = View.GONE + // Set up long click listener to add bookmarks + btnShowSections.setOnLongClickListener { + val currentPosition = mediaController?.currentPosition ?: return@setOnLongClickListener true + val bookmarkTitle = "${Utils.formatDurationMillis(currentPosition)}" + val bookmark = JSONObject().apply { + put("title", bookmarkTitle) + put("timestamp", currentPosition) } + bookmarks.add(bookmark) + Toast.makeText(this, "Section mark added", Toast.LENGTH_SHORT).show() + populateSectionsList() + true + } - seekBar.addListener( - object : TimeBar.OnScrubListener { - override fun onScrubStart(timeBar: TimeBar, position: Long) { - mediaController?.run { - if (isPlaying) { - isPlayingOnScrubStart = true - pause() - } - isFrameRendered = true - scrubStartPosition = currentPosition - previousScrubPosition = currentPosition - scrub(position) - showPlayerInfo( - info = Utils.formatDurationMillis(position), - subInfo = "[${Utils.formatDurationMillisSign(position - scrubStartPosition)}]", - ) - } - } + playInBackgroundButton = binding.playerView.findViewById(R.id.btn_background) + extraControls = binding.playerView.findViewById(R.id.extra_controls) - override fun onScrubMove(timeBar: TimeBar, position: Long) { - scrub(position) - showPlayerInfo( - info = Utils.formatDurationMillis(position), - subInfo = "[${Utils.formatDurationMillisSign(position - scrubStartPosition)}]", - ) - } + if (playerPreferences.controlButtonsPosition == ControlButtonsPosition.RIGHT) { + extraControls.gravity = Gravity.END + } - override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { - hidePlayerInfo(0L) - scrubStartPosition = -1L - if (isPlayingOnScrubStart) { - mediaController?.play() - } - } - }, - ) + if (!isPipSupported) { + pipButton.visibility = View.GONE + } - volumeManager = VolumeManager(audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager) - brightnessManager = BrightnessManager(activity = this) - playerGestureHelper = PlayerGestureHelper( - viewModel = viewModel, - activity = this, - volumeManager = volumeManager, - brightnessManager = brightnessManager, - onScaleChanged = { scale -> - mediaController?.currentMediaItem?.mediaId?.let { - viewModel.updateMediumZoom(uri = it, zoom = scale) - } - }, - ) + seekBar.addListener( + object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) { + // Implementation + } - playerApi = PlayerApi(this) + override fun onScrubMove(timeBar: TimeBar, position: Long) { + // Implementation + } - onBackPressedDispatcher.addCallback { - mediaController?.run { - clearMediaItems() - stop() + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + // Implementation + } + }, + ) + + volumeManager = VolumeManager(audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager) + brightnessManager = BrightnessManager(activity = this) + playerGestureHelper = PlayerGestureHelper( + viewModel = viewModel, + activity = this, + volumeManager = volumeManager, + brightnessManager = brightnessManager, + onScaleChanged = { scale -> + mediaController?.currentMediaItem?.mediaId?.let { + // Implementation } + }, + ) + + playerApi = PlayerApi(this) + + onBackPressedDispatcher.addCallback { + mediaController?.run { + clearMediaItems() + stop() } } +} override fun onStart() { super.onStart() @@ -558,6 +699,8 @@ class PlayerActivity : AppCompatActivity() { } } + + audioTrackButton.setOnClickListener { TrackSelectionDialogFragment( type = C.TRACK_TYPE_AUDIO, @@ -566,6 +709,8 @@ class PlayerActivity : AppCompatActivity() { ).show(supportFragmentManager, "TrackSelectionDialog") } + + subtitleTrackButton.setOnClickListener { TrackSelectionDialogFragment( type = C.TRACK_TYPE_TEXT, @@ -587,6 +732,7 @@ class PlayerActivity : AppCompatActivity() { ).show(supportFragmentManager, "TrackSelectionDialog") } + playbackSpeedButton.setOnClickListener { PlaybackSpeedControlsDialogFragment( mediaController = mediaController ?: return@setOnClickListener, diff --git a/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/extensions/Player.kt b/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/extensions/Player.kt index c0a9a2def..529c74514 100644 --- a/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/extensions/Player.kt +++ b/feature/player/src/main/java/dev/anilbeesetti/nextplayer/feature/player/extensions/Player.kt @@ -88,6 +88,15 @@ fun Player.seekForward(positionMs: Long, shouldFastSeek: Boolean = false) { this.seekTo(positionMs) } +/** + * Jumps to the specified timestamp. + * + * @param timestampMs The timestamp to jump to, in milliseconds. + */ +fun Player.jumpToTimestamp(timestampMs: Long) { + this.seekTo(timestampMs) +} + @get:UnstableApi val Player.audioSessionId: Int get() = when (this) { diff --git a/feature/player/src/main/res/drawable/bottom_sheet_background.xml b/feature/player/src/main/res/drawable/bottom_sheet_background.xml new file mode 100644 index 000000000..123205c28 --- /dev/null +++ b/feature/player/src/main/res/drawable/bottom_sheet_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/feature/player/src/main/res/drawable/ic_export.xml b/feature/player/src/main/res/drawable/ic_export.xml new file mode 100644 index 000000000..81edf577c --- /dev/null +++ b/feature/player/src/main/res/drawable/ic_export.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/feature/player/src/main/res/drawable/ic_sections.xml b/feature/player/src/main/res/drawable/ic_sections.xml new file mode 100644 index 000000000..804d95ec2 --- /dev/null +++ b/feature/player/src/main/res/drawable/ic_sections.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/feature/player/src/main/res/layout/bottom_sheet_sections.xml b/feature/player/src/main/res/layout/bottom_sheet_sections.xml new file mode 100644 index 000000000..b0269382c --- /dev/null +++ b/feature/player/src/main/res/layout/bottom_sheet_sections.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature/player/src/main/res/layout/exo_player_control_view.xml b/feature/player/src/main/res/layout/exo_player_control_view.xml index 1eb814e88..2b5ec5f61 100644 --- a/feature/player/src/main/res/layout/exo_player_control_view.xml +++ b/feature/player/src/main/res/layout/exo_player_control_view.xml @@ -110,6 +110,7 @@ app:layout_constraintStart_toStartOf="parent"> + android:id="@+id/btn_show_sections" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/dimen_controls_horizontal" + android:background="@drawable/transparent_circle_background" + android:contentDescription="Show Sections" + android:padding="12dp" + android:src="@drawable/ic_sections" + app:tint="@android:color/white" /> + + + + + + + + + + + + + + - - - - - - \ No newline at end of file diff --git a/feature/player/src/main/res/values/strings.xml b/feature/player/src/main/res/values/strings.xml new file mode 100644 index 000000000..159fe14a1 --- /dev/null +++ b/feature/player/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Export Sections + Chapters + \ No newline at end of file