Merge pull request #2469 from Kruna1Pate1/feat/new-downloader

New custom downloader
This commit is contained in:
Bnyro 2023-01-14 17:54:20 +01:00 committed by GitHub
commit 3a4a0b1809
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1700 additions and 275 deletions

View File

@ -0,0 +1,470 @@
{
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "3df3f5c01e36e4e7fd3e02ba708e5d86",
"entities": [
{
"tableName": "watchHistoryItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT, `uploadDate` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `thumbnailUrl` TEXT, `duration` INTEGER, PRIMARY KEY(`videoId`))",
"fields": [
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploadDate",
"columnName": "uploadDate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploaderUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderAvatar",
"columnName": "uploaderAvatar",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"videoId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "watchPosition",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`videoId`))",
"fields": [
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"videoId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "searchHistoryItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, PRIMARY KEY(`query`))",
"fields": [
{
"fieldPath": "query",
"columnName": "query",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"query"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "customInstance",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `apiUrl` TEXT NOT NULL, `frontendUrl` TEXT NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "apiUrl",
"columnName": "apiUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "frontendUrl",
"columnName": "frontendUrl",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"name"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "localSubscription",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`channelId` TEXT NOT NULL, PRIMARY KEY(`channelId`))",
"fields": [
{
"fieldPath": "channelId",
"columnName": "channelId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"channelId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "playlistBookmark",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `playlistName` TEXT, `thumbnailUrl` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, PRIMARY KEY(`playlistId`))",
"fields": [
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "playlistName",
"columnName": "playlistName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploaderUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderAvatar",
"columnName": "uploaderAvatar",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"playlistId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "LocalPlaylist",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "LocalPlaylistItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` INTEGER NOT NULL, `videoId` TEXT NOT NULL, `title` TEXT, `uploadDate` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `thumbnailUrl` TEXT, `duration` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploadDate",
"columnName": "uploadDate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploaderUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderAvatar",
"columnName": "uploaderAvatar",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "download",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `uploader` TEXT NOT NULL, `uploadDate` TEXT, `thumbnailPath` TEXT, PRIMARY KEY(`videoId`))",
"fields": [
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uploadDate",
"columnName": "uploadDate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailPath",
"columnName": "thumbnailPath",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"videoId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "downloadItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `videoId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `path` TEXT NOT NULL, `url` TEXT, `format` TEXT, `quality` TEXT, `downloadSize` INTEGER NOT NULL, FOREIGN KEY(`videoId`) REFERENCES `download`(`videoId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "format",
"columnName": "format",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "quality",
"columnName": "quality",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "downloadSize",
"columnName": "downloadSize",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_downloadItem_path",
"unique": true,
"columnNames": [
"path"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloadItem_path` ON `${TABLE_NAME}` (`path`)"
}
],
"foreignKeys": [
{
"table": "download",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"videoId"
],
"referencedColumns": [
"videoId"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3df3f5c01e36e4e7fd3e02ba708e5d86')"
]
}
}

View File

@ -352,6 +352,11 @@
android:name=".services.BackgroundMode"
android:enabled="true"
android:exported="false" />
<receiver
android:name=".receivers.NotificationReceiver"
android:enabled="true"
android:exported="false" />
</application>
</manifest>

View File

@ -77,7 +77,7 @@ class LibreTubeApp : Application() {
private fun initializeNotificationChannels() {
val downloadChannel = NotificationChannelCompat.Builder(
DOWNLOAD_CHANNEL_ID,
NotificationManagerCompat.IMPORTANCE_NONE
NotificationManagerCompat.IMPORTANCE_LOW
)
.setName(getString(R.string.download_channel_name))
.setDescription(getString(R.string.download_channel_description))

View File

@ -43,6 +43,7 @@ const val PLAYER_NOTIFICATION_ID = 1
const val DOWNLOAD_PENDING_NOTIFICATION_ID = 2
const val DOWNLOAD_FAILURE_NOTIFICATION_ID = 3
const val DOWNLOAD_SUCCESS_NOTIFICATION_ID = 4
const val DOWNLOAD_PROGRESS_NOTIFICATION_ID = 5
/**
* Notification Channel IDs

View File

@ -10,5 +10,11 @@ object IntentData {
const val fileName = "fileName"
const val keepQueue = "keepQueue"
const val playlistType = "playlistType"
const val videoFormat = "videoFormate"
const val videoQuality = "videoQuality"
const val audioFormat = "audioFormate"
const val audioQuality = "audioQuality"
const val subtitleCode = "subtitleCode"
const val downloading = "downloading"
const val openAudioPlayer = "openAudioPlayer"
}

View File

@ -127,6 +127,7 @@ object PreferenceKeys {
const val SHARE_WITH_TIME_CODE = "share_with_time_code"
const val CONFIRM_UNSUBSCRIBE = "confirm_unsubscribing"
const val CLEAR_BOOKMARKS = "clear_bookmarks"
const val MAX_CONCURRENT_DOWNLOADS = "max_concurrent_downloads"
/**
* History

View File

@ -4,6 +4,7 @@ import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import com.github.libretube.db.dao.CustomInstanceDao
import com.github.libretube.db.dao.DownloadDao
import com.github.libretube.db.dao.LocalPlaylistsDao
import com.github.libretube.db.dao.LocalSubscriptionDao
import com.github.libretube.db.dao.PlaylistBookmarkDao
@ -11,6 +12,8 @@ import com.github.libretube.db.dao.SearchHistoryDao
import com.github.libretube.db.dao.WatchHistoryDao
import com.github.libretube.db.dao.WatchPositionDao
import com.github.libretube.db.obj.CustomInstance
import com.github.libretube.db.obj.Download
import com.github.libretube.db.obj.DownloadItem
import com.github.libretube.db.obj.LocalPlaylist
import com.github.libretube.db.obj.LocalPlaylistItem
import com.github.libretube.db.obj.LocalSubscription
@ -28,12 +31,15 @@ import com.github.libretube.db.obj.WatchPosition
LocalSubscription::class,
PlaylistBookmark::class,
LocalPlaylist::class,
LocalPlaylistItem::class
LocalPlaylistItem::class,
Download::class,
DownloadItem::class
],
version = 9,
version = 10,
autoMigrations = [
AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9)
AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10)
]
)
abstract class AppDatabase : RoomDatabase() {
@ -71,4 +77,9 @@ abstract class AppDatabase : RoomDatabase() {
* Local playlists
*/
abstract fun localPlaylistsDao(): LocalPlaylistsDao
/**
* Downloads
*/
abstract fun downloadDao(): DownloadDao
}

View File

@ -0,0 +1,51 @@
package com.github.libretube.db.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import com.github.libretube.db.obj.Download
import com.github.libretube.db.obj.DownloadItem
import com.github.libretube.db.obj.DownloadWithItems
@Dao
interface DownloadDao {
@Transaction
@Query("SELECT * FROM download")
fun getAll(): List<DownloadWithItems>
@Transaction
@Query("SELECT * FROM download WHERE videoId = :videoId")
fun findById(videoId: String): DownloadWithItems
@Query("SELECT * FROM downloaditem WHERE id = :id")
fun findDownloadItemById(id: Int): DownloadItem
@Query("SELECT * FROM downloadItem WHERE path = :path")
fun findDownloadItemByFilePath(path: String): DownloadItem
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insertDownload(download: Download)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertDownloadItem(downloadItem: DownloadItem): Long
@Update(onConflict = OnConflictStrategy.REPLACE)
fun updateDownload(download: Download)
@Update(onConflict = OnConflictStrategy.REPLACE)
fun updateDownloadItem(downloadItem: DownloadItem)
@Transaction
@Delete
fun deleteDownload(download: Download)
@Delete
fun deleteDownloadItem(downloadItem: DownloadItem)
@Query("DELETE FROM downloadItem WHERE videoId = :videoId")
fun deleteDownloadItemsByVideoId(videoId: String)
}

View File

@ -0,0 +1,15 @@
package com.github.libretube.db.obj
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "download")
data class Download(
@PrimaryKey(autoGenerate = false)
val videoId: String,
val title: String = "",
val description: String = "",
val uploader: String = "",
val uploadDate: String? = null,
val thumbnailPath: String? = null
)

View File

@ -0,0 +1,32 @@
package com.github.libretube.db.obj
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.github.libretube.enums.FileType
@Entity(
tableName = "downloadItem",
indices = [Index(value = ["path"], unique = true)],
foreignKeys = [
ForeignKey(
entity = Download::class,
parentColumns = ["videoId"],
childColumns = ["videoId"],
onDelete = ForeignKey.CASCADE
)
]
)
data class DownloadItem(
@PrimaryKey(autoGenerate = true)
var id: Int = 0,
val type: FileType,
val videoId: String,
val fileName: String,
var path: String,
var url: String? = null,
var format: String? = null,
var quality: String? = null,
var downloadSize: Long = -1L
)

View File

@ -0,0 +1,13 @@
package com.github.libretube.db.obj
import androidx.room.Embedded
import androidx.room.Relation
data class DownloadWithItems(
@Embedded val download: Download,
@Relation(
parentColumn = "videoId",
entityColumn = "videoId"
)
val downloadItems: List<DownloadItem>
)

View File

@ -0,0 +1,7 @@
package com.github.libretube.enums
enum class FileType {
AUDIO,
VIDEO,
SUBTITLE
}

View File

@ -0,0 +1,24 @@
package com.github.libretube.extensions
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun URL.getContentLength(def: Long = -1): Long {
try {
return withContext(Dispatchers.IO) {
val connection = openConnection() as HttpURLConnection
connection.setRequestProperty("Range", "bytes=0-")
val value = connection.getHeaderField("content-length")
// If connection accepts range header, try to get total bytes
?: connection.getHeaderField("content-range").split("/")[1]
connection.disconnect()
value.toLong()
}
} catch (e: Exception) { e.printStackTrace() }
return def
}

View File

@ -0,0 +1,23 @@
package com.github.libretube.extensions
import java.io.File
import kotlin.math.log2
import kotlin.math.pow
fun File.formatSize(): String {
return length().formatAsFileSize()
}
fun Int.formatAsFileSize(): String {
return toLong().formatAsFileSize()
}
fun Long.formatAsFileSize(): String {
return log2(if (this != 0L) toDouble() else 1.0).toInt().div(10).let {
val precision = when (it) {
0 -> 0; 1 -> 1; else -> 2
}
val prefix = arrayOf("", "K", "M", "G", "T", "P", "E", "Z", "Y")
String.format("%.${precision}f ${prefix[it]}B", toDouble() / 2.0.pow(it * 10.0))
}
}

View File

@ -13,3 +13,10 @@ fun Float.normalize(oldMin: Float, oldMax: Float, newMin: Float, newMax: Float):
return (this - oldMin) * newRange / oldRange + newMin
}
fun Long.normalize(oldMin: Long, oldMax: Long, newMin: Long, newMax: Long): Long {
val oldRange = oldMax - oldMin
val newRange = newMax - newMin
return (this - oldMin) * newRange / oldRange + newMin
}

View File

@ -1,8 +0,0 @@
package com.github.libretube.extensions
/**
* Replace file name specific chars
*/
fun String.sanitize(): String {
return this.replace("[^a-zA-Z0-9\\\\._]+".toRegex(), "_")
}

View File

@ -0,0 +1,61 @@
package com.github.libretube.extensions
import com.github.libretube.api.obj.Streams
import com.github.libretube.db.obj.DownloadItem
import com.github.libretube.enums.FileType
fun Streams.toDownloadItems(
videoId: String,
fileName: String,
videoFormat: String?,
videoQuality: String?,
audioFormat: String?,
audioQuality: String?,
subtitleCode: String?
): List<DownloadItem> {
val items = mutableListOf<DownloadItem>()
if (!videoQuality.isNullOrEmpty() && !videoFormat.isNullOrEmpty()) {
val stream = videoStreams?.find { it.quality == videoQuality && it.format == videoFormat }
items.add(
DownloadItem(
type = FileType.VIDEO,
videoId = videoId,
fileName = fileName + "." + stream?.mimeType?.split("/")?.last(),
path = "",
url = stream?.url,
format = videoFormat,
quality = videoQuality
)
)
}
if (!audioQuality.isNullOrEmpty() && !audioFormat.isNullOrEmpty()) {
val stream = audioStreams?.find { it.quality == audioQuality && it.format == audioFormat }
items.add(
DownloadItem(
type = FileType.AUDIO,
videoId = videoId,
fileName = fileName + "." + stream?.mimeType?.split("/")?.last(),
path = "",
url = stream?.url,
format = audioFormat,
quality = audioQuality
)
)
}
if (!subtitleCode.isNullOrEmpty()) {
items.add(
DownloadItem(
type = FileType.SUBTITLE,
videoId = videoId,
fileName = "$fileName.srt",
path = "",
url = subtitles?.find { it.code == subtitleCode }?.url
)
)
}
return items
}

View File

@ -0,0 +1,16 @@
package com.github.libretube.obj
sealed class DownloadStatus {
object Completed : DownloadStatus()
object Paused : 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()
}

View File

@ -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)
}
}

View File

@ -0,0 +1,33 @@
package com.github.libretube.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import com.github.libretube.services.DownloadService
class NotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == null) return
val serviceIntent = Intent(context, DownloadService::class.java)
serviceIntent.action = intent.action
val id = intent.getIntExtra("id", -1)
if (id == -1) return
serviceIntent.putExtra("id", id)
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
context?.startForegroundService(serviceIntent)
} else {
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"
}
}

View File

@ -1,182 +1,524 @@
package com.github.libretube.services
import android.app.DownloadManager
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.github.libretube.R
import com.github.libretube.api.CronetHelper
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.constants.DOWNLOAD_CHANNEL_ID
import com.github.libretube.constants.DOWNLOAD_FAILURE_NOTIFICATION_ID
import com.github.libretube.constants.DOWNLOAD_SUCCESS_NOTIFICATION_ID
import com.github.libretube.enums.DownloadType
import com.github.libretube.extensions.TAG
import com.github.libretube.constants.DOWNLOAD_PROGRESS_NOTIFICATION_ID
import com.github.libretube.constants.IntentData
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
import com.github.libretube.extensions.awaitQuery
import com.github.libretube.extensions.formatAsFileSize
import com.github.libretube.extensions.getContentLength
import com.github.libretube.extensions.query
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.DownloadHelper.getNotificationId
import com.github.libretube.util.ImageHelper
import java.io.File
import java.net.HttpURLConnection
import java.net.SocketTimeoutException
import java.net.URL
import java.util.concurrent.Executors
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okio.BufferedSink
import okio.buffer
import okio.sink
import okio.source
/**
* Download service with custom implementation of downloading using [HttpURLConnection].
*/
class DownloadService : Service() {
private lateinit var videoName: String
private lateinit var videoUrl: String
private lateinit var audioUrl: String
private var downloadType: DownloadType = DownloadType.NONE
private val binder = LocalBinder()
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val jobMain = SupervisorJob()
private val scope = CoroutineScope(dispatcher + jobMain)
private var videoDownloadId: Long? = null
private var audioDownloadId: Long? = null
private lateinit var notificationManager: NotificationManager
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
override fun onCreate() {
super.onCreate()
IS_DOWNLOAD_RUNNING = true
notifyForeground()
sendBroadcast(Intent(ACTION_SERVICE_STARTED))
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
videoName = intent?.getStringExtra("videoName")!!
videoUrl = intent.getStringExtra("videoUrl")!!
audioUrl = intent.getStringExtra("audioUrl")!!
downloadType = when {
videoUrl != "" && audioUrl != "" -> DownloadType.AUDIO_VIDEO
audioUrl != "" -> DownloadType.AUDIO
videoUrl != "" -> DownloadType.VIDEO
else -> DownloadType.NONE
when (intent?.action) {
ACTION_DOWNLOAD_RESUME -> resume(intent.getIntExtra("id", -1))
ACTION_DOWNLOAD_PAUSE -> pause(intent.getIntExtra("id", -1))
}
if (downloadType != DownloadType.NONE) {
downloadManager()
} else {
onDestroy()
val videoId = intent?.getStringExtra(IntentData.videoId) ?: return START_NOT_STICKY
val fileName = intent.getStringExtra(IntentData.fileName) ?: videoId
val videoFormat = intent.getStringExtra(IntentData.videoFormat)
val videoQuality = intent.getStringExtra(IntentData.videoQuality)
val audioFormat = intent.getStringExtra(IntentData.audioFormat)
val audioQuality = intent.getStringExtra(IntentData.audioQuality)
val subtitleCode = intent.getStringExtra(IntentData.subtitleCode)
scope.launch {
try {
val streams = RetrofitInstance.api.getStreams(videoId)
awaitQuery {
Database.downloadDao().insertDownload(
Download(
videoId = videoId,
title = streams.title ?: "",
thumbnailPath = File(
DownloadHelper.getDownloadDir(
this@DownloadService,
DownloadHelper.THUMBNAIL_DIR
),
fileName
).absolutePath,
description = streams.description ?: "",
uploadDate = streams.uploadDate,
uploader = streams.uploader ?: ""
)
)
}
streams.thumbnailUrl?.let { url ->
ImageHelper.downloadImage(
this@DownloadService,
url,
File(
DownloadHelper.getDownloadDir(
this@DownloadService,
DownloadHelper.THUMBNAIL_DIR
),
fileName
).absolutePath
)
}
val downloadItems = streams.toDownloadItems(
videoId,
fileName,
videoFormat,
videoQuality,
audioFormat,
audioQuality,
subtitleCode
)
downloadItems.forEach { start(it) }
} catch (e: Exception) {
return@launch
}
}
return super.onStartCommand(intent, flags, startId)
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? {
TODO("Not yet implemented")
/**
* Initiate download [Job] using [DownloadItem] by creating file according to [FileType]
* for the requested file.
*/
private fun start(item: DownloadItem) {
val file: File = when (item.type) {
FileType.AUDIO -> {
val audioDownloadDir = DownloadHelper.getDownloadDir(
this,
DownloadHelper.AUDIO_DIR
)
File(audioDownloadDir, item.fileName)
}
FileType.VIDEO -> {
val videoDownloadDir = DownloadHelper.getDownloadDir(
this,
DownloadHelper.VIDEO_DIR
)
File(videoDownloadDir, item.fileName)
}
FileType.SUBTITLE -> {
val subtitleDownloadDir = DownloadHelper.getDownloadDir(
this,
DownloadHelper.SUBTITLE_DIR
)
File(subtitleDownloadDir, item.fileName)
}
}
file.createNewFile()
item.path = file.absolutePath
item.id = awaitQuery {
Database.downloadDao().insertDownloadItem(item)
}.toInt()
jobs[item.id] = scope.launch {
downloadFile(item)
}
}
private fun downloadManager() {
// initialize and create the directories to download into
/**
* Download file and emit [DownloadStatus] to the collectors of [downloadFlow]
* and notification.
*/
private suspend fun downloadFile(item: DownloadItem) {
downloadQueue[item.id] = true
val notificationBuilder = getNotificationBuilder(item)
setResumeNotification(notificationBuilder, item)
val file = File(item.path)
var totalRead = file.length()
val url = URL(item.url ?: return)
val videoDownloadDir = DownloadHelper.getDownloadDir(this, DownloadHelper.VIDEO_DIR)
val audioDownloadDir = DownloadHelper.getDownloadDir(this, DownloadHelper.AUDIO_DIR)
url.getContentLength().let { size ->
if (size > 0 && size != item.downloadSize) {
item.downloadSize = size
query {
Database.downloadDao().updateDownloadItem(item)
}
}
}
// start download
try {
registerReceiver(
onDownloadComplete,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
)
if (downloadType in listOf(DownloadType.VIDEO, DownloadType.AUDIO_VIDEO)) {
videoDownloadId = downloadManagerRequest(
"[${getString(R.string.video)}] $videoName",
getString(R.string.downloading),
videoUrl,
Uri.fromFile(
File(videoDownloadDir, videoName)
)
)
// Set start range where last downloading was held.
val con = CronetHelper.getCronetEngine().openConnection(url) as HttpURLConnection
con.requestMethod = "GET"
con.setRequestProperty("Range", "bytes=$totalRead-")
con.connectTimeout = DownloadHelper.DEFAULT_TIMEOUT
con.readTimeout = DownloadHelper.DEFAULT_TIMEOUT
withContext(Dispatchers.IO) {
// Retry connecting to server for n times.
for (i in 1..DownloadHelper.DEFAULT_RETRY) {
try {
con.connect()
break
} catch (_: SocketTimeoutException) {
val message = getString(R.string.downloadfailed) + " " + i
_downloadFlow.emit(item.id to DownloadStatus.Error(message))
toastFromMainThread(message)
}
}
}
if (downloadType in listOf(DownloadType.AUDIO, DownloadType.AUDIO_VIDEO)) {
audioDownloadId = downloadManagerRequest(
"[${getString(R.string.audio)}] $videoName",
getString(R.string.downloading),
audioUrl,
Uri.fromFile(
File(audioDownloadDir, videoName)
)
)
// If link is expired try to regenerate using available info.
if (con.responseCode == 403) {
regenerateLink(item)
con.disconnect()
downloadFile(item)
return
} else if (con.responseCode !in 200..299) {
val message = getString(R.string.downloadfailed) + ": " + con.responseMessage
_downloadFlow.emit(item.id to DownloadStatus.Error(message))
toastFromMainThread(message)
con.disconnect()
pause(item.id)
return
}
} catch (e: IllegalArgumentException) {
Log.e(TAG(), "download error $e")
downloadFailedNotification()
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.getNotificationId(),
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))
}
withContext(Dispatchers.IO) {
sink.flush()
sink.close()
sourceByte.close()
con.disconnect()
}
} catch (_: Exception) { }
val completed = when {
totalRead < item.downloadSize -> {
_downloadFlow.emit(item.id to DownloadStatus.Paused)
false
}
else -> {
_downloadFlow.emit(item.id to DownloadStatus.Completed)
true
}
}
setPauseNotification(notificationBuilder, item, completed)
pause(item.id)
}
/**
* Resume download which may have been paused.
*/
fun resume(id: Int) {
// If file is already downloading then avoid new download job.
if (downloadQueue[id] == true) return
if (downloadQueue.values.count { it } >= DownloadHelper.getMaxConcurrentDownloads()) {
toastFromMainThread(getString(R.string.concurrent_downloads_limit_reached))
scope.launch {
_downloadFlow.emit(id to DownloadStatus.Paused)
}
return
}
val downloadItem = awaitQuery {
Database.downloadDao().findDownloadItemById(id)
}
scope.launch {
downloadFile(downloadItem)
}
}
private val onDownloadComplete: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// Fetching the download id received with the broadcast
// Checking if the received broadcast is for our enqueued download by matching download id
val downloadId = intent.getLongExtra(
DownloadManager.EXTRA_DOWNLOAD_ID,
-1
/**
* Pause downloading job for given [id]. If no downloads are active, stop the service.
*/
fun pause(id: Int) {
downloadQueue[id] = false
// Stop the service if no downloads are active.
if (downloadQueue.none { it.value }) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_DETACH)
}
sendBroadcast(Intent(ACTION_SERVICE_STOPPED))
stopSelf()
}
}
/**
* Regenerate stream url using available info format and quality.
*/
private suspend fun regenerateLink(item: DownloadItem) {
val streams = RetrofitInstance.api.getStreams(item.videoId)
val stream = when (item.type) {
FileType.AUDIO -> streams.audioStreams
FileType.VIDEO -> streams.videoStreams
else -> null
}
stream?.find { it.format == item.format && it.quality == item.quality }?.let {
item.url = it.url
}
query {
Database.downloadDao().updateDownloadItem(item)
}
}
/**
* Check whether the file downloading or not.
*/
fun isDownloading(id: Int): Boolean {
return downloadQueue[id] ?: false
}
private fun notifyForeground() {
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
summaryNotificationBuilder = NotificationCompat
.Builder(this, DOWNLOAD_CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.downloading))
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setGroup(DOWNLOAD_NOTIFICATION_GROUP)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOnlyAlertOnce(true)
.setGroupSummary(true)
startForeground(DOWNLOAD_PROGRESS_NOTIFICATION_ID, summaryNotificationBuilder.build())
}
private fun getNotificationBuilder(item: DownloadItem): NotificationCompat.Builder {
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
} else {
PendingIntent.FLAG_CANCEL_CURRENT
}
val activityIntent =
PendingIntent.getActivity(
this@DownloadService,
0,
Intent(this@DownloadService, MainActivity::class.java).apply {
putExtra("fragmentToOpen", "downloads")
},
flags
)
if (downloadId == audioDownloadId) {
audioDownloadId = null
} else if (downloadId == videoDownloadId) videoDownloadId = null
if (audioDownloadId != null || videoDownloadId != null) return
downloadSucceededNotification()
onDestroy()
}
return NotificationCompat
.Builder(this, DOWNLOAD_CHANNEL_ID)
.setContentTitle("[${item.type}] ${item.fileName}")
.setProgress(0, 0, true)
.setOngoing(true)
.setContentIntent(activityIntent)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.setGroup(DOWNLOAD_NOTIFICATION_GROUP)
}
private fun downloadManagerRequest(
title: String,
descriptionText: String,
url: String,
destination: Uri
): Long {
val request: DownloadManager.Request =
DownloadManager.Request(Uri.parse(url))
.setTitle(title) // Title of the Download Notification
.setDescription(descriptionText) // Description of the Download Notification
.setDestinationUri(destination)
.setAllowedOverMetered(true) // Set if download is allowed on Mobile network
.setAllowedOverRoaming(true)
private fun setResumeNotification(
notificationBuilder: NotificationCompat.Builder,
item: DownloadItem
) {
notificationBuilder
.setSmallIcon(android.R.drawable.stat_sys_download)
.setWhen(System.currentTimeMillis())
.setOngoing(true)
.clearActions()
.addAction(getPauseAction(item.id))
val downloadManager: DownloadManager =
applicationContext.getSystemService(DOWNLOAD_SERVICE) as DownloadManager
return downloadManager.enqueue(request)
notificationManager.notify(item.getNotificationId(), notificationBuilder.build())
}
private fun downloadFailedNotification() {
val builder = NotificationCompat.Builder(this, DOWNLOAD_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_download)
.setContentTitle(resources.getString(R.string.downloadfailed))
.setContentText(getString(R.string.fail))
.setPriority(NotificationCompat.PRIORITY_HIGH)
private fun setPauseNotification(
notificationBuilder: NotificationCompat.Builder,
item: DownloadItem,
isCompleted: Boolean = false
) {
notificationBuilder
.setProgress(0, 0, false)
.setOngoing(false)
.clearActions()
with(NotificationManagerCompat.from(this)) {
// notificationId is a unique int for each notification that you must define
notify(DOWNLOAD_FAILURE_NOTIFICATION_ID, builder.build())
if (isCompleted) {
notificationBuilder
.setSmallIcon(R.drawable.ic_done)
.setContentText(getString(R.string.download_completed))
} else {
notificationBuilder
.setSmallIcon(R.drawable.ic_pause)
.setContentText(getString(R.string.download_paused))
.addAction(getResumeAction(item.id))
}
notificationManager.notify(item.getNotificationId(), notificationBuilder.build())
}
private fun downloadSucceededNotification() {
Log.i(TAG(), "Download succeeded")
val builder = NotificationCompat.Builder(this, DOWNLOAD_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_download)
.setContentTitle(resources.getString(R.string.success))
.setContentText(getString(R.string.downloadsucceeded))
.setPriority(NotificationCompat.PRIORITY_HIGH)
private fun getResumeAction(id: Int): NotificationCompat.Action {
val intent = Intent(this, NotificationReceiver::class.java)
with(NotificationManagerCompat.from(this)) {
// notificationId is a unique int for each notification that you must define
notify(DOWNLOAD_SUCCESS_NOTIFICATION_ID, builder.build())
intent.action = ACTION_DOWNLOAD_RESUME
intent.putExtra("id", id)
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 NotificationCompat.Action.Builder(
R.drawable.ic_play,
getString(R.string.resume),
PendingIntent.getBroadcast(this, id, intent, flags)
).build()
}
private fun getPauseAction(id: Int): NotificationCompat.Action {
val intent = Intent(this, NotificationReceiver::class.java)
intent.action = ACTION_DOWNLOAD_PAUSE
intent.putExtra("id", id)
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 NotificationCompat.Action.Builder(
R.drawable.ic_pause,
getString(R.string.pause),
PendingIntent.getBroadcast(this, id, intent, flags)
).build()
}
override fun onDestroy() {
try {
unregisterReceiver(onDownloadComplete)
} catch (e: Exception) {
e.printStackTrace()
}
downloadQueue.clear()
IS_DOWNLOAD_RUNNING = false
stopService(Intent(this, DownloadService::class.java))
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
}
inner class LocalBinder : Binder() {
fun getService(): DownloadService = this@DownloadService
}
companion object {
private const val DOWNLOAD_NOTIFICATION_GROUP = "download_notification_group"
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
}
}

View File

@ -35,6 +35,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
@ -423,6 +424,14 @@ class MainActivity : BaseActivity() {
navController.navigate(R.id.subscriptionsFragment)
"library" ->
navController.navigate(R.id.libraryFragment)
"downloads" ->
navController.navigate(R.id.downloadsFragment)
}
if (intent?.getBooleanExtra(IntentData.downloading, false) == true) {
(supportFragmentManager.fragments.find { it is NavHostFragment })
?.childFragmentManager?.fragments?.forEach { fragment ->
(fragment as? DownloadsFragment)?.bindDownloadService()
}
}
}

View File

@ -17,10 +17,12 @@ import androidx.core.view.WindowInsetsControllerCompat
import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.ActivityOfflinePlayerBinding
import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding
import com.github.libretube.db.DatabaseHolder.Companion.Database
import com.github.libretube.enums.FileType
import com.github.libretube.extensions.awaitQuery
import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.ui.extensions.setAspectRatio
import com.github.libretube.ui.models.PlayerViewModel
import com.github.libretube.util.DownloadHelper
import com.github.libretube.util.PlayerHelper
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
@ -33,7 +35,7 @@ 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
private lateinit var playerBinding: ExoStyledPlayerControlViewBinding
@ -46,7 +48,7 @@ class OfflinePlayerActivity : BaseActivity() {
super.onCreate(savedInstanceState)
fileName = intent?.getStringExtra(IntentData.fileName)!!
videoId = intent?.getStringExtra(IntentData.videoId)!!
binding = ActivityOfflinePlayerBinding.inflate(layoutInflater)
setContentView(binding.root)
@ -96,15 +98,17 @@ class OfflinePlayerActivity : BaseActivity() {
}
private fun playVideo() {
val videoUri = File(
DownloadHelper.getDownloadDir(this, DownloadHelper.VIDEO_DIR),
fileName
).toUri()
val downloadFiles = awaitQuery {
Database.downloadDao().findById(videoId).downloadItems
}
val audioUri = File(
DownloadHelper.getDownloadDir(this, DownloadHelper.AUDIO_DIR),
fileName
).toUri()
val video = downloadFiles.firstOrNull { it.type == FileType.VIDEO }
val audio = downloadFiles.firstOrNull { it.type == FileType.AUDIO }
val subtitle = downloadFiles.firstOrNull { it.type == FileType.SUBTITLE }
val videoUri = video?.path?.let { File(it).toUri() }
val audioUri = audio?.path?.let { File(it).toUri() }
val subtitleUri = subtitle?.path?.let { File(it).toUri() }
setMediaSource(
videoUri,

View File

@ -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,56 @@ 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)
val totalSizeInfo = if (downloadSize > 0) {
downloadSize.formatAsFileSize()
} else {
context.getString(R.string.unknown)
}
if (downloadSize > currentSize) {
downloadOverlay.visibility = View.VISIBLE
resumePauseBtn.setImageResource(R.drawable.ic_download)
fileSize.text = "${currentSize.formatAsFileSize()} / $totalSizeInfo"
} else {
downloadOverlay.visibility = View.GONE
fileSize.text = totalSizeInfo
}
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 +98,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 +123,6 @@ class DownloadsAdapter(
}
override fun getItemCount(): Int {
return files.size
return downloads.size
}
}

View File

@ -1,8 +1,8 @@
package com.github.libretube.ui.dialogs
import android.app.Dialog
import android.content.Intent
import android.os.Bundle
import android.text.InputFilter
import android.util.Log
import android.view.View
import android.widget.ArrayAdapter
@ -15,10 +15,8 @@ import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.obj.Streams
import com.github.libretube.databinding.DialogDownloadBinding
import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.sanitize
import com.github.libretube.services.DownloadService
import com.github.libretube.util.ImageHelper
import com.github.libretube.util.MetadataHelper
import com.github.libretube.util.DownloadHelper
import com.github.libretube.util.TextUtils
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.io.IOException
import retrofit2.HttpException
@ -41,6 +39,25 @@ class DownloadDialog(
binding.videoSpinner.visibility = View.VISIBLE
}
binding.fileName.filters += InputFilter { source, start, end, _, _, _ ->
if (source.isNullOrBlank()) {
return@InputFilter null
}
// Extract actual source
val actualSource = source.subSequence(start, end)
// Filter out unsupported characters
val filtered = actualSource.filterNot {
TextUtils.RESERVED_CHARS.contains(it, true)
}
// Check if something was filtered out
return@InputFilter if (actualSource.length != filtered.length) {
filtered
} else {
null
}
}
return MaterialAlertDialogBuilder(requireContext())
.setView(binding.root)
.show()
@ -68,37 +85,45 @@ class DownloadDialog(
binding.fileName.setText(streams.title.toString())
val vidName = arrayListOf<String>()
val videoUrl = arrayListOf<String>()
// add empty selection
vidName.add(getString(R.string.no_video))
videoUrl.add("")
// add all available video streams
for (vid in streams.videoStreams!!) {
if (vid.url != null) {
val name = vid.quality + " " + vid.format
vidName.add(name)
videoUrl.add(vid.url!!)
}
}
val audioName = arrayListOf<String>()
val audioUrl = arrayListOf<String>()
// add empty selection
audioName.add(getString(R.string.no_audio))
audioUrl.add("")
// add all available audio streams
for (audio in streams.audioStreams!!) {
if (audio.url != null) {
val name = audio.quality + " " + audio.format
audioName.add(name)
audioUrl.add(audio.url!!)
}
}
val subtitleName = arrayListOf<String>()
// add empty selection
subtitleName.add(getString(R.string.no_subtitle))
// add all available subtitles
for (subtitle in streams.subtitles!!) {
if (subtitle.url != null) {
subtitleName.add(subtitle.name.toString())
}
}
if (subtitleName.size == 1) binding.subtitleSpinner.visibility = View.GONE
// initialize the video sources
val videoArrayAdapter = ArrayAdapter(
requireContext(),
@ -109,6 +134,7 @@ class DownloadDialog(
binding.videoSpinner.adapter = videoArrayAdapter
if (binding.videoSpinner.size >= 1) binding.videoSpinner.setSelection(1)
if (binding.audioSpinner.size >= 1) binding.audioSpinner.setSelection(1)
if (binding.subtitleSpinner.size >= 1) binding.subtitleSpinner.setSelection(1)
// initialize the audio sources
val audioArrayAdapter = ArrayAdapter(
@ -120,48 +146,55 @@ class DownloadDialog(
binding.audioSpinner.adapter = audioArrayAdapter
if (binding.audioSpinner.size >= 1) binding.audioSpinner.setSelection(1)
// initialize the subtitle sources
val subtitleArrayAdapter = ArrayAdapter(
requireContext(),
android.R.layout.simple_spinner_item,
subtitleName
)
subtitleArrayAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.subtitleSpinner.adapter = subtitleArrayAdapter
if (binding.subtitleSpinner.size >= 1) binding.subtitleSpinner.setSelection(1)
binding.download.setOnClickListener {
if (binding.fileName.text.toString().isEmpty()) {
Toast.makeText(context, R.string.invalid_filename, Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
val vidUrl = videoUrl[binding.videoSpinner.selectedItemPosition]
val audUrl = audioUrl[binding.audioSpinner.selectedItemPosition]
val videoPosition = binding.videoSpinner.selectedItemPosition - 1
val audioPosition = binding.audioSpinner.selectedItemPosition - 1
val subtitlePosition = binding.subtitleSpinner.selectedItemPosition - 1
if (audUrl == "" && vidUrl == "") {
if (videoPosition == -1 && audioPosition == -1 && subtitlePosition == -1) {
Toast.makeText(context, R.string.nothing_selected, Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
val fileName = binding.fileName.text.toString().sanitize()
val metadataHelper = MetadataHelper(requireContext())
metadataHelper.createMetadata(fileName, streams)
streams.thumbnailUrl?.let { thumbnailUrl ->
ImageHelper.downloadImage(
requireContext(),
thumbnailUrl,
fileName
)
val videoStream = when (videoPosition) {
-1 -> null
else -> streams.videoStreams[videoPosition]
}
val audioStream = when (audioPosition) {
-1 -> null
else -> streams.audioStreams[audioPosition]
}
val subtitle = when (subtitlePosition) {
-1 -> null
else -> streams.subtitles[subtitlePosition]
}
val intent = Intent(context, DownloadService::class.java)
intent.putExtra(
"videoName",
fileName
)
intent.putExtra(
"videoUrl",
vidUrl
)
intent.putExtra(
"audioUrl",
audUrl
DownloadHelper.startDownloadService(
context = requireContext(),
videoId = videoId,
fileName = binding.fileName.text.toString(),
videoFormat = videoStream?.format,
videoQuality = videoStream?.quality,
audioFormat = audioStream?.format,
audioQuality = audioStream?.quality,
subtitleCode = subtitle?.code
)
context?.startService(intent)
dismiss()
}
}

View File

@ -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 java.io.File
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
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() +
" /\n" + 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)
}
}
}

View File

@ -1,15 +1,23 @@
package com.github.libretube.util
import android.content.Context
import android.content.Intent
import android.os.Build
import com.github.libretube.obj.DownloadedFile
import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.db.obj.DownloadItem
import com.github.libretube.services.DownloadService
import java.io.File
object DownloadHelper {
const val VIDEO_DIR = "video"
const val AUDIO_DIR = "audio"
const val SUBTITLE_DIR = "subtitle"
const val METADATA_DIR = "metadata"
const val THUMBNAIL_DIR = "thumbnail"
const val DOWNLOAD_CHUNK_SIZE = 8L * 1024
const val DEFAULT_TIMEOUT = 15 * 1000
const val DEFAULT_RETRY = 3
fun getOfflineStorageDir(context: Context): File {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return context.filesDir
@ -30,28 +38,41 @@ object DownloadHelper {
}
}
private fun File.toDownloadedFile(): DownloadedFile {
return DownloadedFile(
name = this.name,
size = this.length()
)
fun getMaxConcurrentDownloads(): Int {
return PreferenceHelper.getString(
PreferenceKeys.MAX_CONCURRENT_DOWNLOADS,
"6"
).toFloat().toInt()
}
fun getDownloadedFiles(context: Context): MutableList<DownloadedFile> {
val videoFiles = getDownloadDir(context, VIDEO_DIR).listFiles().orEmpty()
val audioFiles = getDownloadDir(context, AUDIO_DIR).listFiles().orEmpty().toMutableList()
fun startDownloadService(
context: Context,
videoId: String? = null,
fileName: String? = null,
videoFormat: String? = null,
videoQuality: String? = null,
audioFormat: String? = null,
audioQuality: String? = null,
subtitleCode: String? = null
) {
val intent = Intent(context, DownloadService::class.java)
val files = mutableListOf<DownloadedFile>()
intent.putExtra(IntentData.videoId, videoId)
intent.putExtra(IntentData.fileName, fileName)
intent.putExtra(IntentData.videoFormat, videoFormat)
intent.putExtra(IntentData.videoQuality, videoQuality)
intent.putExtra(IntentData.audioFormat, audioFormat)
intent.putExtra(IntentData.audioQuality, audioQuality)
intent.putExtra(IntentData.subtitleCode, subtitleCode)
videoFiles.forEach {
audioFiles.removeIf { audioFile -> audioFile.name == it.name }
files.add(it.toDownloadedFile())
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
audioFiles.forEach {
files.add(it.toDownloadedFile())
}
return files
fun DownloadItem.getNotificationId(): Int {
return Int.MAX_VALUE - id
}
}

View File

@ -55,15 +55,12 @@ object ImageHelper {
if (!DataSaverMode.isEnabled(target.context)) 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()
@ -71,11 +68,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))
}

View File

@ -17,6 +17,11 @@ object TextUtils {
*/
const val EMAIL_REGEX = "^[A-Za-z](.*)([@]{1})(.{1,})(\\.)(.{1,})"
/**
* Reserved characters by unix which can not be used for file name.
*/
const val RESERVED_CHARS = "?:\"*|/\\<>\u0000"
fun toTwoDecimalsString(num: Int): String {
return if (num >= 10) num.toString() else "0$num"
}

View 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>

View File

@ -2,16 +2,12 @@
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="400"
android:viewportHeight="400">
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M200,15.89C98.47,15.89 15.89,98.48 15.89,200 15.89,301.52 98.47,384.11 200,384.11 301.52,384.11 384.11,301.52 384.11,200 384.11,98.48 301.52,15.89 200,15.89ZM200,359.97C111.79,359.97 40.03,288.2 40.03,200c0,-88.21 71.76,-159.96 159.96,-159.96 88.21,0 159.97,71.76 159.97,159.96 0,88.2 -71.76,159.97 -159.97,159.97z"
android:strokeWidth="27"
android:strokeColor="#000000" />
android:fillColor="@android:color/white"
android:pathData="M20.13,5.41l-1.41,-1.41l-9.19,9.19l-4.25,-4.24l-1.41,1.41l5.66,5.66z" />
<path
android:fillColor="#FF000000"
android:pathData="m266.65,139.73 l-94.94,94.93 -38.38,-38.37c-4.72,-4.71 -12.36,-4.71 -17.07,0 -4.72,4.72 -4.72,12.36 0,17.07l46.91,46.91c2.36,2.35 5.45,3.53 8.53,3.53 3.09,0 6.19,-1.18 8.54,-3.54 0.01,-0.01 0.01,-0.02 0.02,-0.03L283.73,156.8c4.72,-4.71 4.72,-12.36 0,-17.07 -4.72,-4.72 -12.36,-4.72 -17.07,0z"
android:strokeWidth="27"
android:strokeColor="#000000" />
android:fillColor="@android:color/white"
android:pathData="M5,18h14v2h-14z" />
</vector>

View File

@ -58,6 +58,12 @@
android:layout_height="wrap_content"
android:layout_marginVertical="10dp" />
<Spinner
android:id="@+id/subtitle_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="10dp" />
<Button
android:id="@+id/download"
style="@style/CustomDialogButton"

View File

@ -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"
@ -58,8 +86,9 @@
<TextView
android:id="@+id/fileSize"
android:layout_width="wrap_content"
android:layout_width="64dp"
android:layout_height="wrap_content"
android:layout_gravity="center" />
android:layout_gravity="bottom"
android:textSize="13sp"/>
</LinearLayout>

View File

@ -182,9 +182,17 @@
<string name="playerVideoFormat">Video format for player</string>
<string name="no_audio">No audio</string>
<string name="no_video">No video</string>
<string name="no_subtitle">No subtitle</string>
<string name="audio">Audio</string>
<string name="video">Video</string>
<string name="downloading">Downloading…</string>
<string name="download_paused">Download paused</string>
<string name="download_completed">Download completed</string>
<string name="concurrent_downloads">Max concurrent downloads</string>
<string name="concurrent_downloads_limit_reached">Max concurrent downloads limit reached.</string>
<string name="unknown">Unknown</string>
<string name="pause">Pause</string>
<string name="resume">Resume</string>
<string name="player_autoplay">Autoplay</string>
<string name="hideTrendingPage">Hide trending page</string>
<string name="instance_frontend_url">URL to instance frontend</string>

View File

@ -13,6 +13,15 @@
app:key="data_saver_mode_key"
app:title="@string/data_saver_mode" />
<com.github.libretube.ui.views.SliderPreference
android:icon="@drawable/ic_download"
android:key="max_concurrent_downloads"
android:title="@string/concurrent_downloads"
app:defValue="6"
app:stepSize="1"
app:valueFrom="1"
app:valueTo="20" />
<ListPreference
android:entries="@array/cacheSize"
android:entryValues="@array/cacheSizeValues"