mirror of
https://github.com/libre-tube/LibreTube.git
synced 2025-04-28 16:00:31 +05:30
Fix notification action, download fragment and resource leak
- Bind service when service started using notification resume action. - Use `HttpURLConnection` to download file. - Use progress bar to determine overall progress.
This commit is contained in:
parent
8af9e20748
commit
4f0f9b7560
@ -77,7 +77,7 @@ class LibreTubeApp : Application() {
|
||||
private fun initializeNotificationChannels() {
|
||||
val downloadChannel = NotificationChannelCompat.Builder(
|
||||
DOWNLOAD_CHANNEL_ID,
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT
|
||||
NotificationManagerCompat.IMPORTANCE_LOW
|
||||
)
|
||||
.setName(getString(R.string.download_channel_name))
|
||||
.setDescription(getString(R.string.download_channel_description))
|
||||
|
@ -15,4 +15,5 @@ object IntentData {
|
||||
const val audioFormat = "audioFormate"
|
||||
const val audioQuality = "audioQuality"
|
||||
const val subtitleCode = "subtitleCode"
|
||||
const val downloading = "downloading"
|
||||
}
|
||||
|
@ -27,7 +27,7 @@ interface DownloadDao {
|
||||
@Query("SELECT * FROM downloadItem WHERE path = :path")
|
||||
fun findDownloadItemByFilePath(path: String): DownloadItem
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun insertDownload(download: Download)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
|
@ -8,13 +8,14 @@ import java.net.URL
|
||||
suspend fun URL.getContentLength(def: Long = -1): Long {
|
||||
try {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val con = openConnection() as HttpURLConnection
|
||||
con.setRequestProperty("Range", "bytes=0-")
|
||||
val connection = openConnection() as HttpURLConnection
|
||||
connection.setRequestProperty("Range", "bytes=0-")
|
||||
|
||||
val value = con.getHeaderField("content-length")
|
||||
val value = connection.getHeaderField("content-length")
|
||||
// If connection accepts range header, try to get total bytes
|
||||
?: con.getHeaderField("content-range").split("/")[1]
|
||||
?: connection.getHeaderField("content-range").split("/")[1]
|
||||
|
||||
connection.disconnect()
|
||||
value.toLong()
|
||||
}
|
||||
} catch (e: Exception) { e.printStackTrace() }
|
||||
|
@ -23,7 +23,9 @@ fun Streams.toDownloadItems(
|
||||
videoId = videoId,
|
||||
fileName = fileName + "." + stream?.mimeType?.split("/")?.last(),
|
||||
path = "",
|
||||
url = stream?.url
|
||||
url = stream?.url,
|
||||
format = videoFormat,
|
||||
quality = videoQuality
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -36,7 +38,9 @@ fun Streams.toDownloadItems(
|
||||
videoId = videoId,
|
||||
fileName = fileName + "." + stream?.mimeType?.split("/")?.last(),
|
||||
path = "",
|
||||
url = stream?.url
|
||||
url = stream?.url,
|
||||
format = audioFormat,
|
||||
quality = audioQuality
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -2,13 +2,11 @@ package com.github.libretube.obj
|
||||
|
||||
sealed class DownloadStatus {
|
||||
|
||||
object Unknown : DownloadStatus()
|
||||
|
||||
object Completed : DownloadStatus()
|
||||
|
||||
object Paused : DownloadStatus()
|
||||
|
||||
data class Progress(val downloaded: Long, val total: Long) : DownloadStatus()
|
||||
data class Progress(val progress: Long, val downloaded: Long, val total: Long) : DownloadStatus()
|
||||
|
||||
data class Error(val message: String, val cause: Throwable? = null) : DownloadStatus()
|
||||
}
|
||||
|
@ -0,0 +1,24 @@
|
||||
package com.github.libretube.receivers
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.github.libretube.constants.IntentData
|
||||
import com.github.libretube.services.DownloadService
|
||||
import com.github.libretube.ui.activities.MainActivity
|
||||
|
||||
class DownloadReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val activityIntent = Intent(context, MainActivity::class.java)
|
||||
|
||||
when (intent?.action) {
|
||||
DownloadService.ACTION_SERVICE_STARTED -> {
|
||||
activityIntent.putExtra(IntentData.downloading, true)
|
||||
}
|
||||
DownloadService.ACTION_SERVICE_STOPPED -> {
|
||||
activityIntent.putExtra(IntentData.downloading, false)
|
||||
}
|
||||
}
|
||||
context?.startActivity(activityIntent)
|
||||
}
|
||||
}
|
@ -23,4 +23,9 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
context?.startService(serviceIntent)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_DOWNLOAD_RESUME = "com.github.libretube.receivers.NotificationReceiver.ACTION_DOWNLOAD_RESUME"
|
||||
const val ACTION_DOWNLOAD_PAUSE = "com.github.libretube.receivers.NotificationReceiver.ACTION_DOWNLOAD_PAUSE"
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ import com.github.libretube.api.RetrofitInstance
|
||||
import com.github.libretube.constants.DOWNLOAD_CHANNEL_ID
|
||||
import com.github.libretube.constants.DOWNLOAD_PROGRESS_NOTIFICATION_ID
|
||||
import com.github.libretube.constants.IntentData
|
||||
import com.github.libretube.db.DatabaseHolder
|
||||
import com.github.libretube.db.DatabaseHolder.Companion.Database
|
||||
import com.github.libretube.db.obj.Download
|
||||
import com.github.libretube.db.obj.DownloadItem
|
||||
import com.github.libretube.enums.FileType
|
||||
@ -25,29 +25,29 @@ import com.github.libretube.extensions.toDownloadItems
|
||||
import com.github.libretube.extensions.toastFromMainThread
|
||||
import com.github.libretube.obj.DownloadStatus
|
||||
import com.github.libretube.receivers.NotificationReceiver
|
||||
import com.github.libretube.receivers.NotificationReceiver.Companion.ACTION_DOWNLOAD_PAUSE
|
||||
import com.github.libretube.receivers.NotificationReceiver.Companion.ACTION_DOWNLOAD_RESUME
|
||||
import com.github.libretube.ui.activities.MainActivity
|
||||
import com.github.libretube.util.DownloadHelper
|
||||
import com.github.libretube.util.ImageHelper
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.BufferedSink
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* Download service with custom implementation of downloading using [OkHttpClient].
|
||||
* Download service with custom implementation of downloading using [HttpURLConnection].
|
||||
*/
|
||||
class DownloadService : Service() {
|
||||
|
||||
@ -59,6 +59,7 @@ class DownloadService : Service() {
|
||||
private lateinit var summaryNotificationBuilder: NotificationCompat.Builder
|
||||
|
||||
private val jobs = mutableMapOf<Int, Job>()
|
||||
private val downloadQueue = mutableMapOf<Int, Boolean>()
|
||||
private val _downloadFlow = MutableSharedFlow<Pair<Int, DownloadStatus>>()
|
||||
val downloadFlow: SharedFlow<Pair<Int, DownloadStatus>> = _downloadFlow
|
||||
|
||||
@ -66,12 +67,13 @@ class DownloadService : Service() {
|
||||
super.onCreate()
|
||||
IS_DOWNLOAD_RUNNING = true
|
||||
notifyForeground()
|
||||
sendBroadcast(Intent(ACTION_SERVICE_STARTED))
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_RESUME -> resume(intent.getIntExtra("id", -1))
|
||||
ACTION_PAUSE -> pause(intent.getIntExtra("id", -1))
|
||||
ACTION_DOWNLOAD_RESUME -> resume(intent.getIntExtra("id", -1))
|
||||
ACTION_DOWNLOAD_PAUSE -> pause(intent.getIntExtra("id", -1))
|
||||
}
|
||||
|
||||
val videoId = intent?.getStringExtra(IntentData.videoId) ?: return START_NOT_STICKY
|
||||
@ -87,7 +89,7 @@ class DownloadService : Service() {
|
||||
val streams = RetrofitInstance.api.getStreams(videoId)
|
||||
|
||||
awaitQuery {
|
||||
DatabaseHolder.Database.downloadDao().insertDownload(
|
||||
Database.downloadDao().insertDownload(
|
||||
Download(
|
||||
videoId = videoId,
|
||||
title = streams.title ?: "",
|
||||
@ -101,7 +103,17 @@ class DownloadService : Service() {
|
||||
)
|
||||
}
|
||||
streams.thumbnailUrl?.let { url ->
|
||||
ImageHelper.downloadImage(this@DownloadService, url, fileName)
|
||||
ImageHelper.downloadImage(
|
||||
this@DownloadService,
|
||||
url,
|
||||
File(
|
||||
DownloadHelper.getDownloadDir(
|
||||
this@DownloadService,
|
||||
DownloadHelper.THUMBNAIL_DIR
|
||||
),
|
||||
fileName
|
||||
).absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
val downloadItems = streams.toDownloadItems(
|
||||
@ -154,7 +166,7 @@ class DownloadService : Service() {
|
||||
item.path = file.absolutePath
|
||||
|
||||
item.id = awaitQuery {
|
||||
DatabaseHolder.Database.downloadDao().insertDownloadItem(item)
|
||||
Database.downloadDao().insertDownloadItem(item)
|
||||
}.toInt()
|
||||
|
||||
jobs[item.id] = scope.launch {
|
||||
@ -167,14 +179,9 @@ class DownloadService : Service() {
|
||||
* and notification.
|
||||
*/
|
||||
private suspend fun downloadFile(item: DownloadItem) {
|
||||
downloadQueue[item.id] = true
|
||||
val notificationBuilder = getNotificationBuilder(item)
|
||||
setResumeNotification(notificationBuilder, item)
|
||||
|
||||
val okHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(DownloadHelper.DEFAULT_TIMEOUT, TimeUnit.SECONDS)
|
||||
.readTimeout(DownloadHelper.DEFAULT_TIMEOUT, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val file = File(item.path)
|
||||
var totalRead = file.length()
|
||||
val url = URL(item.url ?: return)
|
||||
@ -183,50 +190,69 @@ class DownloadService : Service() {
|
||||
if (size > 0 && size != item.downloadSize) {
|
||||
item.downloadSize = size
|
||||
query {
|
||||
DatabaseHolder.Database.downloadDao().updateDownloadItem(item)
|
||||
Database.downloadDao().updateDownloadItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set start range where last downloading was held.
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Range", "bytes=$totalRead-").build()
|
||||
var lastTime = System.currentTimeMillis() / 1000
|
||||
var lastRead: Long = 0
|
||||
val sink: BufferedSink = file.sink(true).buffer()
|
||||
|
||||
try {
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
val sourceBytes = response.body!!.source()
|
||||
// Set start range where last downloading was held.
|
||||
val con = url.openConnection() as HttpURLConnection
|
||||
con.requestMethod = "GET"
|
||||
con.setRequestProperty("Range", "bytes=$totalRead-")
|
||||
con.connectTimeout = DownloadHelper.DEFAULT_TIMEOUT
|
||||
con.readTimeout = DownloadHelper.DEFAULT_TIMEOUT
|
||||
con.connect()
|
||||
|
||||
// Check if job is still active and read next bytes.
|
||||
while (coroutineContext.isActive &&
|
||||
sourceBytes
|
||||
.read(sink.buffer, DownloadHelper.DOWNLOAD_CHUNK_SIZE)
|
||||
.also { lastRead = it } != -1L
|
||||
) {
|
||||
sink.emit()
|
||||
totalRead += lastRead
|
||||
_downloadFlow.emit(item.id to DownloadStatus.Progress(totalRead, item.downloadSize))
|
||||
|
||||
if (item.downloadSize != -1L &&
|
||||
System.currentTimeMillis() / 1000 > lastTime
|
||||
) {
|
||||
notificationBuilder
|
||||
.setContentText("${totalRead.formatAsFileSize()} / ${item.downloadSize.formatAsFileSize()}")
|
||||
.setProgress(item.downloadSize.toInt(), totalRead.toInt(), false)
|
||||
notificationManager.notify(item.id, notificationBuilder.build())
|
||||
lastTime = System.currentTimeMillis() / 1000
|
||||
}
|
||||
if (con.responseCode !in 200..299) {
|
||||
val message = getString(R.string.downloadfailed) + ": " + con.responseMessage
|
||||
_downloadFlow.emit(item.id to DownloadStatus.Error(message))
|
||||
toastFromMainThread(message)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
toastFromMainThread("${getString(R.string.download)}: ${e.message.toString()}")
|
||||
_downloadFlow.emit(item.id to DownloadStatus.Error(e.message.toString(), e))
|
||||
} finally {
|
||||
|
||||
val sink: BufferedSink = file.sink(true).buffer()
|
||||
val sourceByte = con.inputStream.source()
|
||||
|
||||
var lastTime = System.currentTimeMillis() / 1000
|
||||
var lastRead: Long = 0
|
||||
|
||||
try {
|
||||
// Check if downloading is still active and read next bytes.
|
||||
while (downloadQueue[item.id] == true &&
|
||||
sourceByte
|
||||
.read(sink.buffer, DownloadHelper.DOWNLOAD_CHUNK_SIZE)
|
||||
.also { lastRead = it } != -1L
|
||||
) {
|
||||
sink.emit()
|
||||
totalRead += lastRead
|
||||
_downloadFlow.emit(
|
||||
item.id to DownloadStatus.Progress(
|
||||
lastRead,
|
||||
totalRead,
|
||||
item.downloadSize
|
||||
)
|
||||
)
|
||||
if (item.downloadSize != -1L &&
|
||||
System.currentTimeMillis() / 1000 > lastTime
|
||||
) {
|
||||
notificationBuilder
|
||||
.setContentText("${totalRead.formatAsFileSize()} / ${item.downloadSize.formatAsFileSize()}")
|
||||
.setProgress(item.downloadSize.toInt(), totalRead.toInt(), false)
|
||||
notificationManager.notify(item.id, notificationBuilder.build())
|
||||
lastTime = System.currentTimeMillis() / 1000
|
||||
}
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
} catch (e: Exception) {
|
||||
toastFromMainThread("${getString(R.string.download)}: ${e.message}")
|
||||
_downloadFlow.emit(item.id to DownloadStatus.Error(e.message.toString(), e))
|
||||
}
|
||||
|
||||
sink.flush()
|
||||
sink.close()
|
||||
}
|
||||
sourceByte.close()
|
||||
con.disconnect()
|
||||
} catch (_: Exception) { }
|
||||
|
||||
val completed = when (totalRead) {
|
||||
item.downloadSize -> {
|
||||
@ -238,45 +264,46 @@ class DownloadService : Service() {
|
||||
false
|
||||
}
|
||||
}
|
||||
pause(item.id)
|
||||
setPauseNotification(notificationBuilder, item, completed)
|
||||
pause(item.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume download which may have been paused.
|
||||
*/
|
||||
fun resume(id: Int) {
|
||||
// Cancel last job if it is still active to avoid multiple
|
||||
// jobs for same file.
|
||||
jobs[id]?.cancel()
|
||||
// If file is already downloading then avoid new download job.
|
||||
if (downloadQueue[id] == true) return
|
||||
|
||||
val downloadItem = awaitQuery {
|
||||
DatabaseHolder.Database.downloadDao().findDownloadItemById(id)
|
||||
Database.downloadDao().findDownloadItemById(id)
|
||||
}
|
||||
jobs[id] = scope.launch {
|
||||
scope.launch {
|
||||
downloadFile(downloadItem)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause downloading job for given [id]. If no [jobs] are active, stop the service.
|
||||
* Pause downloading job for given [id]. If no downloads are active, stop the service.
|
||||
*/
|
||||
fun pause(id: Int) {
|
||||
jobs[id]?.cancel()
|
||||
downloadQueue[id] = false
|
||||
|
||||
// Stop the service if no downloads are active.
|
||||
if (jobs.values.none { it.isActive }) {
|
||||
if (downloadQueue.none { it.value }) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_DETACH)
|
||||
}
|
||||
sendBroadcast(Intent(ACTION_SERVICE_STOPPED))
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the file downloading job is active or not.
|
||||
* Check whether the file downloading or not.
|
||||
*/
|
||||
fun isDownloading(id: Int): Boolean {
|
||||
return jobs[id]?.isActive ?: false
|
||||
return downloadQueue[id] ?: false
|
||||
}
|
||||
|
||||
private fun notifyForeground() {
|
||||
@ -314,7 +341,7 @@ class DownloadService : Service() {
|
||||
|
||||
return NotificationCompat
|
||||
.Builder(this, DOWNLOAD_CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.downloading))
|
||||
.setContentTitle("[${item.type}] ${item.fileName}")
|
||||
.setProgress(0, 0, true)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(activityIntent)
|
||||
@ -325,7 +352,6 @@ class DownloadService : Service() {
|
||||
|
||||
private fun setResumeNotification(notificationBuilder: NotificationCompat.Builder, item: DownloadItem) {
|
||||
notificationBuilder
|
||||
.setContentTitle(item.fileName)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.setOngoing(true)
|
||||
@ -335,7 +361,11 @@ class DownloadService : Service() {
|
||||
notificationManager.notify(item.id, notificationBuilder.build())
|
||||
}
|
||||
|
||||
private fun setPauseNotification(notificationBuilder: NotificationCompat.Builder, item: DownloadItem, isCompleted: Boolean = false) {
|
||||
private fun setPauseNotification(
|
||||
notificationBuilder: NotificationCompat.Builder,
|
||||
item: DownloadItem,
|
||||
isCompleted: Boolean = false
|
||||
) {
|
||||
notificationBuilder
|
||||
.setProgress(0, 0, false)
|
||||
.setOngoing(false)
|
||||
@ -357,7 +387,7 @@ class DownloadService : Service() {
|
||||
private fun getResumeAction(id: Int): NotificationCompat.Action {
|
||||
val intent = Intent(this, NotificationReceiver::class.java)
|
||||
|
||||
intent.action = ACTION_RESUME
|
||||
intent.action = ACTION_DOWNLOAD_RESUME
|
||||
intent.putExtra("id", id)
|
||||
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
@ -376,7 +406,7 @@ class DownloadService : Service() {
|
||||
private fun getPauseAction(id: Int): NotificationCompat.Action {
|
||||
val intent = Intent(this, NotificationReceiver::class.java)
|
||||
|
||||
intent.action = ACTION_PAUSE
|
||||
intent.action = ACTION_DOWNLOAD_PAUSE
|
||||
intent.putExtra("id", id)
|
||||
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
@ -393,12 +423,15 @@ class DownloadService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
jobMain.cancel()
|
||||
downloadQueue.clear()
|
||||
IS_DOWNLOAD_RUNNING = false
|
||||
sendBroadcast(Intent(ACTION_SERVICE_STOPPED))
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
val ids = intent?.getIntArrayExtra("ids")
|
||||
ids?.forEach { id -> resume(id) }
|
||||
return binder
|
||||
}
|
||||
|
||||
@ -408,8 +441,10 @@ class DownloadService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val DOWNLOAD_NOTIFICATION_GROUP = "download_notification_group"
|
||||
const val ACTION_RESUME = "com.github.libretube.services.DownloadService.ACTION_RESUME"
|
||||
const val ACTION_PAUSE = "com.github.libretube.services.DownloadService.ACTION_PAUSE"
|
||||
const val ACTION_SERVICE_STARTED =
|
||||
"com.github.libretube.services.DownloadService.ACTION_SERVICE_STARTED"
|
||||
const val ACTION_SERVICE_STOPPED =
|
||||
"com.github.libretube.services.DownloadService.ACTION_SERVICE_STOPPED"
|
||||
var IS_DOWNLOAD_RUNNING = false
|
||||
}
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ import androidx.core.view.children
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.github.libretube.R
|
||||
import com.github.libretube.constants.IntentData
|
||||
@ -30,6 +31,7 @@ import com.github.libretube.extensions.toID
|
||||
import com.github.libretube.services.ClosingService
|
||||
import com.github.libretube.ui.base.BaseActivity
|
||||
import com.github.libretube.ui.dialogs.ErrorDialog
|
||||
import com.github.libretube.ui.fragments.DownloadsFragment
|
||||
import com.github.libretube.ui.fragments.PlayerFragment
|
||||
import com.github.libretube.ui.models.PlayerViewModel
|
||||
import com.github.libretube.ui.models.SearchViewModel
|
||||
@ -382,11 +384,19 @@ class MainActivity : BaseActivity() {
|
||||
navController.navigate(R.id.subscriptionsFragment)
|
||||
"library" ->
|
||||
navController.navigate(R.id.libraryFragment)
|
||||
"downloads" ->
|
||||
navController.navigate(R.id.downloadsFragment)
|
||||
}
|
||||
if (intent?.getBooleanExtra(IntentData.openQueueOnce, false) == true) {
|
||||
PlayingQueueSheet()
|
||||
.show(supportFragmentManager)
|
||||
}
|
||||
if (intent?.getBooleanExtra(IntentData.downloading, false) == true) {
|
||||
(supportFragmentManager.fragments.find { it is NavHostFragment })
|
||||
?.childFragmentManager?.fragments?.forEach { fragment ->
|
||||
(fragment as? DownloadsFragment)?.bindDownloadService()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadVideo(videoId: String, timeStamp: Long?) {
|
||||
|
@ -33,7 +33,6 @@ import java.io.File
|
||||
|
||||
class OfflinePlayerActivity : BaseActivity() {
|
||||
private lateinit var binding: ActivityOfflinePlayerBinding
|
||||
private lateinit var fileName: String
|
||||
private lateinit var videoId: String
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var playerView: StyledPlayerView
|
||||
|
@ -1,24 +1,29 @@
|
||||
package com.github.libretube.ui.adapters
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.libretube.R
|
||||
import com.github.libretube.constants.IntentData
|
||||
import com.github.libretube.databinding.DownloadedMediaRowBinding
|
||||
import com.github.libretube.extensions.formatShort
|
||||
import com.github.libretube.obj.DownloadedFile
|
||||
import com.github.libretube.db.DatabaseHolder
|
||||
import com.github.libretube.db.obj.DownloadWithItems
|
||||
import com.github.libretube.extensions.formatAsFileSize
|
||||
import com.github.libretube.extensions.query
|
||||
import com.github.libretube.ui.activities.OfflinePlayerActivity
|
||||
import com.github.libretube.ui.viewholders.DownloadsViewHolder
|
||||
import com.github.libretube.util.DownloadHelper
|
||||
import com.github.libretube.util.TextUtils
|
||||
import com.github.libretube.util.ImageHelper
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import java.io.File
|
||||
|
||||
class DownloadsAdapter(
|
||||
private val files: MutableList<DownloadedFile>
|
||||
private val context: Context,
|
||||
private val downloads: MutableList<DownloadWithItems>,
|
||||
private val toogleDownload: (DownloadWithItems) -> Boolean
|
||||
) : RecyclerView.Adapter<DownloadsViewHolder>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadsViewHolder {
|
||||
val binding = DownloadedMediaRowBinding.inflate(
|
||||
@ -31,24 +36,51 @@ class DownloadsAdapter(
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBindViewHolder(holder: DownloadsViewHolder, position: Int) {
|
||||
val file = files[position]
|
||||
val download = downloads[position].download
|
||||
val items = downloads[position].downloadItems
|
||||
holder.binding.apply {
|
||||
fileName.text = file.name
|
||||
fileSize.text = "${file.size / (1024 * 1024)} MiB"
|
||||
title.text = download.title
|
||||
uploaderName.text = download.uploader
|
||||
videoInfo.text = download.uploadDate
|
||||
|
||||
file.metadata?.let {
|
||||
uploaderName.text = it.uploader
|
||||
videoInfo.text = it.views.formatShort() + " " +
|
||||
root.context.getString(R.string.views_placeholder) +
|
||||
TextUtils.SEPARATOR + it.uploadDate
|
||||
val downloadSize = items.sumOf { it.downloadSize }
|
||||
val currentSize = items.sumOf { File(it.path).length() }
|
||||
|
||||
if (downloadSize == -1L) {
|
||||
progressBar.isIndeterminate = true
|
||||
} else {
|
||||
progressBar.max = downloadSize.toInt()
|
||||
progressBar.progress = currentSize.toInt()
|
||||
}
|
||||
|
||||
thumbnailImage.setImageBitmap(file.thumbnail)
|
||||
if (downloadSize > currentSize) {
|
||||
downloadOverlay.visibility = View.VISIBLE
|
||||
resumePauseBtn.setImageResource(R.drawable.ic_download)
|
||||
fileSize.text = "${currentSize.formatAsFileSize()} / ${downloadSize.formatAsFileSize()}"
|
||||
} else {
|
||||
downloadOverlay.visibility = View.GONE
|
||||
fileSize.text = downloadSize.formatAsFileSize()
|
||||
}
|
||||
|
||||
download.thumbnailPath?.let { path ->
|
||||
thumbnailImage.setImageBitmap(ImageHelper.getDownloadedImage(context, path))
|
||||
}
|
||||
|
||||
progressBar.setOnClickListener {
|
||||
val isDownloading = toogleDownload(downloads[position])
|
||||
|
||||
resumePauseBtn.setImageResource(
|
||||
if (isDownloading) {
|
||||
R.drawable.ic_pause
|
||||
} else {
|
||||
R.drawable.ic_download
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
root.setOnClickListener {
|
||||
val intent = Intent(root.context, OfflinePlayerActivity::class.java).also {
|
||||
it.putExtra(IntentData.fileName, file.name)
|
||||
}
|
||||
val intent = Intent(root.context, OfflinePlayerActivity::class.java)
|
||||
intent.putExtra(IntentData.videoId, download.videoId)
|
||||
root.context.startActivity(intent)
|
||||
}
|
||||
|
||||
@ -61,27 +93,18 @@ class DownloadsAdapter(
|
||||
) { _, index ->
|
||||
when (index) {
|
||||
0 -> {
|
||||
val audioDir = DownloadHelper.getDownloadDir(
|
||||
root.context,
|
||||
DownloadHelper.AUDIO_DIR
|
||||
)
|
||||
val videoDir = DownloadHelper.getDownloadDir(
|
||||
root.context,
|
||||
DownloadHelper.VIDEO_DIR
|
||||
)
|
||||
|
||||
listOf(audioDir, videoDir).forEach {
|
||||
val f = File(it, file.name)
|
||||
if (f.exists()) {
|
||||
items.map { File(it.path) }.forEach { file ->
|
||||
if (file.exists()) {
|
||||
try {
|
||||
f.delete()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
file.delete()
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
files.removeAt(position)
|
||||
query {
|
||||
DatabaseHolder.Database.downloadDao().deleteDownload(download)
|
||||
}
|
||||
downloads.removeAt(position)
|
||||
notifyItemRemoved(position)
|
||||
notifyItemRangeChanged(position, itemCount)
|
||||
}
|
||||
@ -95,6 +118,6 @@ class DownloadsAdapter(
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return files.size
|
||||
return downloads.size
|
||||
}
|
||||
}
|
||||
|
@ -1,21 +1,62 @@
|
||||
package com.github.libretube.ui.fragments
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.size
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.libretube.R
|
||||
import com.github.libretube.databinding.FragmentDownloadsBinding
|
||||
import com.github.libretube.db.DatabaseHolder.Companion.Database
|
||||
import com.github.libretube.db.obj.DownloadWithItems
|
||||
import com.github.libretube.extensions.awaitQuery
|
||||
import com.github.libretube.extensions.formatAsFileSize
|
||||
import com.github.libretube.obj.DownloadStatus
|
||||
import com.github.libretube.receivers.DownloadReceiver
|
||||
import com.github.libretube.services.DownloadService
|
||||
import com.github.libretube.ui.adapters.DownloadsAdapter
|
||||
import com.github.libretube.ui.base.BaseFragment
|
||||
import com.github.libretube.ui.viewholders.DownloadsViewHolder
|
||||
import com.github.libretube.util.DownloadHelper
|
||||
import com.github.libretube.util.ImageHelper
|
||||
import com.github.libretube.util.MetadataHelper
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
class DownloadsFragment : BaseFragment() {
|
||||
private lateinit var binding: FragmentDownloadsBinding
|
||||
private var binder: DownloadService.LocalBinder? = null
|
||||
private val downloads = mutableListOf<DownloadWithItems>()
|
||||
private val downloadReceiver = DownloadReceiver()
|
||||
|
||||
private val serviceConnection = object : ServiceConnection {
|
||||
var isBound = false
|
||||
var job: Job? = null
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, iBinder: IBinder?) {
|
||||
binder = iBinder as DownloadService.LocalBinder
|
||||
isBound = true
|
||||
job?.cancel()
|
||||
job = lifecycleScope.launch {
|
||||
binder?.getService()?.downloadFlow?.collectLatest {
|
||||
updateProgress(it.first, it.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
binder = null
|
||||
isBound = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
@ -29,36 +70,118 @@ class DownloadsFragment : BaseFragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val files = DownloadHelper.getDownloadedFiles(requireContext())
|
||||
|
||||
if (files.isEmpty()) return
|
||||
|
||||
val metadataHelper = MetadataHelper(requireContext())
|
||||
files.forEach {
|
||||
metadataHelper.getMetadata(it.name)?.let { streams ->
|
||||
it.metadata = streams
|
||||
}
|
||||
ImageHelper.getDownloadedImage(requireContext(), it.name)?.let { bitmap ->
|
||||
it.thumbnail = bitmap
|
||||
}
|
||||
awaitQuery {
|
||||
downloads.addAll(Database.downloadDao().getAll())
|
||||
}
|
||||
if (downloads.isEmpty()) return
|
||||
|
||||
binding.downloadsEmpty.visibility = View.GONE
|
||||
binding.downloads.visibility = View.VISIBLE
|
||||
|
||||
binding.downloads.layoutManager = LinearLayoutManager(context)
|
||||
binding.downloads.adapter = DownloadsAdapter(files)
|
||||
|
||||
binding.downloads.adapter = DownloadsAdapter(requireContext(), downloads) {
|
||||
var isDownloading = false
|
||||
val ids = it.downloadItems
|
||||
.filter { item -> File(item.path).length() < item.downloadSize }
|
||||
.map { item -> item.id }
|
||||
|
||||
if (!serviceConnection.isBound) {
|
||||
DownloadHelper.startDownloadService(requireContext())
|
||||
bindDownloadService(ids.toIntArray())
|
||||
return@DownloadsAdapter true
|
||||
}
|
||||
|
||||
binder?.getService()?.let { service ->
|
||||
isDownloading = ids.any { id -> service.isDownloading(id) }
|
||||
|
||||
ids.forEach { id ->
|
||||
if (isDownloading) {
|
||||
service.pause(id)
|
||||
} else {
|
||||
service.resume(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return@DownloadsAdapter isDownloading.not()
|
||||
}
|
||||
|
||||
binding.downloads.adapter?.registerAdapterDataObserver(
|
||||
object : RecyclerView.AdapterDataObserver() {
|
||||
override fun onChanged() {
|
||||
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
|
||||
if (binding.downloads.size == 0) {
|
||||
binding.downloads.visibility = View.GONE
|
||||
binding.downloadsEmpty.visibility = View.VISIBLE
|
||||
}
|
||||
super.onChanged()
|
||||
super.onItemRangeRemoved(positionStart, itemCount)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
if (DownloadService.IS_DOWNLOAD_RUNNING) {
|
||||
val intent = Intent(requireContext(), DownloadService::class.java)
|
||||
context?.bindService(intent, serviceConnection, 0)
|
||||
}
|
||||
super.onStart()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val filter = IntentFilter()
|
||||
filter.addAction(DownloadService.ACTION_SERVICE_STARTED)
|
||||
filter.addAction(DownloadService.ACTION_SERVICE_STOPPED)
|
||||
context?.registerReceiver(downloadReceiver, filter)
|
||||
}
|
||||
|
||||
fun bindDownloadService(ids: IntArray? = null) {
|
||||
if (serviceConnection.isBound) return
|
||||
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
intent.putExtra("ids", ids)
|
||||
context?.bindService(intent, serviceConnection, 0)
|
||||
}
|
||||
|
||||
fun updateProgress(id: Int, status: DownloadStatus) {
|
||||
val index = downloads.indexOfFirst {
|
||||
it.downloadItems.any { item -> item.id == id }
|
||||
}
|
||||
val view = binding.downloads.findViewHolderForAdapterPosition(index) as? DownloadsViewHolder
|
||||
|
||||
view?.binding?.apply {
|
||||
when (status) {
|
||||
DownloadStatus.Paused -> {
|
||||
resumePauseBtn.setImageResource(R.drawable.ic_download)
|
||||
}
|
||||
DownloadStatus.Completed -> {
|
||||
downloadOverlay.visibility = View.GONE
|
||||
}
|
||||
is DownloadStatus.Progress -> {
|
||||
downloadOverlay.visibility = View.VISIBLE
|
||||
resumePauseBtn.setImageResource(R.drawable.ic_pause)
|
||||
if (progressBar.isIndeterminate) return
|
||||
progressBar.incrementProgressBy(status.progress.toInt())
|
||||
val progressInfo = progressBar.progress.formatAsFileSize() +
|
||||
" / " + progressBar.max.formatAsFileSize()
|
||||
fileSize.text = progressInfo
|
||||
}
|
||||
is DownloadStatus.Error -> {
|
||||
resumePauseBtn.setImageResource(R.drawable.ic_restart)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
context?.unregisterReceiver(downloadReceiver)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (serviceConnection.isBound) {
|
||||
context?.unbindService(serviceConnection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import com.github.libretube.constants.IntentData
|
||||
import com.github.libretube.obj.DownloadedFile
|
||||
import com.github.libretube.db.obj.DownloadItem
|
||||
import com.github.libretube.services.DownloadService
|
||||
import java.io.File
|
||||
|
||||
@ -15,7 +15,7 @@ object DownloadHelper {
|
||||
const val METADATA_DIR = "metadata"
|
||||
const val THUMBNAIL_DIR = "thumbnail"
|
||||
const val DOWNLOAD_CHUNK_SIZE = 8L * 1024
|
||||
const val DEFAULT_TIMEOUT = 30L
|
||||
const val DEFAULT_TIMEOUT = 15 * 1000
|
||||
|
||||
fun getOfflineStorageDir(context: Context): File {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return context.filesDir
|
||||
@ -36,31 +36,6 @@ object DownloadHelper {
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.toDownloadedFile(): DownloadedFile {
|
||||
return DownloadedFile(
|
||||
name = this.name,
|
||||
size = this.length()
|
||||
)
|
||||
}
|
||||
|
||||
fun getDownloadedFiles(context: Context): MutableList<DownloadedFile> {
|
||||
val videoFiles = getDownloadDir(context, VIDEO_DIR).listFiles().orEmpty()
|
||||
val audioFiles = getDownloadDir(context, AUDIO_DIR).listFiles().orEmpty().toMutableList()
|
||||
|
||||
val files = mutableListOf<DownloadedFile>()
|
||||
|
||||
videoFiles.forEach {
|
||||
audioFiles.removeIf { audioFile -> audioFile.name == it.name }
|
||||
files.add(it.toDownloadedFile())
|
||||
}
|
||||
|
||||
audioFiles.forEach {
|
||||
files.add(it.toDownloadedFile())
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
fun startDownloadService(
|
||||
context: Context,
|
||||
videoId: String? = null,
|
||||
|
@ -59,15 +59,12 @@ object ImageHelper {
|
||||
if (!dataSaverModeEnabled) target.load(url, imageLoader)
|
||||
}
|
||||
|
||||
fun downloadImage(context: Context, url: String, fileName: String) {
|
||||
fun downloadImage(context: Context, url: String, path: String) {
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
.target { result ->
|
||||
val bitmap = (result as BitmapDrawable).bitmap
|
||||
val file = File(
|
||||
DownloadHelper.getDownloadDir(context, DownloadHelper.THUMBNAIL_DIR),
|
||||
fileName
|
||||
)
|
||||
val file = File(path)
|
||||
saveImage(context, bitmap, Uri.fromFile(file))
|
||||
}
|
||||
.build()
|
||||
@ -75,11 +72,8 @@ object ImageHelper {
|
||||
imageLoader.enqueue(request)
|
||||
}
|
||||
|
||||
fun getDownloadedImage(context: Context, fileName: String): Bitmap? {
|
||||
val file = File(
|
||||
DownloadHelper.getDownloadDir(context, DownloadHelper.THUMBNAIL_DIR),
|
||||
fileName
|
||||
)
|
||||
fun getDownloadedImage(context: Context, path: String): Bitmap? {
|
||||
val file = File(path)
|
||||
if (!file.exists()) return null
|
||||
return getImage(context, Uri.fromFile(file))
|
||||
}
|
||||
|
26
app/src/main/res/drawable/circular_progress.xml
Normal file
26
app/src/main/res/drawable/circular_progress.xml
Normal file
@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item android:id="@android:id/background">
|
||||
<shape
|
||||
android:shape="ring"
|
||||
android:thickness="2.5dp"
|
||||
android:useLevel="false">
|
||||
<solid android:color="#4e4e4e" />
|
||||
</shape>
|
||||
</item>
|
||||
|
||||
<item android:id="@android:id/progress">
|
||||
<rotate android:fromDegrees="270"
|
||||
android:toDegrees="270">
|
||||
<shape
|
||||
android:shape="ring"
|
||||
android:thickness="2.5dp"
|
||||
android:useLevel="true">
|
||||
<solid android:color="?android:colorAccent" />
|
||||
<corners android:radius="20dp" />
|
||||
</shape>
|
||||
</rotate>
|
||||
</item>
|
||||
|
||||
</layer-list>
|
@ -23,6 +23,34 @@
|
||||
android:scaleType="fitXY"
|
||||
tools:src="@tools:sample/backgrounds/scenic" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/downloadOverlay"
|
||||
android:layout_width="140dp"
|
||||
android:layout_height="80dp"
|
||||
android:background="#BF000000">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="52dp"
|
||||
android:layout_height="52dp"
|
||||
android:indeterminateOnly="false"
|
||||
android:progressDrawable="@drawable/circular_progress"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/resumePauseBtn"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:src="@drawable/ic_download"
|
||||
app:layout_constraintBottom_toBottomOf="@id/progressBar"
|
||||
app:layout_constraintLeft_toLeftOf="@id/progressBar"
|
||||
app:layout_constraintRight_toRightOf="@id/progressBar"
|
||||
app:layout_constraintTop_toTopOf="@id/progressBar" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<LinearLayout
|
||||
@ -33,7 +61,7 @@
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/fileName"
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginVertical="2dp"
|
||||
|
Loading…
x
Reference in New Issue
Block a user