diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0e7c882e..7a814b1e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -12,6 +12,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TPStreamsAndroidPlayer"
+ android:enableOnBackInvokedCallback="true"
tools:targetApi="31">
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/tpstreams/player/DownloadsActivity.kt b/app/src/main/java/com/tpstreams/player/DownloadsActivity.kt
new file mode 100644
index 00000000..110f619b
--- /dev/null
+++ b/app/src/main/java/com/tpstreams/player/DownloadsActivity.kt
@@ -0,0 +1,548 @@
+package com.tpstreams.player
+
+import android.content.Context
+import android.content.Intent
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ImageButton
+import android.widget.ProgressBar
+import android.widget.TextView
+import android.widget.Toast
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.offline.Download
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.button.MaterialButton
+import com.tpstreams.player.databinding.ActivityDownloadsBinding
+import com.tpstreams.player.offline.DownloadUtils
+import com.tpstreams.player.utils.NetworkUtils
+
+@UnstableApi
+class DownloadsActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityDownloadsBinding
+ private lateinit var adapter: DownloadAdapter
+ private val TAG = "DownloadsActivity"
+
+ // Map to track currently playing content
+ private val playersMap = mutableMapOf()
+
+ // Handler for updating download progress
+ private val handler = Handler(Looper.getMainLooper())
+ private val updateRunnable = object : Runnable {
+ override fun run() {
+ updateDownloadProgress()
+ handler.postDelayed(this, 1000) // Update every second
+ }
+ }
+
+ // Add this property to the DownloadsActivity class
+ private val pausedDownloads = mutableMapOf()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityDownloadsBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ // Set up toolbar
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ supportActionBar?.title = "Downloads"
+
+ // Set up RecyclerView
+ binding.recyclerView.layoutManager = LinearLayoutManager(this)
+ binding.recyclerView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
+
+ // Create adapter
+ adapter = DownloadAdapter()
+ binding.recyclerView.adapter = adapter
+
+ // Load downloads
+ loadDownloads()
+
+ // Check network status
+ if (NetworkUtils.isOfflineMode(this)) {
+ Toast.makeText(this, "Offline mode - Playing downloaded videos only", Toast.LENGTH_SHORT).show()
+ }
+
+ // Restore saved state if available
+ savedInstanceState?.let { restoreSavedState(it) }
+
+ // Restore paused downloads from database
+ restorePausedDownloadsState()
+
+ // Start progress updates
+ handler.post(updateRunnable)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ loadDownloads()
+ handler.post(updateRunnable)
+ }
+
+ override fun onPause() {
+ super.onPause()
+ handler.removeCallbacks(updateRunnable)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ // Remove callbacks
+ handler.removeCallbacks(updateRunnable)
+
+ // Release all players
+ playersMap.values.forEach { it.release() }
+ playersMap.clear()
+ }
+
+ override fun onSupportNavigateUp(): Boolean {
+ finish()
+ return true
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+
+ // Save paused downloads state
+ val pausedArray = pausedDownloads.keys.toTypedArray()
+ outState.putStringArray("paused_downloads", pausedArray)
+ }
+
+ private fun restoreSavedState(savedInstanceState: Bundle) {
+ // Restore paused downloads
+ val pausedArray = savedInstanceState.getStringArray("paused_downloads")
+ pausedArray?.forEach { contentId ->
+ pausedDownloads[contentId] = true
+ }
+ }
+
+ /**
+ * Restore paused downloads state from the database
+ */
+ private fun restorePausedDownloadsState() {
+ try {
+ // Get all downloads from the database
+ val downloads = DownloadUtils.getDownloads(this)
+
+ // Check each download to see if it's paused
+ downloads.forEach { download ->
+ if (download.state == Download.STATE_STOPPED && download.stopReason == 1) {
+ // This download is paused, add it to the pausedDownloads map
+ pausedDownloads[download.request.id] = true
+ Log.d(TAG, "Restored paused state for download: ${download.request.id}")
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error restoring paused downloads state: ${e.message}", e)
+ }
+ }
+
+ private fun loadDownloads() {
+ // Use DownloadUtils to get simplified download items
+ val downloadItems = DownloadUtils.getDownloadItems(this)
+
+ if (downloadItems.isEmpty()) {
+ binding.recyclerView.visibility = View.GONE
+ binding.emptyView.visibility = View.VISIBLE
+ } else {
+ binding.recyclerView.visibility = View.VISIBLE
+ binding.emptyView.visibility = View.GONE
+ adapter.updateDownloads(downloadItems)
+ }
+ }
+
+ private fun updateDownloadProgress() {
+ try {
+ val downloadItems = DownloadUtils.getDownloadItems(this)
+ if (downloadItems.isNotEmpty()) {
+ adapter.updateDownloadProgress(downloadItems)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error updating download progress: ${e.message}", e)
+ }
+ }
+
+ private fun showDeleteConfirmation(downloadItem: DownloadUtils.DownloadItem) {
+ AlertDialog.Builder(this)
+ .setTitle("Delete Download")
+ .setMessage("Are you sure you want to delete this download?")
+ .setPositiveButton("Delete") { _, _ ->
+ deleteDownload(downloadItem)
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+
+ private fun deleteDownload(downloadItem: DownloadUtils.DownloadItem) {
+ // Show deletion progress dialog
+ val progressDialog = AlertDialog.Builder(this)
+ .setTitle("Deleting")
+ .setMessage("Deleting download...")
+ .setCancelable(false)
+ .create()
+
+ progressDialog.show()
+
+ // Stop playback if it's playing
+ stopPlayback(downloadItem.contentId)
+
+ // Use a background thread for deletion to avoid blocking the UI
+ Thread {
+ try {
+ // Delete the download
+ DownloadUtils.deleteDownload(this, downloadItem.contentId)
+
+ // Run on UI thread to update the UI
+ runOnUiThread {
+ progressDialog.dismiss()
+
+ // Remove the item from the adapter
+ adapter.removeDownload(downloadItem.contentId)
+
+ // Check if the list is now empty
+ if (adapter.itemCount == 0) {
+ binding.recyclerView.visibility = View.GONE
+ binding.emptyView.visibility = View.VISIBLE
+ }
+
+ Toast.makeText(this, "Download deleted successfully", Toast.LENGTH_SHORT).show()
+ }
+ } catch (e: Exception) {
+ // Handle any errors
+ runOnUiThread {
+ progressDialog.dismiss()
+ Toast.makeText(this, "Error deleting download: ${e.message}", Toast.LENGTH_SHORT).show()
+ Log.e(TAG, "Error deleting download: ${e.message}", e)
+ }
+ }
+ }.start()
+ }
+
+ private fun playDownload(contentId: String, viewHolder: DownloadAdapter.ViewHolder) {
+ try {
+ // First verify the download is complete and valid
+ if (!DownloadUtils.verifyDownload(this, contentId)) {
+ Toast.makeText(this, "Content not fully downloaded or download is invalid", Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ // Launch PlayerActivity with the downloaded content
+ val intent = Intent(this, PlayerActivity::class.java).apply {
+ putExtra(PlayerActivity.EXTRA_CONTENT_TYPE, PlayerActivity.CONTENT_TYPE_DOWNLOAD)
+ putExtra(PlayerActivity.EXTRA_CONTENT_ID, contentId)
+ }
+ startActivity(intent)
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Error playing download: ${e.message}", e)
+ Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ /**
+ * Pause a download
+ * @param contentId The ID of the content to pause
+ */
+ private fun pauseDownload(contentId: String) {
+ try {
+ Log.d(TAG, "Attempting to pause download for contentId: $contentId")
+
+ // Call the actual pause download method from DownloadUtils
+ DownloadUtils.pauseDownload(this, contentId)
+
+ Toast.makeText(this, "Download paused", Toast.LENGTH_SHORT).show()
+
+ // Store the paused state in a map
+ pausedDownloads[contentId] = true
+ Log.d(TAG, "Added contentId to pausedDownloads map: $contentId")
+
+ // Force refresh the UI to show the paused state
+ refreshDownloads()
+
+ // Verify the download was actually paused
+ val isPaused = DownloadUtils.isDownloadPaused(this, contentId)
+ Log.d(TAG, "After pause operation, isDownloadPaused = $isPaused for contentId: $contentId")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error pausing download: ${e.message}", e)
+ Toast.makeText(this, "Error pausing download", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ /**
+ * Resume a paused download
+ * @param contentId The ID of the content to resume
+ */
+ private fun resumeDownload(contentId: String) {
+ try {
+ Log.d(TAG, "Attempting to resume download for contentId: $contentId")
+
+ // Call the actual resume download method from DownloadUtils
+ DownloadUtils.resumeDownload(this, contentId)
+
+ // Remove the paused state from the map
+ pausedDownloads.remove(contentId)
+ Log.d(TAG, "Removed contentId from pausedDownloads map: $contentId")
+
+ // Force refresh the UI to show the resumed state
+ refreshDownloads()
+
+ // Verify the download was actually resumed
+ val isPaused = DownloadUtils.isDownloadPaused(this, contentId)
+ Log.d(TAG, "After resume operation, isDownloadPaused = $isPaused for contentId: $contentId")
+
+ // If the download is still paused, try restarting it
+ if (isPaused) {
+ Log.d(TAG, "Resume failed, attempting to restart download for contentId: $contentId")
+
+ // Show a progress dialog while restarting
+ val progressDialog = AlertDialog.Builder(this)
+ .setTitle("Restarting Download")
+ .setMessage("Attempting to restart download...")
+ .setCancelable(false)
+ .create()
+
+ progressDialog.show()
+
+ // Use a background thread for restarting
+ Thread {
+ try {
+ // Restart the download
+ DownloadUtils.restartDownload(this, contentId)
+
+ // Run on UI thread to update the UI
+ runOnUiThread {
+ progressDialog.dismiss()
+ Toast.makeText(this, "Download restarted", Toast.LENGTH_SHORT).show()
+ refreshDownloads()
+ }
+ } catch (e: Exception) {
+ // Handle any errors
+ runOnUiThread {
+ progressDialog.dismiss()
+ Toast.makeText(this, "Error restarting download: ${e.message}", Toast.LENGTH_SHORT).show()
+ Log.e(TAG, "Error restarting download: ${e.message}", e)
+ }
+ }
+ }.start()
+ } else {
+ Toast.makeText(this, "Download resumed", Toast.LENGTH_SHORT).show()
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error resuming download: ${e.message}", e)
+ Toast.makeText(this, "Error resuming download", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ /**
+ * Refresh the downloads list
+ */
+ private fun refreshDownloads() {
+ val downloads = DownloadUtils.getDownloadItems(this)
+ adapter.updateDownloadProgress(downloads)
+ }
+
+ private fun stopPlayback(contentId: String) {
+ try {
+ val player = playersMap.remove(contentId) ?: return
+ player.stop()
+ player.release()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error stopping playback: ${e.message}", e)
+ }
+ }
+
+ /**
+ * RecyclerView adapter for displaying downloads
+ */
+ inner class DownloadAdapter : RecyclerView.Adapter() {
+
+ private var downloads: MutableList = mutableListOf()
+ private val viewHolders = mutableMapOf()
+
+ fun updateDownloads(newDownloads: List) {
+ downloads.clear()
+ downloads.addAll(newDownloads)
+ notifyDataSetChanged()
+ }
+
+ fun removeDownload(contentId: String) {
+ val position = downloads.indexOfFirst { it.contentId == contentId }
+ if (position != -1) {
+ downloads.removeAt(position)
+ notifyItemRemoved(position)
+ viewHolders.remove(contentId)
+ }
+ }
+
+ fun updateDownloadProgress(updatedDownloads: List) {
+ try {
+ // Update progress for existing items without full refresh
+ updatedDownloads.forEach { updatedItem ->
+ val existingItem = downloads.find { it.contentId == updatedItem.contentId }
+ if (existingItem != null &&
+ (existingItem.progress != updatedItem.progress ||
+ existingItem.status != updatedItem.status ||
+ existingItem.isComplete != updatedItem.isComplete)) {
+
+ // Update the ViewHolder if it's visible
+ viewHolders[updatedItem.contentId]?.updateProgress(updatedItem)
+ }
+ }
+
+ // If the list has changed (items added/removed), do a full refresh
+ if (downloads.map { it.contentId }.toSet() != updatedDownloads.map { it.contentId }.toSet()) {
+ downloads.clear()
+ downloads.addAll(updatedDownloads)
+ notifyDataSetChanged()
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error updating download progress in adapter: ${e.message}", e)
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val view = LayoutInflater.from(parent.context)
+ .inflate(R.layout.item_download, parent, false)
+ return ViewHolder(view)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val downloadItem = downloads[position]
+ holder.bind(downloadItem)
+ viewHolders[downloadItem.contentId] = holder
+ }
+
+ override fun getItemCount(): Int = downloads.size
+
+ inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ private val titleTextView: TextView = itemView.findViewById(R.id.titleTextView)
+ private val statusTextView: TextView = itemView.findViewById(R.id.statusTextView)
+ private val progressBar: ProgressBar = itemView.findViewById(R.id.progressBar)
+ private val playButton: MaterialButton = itemView.findViewById(R.id.playButton)
+ private val pauseButton: MaterialButton = itemView.findViewById(R.id.pauseButton)
+ private val resumeButton: MaterialButton = itemView.findViewById(R.id.resumeButton)
+ private val deleteButton: MaterialButton = itemView.findViewById(R.id.deleteButton)
+
+ private var currentContentId: String? = null
+ private var currentItem: DownloadUtils.DownloadItem? = null
+
+ fun bind(downloadItem: DownloadUtils.DownloadItem) {
+ currentContentId = downloadItem.contentId
+ currentItem = downloadItem
+
+ titleTextView.text = downloadItem.contentId
+
+ // Update UI based on download status
+ updateProgress(downloadItem)
+
+ // Set up click listeners
+ playButton.setOnClickListener {
+ currentContentId?.let { contentId ->
+ playDownload(contentId, this)
+ }
+ }
+
+ pauseButton.setOnClickListener {
+ currentContentId?.let { contentId ->
+ pauseDownload(contentId)
+ pauseButton.visibility = View.GONE
+ resumeButton.visibility = View.VISIBLE
+ }
+ }
+
+ resumeButton.setOnClickListener {
+ currentContentId?.let { contentId ->
+ resumeDownload(contentId)
+ resumeButton.visibility = View.GONE
+ pauseButton.visibility = View.VISIBLE
+ }
+ }
+
+ deleteButton.setOnClickListener {
+ showDeleteConfirmation(downloadItem)
+ }
+ }
+
+ fun updateProgress(downloadItem: DownloadUtils.DownloadItem) {
+ currentItem = downloadItem
+
+ // Show appropriate UI based on download status
+ if (downloadItem.isComplete) {
+ // Download is complete - show play button, hide progress and pause/resume buttons
+ progressBar.visibility = View.GONE
+ statusTextView.text = "Downloaded"
+
+ // Show play button, hide pause/resume buttons
+ playButton.visibility = View.VISIBLE
+ pauseButton.visibility = View.GONE
+ resumeButton.visibility = View.GONE
+
+ Log.d(TAG, "Download complete for contentId: ${downloadItem.contentId}, showing play button")
+ } else {
+ // Download is in progress - show progress and pause/resume button
+ progressBar.visibility = View.VISIBLE
+ progressBar.progress = downloadItem.progress
+ statusTextView.text = "Downloading ${downloadItem.progress}%"
+
+ // Check if download is actually paused using the library function
+ val isPaused = DownloadUtils.isDownloadPaused(itemView.context, downloadItem.contentId)
+ Log.d(TAG, "Download in progress for contentId: ${downloadItem.contentId}, progress: ${downloadItem.progress}%, isPaused: $isPaused")
+
+ // Show play button if complete, otherwise hide it
+ playButton.visibility = View.GONE
+
+ // Show pause button if not paused, otherwise show resume button
+ if (isPaused) {
+ pauseButton.visibility = View.GONE
+ resumeButton.visibility = View.VISIBLE
+ statusTextView.text = "Paused ${downloadItem.progress}%"
+ Log.d(TAG, "Showing resume button for contentId: ${downloadItem.contentId}")
+ } else {
+ pauseButton.visibility = View.VISIBLE
+ resumeButton.visibility = View.GONE
+ Log.d(TAG, "Showing pause button for contentId: ${downloadItem.contentId}")
+ }
+ }
+ }
+
+ fun updatePlaybackControls(isPlaying: Boolean) {
+ // This method is only relevant for completed downloads being played
+ if (currentItem?.isComplete == true) {
+ if (isPlaying) {
+ playButton.visibility = View.GONE
+ pauseButton.visibility = View.VISIBLE
+ resumeButton.visibility = View.GONE
+ } else {
+ // Check if we have a paused player for this content
+ val hasPausedPlayer = currentContentId?.let { contentId ->
+ val player = playersMap[contentId]
+ player != null && player.playbackState != Player.STATE_IDLE
+ } ?: false
+
+ if (hasPausedPlayer) {
+ playButton.visibility = View.GONE
+ pauseButton.visibility = View.GONE
+ resumeButton.visibility = View.VISIBLE
+ } else {
+ playButton.visibility = View.VISIBLE
+ pauseButton.visibility = View.GONE
+ resumeButton.visibility = View.GONE
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/tpstreams/player/MainActivity.kt b/app/src/main/java/com/tpstreams/player/MainActivity.kt
index 4fc1ed42..403e1c74 100644
--- a/app/src/main/java/com/tpstreams/player/MainActivity.kt
+++ b/app/src/main/java/com/tpstreams/player/MainActivity.kt
@@ -1,19 +1,30 @@
package com.tpstreams.player
+import android.content.Intent
import android.os.Bundle
import android.util.Log
-import androidx.activity.viewModels
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity
import androidx.media3.common.util.UnstableApi
import com.tpstreams.player.databinding.ActivityMainBinding
+import com.tpstreams.player.utils.NetworkUtils
+@UnstableApi
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
- private val viewModel: PlayerUIViewModel by viewModels()
+ private val TAG = "MainActivity"
+
+ // DRM content credentials
+ private val drmContentId = "3G2p5NdMaRu" // DRM content ID
+ private val drmAccessToken = "328f6f1c-c188-4c3f-8e38-345c9aaa1a51" // DRM access token
+
+ // Non-DRM content credentials
+ private val nonDrmContentId = "ACGhHuD7DEa" // Non-DRM content ID
+ private val nonDrmAccessToken = "5bea276d-7882-4f8f-951a-c628622817e0" // Non-DRM access token
- @OptIn(UnstableApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -21,9 +32,53 @@ class MainActivity : AppCompatActivity() {
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
- // Initialize SDK once
- TPStreamsPlayer.init("6332n7")
+ // Initialize SDK once with application context
+ TPStreamsPlayer.init("9q94nm", applicationContext)
- binding.playerView.player = viewModel.player
+ // Set up button click listeners
+ setupButtonListeners()
+
+ // Check network status
+ if (NetworkUtils.isOfflineMode(this)) {
+ Toast.makeText(this, "You are in offline mode. Only downloaded videos can be played.", Toast.LENGTH_LONG).show()
+ }
+ }
+
+ private fun setupButtonListeners() {
+ // DRM button click listener
+ binding.drmButton.setOnClickListener {
+ if (NetworkUtils.isOfflineMode(this)) {
+ Toast.makeText(this, "Cannot play DRM content in offline mode", Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+
+ val intent = Intent(this, PlayerActivity::class.java).apply {
+ putExtra(PlayerActivity.EXTRA_CONTENT_TYPE, PlayerActivity.CONTENT_TYPE_DRM)
+ putExtra(PlayerActivity.EXTRA_CONTENT_ID, drmContentId)
+ putExtra(PlayerActivity.EXTRA_ACCESS_TOKEN, drmAccessToken)
+ }
+ startActivity(intent)
+ }
+
+ // Non-DRM button click listener
+ binding.nonDrmButton.setOnClickListener {
+ if (NetworkUtils.isOfflineMode(this)) {
+ Toast.makeText(this, "Cannot play streaming content in offline mode", Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+
+ val intent = Intent(this, PlayerActivity::class.java).apply {
+ putExtra(PlayerActivity.EXTRA_CONTENT_TYPE, PlayerActivity.CONTENT_TYPE_NON_DRM)
+ putExtra(PlayerActivity.EXTRA_CONTENT_ID, nonDrmContentId)
+ putExtra(PlayerActivity.EXTRA_ACCESS_TOKEN, nonDrmAccessToken)
+ }
+ startActivity(intent)
+ }
+
+ // Downloads button click listener
+ binding.downloadsButton.setOnClickListener {
+ val intent = Intent(this, DownloadsActivity::class.java)
+ startActivity(intent)
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/tpstreams/player/PlayerActivity.kt b/app/src/main/java/com/tpstreams/player/PlayerActivity.kt
new file mode 100644
index 00000000..63353a08
--- /dev/null
+++ b/app/src/main/java/com/tpstreams/player/PlayerActivity.kt
@@ -0,0 +1,159 @@
+package com.tpstreams.player
+
+import android.os.Bundle
+import android.util.Log
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import com.tpstreams.player.databinding.ActivityPlayerBinding
+import com.tpstreams.player.offline.DownloadUtils
+import com.tpstreams.player.utils.NetworkUtils
+
+@UnstableApi
+class PlayerActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityPlayerBinding
+ private var player: TPStreamsPlayer? = null
+ private val TAG = "PlayerActivity"
+
+ companion object {
+ const val EXTRA_CONTENT_TYPE = "content_type"
+ const val EXTRA_CONTENT_ID = "content_id"
+ const val EXTRA_ACCESS_TOKEN = "access_token"
+
+ const val CONTENT_TYPE_DRM = "drm"
+ const val CONTENT_TYPE_NON_DRM = "non_drm"
+ const val CONTENT_TYPE_DOWNLOAD = "download"
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityPlayerBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ // Initialize SDK
+ TPStreamsPlayer.init("9q94nm", applicationContext)
+
+ // Get content type and details from intent
+ val contentType = intent.getStringExtra(EXTRA_CONTENT_TYPE) ?: ""
+ val contentId = intent.getStringExtra(EXTRA_CONTENT_ID) ?: ""
+ val accessToken = intent.getStringExtra(EXTRA_ACCESS_TOKEN) ?: ""
+
+ // Set up back button with appropriate text
+ if (contentType == CONTENT_TYPE_DOWNLOAD) {
+ binding.backButton.text = "Back to Downloads"
+ } else {
+ binding.backButton.text = "Back to Home"
+ }
+
+ binding.backButton.setOnClickListener {
+ finish()
+ }
+
+ // Check network status for streaming content
+ if ((contentType == CONTENT_TYPE_DRM || contentType == CONTENT_TYPE_NON_DRM) &&
+ NetworkUtils.isOfflineMode(this)) {
+ Toast.makeText(this, "Cannot play streaming content in offline mode", Toast.LENGTH_LONG).show()
+ finish()
+ return
+ }
+
+ // Initialize player based on content type
+ when (contentType) {
+ CONTENT_TYPE_DRM -> {
+ binding.contentTitle.text = "DRM Protected Content"
+ initializeStreamingPlayer(contentId, accessToken)
+ }
+ CONTENT_TYPE_NON_DRM -> {
+ binding.contentTitle.text = "Non-DRM Content"
+ initializeStreamingPlayer(contentId, accessToken)
+ }
+ CONTENT_TYPE_DOWNLOAD -> {
+ binding.contentTitle.text = "Downloaded Content"
+ initializeOfflinePlayer(contentId)
+ }
+ else -> {
+ Toast.makeText(this, "Invalid content type", Toast.LENGTH_SHORT).show()
+ finish()
+ }
+ }
+ }
+
+ private fun initializeStreamingPlayer(contentId: String, accessToken: String) {
+ try {
+ // Check for internet connectivity
+ if (!NetworkUtils.isNetworkAvailable(this)) {
+ Toast.makeText(this, "No internet connection available. Cannot play streaming content.", Toast.LENGTH_LONG).show()
+ binding.contentTitle.text = "${binding.contentTitle.text} (No Internet)"
+ return
+ }
+
+ player = TPStreamsPlayer.create(
+ context = applicationContext,
+ assetId = contentId,
+ accessToken = accessToken,
+ shouldAutoPlay = true
+ )
+
+ setupPlayerView()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error initializing streaming player: ${e.message}", e)
+ Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun initializeOfflinePlayer(contentId: String) {
+ try {
+ // First verify the download is complete and valid
+ if (!DownloadUtils.verifyDownload(this, contentId)) {
+ Toast.makeText(this, "Content not fully downloaded or download is invalid", Toast.LENGTH_SHORT).show()
+ finish()
+ return
+ }
+
+ player = TPStreamsPlayer.create(
+ this,
+ contentId,
+ "", // No access token needed for offline playback
+ false // Don't auto-play, we'll control it manually
+ )
+
+ val success = DownloadUtils.playOfflineContent(player!!, contentId)
+ if (success) {
+ player?.play()
+ setupPlayerView()
+ } else {
+ Toast.makeText(this, "Failed to play downloaded content", Toast.LENGTH_SHORT).show()
+ finish()
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error playing offline content: ${e.message}", e)
+ Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_SHORT).show()
+ finish()
+ }
+ }
+
+ private fun setupPlayerView() {
+ binding.playerView.player = player
+ binding.playerView.showController()
+
+ // Add error listener
+ player?.addListener(object : Player.Listener {
+ override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
+ Log.e(TAG, "Player error: ${error.message}")
+ Toast.makeText(
+ this@PlayerActivity,
+ "Playback error: ${error.message}",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ })
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ player?.release()
+ player = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/tpstreams/player/PlayerUIViewModel.kt b/app/src/main/java/com/tpstreams/player/PlayerUIViewModel.kt
index b9b3f6e0..d945da34 100644
--- a/app/src/main/java/com/tpstreams/player/PlayerUIViewModel.kt
+++ b/app/src/main/java/com/tpstreams/player/PlayerUIViewModel.kt
@@ -9,14 +9,27 @@ const val TAG = "PlayerUIViewModel"
class PlayerUIViewModel(application: Application) : AndroidViewModel(application) {
// Use application context to avoid memory leaks
- val player: TPStreamsPlayer by lazy {
- TPStreamsPlayer.create(
+ var player: TPStreamsPlayer = createDefaultPlayer(application)
+
+ private fun createDefaultPlayer(application: Application): TPStreamsPlayer {
+ // Initialize the SDK with the correct organization ID
+ TPStreamsPlayer.init("9q94nm", application.applicationContext)
+
+ return TPStreamsPlayer.create(
context = application.applicationContext,
- assetId = "8rEx9apZHFF",
- accessToken = "19aa0055-d965-4654-8fce-b804e70a46b0",
+ assetId = "ACGhHuD7DEa", // Non-DRM content ID
+ accessToken = "5bea276d-7882-4f8f-951a-c628622817e0",
shouldAutoPlay = false
)
}
+
+ fun updatePlayer(newPlayer: TPStreamsPlayer) {
+ // Release the old player
+ player.release()
+
+ // Update with the new player
+ player = newPlayer
+ }
override fun onCleared() {
super.onCleared()
diff --git a/app/src/main/java/com/tpstreams/player/utils/NetworkUtils.kt b/app/src/main/java/com/tpstreams/player/utils/NetworkUtils.kt
new file mode 100644
index 00000000..f333e258
--- /dev/null
+++ b/app/src/main/java/com/tpstreams/player/utils/NetworkUtils.kt
@@ -0,0 +1,34 @@
+package com.tpstreams.player.utils
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+
+/**
+ * Utility class for network-related operations
+ */
+object NetworkUtils {
+
+ /**
+ * Check if the device has an active internet connection
+ *
+ * @param context Application or activity context
+ * @return true if internet is available, false otherwise
+ */
+ fun isNetworkAvailable(context: Context): Boolean {
+ val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val network = connectivityManager.activeNetwork ?: return false
+ val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
+ return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ }
+
+ /**
+ * Check if the device is in offline mode (no internet connection)
+ *
+ * @param context Application or activity context
+ * @return true if device is offline, false otherwise
+ */
+ fun isOfflineMode(context: Context): Boolean {
+ return !isNetworkAvailable(context)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/drawable/rounded_button_background.xml b/app/src/main/res/drawable/rounded_button_background.xml
new file mode 100644
index 00000000..d64b74a9
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_button_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_downloads.xml b/app/src/main/res/layout/activity_downloads.xml
new file mode 100644
index 00000000..3cb10317
--- /dev/null
+++ b/app/src/main/res/layout/activity_downloads.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index ecd6e3fb..abd48746 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -4,16 +4,53 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
+ android:fitsSystemWindows="true"
tools:context=".MainActivity">
-
+ app:layout_constraintEnd_toEndOf="parent">
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml
new file mode 100644
index 00000000..fc658c6c
--- /dev/null
+++ b/app/src/main/res/layout/activity_player.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_download.xml b/app/src/main/res/layout/item_download.xml
new file mode 100644
index 00000000..84c76751
--- /dev/null
+++ b/app/src/main/res/layout/item_download.xml
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tpstreams-android-player/build.gradle.kts b/tpstreams-android-player/build.gradle.kts
index bed24cec..a4462ce8 100644
--- a/tpstreams-android-player/build.gradle.kts
+++ b/tpstreams-android-player/build.gradle.kts
@@ -1,6 +1,8 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
+ id("maven-publish")
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21"
}
android {
@@ -30,6 +32,18 @@ android {
kotlinOptions {
jvmTarget = "11"
}
+ lint {
+ abortOnError = false
+ checkReleaseBuilds = false
+ ignoreWarnings = true
+ quiet = true
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.8"
+ }
}
dependencies {
@@ -37,16 +51,43 @@ dependencies {
implementation(libs.androidx.appcompat)
api(libs.material)
+ // Media3 dependencies
api(libs.androidx.media3.exoplayer)
api(libs.androidx.media3.exoplayer.dash)
- api(libs.androidx.media3.exoplayer.hls)
api(libs.androidx.media3.ui)
+ api(libs.androidx.media3.exoplayer.hls)
+
+ // Additional Media3 dependencies for download functionality
+ implementation("androidx.media3:media3-database:${libs.versions.media3.get()}")
+ implementation("androidx.media3:media3-datasource:${libs.versions.media3.get()}")
+ implementation("androidx.media3:media3-common:${libs.versions.media3.get()}")
+ implementation("androidx.media3:media3-exoplayer:${libs.versions.media3.get()}")
+ implementation("androidx.media3:media3-exoplayer-workmanager:${libs.versions.media3.get()}")
+
+ // Compose dependencies for UI components
+ implementation("androidx.compose.material3:material3:1.2.0")
+ implementation("androidx.compose.material:material-icons-extended:1.6.3")
+
implementation(libs.kotlinx.coroutines.android)
api(libs.okhttp)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
+ afterEvaluate {
+ publishing {
+ publications {
+ create("release") {
+ groupId = "com.tpstreams"
+ artifactId = "tpstreams-player"
+ version = "1.0.2"
+ from(components["release"])
+ }
+ }
+ repositories {
+ mavenLocal()
+ }
+ }
+ }
}
-apply(from = rootProject.file("gradle/gradle-mvn-build-packages.gradle"))
diff --git a/tpstreams-android-player/src/main/AndroidManifest.xml b/tpstreams-android-player/src/main/AndroidManifest.xml
index 74b7379f..8805b19a 100644
--- a/tpstreams-android-player/src/main/AndroidManifest.xml
+++ b/tpstreams-android-player/src/main/AndroidManifest.xml
@@ -1,3 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/PlayerSettingsBottomSheet.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/PlayerSettingsBottomSheet.kt
index a7591971..806ea352 100644
--- a/tpstreams-android-player/src/main/java/com/tpstreams/player/PlayerSettingsBottomSheet.kt
+++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/PlayerSettingsBottomSheet.kt
@@ -1,28 +1,35 @@
package com.tpstreams.player
import android.app.Dialog
+import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
+import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.FragmentManager
+import androidx.media3.common.util.UnstableApi
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.tpstreams.player.offline.DownloadListActivity
+@UnstableApi
class PlayerSettingsBottomSheet : BottomSheetDialogFragment() {
interface SettingsListener {
fun onQualitySelected()
fun onCaptionsSelected()
fun onPlaybackSpeedSelected()
+ fun onDownloadSelected()
fun getCurrentQuality(): String
fun getCurrentCaptionStatus(): String
fun getPlaybackSpeed(): Float
+ fun isVideoDownloaded(): Boolean
}
private var listener: SettingsListener? = null
@@ -77,6 +84,23 @@ class PlayerSettingsBottomSheet : BottomSheetDialogFragment() {
val currentSpeed = listener?.getPlaybackSpeed() ?: 1.0f
currentSpeedText.text = String.format("%.2fx", currentSpeed)
+ // Update download option visibility based on download status
+ val isDownloaded = listener?.isVideoDownloaded() ?: false
+ val downloadOption = view.findViewById(R.id.download_option)
+ val currentDownloadText = view.findViewById(R.id.current_download_text)
+ val downloadChevron = view.findViewById(R.id.download_chevron)
+
+ if (isDownloaded) {
+ currentDownloadText.text = "Downloaded"
+ downloadChevron.visibility = View.GONE
+ } else {
+ currentDownloadText.text = ""
+ downloadChevron.visibility = View.VISIBLE
+ }
+
+ // Hide the view downloads option
+ view.findViewById(R.id.view_downloads_option)?.visibility = View.GONE
+
view.findViewById(R.id.quality_option)?.setOnClickListener {
Log.d(TAG, "Quality option clicked")
showQualityOptions()
@@ -94,6 +118,12 @@ class PlayerSettingsBottomSheet : BottomSheetDialogFragment() {
listener?.onPlaybackSpeedSelected()
dismiss()
}
+
+ view.findViewById(R.id.download_option)?.setOnClickListener {
+ Log.d(TAG, "Download option clicked")
+ listener?.onDownloadSelected()
+ dismiss()
+ }
}
private fun showQualityOptions() {
diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/TPStreamsPlayer.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/TPStreamsPlayer.kt
index cf1b5bb6..cef31cd5 100644
--- a/tpstreams-android-player/src/main/java/com/tpstreams/player/TPStreamsPlayer.kt
+++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/TPStreamsPlayer.kt
@@ -24,18 +24,26 @@ import androidx.media3.common.MimeTypes
import androidx.media3.common.Tracks
import androidx.media3.common.PlaybackException
import androidx.media3.common.PlaybackParameters
+import androidx.media3.datasource.cache.SimpleCache
+import androidx.media3.datasource.cache.NoOpCacheEvictor
+import androidx.media3.datasource.cache.Cache
+import androidx.media3.datasource.cache.CacheDataSource
+import androidx.media3.database.StandaloneDatabaseProvider
+import java.io.File
class TPStreamsPlayer @OptIn(UnstableApi::class)
private constructor(
- private val exoPlayer: ExoPlayer,
+ internal val exoPlayer: ExoPlayer,
private val trackSelector: DefaultTrackSelector,
- assetId: String,
- accessToken: String,
- private val shouldAutoPlay: Boolean = true
+ private val context: Context,
+ private var currentAssetId: String,
+ private var accessToken: String,
+ private var shouldAutoPlay: Boolean
) : Player by exoPlayer {
private var isPrepared = false
private var requestedPlay = false
+ private var videoUrl: String = ""
private var subtitleMetadata = mapOf()
@@ -74,10 +82,11 @@ private constructor(
val org = organizationId
?: throw IllegalStateException("TPStreamsPlayer.init(organizationId) must be called before using the player.")
- fetchAndPrepare(org, assetId, accessToken)
+ fetchAndPrepare(org, currentAssetId, accessToken)
}
private fun fetchAndPrepare(orgId: String, assetId: String, accessToken: String) {
+ Log.d("TPStreamsPlayer", "Starting fetchAndPrepare for assetId: $assetId")
CoroutineScope(Dispatchers.IO).launch {
try {
val assetApiUrl =
@@ -101,6 +110,11 @@ private constructor(
} else {
videoObj.getString("playback_url")
}
+
+ // Store the video URL for download functionality
+ videoUrl = mediaUrl
+ Log.d("TPStreamsPlayer", "Video URL set: $videoUrl")
+ Log.d("TPStreamsPlayer", "Asset ID: $currentAssetId")
// Extract subtitle tracks from metadata
val subtitleConfigurations = mutableListOf()
@@ -359,11 +373,133 @@ private constructor(
return subtitleMetadata[language] ?: false
}
+ /**
+ * Get the current video URL for downloading
+ * @return The URL of the current video
+ */
+ fun getVideoUrl(): String {
+ Log.d("TPStreamsPlayer", "getVideoUrl called, returning: $videoUrl")
+ return videoUrl
+ }
+
+ /**
+ * Get the current asset ID
+ * @return The ID of the current asset
+ */
+ fun getAssetId(): String {
+ Log.d("TPStreamsPlayer", "getAssetId called, returning: $currentAssetId")
+ return currentAssetId
+ }
+
+ /**
+ * Get the player context
+ * @return The context
+ */
+ fun getContext(): Context {
+ return context
+ }
+
+ /**
+ * Get the ExoPlayer instance
+ * @return The ExoPlayer instance
+ */
+ fun getExoPlayer(): androidx.media3.exoplayer.ExoPlayer {
+ return exoPlayer
+ }
+
+ /**
+ * Play offline content using its content ID
+ * @param contentId The unique identifier of the content
+ * @param context The context to use for accessing the download manager
+ * @return true if successful
+ */
+ @OptIn(UnstableApi::class)
+ fun playOfflineContent(contentId: String, context: Context? = null): Boolean {
+ try {
+ Log.d("TPStreamsPlayer", "Playing offline content: $contentId")
+
+ // Get the download from the download manager
+ val appContext = context ?: this.context
+ val downloads = com.tpstreams.player.offline.VideoDownloadManager.getDownloads(appContext)
+ val download = downloads.find { it.request.id == contentId }
+
+ if (download == null) {
+ Log.e("TPStreamsPlayer", "Download not found for contentId: $contentId")
+ return false
+ }
+
+ // Get the URI from the download request
+ val uri = download.request.uri
+
+ // Create a media source factory with the cache
+ val cache = com.tpstreams.player.offline.SharedCacheUtil.getCache(appContext)
+ val httpDataSourceFactory = com.tpstreams.player.offline.VideoDownloadManager.getHttpDataSourceFactory(appContext)
+
+ val dataSourceFactory = androidx.media3.datasource.cache.CacheDataSource.Factory()
+ .setCache(cache)
+ .setUpstreamDataSourceFactory(httpDataSourceFactory)
+ .setCacheWriteDataSinkFactory(null) // Disable writing to cache during playback
+ .setFlags(androidx.media3.datasource.cache.CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
+
+ // Create a media source based on the content type
+ val mediaSourceFactory = androidx.media3.exoplayer.source.DefaultMediaSourceFactory(dataSourceFactory)
+
+ // Create a media item with the content ID
+ val mediaItemBuilder = androidx.media3.common.MediaItem.Builder()
+ .setMediaId(contentId)
+ .setUri(uri)
+
+ // Add DRM configuration only for DRM content
+ val uriString = uri.toString()
+ if (uriString.endsWith(".mpd") || uriString.contains(".mpd?")) {
+ // DASH content is likely DRM-protected
+ mediaItemBuilder.setDrmConfiguration(
+ androidx.media3.common.MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID)
+ .setForceDefaultLicenseUri(false)
+ .build()
+ )
+ }
+
+ val mediaItem = mediaItemBuilder.build()
+
+ // Create the media source directly
+ val mediaSource = mediaSourceFactory.createMediaSource(mediaItem)
+
+ // Set the media source and prepare the player
+ exoPlayer.setMediaSource(mediaSource)
+ exoPlayer.prepare()
+ isPrepared = true
+
+ // Update current asset ID for tracking
+ currentAssetId = contentId
+ videoUrl = uri.toString()
+
+ if (shouldAutoPlay) {
+ exoPlayer.play()
+ }
+
+ return true
+ } catch (e: Exception) {
+ Log.e("TPStreamsPlayer", "Error playing offline content: ${e.message}", e)
+ return false
+ }
+ }
+
companion object {
private var organizationId: String? = null
- fun init(orgId: String) {
+ fun init(orgId: String, context: Context? = null) {
organizationId = orgId
+
+ // Initialize download manager if context is provided
+ if (context != null) {
+ try {
+ Log.d("TPStreamsPlayer", "Automatically initializing download manager")
+ com.tpstreams.player.offline.VideoDownloadManager.initialize(context)
+ } catch (e: Exception) {
+ Log.e("TPStreamsPlayer", "Failed to initialize download manager", e)
+ }
+ }
}
@OptIn(UnstableApi::class)
@@ -374,10 +510,26 @@ private constructor(
.build()
}
- val dataSourceFactory = DefaultHttpDataSource.Factory()
+ // Create HTTP data source factory with cross-protocol redirects enabled
+ val httpDataSourceFactory = DefaultHttpDataSource.Factory()
.setUserAgent("TPStreamsPlayer")
.setAllowCrossProtocolRedirects(true)
+ // Use cache data source factory for offline playback support
+ val dataSourceFactory = try {
+ // Get the shared cache instance
+ val cache = com.tpstreams.player.offline.SharedCacheUtil.getCache(context)
+
+ CacheDataSource.Factory()
+ .setCache(cache)
+ .setUpstreamDataSourceFactory(httpDataSourceFactory)
+ .setCacheWriteDataSinkFactory(null) // Disable writing to cache during playback
+ .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
+ } catch (e: Exception) {
+ Log.e("TPStreamsPlayer", "Error creating cache data source, falling back to HTTP", e)
+ httpDataSourceFactory
+ }
+
val mediaSourceFactory = DefaultMediaSourceFactory(context)
.setDataSourceFactory(dataSourceFactory)
@@ -395,7 +547,7 @@ private constructor(
shouldAutoPlay: Boolean = true
): TPStreamsPlayer {
val (exo, trackSelector) = createExoPlayer(context)
- return TPStreamsPlayer(exo, trackSelector, assetId, accessToken, shouldAutoPlay)
+ return TPStreamsPlayer(exo, trackSelector, context, assetId, accessToken, shouldAutoPlay)
}
}
}
diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/TPStreamsPlayerView.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/TPStreamsPlayerView.kt
index ecc40d90..31ce2295 100644
--- a/tpstreams-android-player/src/main/java/com/tpstreams/player/TPStreamsPlayerView.kt
+++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/TPStreamsPlayerView.kt
@@ -4,17 +4,22 @@ import android.content.Context
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.graphics.Color
+import android.os.Bundle
import android.util.AttributeSet
import android.util.Log
import android.view.View
import android.view.ViewGroup
+import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
+import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.LifecycleOwner
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerView
+import com.tpstreams.player.offline.DownloadQualityBottomSheet
+import com.tpstreams.player.offline.TPStreamsPlayerDownloadExt
import java.util.Locale
@UnstableApi
@@ -27,7 +32,8 @@ class TPStreamsPlayerView @JvmOverloads constructor(
QualityOptionsBottomSheet.QualityOptionsListener,
AdvancedResolutionBottomSheet.ResolutionSelectionListener,
PlaybackSpeedBottomSheet.PlaybackSpeedListener,
- CaptionsOptionsBottomSheet.CaptionsOptionsListener {
+ CaptionsOptionsBottomSheet.CaptionsOptionsListener,
+ DownloadQualityBottomSheet.DownloadQualityListener {
private var playerControlView: TPStreamsPlayerControlView? = null
@@ -92,6 +98,12 @@ class TPStreamsPlayerView @JvmOverloads constructor(
}
}
+ private val downloadQualityBottomSheet: DownloadQualityBottomSheet by lazy {
+ DownloadQualityBottomSheet().apply {
+ setDownloadQualityListener(this@TPStreamsPlayerView)
+ }
+ }
+
// Current quality setting, updated when user changes quality
private var currentQuality: String = QualityOptionsBottomSheet.QUALITY_AUTO
private var availableResolutions: List = emptyList()
@@ -103,6 +115,11 @@ class TPStreamsPlayerView @JvmOverloads constructor(
private var currentCaptionLanguage: String? = null
private var availableCaptions: List> = emptyList()
+ // Current video URL and content ID
+ private var currentVideoUrl: String = ""
+ private var currentContentId: String = ""
+ private var isDownloaded: Boolean = false
+
override fun onFinishInflate() {
super.onFinishInflate()
playerControlView = findViewById(androidx.media3.ui.R.id.exo_controller) as? TPStreamsPlayerControlView
@@ -455,11 +472,34 @@ class TPStreamsPlayerView @JvmOverloads constructor(
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY) {
updateAvailableCaptions()
+
+ // Update video info when playback is ready
+ updateVideoInfo()
}
}
})
updateAvailableCaptions()
+
+ // Set video info for download functionality
+ updateVideoInfo()
+ }
+ }
+
+ /**
+ * Update video info from the player for download functionality
+ */
+ private fun updateVideoInfo() {
+ val tpsPlayer = player as? TPStreamsPlayer ?: return
+ val videoUrl = tpsPlayer.getVideoUrl()
+ val contentId = tpsPlayer.getAssetId()
+
+ Log.d("TPStreamsPlayerView", "Updating video info - URL: $videoUrl, ID: $contentId")
+
+ if (videoUrl.isNotEmpty() && contentId.isNotEmpty()) {
+ setVideoInfo(videoUrl, contentId)
+ } else {
+ Log.w("TPStreamsPlayerView", "Video URL or content ID is empty")
}
}
@@ -604,4 +644,113 @@ class TPStreamsPlayerView @JvmOverloads constructor(
lifecycleOwner.lifecycle.removeObserver(lifecycleManager!!)
}
}
+
+ /**
+ * Set the current video URL and content ID for download functionality
+ */
+ fun setVideoInfo(videoUrl: String, contentId: String) {
+ this.currentVideoUrl = videoUrl
+ this.currentContentId = contentId
+
+ // Check if this video is already downloaded
+ checkDownloadStatus()
+ }
+
+ /**
+ * Check if the current video is downloaded
+ */
+ private fun checkDownloadStatus() {
+ if (currentContentId.isEmpty()) {
+ Log.d("TPStreamsPlayerView", "Cannot check download status: Content ID is empty")
+ return
+ }
+
+ try {
+ // Initialize the download manager first to ensure it's ready
+ TPStreamsPlayerDownloadExt.initializeDownloadManager(context)
+
+ isDownloaded = TPStreamsPlayerDownloadExt.isDownloaded(context, currentContentId)
+ Log.d("TPStreamsPlayerView", "Checked download status for $currentContentId: $isDownloaded")
+ } catch (e: Exception) {
+ Log.e("TPStreamsPlayerView", "Error checking download status", e)
+ isDownloaded = false
+ }
+ }
+
+ // Implementation of PlayerSettingsBottomSheet.SettingsListener
+ override fun onDownloadSelected() {
+ val activity = getActivity() ?: return
+
+ if (currentVideoUrl.isEmpty() || currentContentId.isEmpty()) {
+ Log.e("TPStreamsPlayerView", "Cannot download: Video URL or Content ID is empty")
+ Toast.makeText(context, "Cannot download this video", Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ // Check if this is a DRM-protected DASH stream
+ if (currentVideoUrl.endsWith(".mpd") || currentVideoUrl.contains(".mpd?")) {
+ Log.e("TPStreamsPlayerView", "Cannot download DRM-protected content: $currentVideoUrl")
+ Toast.makeText(context, "DRM-protected content cannot be downloaded", Toast.LENGTH_LONG).show()
+ return
+ }
+
+ try {
+ // Initialize the download manager if needed
+ TPStreamsPlayerDownloadExt.initializeDownloadManager(context)
+
+ if (isDownloaded) {
+ // If already downloaded, delete it directly
+ TPStreamsPlayerDownloadExt.removeDownload(context, currentContentId)
+ isDownloaded = false
+ Toast.makeText(context, "Download removed", Toast.LENGTH_SHORT).show()
+ } else {
+ // Show the download quality selection bottom sheet
+ downloadQualityBottomSheet.show(activity.supportFragmentManager)
+ }
+ } catch (e: Exception) {
+ Log.e("TPStreamsPlayerView", "Error in download selection", e)
+ Toast.makeText(context, "Download functionality unavailable", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun isVideoDownloaded(): Boolean {
+ checkDownloadStatus() // Refresh status
+ return isDownloaded
+ }
+
+ // Implementation of DownloadQualityBottomSheet.DownloadQualityListener
+ override fun onDownloadQualitySelected(videoUrl: String, contentId: String, quality: String) {
+ Log.d("TPStreamsPlayerView", "Starting download: $videoUrl, $contentId, $quality")
+ TPStreamsPlayerDownloadExt.startDownload(
+ context,
+ videoUrl,
+ contentId,
+ selectedQuality = quality
+ )
+
+ // Update download status after a delay to allow download to start
+ postDelayed({
+ checkDownloadStatus()
+ }, 1000)
+ }
+
+ override fun getAvailableQualities(): List {
+ // Use the same resolutions as available for playback
+ return availableResolutions.ifEmpty { listOf("Auto") }
+ }
+
+ override fun getVideoUrl(): String = currentVideoUrl
+
+ override fun getContentId(): String = currentContentId
+
+ /**
+ * Manually set video info for download functionality
+ * This can be used when the automatic detection fails
+ * @param videoUrl The URL of the video to download
+ * @param contentId A unique identifier for the content
+ */
+ fun manuallySetVideoInfo(videoUrl: String, contentId: String) {
+ Log.d("TPStreamsPlayerView", "Manually setting video info - URL: $videoUrl, ID: $contentId")
+ setVideoInfo(videoUrl, contentId)
+ }
}
diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/DownloadListActivity.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/DownloadListActivity.kt
new file mode 100644
index 00000000..b16c0e5a
--- /dev/null
+++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/DownloadListActivity.kt
@@ -0,0 +1,154 @@
+package com.tpstreams.player.offline
+
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ProgressBar
+import android.widget.TextView
+import android.widget.Toast
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.Toolbar
+import androidx.core.content.ContextCompat
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.offline.Download
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.tpstreams.player.R
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@UnstableApi
+class DownloadListActivity : AppCompatActivity() {
+
+ private lateinit var recyclerView: RecyclerView
+ private lateinit var emptyView: TextView
+ private lateinit var adapter: DownloadAdapter
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_download_list)
+
+ val toolbar = findViewById(R.id.toolbar)
+ setSupportActionBar(toolbar)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ supportActionBar?.title = "Downloads"
+
+ toolbar.setNavigationOnClickListener {
+ onBackPressed()
+ }
+
+ recyclerView = findViewById(R.id.recyclerView)
+ emptyView = findViewById(R.id.emptyView)
+
+ recyclerView.layoutManager = LinearLayoutManager(this)
+ recyclerView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
+
+ adapter = DownloadAdapter(emptyList()) { download ->
+ showDeleteConfirmation(download)
+ }
+ recyclerView.adapter = adapter
+
+ // Initialize download manager
+ TPStreamsPlayerDownloadExt.initializeDownloadManager(this)
+
+ // Load downloads
+ loadDownloads()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ loadDownloads()
+ }
+
+ private fun loadDownloads() {
+ val downloads = TPStreamsPlayerDownloadExt.getDownloads(this)
+
+ if (downloads.isEmpty()) {
+ recyclerView.visibility = View.GONE
+ emptyView.visibility = View.VISIBLE
+ } else {
+ recyclerView.visibility = View.VISIBLE
+ emptyView.visibility = View.GONE
+ adapter.updateDownloads(downloads)
+ }
+ }
+
+ private fun showDeleteConfirmation(download: Download) {
+ AlertDialog.Builder(this)
+ .setTitle("Delete Download")
+ .setMessage("Are you sure you want to delete this download?")
+ .setPositiveButton("Delete") { _, _ ->
+ TPStreamsPlayerDownloadExt.removeDownload(this, download.request.id)
+ Toast.makeText(this, "Download deleted", Toast.LENGTH_SHORT).show()
+ loadDownloads()
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+
+ inner class DownloadAdapter(
+ private var downloads: List,
+ private val onDeleteClick: (Download) -> Unit
+ ) : RecyclerView.Adapter() {
+
+ fun updateDownloads(newDownloads: List) {
+ downloads = newDownloads
+ notifyDataSetChanged()
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val view = LayoutInflater.from(parent.context)
+ .inflate(R.layout.item_download, parent, false)
+ return ViewHolder(view)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val download = downloads[position]
+ holder.bind(download)
+ }
+
+ override fun getItemCount(): Int = downloads.size
+
+ inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ private val titleTextView: TextView = itemView.findViewById(R.id.titleTextView)
+ private val statusTextView: TextView = itemView.findViewById(R.id.statusTextView)
+ private val progressBar: ProgressBar = itemView.findViewById(R.id.progressBar)
+ private val deleteButton: Button = itemView.findViewById(R.id.deleteButton)
+
+ fun bind(download: Download) {
+ titleTextView.text = download.request.id
+
+ val status = when (download.state) {
+ Download.STATE_COMPLETED -> "Completed"
+ Download.STATE_DOWNLOADING -> "Downloading ${download.percentDownloaded.toInt()}%"
+ Download.STATE_FAILED -> "Failed"
+ Download.STATE_QUEUED -> "Queued"
+ Download.STATE_REMOVING -> "Removing"
+ Download.STATE_RESTARTING -> "Restarting"
+ Download.STATE_STOPPED -> "Stopped"
+ else -> "Unknown"
+ }
+
+ statusTextView.text = status
+
+ if (download.state == Download.STATE_DOWNLOADING) {
+ progressBar.visibility = View.VISIBLE
+ progressBar.progress = download.percentDownloaded.toInt()
+ } else {
+ progressBar.visibility = if (download.state == Download.STATE_COMPLETED) View.GONE else View.VISIBLE
+ progressBar.progress = 0
+ }
+
+ deleteButton.setOnClickListener {
+ onDeleteClick(download)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/DownloadNotificationHelper.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/DownloadNotificationHelper.kt
new file mode 100644
index 00000000..5c327dc8
--- /dev/null
+++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/DownloadNotificationHelper.kt
@@ -0,0 +1,112 @@
+package com.tpstreams.player.offline
+
+import android.annotation.SuppressLint
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import com.tpstreams.player.R
+
+private const val TAG = "TPStreamsDownloadNotification"
+
+class DownloadNotificationHelper(private val context: Context) {
+
+ init {
+ createNotificationChannel()
+ }
+
+ @SuppressLint("LongLogTag")
+ fun buildProgressNotification(
+ contentId: String,
+ progress: Float,
+ smallIcon: Int = R.drawable.ic_download
+ ): Notification {
+ Log.d(TAG, "Building progress notification for $contentId: ${progress.toInt()}%")
+ val builder = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(smallIcon)
+ .setContentTitle("Downloading video")
+ .setContentText("$contentId: ${progress.toInt()}%")
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setOngoing(true)
+ .setContentIntent(getContentIntent())
+
+ if (progress > 0) {
+ builder.setProgress(100, progress.toInt(), false)
+ } else {
+ builder.setProgress(100, 0, true)
+ }
+
+ return builder.build()
+ }
+
+ fun buildCompletedNotification(
+ contentId: String,
+ smallIcon: Int = R.drawable.ic_download
+ ): Notification {
+ Log.d(TAG, "Building completed notification for $contentId")
+ return NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(smallIcon)
+ .setContentTitle("Download complete")
+ .setContentText(contentId)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setAutoCancel(true)
+ .setContentIntent(getContentIntent())
+ .build()
+ }
+
+ fun buildFailedNotification(
+ contentId: String,
+ smallIcon: Int = R.drawable.ic_download
+ ): Notification {
+ Log.d(TAG, "Building failed notification for $contentId")
+ return NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(smallIcon)
+ .setContentTitle("Download failed")
+ .setContentText("Failed to download $contentId")
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setAutoCancel(true)
+ .setContentIntent(getContentIntent())
+ .build()
+ }
+
+ private fun getContentIntent(): PendingIntent {
+ // Create a generic intent that will be handled by the app's main activity
+ val intent = Intent().apply {
+ setPackage(context.packageName)
+ action = "com.tpstreams.player.action.OPEN_DOWNLOADS"
+ }
+
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ } else {
+ PendingIntent.FLAG_UPDATE_CURRENT
+ }
+
+ return PendingIntent.getActivity(context, 0, intent, flags)
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val name = context.getString(R.string.download_channel_name)
+ val description = context.getString(R.string.download_channel_description)
+ val importance = NotificationManager.IMPORTANCE_LOW
+ val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
+ this.description = description
+ }
+ val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.createNotificationChannel(channel)
+ Log.d(TAG, "Notification channel created")
+ }
+ }
+
+ companion object {
+ const val CHANNEL_ID = VideoDownloadManager.DOWNLOAD_NOTIFICATION_CHANNEL_ID
+ const val NOTIFICATION_ID = 1
+ const val COMPLETION_NOTIFICATION_ID = 2
+ }
+}
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/DownloadQualityBottomSheet.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/DownloadQualityBottomSheet.kt
new file mode 100644
index 00000000..fe7162c8
--- /dev/null
+++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/DownloadQualityBottomSheet.kt
@@ -0,0 +1,130 @@
+package com.tpstreams.player.offline
+
+import android.app.Dialog
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.RadioButton
+import android.widget.RadioGroup
+import android.widget.TextView
+import android.widget.Toast
+import androidx.fragment.app.FragmentManager
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.tpstreams.player.R
+
+class DownloadQualityBottomSheet : BottomSheetDialogFragment() {
+
+ interface DownloadQualityListener {
+ fun onDownloadQualitySelected(videoUrl: String, contentId: String, quality: String)
+ fun getAvailableQualities(): List
+ fun getVideoUrl(): String
+ fun getContentId(): String
+ }
+
+ private var listener: DownloadQualityListener? = null
+
+ fun setDownloadQualityListener(listener: DownloadQualityListener) {
+ this.listener = listener
+ }
+
+ override fun getTheme(): Int = R.style.BottomSheetDialogTheme
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
+
+ dialog.setOnShowListener { dialogInterface ->
+ val bottomSheetDialog = dialogInterface as BottomSheetDialog
+ val bottomSheet = bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet)
+
+ bottomSheet?.let {
+ val behavior = BottomSheetBehavior.from(it)
+ behavior.skipCollapsed = true
+ behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ }
+ }
+
+ return dialog
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ return inflater.inflate(R.layout.layout_download_quality_bottom_sheet, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ Log.d(TAG, "Setting up download quality bottom sheet")
+
+ val titleText = view.findViewById(R.id.title_text)
+ titleText.text = "Select Download Quality"
+
+ val radioGroup = view.findViewById(R.id.quality_radio_group)
+ radioGroup.removeAllViews()
+
+ // Get available qualities from listener
+ val qualities = listener?.getAvailableQualities() ?: listOf("Auto")
+ Log.d(TAG, "Available qualities: $qualities")
+
+ // Add radio buttons for each quality
+ qualities.forEachIndexed { index, quality ->
+ val radioButton = RadioButton(context).apply {
+ id = View.generateViewId()
+ text = quality
+ textSize = 16f
+ setPadding(0, 24, 0, 24)
+ }
+ radioGroup.addView(radioButton)
+
+ // Select the first option by default
+ if (index == 0) {
+ radioButton.isChecked = true
+ }
+ }
+
+ // Add apply button click listener
+ view.findViewById(R.id.apply_button).setOnClickListener {
+ val selectedId = radioGroup.checkedRadioButtonId
+ val selectedRadioButton = view.findViewById(selectedId)
+ val selectedQuality = selectedRadioButton?.text?.toString() ?: "Auto"
+
+ val videoUrl = listener?.getVideoUrl() ?: ""
+ val contentId = listener?.getContentId() ?: ""
+
+ Log.d(TAG, "Selected quality: $selectedQuality for video: $videoUrl, contentId: $contentId")
+
+ if (videoUrl.isNotEmpty() && contentId.isNotEmpty()) {
+ // Start download directly with selected quality
+ TPStreamsPlayerDownloadExt.startDownload(
+ context = requireContext(),
+ videoUrl = videoUrl,
+ contentId = contentId,
+ selectedQuality = selectedQuality
+ )
+
+ listener?.onDownloadQualitySelected(videoUrl, contentId, selectedQuality)
+ dismiss()
+ } else {
+ Log.e(TAG, "Cannot start download: videoUrl or contentId is empty")
+ }
+ }
+ }
+
+ fun show(fragmentManager: FragmentManager) {
+ if (!isAdded) {
+ show(fragmentManager, TAG)
+ }
+ }
+
+ companion object {
+ const val TAG = "DownloadQualityBottomSheet"
+ }
+}
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/DownloadTask.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/DownloadTask.kt
new file mode 100644
index 00000000..045cf3d0
--- /dev/null
+++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/DownloadTask.kt
@@ -0,0 +1,463 @@
+package com.tpstreams.player.offline
+
+import android.app.AlertDialog
+import android.content.Context
+import android.util.Log
+import android.widget.Toast
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.StreamKey
+import androidx.media3.common.TrackGroup
+import androidx.media3.common.TrackSelectionParameters
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.DataSource
+import androidx.media3.datasource.DataSpec
+import androidx.media3.datasource.DefaultHttpDataSource
+import androidx.media3.exoplayer.offline.DownloadHelper
+import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
+import androidx.media3.exoplayer.trackselection.MappingTrackSelector
+import com.tpstreams.player.R
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.BufferedReader
+import java.io.IOException
+import java.io.InputStreamReader
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.concurrent.Executors
+import java.util.regex.Pattern
+
+private const val TAG = "TPStreamsDownloadTask"
+
+@UnstableApi
+class DownloadTask(private val context: Context) {
+
+ // Set this to true to show debug information about available qualities
+ private val DEBUG_QUALITIES = true
+
+ /**
+ * Start the download process
+ * @param videoUrl The URL of the video to download
+ * @param contentId A unique identifier for the content
+ * @param selectedQuality Optional quality to use for download
+ */
+ fun startDownload(
+ videoUrl: String,
+ contentId: String,
+ selectedQuality: String? = null
+ ) {
+ Log.d(TAG, "Starting download preparation for URL: $videoUrl")
+
+ // Check if the URL is valid and is a supported stream type
+ if (!isValidStreamUrl(videoUrl)) {
+ Log.e(TAG, "Invalid stream URL: $videoUrl")
+ return
+ }
+
+ // Determine the MIME type based on the URL
+ val mimeType = when {
+ videoUrl.endsWith(".m3u8") || videoUrl.contains(".m3u8?") -> "application/x-mpegURL"
+ videoUrl.endsWith(".mpd") || videoUrl.contains(".mpd?") -> "application/dash+xml"
+ else -> "video/*" // Generic fallback
+ }
+
+ // Create a media item for the video with appropriate MIME type
+ val mediaItem = MediaItem.Builder()
+ .setUri(videoUrl)
+ .setMediaId(contentId)
+ .setMimeType(mimeType)
+ .build()
+
+ // Verify that the URI is valid
+ if (mediaItem.localConfiguration?.uri == null) {
+ Log.e(TAG, "Invalid URI in MediaItem")
+ return
+ }
+
+ Log.d(TAG, "MediaItem created successfully with URI: ${mediaItem.localConfiguration?.uri}, MIME type: $mimeType")
+
+ // For DASH streams, use a different approach
+ if (isDashStream(videoUrl)) {
+ val defaultQualities = createDashQualities(videoUrl)
+ val selectedTrackInfo = if (selectedQuality != null) {
+ defaultQualities.find { it.quality.startsWith(selectedQuality) }
+ } else {
+ defaultQualities.firstOrNull()
+ }
+
+ if (selectedTrackInfo != null) {
+ downloadVariant(mediaItem, selectedTrackInfo)
+ return
+ }
+
+ handleDashStream(videoUrl, contentId, mediaItem, selectedQuality)
+ return
+ }
+
+ // For HLS streams, try to parse the manifest
+ CoroutineScope(Dispatchers.Main).launch {
+ try {
+ // Try to fetch and parse the master playlist
+ val manifestContent = withContext(Dispatchers.IO) {
+ fetchManifestContent(videoUrl)
+ }
+
+ if (manifestContent.isNotEmpty()) {
+ Log.d(TAG, "Successfully fetched manifest content, length: ${manifestContent.length}")
+
+ val trackInfoList = parseM3U8Content(manifestContent, videoUrl)
+
+ if (trackInfoList.isNotEmpty()) {
+ Log.d(TAG, "Found ${trackInfoList.size} qualities from direct m3u8 parsing")
+
+ // Use the selected quality or default to highest
+ val selectedTrackInfo = if (selectedQuality != null) {
+ trackInfoList.find { it.quality.startsWith(selectedQuality) }
+ } else {
+ trackInfoList.firstOrNull()
+ }
+
+ if (selectedTrackInfo != null) {
+ downloadVariant(mediaItem, selectedTrackInfo)
+ } else {
+ downloadVariant(mediaItem, trackInfoList.first())
+ }
+ } else {
+ Log.e(TAG, "No qualities found in the stream")
+ // Fallback to default qualities for HLS
+ val defaultQualities = createDefaultQualities(videoUrl)
+
+ // Use the selected quality or default to highest
+ val selectedTrackInfo = if (selectedQuality != null) {
+ defaultQualities.find { it.quality.startsWith(selectedQuality) }
+ } else {
+ defaultQualities.firstOrNull()
+ }
+
+ if (selectedTrackInfo != null) {
+ downloadVariant(mediaItem, selectedTrackInfo)
+ } else {
+ downloadVariant(mediaItem, defaultQualities.first())
+ }
+ }
+ } else {
+ Log.e(TAG, "Failed to fetch manifest content")
+ // Fallback to default qualities
+ val defaultQualities = createDefaultQualities(videoUrl)
+
+ // Use the selected quality or default to highest
+ val selectedTrackInfo = if (selectedQuality != null) {
+ defaultQualities.find { it.quality.startsWith(selectedQuality) }
+ } else {
+ defaultQualities.firstOrNull()
+ }
+
+ if (selectedTrackInfo != null) {
+ downloadVariant(mediaItem, selectedTrackInfo)
+ } else {
+ downloadVariant(mediaItem, defaultQualities.first())
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error parsing manifest: ${e.message}", e)
+ // Fallback to default qualities
+ val defaultQualities = createDefaultQualities(videoUrl)
+
+ // Use the selected quality or default to highest
+ val selectedTrackInfo = if (selectedQuality != null) {
+ defaultQualities.find { it.quality.startsWith(selectedQuality) }
+ } else {
+ defaultQualities.firstOrNull()
+ }
+
+ if (selectedTrackInfo != null) {
+ downloadVariant(mediaItem, selectedTrackInfo)
+ } else {
+ downloadVariant(mediaItem, defaultQualities.first())
+ }
+ }
+ }
+ }
+
+ /**
+ * Handle DASH stream download
+ */
+ private fun handleDashStream(
+ videoUrl: String,
+ contentId: String,
+ mediaItem: MediaItem,
+ selectedQuality: String? = null
+ ) {
+ Log.d(TAG, "Handling DASH stream: $videoUrl")
+
+ // For DASH streams, we'll use a set of default quality options
+ val defaultQualities = createDashQualities(videoUrl)
+
+ // Use the selected quality or default to highest
+ val selectedTrackInfo = if (selectedQuality != null) {
+ defaultQualities.find { it.quality.startsWith(selectedQuality) }
+ } else {
+ defaultQualities.firstOrNull()
+ }
+
+ if (selectedTrackInfo != null) {
+ downloadVariant(mediaItem, selectedTrackInfo)
+ } else {
+ downloadVariant(mediaItem, defaultQualities.first())
+ }
+ }
+
+ /**
+ * Create default quality options for DASH streams
+ */
+ private fun createDashQualities(videoUrl: String): List {
+ return listOf(
+ TrackInfo(0, 0, 0, 0, "1080p", 4500000, "~20 MB/min", videoUrl),
+ TrackInfo(0, 0, 1, 1, "720p", 2500000, "~11 MB/min", videoUrl),
+ TrackInfo(0, 0, 2, 2, "480p", 1100000, "~5 MB/min", videoUrl),
+ TrackInfo(0, 0, 3, 3, "360p", 730000, "~3.5 MB/min", videoUrl),
+ TrackInfo(0, 0, 4, 4, "240p", 365000, "~1.7 MB/min", videoUrl)
+ )
+ }
+
+ /**
+ * Create default quality options when manifest parsing fails
+ */
+ private fun createDefaultQualities(videoUrl: String): List {
+ return listOf(
+ TrackInfo(0, 0, 0, 0, "720p", 2500000, "~11 MB/min", videoUrl),
+ TrackInfo(0, 0, 1, 1, "480p", 1100000, "~5 MB/min", videoUrl),
+ TrackInfo(0, 0, 2, 2, "360p", 730000, "~3.5 MB/min", videoUrl),
+ TrackInfo(0, 0, 3, 3, "240p", 365000, "~1.7 MB/min", videoUrl)
+ )
+ }
+
+ /**
+ * Check if the URL is a valid stream URL (HLS or DASH)
+ */
+ private fun isValidStreamUrl(url: String): Boolean {
+ return url.isNotEmpty() && (isHlsStream(url) || isDashStream(url))
+ }
+
+ /**
+ * Check if the URL is an HLS stream
+ */
+ private fun isHlsStream(url: String): Boolean {
+ return url.endsWith(".m3u8") || url.contains(".m3u8?")
+ }
+
+ /**
+ * Check if the URL is a DASH stream
+ */
+ private fun isDashStream(url: String): Boolean {
+ return url.endsWith(".mpd") || url.contains(".mpd?")
+ }
+
+ /**
+ * Legacy method for backward compatibility
+ */
+ private fun isValidHlsUrl(url: String): Boolean {
+ return isValidStreamUrl(url)
+ }
+
+ private suspend fun fetchManifestContent(url: String): String {
+ return withContext(Dispatchers.IO) {
+ try {
+ Log.d(TAG, "Fetching manifest from: $url")
+ val connection = URL(url).openConnection() as HttpURLConnection
+ connection.requestMethod = "GET"
+ connection.setRequestProperty("User-Agent", "TPStreamsPlayer")
+ connection.connectTimeout = 15000
+ connection.readTimeout = 15000
+
+ if (connection.responseCode == HttpURLConnection.HTTP_OK) {
+ val reader = BufferedReader(InputStreamReader(connection.inputStream))
+ val content = reader.readText()
+ reader.close()
+ connection.disconnect()
+ content
+ } else {
+ Log.e(TAG, "Failed to fetch M3U8 manifest: HTTP ${connection.responseCode}")
+ ""
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error fetching manifest: ${e.message}", e)
+ ""
+ }
+ }
+ }
+
+ private fun parseM3U8Content(content: String, masterUrl: String): List {
+ val trackInfoList = mutableListOf()
+
+ try {
+ // Check if this is a master playlist or a media playlist
+ if (!content.contains("#EXT-X-STREAM-INF")) {
+ Log.d(TAG, "This appears to be a media playlist, not a master playlist")
+ return emptyList()
+ }
+
+ Log.d(TAG, "Parsing master playlist content")
+ val lines = content.lines()
+ var lineIndex = 0
+ var currentBandwidth = 0
+ var currentResolution = ""
+
+ // Regular expressions to extract information
+ val bandwidthPattern = Pattern.compile("BANDWIDTH=(\\d+)")
+ val resolutionPattern = Pattern.compile("RESOLUTION=(\\d+x\\d+)")
+
+ var i = 0
+ while (i < lines.size) {
+ val line = lines[i]
+ if (line.startsWith("#EXT-X-STREAM-INF:")) {
+ Log.d(TAG, "Found stream info: $line")
+ // Extract bandwidth
+ val bandwidthMatcher = bandwidthPattern.matcher(line)
+ if (bandwidthMatcher.find()) {
+ currentBandwidth = bandwidthMatcher.group(1)?.toIntOrNull() ?: 0
+ Log.d(TAG, "Extracted bandwidth: $currentBandwidth")
+ }
+
+ // Extract resolution
+ val resolutionMatcher = resolutionPattern.matcher(line)
+ if (resolutionMatcher.find()) {
+ currentResolution = resolutionMatcher.group(1) ?: ""
+ Log.d(TAG, "Extracted resolution: $currentResolution")
+ }
+
+ // Look for the next non-comment line, which should be the variant URL
+ var variantUrl = ""
+ var j = i + 1
+ while (j < lines.size) {
+ if (!lines[j].startsWith("#")) {
+ variantUrl = lines[j]
+ Log.d(TAG, "Found variant URL: $variantUrl")
+ break
+ }
+ j++
+ }
+
+ if (variantUrl.isNotEmpty()) {
+ // Resolve relative URL if needed
+ val fullUrl = if (variantUrl.startsWith("http")) {
+ variantUrl
+ } else {
+ // Handle relative URLs
+ val baseUrl = masterUrl.substring(0, masterUrl.lastIndexOf("/") + 1)
+ baseUrl + variantUrl
+ }
+
+ // Only add if we have a resolution
+ if (currentResolution.isNotEmpty()) {
+ // Calculate quality label
+ val resolution = currentResolution.split("x")
+ val width = resolution.getOrNull(0)?.toIntOrNull() ?: 0
+ val height = resolution.getOrNull(1)?.toIntOrNull() ?: 0
+
+ // Create user-friendly quality label
+ val qualityLabel = when {
+ height >= 2160 -> "$currentResolution (4K)"
+ height >= 1440 -> "$currentResolution (2K)"
+ height >= 1080 -> "$currentResolution (Full HD)"
+ height >= 720 -> "$currentResolution (HD)"
+ height >= 480 -> "$currentResolution (SD)"
+ else -> currentResolution
+ }
+
+ // Calculate approximate size
+ val bitrateInMbps = currentBandwidth / 8.0 / 1024.0 / 1024.0 * 60.0
+ val approxSizeMB = if (bitrateInMbps > 0) {
+ String.format("%.1f MB/min", bitrateInMbps)
+ } else {
+ "Unknown size"
+ }
+
+ trackInfoList.add(
+ TrackInfo(
+ periodIndex = 0,
+ rendererIndex = 0,
+ groupIndex = lineIndex,
+ trackIndex = lineIndex,
+ quality = qualityLabel,
+ bitrate = currentBandwidth,
+ approxSize = approxSizeMB,
+ variantUrl = fullUrl
+ )
+ )
+
+ lineIndex++
+ }
+ }
+ }
+ i++
+ }
+
+ // Sort by resolution (height)
+ return trackInfoList.sortedByDescending {
+ val heightStr = it.quality.split("x").getOrNull(1)?.split(" ")?.getOrNull(0) ?: "0"
+ heightStr.toIntOrNull() ?: 0
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error parsing M3U8 content: ${e.message}", e)
+ return emptyList()
+ }
+ }
+
+ private fun downloadVariant(mediaItem: MediaItem, selectedTrack: TrackInfo) {
+ Log.d(TAG, "Starting download for quality: ${selectedTrack.quality}")
+
+ // For a variant stream, we need to use the specific variant URL if available
+ val downloadUri = if (selectedTrack.variantUrl.isNotEmpty()) {
+ Log.d(TAG, "Using specific variant URL: ${selectedTrack.variantUrl}")
+ selectedTrack.variantUrl
+ } else {
+ Log.d(TAG, "Using original URL: ${mediaItem.localConfiguration?.uri}")
+ mediaItem.localConfiguration?.uri.toString()
+ }
+
+ Log.d(TAG, "Using download URI: $downloadUri")
+
+ try {
+ // Create stream keys for the selection
+ val streamKeys = mutableListOf()
+ streamKeys.add(StreamKey(selectedTrack.periodIndex, selectedTrack.rendererIndex, selectedTrack.trackIndex))
+
+ // Build download request
+ val request = VideoDownloadManager.createDownloadRequest(
+ mediaItem.mediaId,
+ downloadUri,
+ streamKeys
+ )
+
+ // Start download
+ VideoDownloadService.startDownload(context, request)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error starting download: ${e.message}", e)
+ }
+ }
+
+ /**
+ * Format bitrate in a human-readable format
+ */
+ private fun formatBitrate(bitrate: Int): String {
+ return when {
+ bitrate <= 0 -> "Unknown bitrate"
+ bitrate < 1000000 -> String.format("%.0f Kbps", bitrate / 1000.0)
+ else -> String.format("%.1f Mbps", bitrate / 1000000.0)
+ }
+ }
+
+ data class TrackInfo(
+ val periodIndex: Int,
+ val rendererIndex: Int,
+ val groupIndex: Int,
+ val trackIndex: Int,
+ val quality: String,
+ val bitrate: Int,
+ val approxSize: String = "Unknown size",
+ val variantUrl: String = ""
+ )
+}
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/DownloadUtils.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/DownloadUtils.kt
new file mode 100644
index 00000000..7c830633
--- /dev/null
+++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/DownloadUtils.kt
@@ -0,0 +1,271 @@
+package com.tpstreams.player.offline
+
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.offline.Download
+import com.tpstreams.player.TPStreamsPlayer
+import com.tpstreams.player.TPStreamsPlayerView
+
+/**
+ * Utility class providing download functionality for app developers
+ */
+@UnstableApi
+object DownloadUtils {
+
+ private const val TAG = "DownloadUtils"
+
+ /**
+ * Get a list of all downloaded videos
+ * @param context Application context
+ * @return List of Download objects
+ */
+ fun getDownloads(context: Context): List {
+ TPStreamsPlayerDownloadExt.initializeDownloadManager(context)
+ return TPStreamsPlayerDownloadExt.getDownloads(context)
+ }
+
+ /**
+ * Check if a specific video is downloaded
+ * @param context Application context
+ * @param contentId The unique identifier of the content
+ * @return true if the video is downloaded, false otherwise
+ */
+ fun isDownloaded(context: Context, contentId: String): Boolean {
+ return TPStreamsPlayerDownloadExt.isDownloaded(context, contentId)
+ }
+
+ /**
+ * Verify if a download is complete and valid
+ * @param context Application context
+ * @param contentId The unique identifier of the content
+ * @return true if the download is complete and valid, false otherwise
+ */
+ fun verifyDownload(context: Context, contentId: String): Boolean {
+ try {
+ // Check if download exists
+ if (!isDownloaded(context, contentId)) {
+ Log.d(TAG, "Download not found for contentId: $contentId")
+ return false
+ }
+
+ // Get the download and check its state
+ val downloads = getDownloads(context)
+ val download = downloads.find { it.request.id == contentId }
+
+ if (download == null) {
+ Log.d(TAG, "Download object not found for contentId: $contentId")
+ return false
+ }
+
+ // Check if download is complete
+ if (download.state != Download.STATE_COMPLETED) {
+ Log.d(TAG, "Download not complete for contentId: $contentId, state: ${download.state}")
+ return false
+ }
+
+ // Check if download percentage is 100%
+ if (download.percentDownloaded < 100) {
+ Log.d(TAG, "Download not 100% for contentId: $contentId, progress: ${download.percentDownloaded}%")
+ return false
+ }
+
+ // Check if the download URI is valid
+ val uri = download.request.uri
+ if (uri == null || uri.toString().isEmpty()) {
+ Log.d(TAG, "Download URI is null or empty for contentId: $contentId")
+ return false
+ }
+
+ // Check if we have a valid cache entry for this download
+ try {
+ val cache = SharedCacheUtil.getCache(context)
+ val keys = cache.keys
+ var hasCacheEntry = false
+
+ // Look for cache entries that match this download's ID
+ for (key in keys) {
+ if (key.contains(contentId) || key.contains(uri.toString())) {
+ hasCacheEntry = true
+ break
+ }
+ }
+
+ if (!hasCacheEntry) {
+ Log.d(TAG, "No cache entry found for contentId: $contentId")
+ return false
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error checking cache for contentId: $contentId", e)
+ // Don't fail just because we couldn't check the cache
+ }
+
+ return true
+ } catch (e: Exception) {
+ Log.e(TAG, "Error verifying download: ${e.message}", e)
+ return false
+ }
+ }
+
+ /**
+ * Get the download progress percentage for a specific video
+ * @param context Application context
+ * @param contentId The unique identifier of the content
+ * @return Download percentage (0-100), or 0 if not downloading
+ */
+ fun getDownloadPercentage(context: Context, contentId: String): Float {
+ return TPStreamsPlayerDownloadExt.getDownloadPercentage(context, contentId)
+ }
+
+ /**
+ * Delete a downloaded video
+ * @param context Application context
+ * @param contentId The unique identifier of the content
+ */
+ fun deleteDownload(context: Context, contentId: String) {
+ TPStreamsPlayerDownloadExt.removeDownload(context, contentId)
+ }
+
+ /**
+ * Start downloading a video
+ * @param context Application context
+ * @param videoUrl The URL of the video to download
+ * @param contentId A unique identifier for the content
+ * @param quality Optional quality to download (e.g. "720p")
+ */
+ fun startDownload(context: Context, videoUrl: String, contentId: String, quality: String? = null) {
+ TPStreamsPlayerDownloadExt.startDownload(
+ context,
+ videoUrl,
+ contentId,
+ selectedQuality = quality
+ )
+ }
+
+ /**
+ * Launch the built-in download list activity
+ * @param context Application context
+ */
+ fun showDownloadListActivity(context: Context) {
+ val intent = Intent(context, DownloadListActivity::class.java)
+ context.startActivity(intent)
+ }
+
+ /**
+ * Get a human-readable status string for a download
+ * @param download The download object
+ * @return A string representing the download status
+ */
+ fun getDownloadStatusString(download: Download): String {
+ return when (download.state) {
+ Download.STATE_COMPLETED -> "Completed"
+ Download.STATE_DOWNLOADING -> "Downloading ${download.percentDownloaded.toInt()}%"
+ Download.STATE_FAILED -> "Failed"
+ Download.STATE_QUEUED -> "Queued"
+ Download.STATE_REMOVING -> "Removing"
+ Download.STATE_RESTARTING -> "Restarting"
+ Download.STATE_STOPPED -> "Stopped"
+ else -> "Unknown"
+ }
+ }
+
+ /**
+ * Data class representing a simplified download item for app developers
+ */
+ data class DownloadItem(
+ val contentId: String,
+ val status: String,
+ val progress: Int,
+ val isComplete: Boolean
+ )
+
+ /**
+ * Get a list of simplified download items
+ * @param context Application context
+ * @return List of DownloadItem objects
+ */
+ fun getDownloadItems(context: Context): List {
+ return getDownloads(context).map { download ->
+ DownloadItem(
+ contentId = download.request.id,
+ status = getDownloadStatusString(download),
+ progress = download.percentDownloaded.toInt(),
+ isComplete = download.state == Download.STATE_COMPLETED
+ )
+ }
+ }
+
+ /**
+ * Play a downloaded video
+ * @param player The TPStreamsPlayer instance
+ * @param contentId The unique identifier of the content
+ * @param context Optional context to use for accessing the download manager
+ * @return true if the video was found and playback started, false otherwise
+ */
+ fun playOfflineContent(player: TPStreamsPlayer, contentId: String, context: Context? = null): Boolean {
+ return try {
+ // First verify the download is complete and valid
+ val appContext = context ?: player.getContext()
+
+ if (!verifyDownload(appContext, contentId)) {
+ Log.e(TAG, "Cannot play offline content: Download verification failed for $contentId")
+ return false
+ }
+
+ player.playOfflineContent(contentId, appContext)
+ true
+ } catch (e: Exception) {
+ Log.e(TAG, "Error playing offline content: ${e.message}", e)
+ false
+ }
+ }
+
+ /**
+ * Play a downloaded video with a player view
+ * @param playerView The TPStreamsPlayerView instance
+ * @param contentId The unique identifier of the content
+ * @return true if the video was found and playback started, false otherwise
+ */
+ fun playOfflineContent(playerView: TPStreamsPlayerView, contentId: String): Boolean {
+ val player = playerView.player as? TPStreamsPlayer ?: return false
+ return playOfflineContent(player, contentId, playerView.context)
+ }
+
+ /**
+ * Pause a download
+ * @param context Application context
+ * @param contentId The unique identifier of the content
+ */
+ fun pauseDownload(context: Context, contentId: String) {
+ VideoDownloadManager.pauseDownload(context, contentId)
+ }
+
+ /**
+ * Resume a paused download
+ * @param context Application context
+ * @param contentId The unique identifier of the content
+ */
+ fun resumeDownload(context: Context, contentId: String) {
+ VideoDownloadManager.resumeDownload(context, contentId)
+ }
+
+ /**
+ * Check if a download is paused
+ * @param context Application context
+ * @param contentId The unique identifier of the content
+ * @return true if the download is paused
+ */
+ fun isDownloadPaused(context: Context, contentId: String): Boolean {
+ return VideoDownloadManager.isDownloadPaused(context, contentId)
+ }
+
+ /**
+ * Restart a download that might be in a problematic state
+ * @param context Application context
+ * @param contentId The unique identifier of the content
+ */
+ fun restartDownload(context: Context, contentId: String) {
+ VideoDownloadManager.restartDownload(context, contentId)
+ }
+}
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/README.md b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/README.md
new file mode 100644
index 00000000..3c5ed886
--- /dev/null
+++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/README.md
@@ -0,0 +1,147 @@
+# TPStreams Download Functionality
+
+This package provides offline playback functionality for the TPStreams Player library.
+
+## Features
+
+- Download videos for offline playback
+- Quality selection for downloads
+- Download progress tracking
+- Manage and play downloaded videos
+
+## Usage
+
+### Initialize the Download Manager
+
+Initialize the download manager in your application's `onCreate()` method:
+
+```kotlin
+class MyApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ TPStreamsPlayer.init("your-organization-id", applicationContext)
+ // The download manager is initialized automatically when TPStreamsPlayer.init is called
+ }
+}
+```
+
+### Using the Built-in Download UI
+
+The SDK provides a built-in UI for downloading and managing videos:
+
+```kotlin
+// The download option is automatically available in the player settings bottom sheet
+// when using TPStreamsPlayerView
+```
+
+### Integrating Downloads into Your App
+
+If you want to integrate download functionality directly into your app's UI, you can use the `DownloadUtils` class:
+
+#### Check if a Video is Downloaded
+
+```kotlin
+val isDownloaded = DownloadUtils.isDownloaded(context, contentId)
+```
+
+#### Start a Download
+
+```kotlin
+// Start a download with quality selection dialog
+DownloadUtils.startDownload(context, videoUrl, contentId)
+
+// Start a download with a specific quality
+DownloadUtils.startDownload(context, videoUrl, contentId, quality = "720p")
+```
+
+#### Get Download Progress
+
+```kotlin
+val progress = DownloadUtils.getDownloadPercentage(context, contentId)
+```
+
+#### Delete a Download
+
+```kotlin
+DownloadUtils.deleteDownload(context, contentId)
+```
+
+#### Get All Downloads
+
+```kotlin
+// Get raw download objects
+val downloads = DownloadUtils.getDownloads(context)
+
+// Get simplified download items
+val downloadItems = DownloadUtils.getDownloadItems(context)
+```
+
+#### Show the Download List Activity
+
+```kotlin
+// To programmatically show the built-in download list activity
+DownloadUtils.showDownloadListActivity(context)
+```
+
+### Playing Downloaded Videos
+
+To play a downloaded video:
+
+```kotlin
+// Check if the video is downloaded first
+if (DownloadUtils.isDownloaded(context, contentId)) {
+ // Create player
+ val player = TPStreamsPlayer.create(context, contentId, "", true)
+
+ // Play the downloaded content
+ DownloadUtils.playOfflineContent(player, contentId)
+
+ // Or with a player view
+ playerView.player = player
+ DownloadUtils.playOfflineContent(playerView, contentId)
+}
+```
+
+## Managing Video Metadata
+
+When implementing your own download list UI, you'll likely want to display more information about each video than just the content ID. Here's how to manage video metadata:
+
+### Storing Metadata with Downloads
+
+```kotlin
+// Create a metadata class for your videos
+data class VideoMetadata(
+ val contentId: String,
+ val title: String,
+ val description: String,
+ val thumbnailUrl: String,
+ val duration: Long
+)
+
+// Store metadata when starting a download
+val metadata = VideoMetadata(
+ contentId = "video123",
+ title = "My Video",
+ description = "This is a great video",
+ thumbnailUrl = "https://example.com/thumbnail.jpg",
+ duration = 120000 // in milliseconds
+)
+
+// Save to SharedPreferences or a database
+val gson = Gson()
+val json = gson.toJson(metadata)
+sharedPreferences.edit().putString("metadata_$contentId", json).apply()
+
+// Retrieve metadata when displaying downloads
+val json = sharedPreferences.getString("metadata_$contentId", null)
+val metadata = gson.fromJson(json, VideoMetadata::class.java)
+```
+
+## Notes
+
+- Downloaded videos are stored in the app's internal storage and are not accessible to other apps
+- Downloads are automatically removed when the app is uninstalled
+- The download functionality requires the `INTERNET` permission, which is already included in the SDK
+- For large video files, consider showing a notification to the user when downloads complete
+- You may want to implement download restrictions based on network type (e.g., only download on Wi-Fi)
+- Remember to clean up metadata when a download is deleted
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/SharedCacheUtil.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/SharedCacheUtil.kt
new file mode 100644
index 00000000..cafb63a1
--- /dev/null
+++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/SharedCacheUtil.kt
@@ -0,0 +1,65 @@
+package com.tpstreams.player.offline
+
+import android.content.Context
+import android.util.Log
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.database.StandaloneDatabaseProvider
+import androidx.media3.datasource.cache.Cache
+import androidx.media3.datasource.cache.NoOpCacheEvictor
+import androidx.media3.datasource.cache.SimpleCache
+import java.io.File
+
+/**
+ * Utility class to provide a singleton cache instance that can be shared
+ * between the player and download components
+ */
+@UnstableApi
+object SharedCacheUtil {
+ private const val TAG = "SharedCacheUtil"
+ const val DOWNLOAD_CONTENT_DIRECTORY = "tpstreams_downloads"
+
+ private var cache: Cache? = null
+ private var databaseProvider: StandaloneDatabaseProvider? = null
+
+ @Synchronized
+ fun getCache(context: Context): Cache {
+ if (cache == null) {
+ Log.d(TAG, "Creating new shared cache instance")
+
+ // Create database provider if not already created
+ if (databaseProvider == null) {
+ databaseProvider = StandaloneDatabaseProvider(context)
+ }
+
+ // Create the cache directory
+ val downloadDirectory = File(context.filesDir, DOWNLOAD_CONTENT_DIRECTORY)
+ if (!downloadDirectory.exists()) {
+ downloadDirectory.mkdirs()
+ }
+
+ // Create the cache
+ try {
+ Log.d(TAG, "Creating download cache at: ${downloadDirectory.absolutePath}")
+ cache = SimpleCache(
+ downloadDirectory,
+ NoOpCacheEvictor(),
+ databaseProvider!!
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Error creating cache", e)
+ throw e
+ }
+ }
+
+ return cache ?: throw IllegalStateException("Cache could not be initialized")
+ }
+
+ @Synchronized
+ fun getDatabaseProvider(context: Context): StandaloneDatabaseProvider {
+ if (databaseProvider == null) {
+ databaseProvider = StandaloneDatabaseProvider(context)
+ }
+
+ return databaseProvider ?: throw IllegalStateException("DatabaseProvider could not be initialized")
+ }
+}
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/TPStreamsPlayerDownloadExt.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/TPStreamsPlayerDownloadExt.kt
new file mode 100644
index 00000000..062e9d37
--- /dev/null
+++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/TPStreamsPlayerDownloadExt.kt
@@ -0,0 +1,217 @@
+package com.tpstreams.player.offline
+
+import android.content.Context
+import android.util.Log
+import androidx.media3.common.util.UnstableApi
+import com.tpstreams.player.TPStreamsPlayer
+
+/**
+ * Extension functions for TPStreamsPlayer to support offline playback
+ */
+@UnstableApi
+object TPStreamsPlayerDownloadExt {
+
+ private var isInitialized = false
+
+ /**
+ * Initialize the download manager for offline playback
+ * @param context Application context
+ */
+ fun initializeDownloadManager(context: Context) {
+ if (!isInitialized) {
+ try {
+ Log.d(TAG, "Initializing download manager")
+ VideoDownloadManager.initialize(context)
+ isInitialized = true
+ Log.d(TAG, "Download manager initialized successfully")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to initialize download manager", e)
+ }
+ }
+ }
+
+ /**
+ * Start downloading a video
+ * @param context Application context
+ * @param videoUrl The URL of the video to download
+ * @param contentId A unique identifier for the content
+ * @param selectedQuality Optional quality to use for download
+ */
+ fun startDownload(
+ context: Context,
+ videoUrl: String,
+ contentId: String,
+ selectedQuality: String? = null
+ ) {
+ Log.d(TAG, "Starting download for contentId: $contentId, URL: $videoUrl")
+ if (videoUrl.isEmpty() || contentId.isEmpty()) {
+ Log.e(TAG, "Cannot download: Video URL or Content ID is empty")
+ return
+ }
+
+ // Check if this is a DRM-protected DASH stream (typically .mpd files)
+ if (videoUrl.endsWith(".mpd") || videoUrl.contains(".mpd?")) {
+ Log.e(TAG, "Cannot download DRM-protected content: $videoUrl")
+ // Show a toast on the main thread
+ android.os.Handler(android.os.Looper.getMainLooper()).post {
+ android.widget.Toast.makeText(
+ context,
+ "DRM-protected content cannot be downloaded",
+ android.widget.Toast.LENGTH_LONG
+ ).show()
+ }
+ return
+ }
+
+ try {
+ ensureInitialized(context)
+ val downloadTask = DownloadTask(context)
+ downloadTask.startDownload(videoUrl, contentId, selectedQuality)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error starting download", e)
+ }
+ }
+
+ /**
+ * Check if a video is already downloaded
+ * @param context Application context
+ * @param contentId The unique identifier of the content
+ * @return true if the video is downloaded, false otherwise
+ */
+ fun isDownloaded(context: Context, contentId: String): Boolean {
+ if (contentId.isEmpty()) {
+ Log.d(TAG, "Cannot check download status: Content ID is empty")
+ return false
+ }
+
+ try {
+ ensureInitialized(context)
+ return VideoDownloadManager.isDownloaded(context, contentId)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error checking download status", e)
+ return false
+ }
+ }
+
+ /**
+ * Get the download percentage of a video
+ * @param context Application context
+ * @param contentId The unique identifier of the content
+ * @return The download percentage (0-100), or 0 if not downloading
+ */
+ fun getDownloadPercentage(context: Context, contentId: String): Float {
+ if (contentId.isEmpty()) {
+ return 0f
+ }
+
+ try {
+ ensureInitialized(context)
+ return VideoDownloadManager.getDownloadPercentage(context, contentId)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting download percentage", e)
+ return 0f
+ }
+ }
+
+ /**
+ * Remove a downloaded video
+ * @param context Application context
+ * @param contentId The unique identifier of the content
+ */
+ fun removeDownload(context: Context, contentId: String) {
+ if (contentId.isEmpty()) {
+ return
+ }
+
+ try {
+ ensureInitialized(context)
+ VideoDownloadManager.getDownloadManager(context).removeDownload(contentId)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error removing download", e)
+ }
+ }
+
+ /**
+ * Get all downloads
+ * @param context Application context
+ * @return List of all downloads
+ */
+ fun getDownloads(context: Context): List {
+ try {
+ ensureInitialized(context)
+ return VideoDownloadManager.getDownloads(context)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting downloads", e)
+ return emptyList()
+ }
+ }
+
+ /**
+ * Ensure the download manager is initialized
+ */
+ private fun ensureInitialized(context: Context) {
+ if (!isInitialized) {
+ initializeDownloadManager(context)
+ }
+ }
+
+ /**
+ * Pause a download
+ * @param context Application context
+ * @param contentId The unique identifier of the content
+ */
+ fun pauseDownload(context: Context, contentId: String) {
+ if (contentId.isEmpty()) {
+ Log.d(TAG, "Cannot pause download: Content ID is empty")
+ return
+ }
+
+ try {
+ ensureInitialized(context)
+ VideoDownloadManager.pauseDownload(context, contentId)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error pausing download: ${e.message}", e)
+ }
+ }
+
+ /**
+ * Resume a paused download
+ * @param context Application context
+ * @param contentId The unique identifier of the content
+ */
+ fun resumeDownload(context: Context, contentId: String) {
+ if (contentId.isEmpty()) {
+ Log.d(TAG, "Cannot resume download: Content ID is empty")
+ return
+ }
+
+ try {
+ ensureInitialized(context)
+ VideoDownloadManager.resumeDownload(context, contentId)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error resuming download: ${e.message}", e)
+ }
+ }
+
+ /**
+ * Check if a download is paused
+ * @param context Application context
+ * @param contentId The unique identifier of the content
+ * @return true if the download is paused, false otherwise
+ */
+ fun isDownloadPaused(context: Context, contentId: String): Boolean {
+ if (contentId.isEmpty()) {
+ return false
+ }
+
+ try {
+ ensureInitialized(context)
+ return VideoDownloadManager.isDownloadPaused(context, contentId)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error checking if download is paused: ${e.message}", e)
+ return false
+ }
+ }
+
+ private const val TAG = "TPStreamsPlayerDownloadExt"
+}
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/VideoDownloadManager.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/VideoDownloadManager.kt
new file mode 100644
index 00000000..aa4b4673
--- /dev/null
+++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/VideoDownloadManager.kt
@@ -0,0 +1,381 @@
+package com.tpstreams.player.offline
+
+import android.content.Context
+import android.util.Log
+import androidx.media3.common.StreamKey
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.database.DatabaseProvider
+import androidx.media3.database.StandaloneDatabaseProvider
+import androidx.media3.datasource.DataSource
+import androidx.media3.datasource.DefaultDataSource
+import androidx.media3.datasource.DefaultHttpDataSource
+import androidx.media3.datasource.cache.Cache
+import androidx.media3.datasource.cache.CacheDataSource
+import androidx.media3.datasource.cache.NoOpCacheEvictor
+import androidx.media3.datasource.cache.SimpleCache
+import androidx.media3.exoplayer.offline.Download
+import androidx.media3.exoplayer.offline.DownloadManager
+import androidx.media3.exoplayer.offline.DownloadRequest
+import androidx.media3.exoplayer.scheduler.PlatformScheduler
+import androidx.media3.exoplayer.scheduler.Scheduler
+import java.io.File
+import java.util.concurrent.Executors
+
+private const val TAG = "TPStreamsDownloadManager"
+
+@UnstableApi
+object VideoDownloadManager {
+ const val DOWNLOAD_CONTENT_DIRECTORY = "tpstreams_downloads"
+ const val DOWNLOAD_NOTIFICATION_CHANNEL_ID = "tpstreams_download_channel"
+ private const val DOWNLOAD_JOB_ID = 1000
+
+ private var isInitialized = false
+ private var downloadManager: DownloadManager? = null
+
+ @Synchronized
+ fun initialize(context: Context) {
+ if (isInitialized) {
+ Log.d(TAG, "VideoDownloadManager already initialized")
+ return
+ }
+
+ try {
+ Log.d(TAG, "Initializing VideoDownloadManager")
+
+ // Create download manager right away to ensure everything is initialized
+ createDownloadManager(context)
+
+ isInitialized = true
+ Log.d(TAG, "VideoDownloadManager initialization complete")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error initializing VideoDownloadManager", e)
+ }
+ }
+
+ @Synchronized
+ private fun createDownloadManager(context: Context) {
+ if (downloadManager != null) {
+ return
+ }
+
+ Log.d(TAG, "Creating DownloadManager")
+ val downloadExecutor = Executors.newFixedThreadPool(3)
+
+ try {
+ // Get the shared cache and database provider
+ val downloadCache = SharedCacheUtil.getCache(context)
+ val databaseProvider = SharedCacheUtil.getDatabaseProvider(context)
+
+ val dataSourceFactory = DefaultDataSource.Factory(
+ context,
+ getHttpDataSourceFactory(context)
+ )
+
+ downloadManager = DownloadManager(
+ context,
+ databaseProvider,
+ downloadCache,
+ dataSourceFactory,
+ downloadExecutor
+ ).apply {
+ maxParallelDownloads = 3
+ minRetryCount = 5 // Increase retry count for better reliability
+ Log.d(TAG, "DownloadManager created with maxParallelDownloads=3, minRetryCount=5")
+ }
+
+ downloadManager?.addListener(DownloadManagerListener())
+ } catch (e: Exception) {
+ Log.e(TAG, "Error creating DownloadManager", e)
+ }
+ }
+
+ @Synchronized
+ fun getDownloadManager(context: Context): DownloadManager {
+ if (!isInitialized || downloadManager == null) {
+ initialize(context)
+ }
+
+ return downloadManager ?: throw IllegalStateException("DownloadManager could not be initialized")
+ }
+
+ fun getHttpDataSourceFactory(context: Context): DataSource.Factory {
+ return DefaultHttpDataSource.Factory()
+ .setUserAgent("TPStreamsPlayer")
+ .setConnectTimeoutMs(30000) // 30 seconds
+ .setReadTimeoutMs(30000) // 30 seconds
+ .setAllowCrossProtocolRedirects(true)
+ }
+
+ fun getCacheDataSourceFactory(context: Context): CacheDataSource.Factory {
+ try {
+ val downloadCache = SharedCacheUtil.getCache(context)
+
+ return CacheDataSource.Factory()
+ .setCache(downloadCache)
+ .setUpstreamDataSourceFactory(getHttpDataSourceFactory(context))
+ .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error creating CacheDataSourceFactory", e)
+ throw e
+ }
+ }
+
+ fun getScheduler(context: Context): Scheduler {
+ return PlatformScheduler(context, DOWNLOAD_JOB_ID)
+ }
+
+ fun isDownloaded(context: Context, mediaId: String): Boolean {
+ if (!isInitialized) {
+ initialize(context)
+ }
+
+ try {
+ val manager = getDownloadManager(context)
+ val downloadIndex = manager.downloadIndex
+ val download = downloadIndex.getDownload(mediaId)
+ return download != null && download.state == Download.STATE_COMPLETED
+ } catch (e: Exception) {
+ Log.e(TAG, "Error checking if media is downloaded", e)
+ return false
+ }
+ }
+
+ fun getDownloadPercentage(context: Context, mediaId: String): Float {
+ if (!isInitialized) {
+ initialize(context)
+ }
+
+ try {
+ val manager = getDownloadManager(context)
+ val downloadIndex = manager.downloadIndex
+ val download = downloadIndex.getDownload(mediaId)
+ if (download != null) {
+ return download.percentDownloaded.toFloat()
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting download percentage", e)
+ }
+ return 0f
+ }
+
+ fun getDownloads(context: Context): List {
+ if (!isInitialized) {
+ initialize(context)
+ }
+
+ val downloads = mutableListOf()
+
+ try {
+ val manager = getDownloadManager(context)
+ val cursor = manager.downloadIndex.getDownloads()
+
+ while (cursor.moveToNext()) {
+ downloads.add(cursor.download)
+ }
+ cursor.close()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting downloads", e)
+ }
+
+ return downloads
+ }
+
+ fun createDownloadRequest(mediaId: String, uri: String, streamKeys: List): DownloadRequest {
+ Log.d(TAG, "Creating download request for mediaId: $mediaId, uri: $uri, streamKeys: $streamKeys")
+
+ // Determine the MIME type based on the URL
+ val mimeType = when {
+ uri.endsWith(".m3u8") || uri.contains(".m3u8?") -> "application/x-mpegURL"
+ uri.endsWith(".mpd") || uri.contains(".mpd?") -> "application/dash+xml"
+ else -> "video/*" // Generic fallback
+ }
+
+ Log.d(TAG, "Using MIME type: $mimeType for URI: $uri")
+
+ // For HLS streams, make sure we have at least one stream key
+ val finalStreamKeys = if (streamKeys.isEmpty() && (uri.contains(".m3u8") || uri.contains(".mpd"))) {
+ Log.d(TAG, "No stream keys provided, adding default stream key")
+ listOf(StreamKey(0, 0, 0))
+ } else {
+ streamKeys
+ }
+
+ return DownloadRequest.Builder(mediaId, android.net.Uri.parse(uri))
+ .setStreamKeys(finalStreamKeys)
+ .setData(mediaId.toByteArray())
+ .setMimeType(mimeType)
+ .build()
+ }
+
+ fun pauseDownload(context: Context, mediaId: String) {
+ if (!isInitialized) {
+ initialize(context)
+ }
+
+ try {
+ Log.d(TAG, "Pausing download for mediaId: $mediaId")
+ val manager = getDownloadManager(context)
+
+ // Ensure the download exists before trying to pause it
+ val download = manager.downloadIndex.getDownload(mediaId)
+ if (download == null) {
+ Log.e(TAG, "Cannot pause download: Download not found for mediaId: $mediaId")
+ return
+ }
+
+ // Set the stop reason for pausing the download
+ // Use a custom stop reason value (any non-zero value)
+ manager.setStopReason(mediaId, 1) // Using 1 as custom STOP_REASON_PAUSED
+
+ // Additional logging to verify the pause operation
+ val updatedDownload = manager.downloadIndex.getDownload(mediaId)
+ if (updatedDownload != null) {
+ Log.d(TAG, "Download state after pause: ${getDownloadStateString(updatedDownload.state)}, stopReason: ${updatedDownload.stopReason}")
+ }
+
+ Log.d(TAG, "Download paused for mediaId: $mediaId")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error pausing download: ${e.message}", e)
+ }
+ }
+
+ fun resumeDownload(context: Context, mediaId: String) {
+ if (!isInitialized) {
+ initialize(context)
+ }
+
+ try {
+ Log.d(TAG, "Resuming download for mediaId: $mediaId")
+ val manager = getDownloadManager(context)
+
+ // Ensure the download exists before trying to resume it
+ val download = manager.downloadIndex.getDownload(mediaId)
+ if (download == null) {
+ Log.e(TAG, "Cannot resume download: Download not found for mediaId: $mediaId")
+ return
+ }
+
+ // Clear the stop reason to resume the download
+ manager.setStopReason(mediaId, 0) // STOP_REASON_NONE = 0
+
+ // Additional logging to verify the resume operation
+ val updatedDownload = manager.downloadIndex.getDownload(mediaId)
+ if (updatedDownload != null) {
+ Log.d(TAG, "Download state after resume: ${getDownloadStateString(updatedDownload.state)}, stopReason: ${updatedDownload.stopReason}")
+ }
+
+ Log.d(TAG, "Download resumed for mediaId: $mediaId")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error resuming download: ${e.message}", e)
+ }
+ }
+
+ private fun getDownloadStateString(state: Int): String {
+ return when (state) {
+ Download.STATE_QUEUED -> "QUEUED"
+ Download.STATE_STOPPED -> "STOPPED"
+ Download.STATE_DOWNLOADING -> "DOWNLOADING"
+ Download.STATE_COMPLETED -> "COMPLETED"
+ Download.STATE_FAILED -> "FAILED"
+ Download.STATE_REMOVING -> "REMOVING"
+ Download.STATE_RESTARTING -> "RESTARTING"
+ else -> "UNKNOWN"
+ }
+ }
+
+ fun isDownloadPaused(context: Context, mediaId: String): Boolean {
+ if (!isInitialized) {
+ initialize(context)
+ }
+
+ try {
+ val manager = getDownloadManager(context)
+ val download = manager.downloadIndex.getDownload(mediaId)
+
+ if (download == null) {
+ Log.d(TAG, "isDownloadPaused: Download not found for mediaId: $mediaId")
+ return false
+ }
+
+ // Check if download is stopped with our custom pause reason
+ val isPaused = download.state == Download.STATE_STOPPED && download.stopReason == 1
+ Log.d(TAG, "isDownloadPaused: mediaId=$mediaId, state=${getDownloadStateString(download.state)}, stopReason=${download.stopReason}, isPaused=$isPaused")
+ return isPaused
+ } catch (e: Exception) {
+ Log.e(TAG, "Error checking if download is paused: ${e.message}", e)
+ return false
+ }
+ }
+
+ /**
+ * Restart a download that might be in a problematic state
+ * This method will remove the download and then add it back
+ * @param context Application context
+ * @param mediaId The unique identifier of the media
+ */
+ fun restartDownload(context: Context, mediaId: String) {
+ if (!isInitialized) {
+ initialize(context)
+ }
+
+ try {
+ Log.d(TAG, "Restarting download for mediaId: $mediaId")
+ val manager = getDownloadManager(context)
+
+ // Get the download to preserve its request
+ val download = manager.downloadIndex.getDownload(mediaId)
+ if (download == null) {
+ Log.e(TAG, "Cannot restart download: Download not found for mediaId: $mediaId")
+ return
+ }
+
+ // Save the request
+ val request = download.request
+
+ // Remove the download
+ manager.removeDownload(mediaId)
+ Log.d(TAG, "Removed download for mediaId: $mediaId")
+
+ // Wait a moment for the removal to complete
+ Thread.sleep(1000)
+
+ // Add the download back
+ manager.addDownload(request)
+ Log.d(TAG, "Re-added download for mediaId: $mediaId")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error restarting download: ${e.message}", e)
+ }
+ }
+
+ private class DownloadManagerListener : DownloadManager.Listener {
+ override fun onDownloadChanged(
+ downloadManager: DownloadManager,
+ download: Download,
+ finalException: Exception?
+ ) {
+ val state = when (download.state) {
+ Download.STATE_COMPLETED -> "COMPLETED"
+ Download.STATE_DOWNLOADING -> "DOWNLOADING (${download.percentDownloaded.toInt()}%)"
+ Download.STATE_FAILED -> "FAILED"
+ Download.STATE_QUEUED -> "QUEUED"
+ Download.STATE_REMOVING -> "REMOVING"
+ Download.STATE_RESTARTING -> "RESTARTING"
+ Download.STATE_STOPPED -> "STOPPED"
+ else -> "UNKNOWN"
+ }
+
+ Log.d(TAG, "Download changed - ID: ${download.request.id}, State: $state")
+
+ if (finalException != null) {
+ Log.e(TAG, "Download error", finalException)
+ }
+ }
+
+ override fun onDownloadRemoved(
+ downloadManager: DownloadManager,
+ download: Download
+ ) {
+ Log.d(TAG, "Download removed - ID: ${download.request.id}")
+ }
+ }
+}
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/VideoDownloadService.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/VideoDownloadService.kt
new file mode 100644
index 00000000..cf04978f
--- /dev/null
+++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/offline/VideoDownloadService.kt
@@ -0,0 +1,120 @@
+package com.tpstreams.player.offline
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.common.util.Util
+import androidx.media3.exoplayer.offline.DownloadService
+import com.tpstreams.player.R
+import java.util.concurrent.ConcurrentHashMap
+
+private const val TAG = "TPStreamsDownloadService"
+
+@UnstableApi
+class VideoDownloadService : DownloadService(
+ NOTIFICATION_ID,
+ DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
+ CHANNEL_ID,
+ R.string.download_channel_name,
+ 0
+) {
+
+ private lateinit var notificationManager: NotificationManager
+ private val activeDownloads = ConcurrentHashMap()
+
+ override fun onCreate() {
+ super.onCreate()
+ Log.d(TAG, "VideoDownloadService created")
+ notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ createNotificationChannel()
+ }
+
+ override fun getDownloadManager(): androidx.media3.exoplayer.offline.DownloadManager {
+ Log.d(TAG, "Getting DownloadManager")
+ return VideoDownloadManager.getDownloadManager(this)
+ }
+
+ override fun getScheduler(): androidx.media3.exoplayer.scheduler.Scheduler {
+ Log.d(TAG, "Getting Scheduler")
+ return VideoDownloadManager.getScheduler(this)
+ }
+
+ override fun getForegroundNotification(
+ downloads: List,
+ notMetRequirements: Int
+ ): Notification {
+ val downloadNotificationHelper = DownloadNotificationHelper(this)
+
+ if (downloads.isEmpty()) {
+ Log.d(TAG, "Creating notification for empty downloads list")
+ return downloadNotificationHelper.buildProgressNotification(
+ "Preparing download...",
+ 0f
+ )
+ }
+
+ val download = downloads[0]
+ val contentId = download.request.id
+ val progress = download.percentDownloaded
+
+ Log.d(TAG, "Creating notification for download: $contentId, state: ${download.state}, progress: $progress")
+
+ return when (download.state) {
+ androidx.media3.exoplayer.offline.Download.STATE_COMPLETED ->
+ downloadNotificationHelper.buildCompletedNotification(contentId)
+ androidx.media3.exoplayer.offline.Download.STATE_FAILED -> {
+ Log.e(TAG, "Download failed: $contentId")
+ downloadNotificationHelper.buildFailedNotification(contentId)
+ }
+ else ->
+ downloadNotificationHelper.buildProgressNotification(contentId, progress)
+ }
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val name = getString(R.string.download_channel_name)
+ val description = getString(R.string.download_channel_description)
+ val importance = NotificationManager.IMPORTANCE_LOW
+ val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
+ this.description = description
+ }
+ notificationManager.createNotificationChannel(channel)
+ Log.d(TAG, "Notification channel created: $CHANNEL_ID")
+ }
+ }
+
+ override fun onDestroy() {
+ Log.d(TAG, "VideoDownloadService destroyed")
+ super.onDestroy()
+ }
+
+ companion object {
+ private const val NOTIFICATION_ID = 1
+ private const val COMPLETION_NOTIFICATION_ID = 2
+ private const val CHANNEL_ID = VideoDownloadManager.DOWNLOAD_NOTIFICATION_CHANNEL_ID
+ private const val DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL = 1000L
+
+ fun startDownload(context: Context, downloadRequest: androidx.media3.exoplayer.offline.DownloadRequest) {
+ Log.d(TAG, "Starting download for: ${downloadRequest.id}, URI: ${downloadRequest.uri}")
+ try {
+ val intent = buildAddDownloadIntent(
+ context,
+ VideoDownloadService::class.java,
+ downloadRequest,
+ /* foreground= */ false
+ )
+ Util.startForegroundService(context, intent)
+ Log.d(TAG, "Download service started")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error starting download service", e)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/res/drawable/ic_download.xml b/tpstreams-android-player/src/main/res/drawable/ic_download.xml
index 0a16f163..12c4a43c 100644
--- a/tpstreams-android-player/src/main/res/drawable/ic_download.xml
+++ b/tpstreams-android-player/src/main/res/drawable/ic_download.xml
@@ -1,5 +1,11 @@
-
-
-
-
+
+
+
diff --git a/tpstreams-android-player/src/main/res/drawable/ic_download_option.xml b/tpstreams-android-player/src/main/res/drawable/ic_download_option.xml
new file mode 100644
index 00000000..c2824ce9
--- /dev/null
+++ b/tpstreams-android-player/src/main/res/drawable/ic_download_option.xml
@@ -0,0 +1,11 @@
+
+
+
+
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/res/drawable/rounded_button_background.xml b/tpstreams-android-player/src/main/res/drawable/rounded_button_background.xml
new file mode 100644
index 00000000..1b12342b
--- /dev/null
+++ b/tpstreams-android-player/src/main/res/drawable/rounded_button_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/res/layout/activity_download_list.xml b/tpstreams-android-player/src/main/res/layout/activity_download_list.xml
new file mode 100644
index 00000000..96198e29
--- /dev/null
+++ b/tpstreams-android-player/src/main/res/layout/activity_download_list.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/res/layout/item_download.xml b/tpstreams-android-player/src/main/res/layout/item_download.xml
new file mode 100644
index 00000000..19e83a65
--- /dev/null
+++ b/tpstreams-android-player/src/main/res/layout/item_download.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/res/layout/layout_download_quality_bottom_sheet.xml b/tpstreams-android-player/src/main/res/layout/layout_download_quality_bottom_sheet.xml
new file mode 100644
index 00000000..ac72c4cd
--- /dev/null
+++ b/tpstreams-android-player/src/main/res/layout/layout_download_quality_bottom_sheet.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/res/layout/layout_player_settings_bottom_sheet.xml b/tpstreams-android-player/src/main/res/layout/layout_player_settings_bottom_sheet.xml
index 6a755654..61685f9a 100644
--- a/tpstreams-android-player/src/main/res/layout/layout_player_settings_bottom_sheet.xml
+++ b/tpstreams-android-player/src/main/res/layout/layout_player_settings_bottom_sheet.xml
@@ -150,4 +150,85 @@
android:layout_gravity="center_vertical"
android:tint="#757575" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/res/values/colors.xml b/tpstreams-android-player/src/main/res/values/colors.xml
new file mode 100644
index 00000000..7e685240
--- /dev/null
+++ b/tpstreams-android-player/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #2196F3
+
\ No newline at end of file
diff --git a/tpstreams-android-player/src/main/res/values/strings.xml b/tpstreams-android-player/src/main/res/values/strings.xml
index 4d143127..827b203b 100644
--- a/tpstreams-android-player/src/main/res/values/strings.xml
+++ b/tpstreams-android-player/src/main/res/values/strings.xml
@@ -1,5 +1,32 @@
+ TPStreams Player
+
+
+ Video Downloads
+ Notifications for video downloads
+ Download
+ Download completed
+ Download failed
+ Downloading
+ Download paused
+ Download queued
+ No downloads yet
+ Select Video Quality
+ Cancel
+ Error
+ Preparing download…
+ This video cannot be downloaded
+ Already downloaded
+
+
+ Settings
+ Quality
+ Playback Speed
+ Captions
+ Fullscreen
+ Exit Fullscreen
+
00:00
/
00:00
@@ -20,7 +47,6 @@
Select video quality
- Playback speed
Normal
Apply
%.2fx