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:name=".services.BackgroundMode"
android:enabled="true" android:enabled="true"
android:exported="false" /> android:exported="false" />
<receiver
android:name=".receivers.NotificationReceiver"
android:enabled="true"
android:exported="false" />
</application> </application>
</manifest> </manifest>

View File

@ -77,7 +77,7 @@ class LibreTubeApp : Application() {
private fun initializeNotificationChannels() { private fun initializeNotificationChannels() {
val downloadChannel = NotificationChannelCompat.Builder( val downloadChannel = NotificationChannelCompat.Builder(
DOWNLOAD_CHANNEL_ID, DOWNLOAD_CHANNEL_ID,
NotificationManagerCompat.IMPORTANCE_NONE NotificationManagerCompat.IMPORTANCE_LOW
) )
.setName(getString(R.string.download_channel_name)) .setName(getString(R.string.download_channel_name))
.setDescription(getString(R.string.download_channel_description)) .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_PENDING_NOTIFICATION_ID = 2
const val DOWNLOAD_FAILURE_NOTIFICATION_ID = 3 const val DOWNLOAD_FAILURE_NOTIFICATION_ID = 3
const val DOWNLOAD_SUCCESS_NOTIFICATION_ID = 4 const val DOWNLOAD_SUCCESS_NOTIFICATION_ID = 4
const val DOWNLOAD_PROGRESS_NOTIFICATION_ID = 5
/** /**
* Notification Channel IDs * Notification Channel IDs

View File

@ -10,5 +10,11 @@ object IntentData {
const val fileName = "fileName" const val fileName = "fileName"
const val keepQueue = "keepQueue" const val keepQueue = "keepQueue"
const val playlistType = "playlistType" 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" const val openAudioPlayer = "openAudioPlayer"
} }

View File

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

View File

@ -4,6 +4,7 @@ import androidx.room.AutoMigration
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import com.github.libretube.db.dao.CustomInstanceDao 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.LocalPlaylistsDao
import com.github.libretube.db.dao.LocalSubscriptionDao import com.github.libretube.db.dao.LocalSubscriptionDao
import com.github.libretube.db.dao.PlaylistBookmarkDao 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.WatchHistoryDao
import com.github.libretube.db.dao.WatchPositionDao import com.github.libretube.db.dao.WatchPositionDao
import com.github.libretube.db.obj.CustomInstance 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.LocalPlaylist
import com.github.libretube.db.obj.LocalPlaylistItem import com.github.libretube.db.obj.LocalPlaylistItem
import com.github.libretube.db.obj.LocalSubscription import com.github.libretube.db.obj.LocalSubscription
@ -28,12 +31,15 @@ import com.github.libretube.db.obj.WatchPosition
LocalSubscription::class, LocalSubscription::class,
PlaylistBookmark::class, PlaylistBookmark::class,
LocalPlaylist::class, LocalPlaylist::class,
LocalPlaylistItem::class LocalPlaylistItem::class,
Download::class,
DownloadItem::class
], ],
version = 9, version = 10,
autoMigrations = [ autoMigrations = [
AutoMigration(from = 7, to = 8), AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9) AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10)
] ]
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@ -71,4 +77,9 @@ abstract class AppDatabase : RoomDatabase() {
* Local playlists * Local playlists
*/ */
abstract fun localPlaylistsDao(): LocalPlaylistsDao 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 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 package com.github.libretube.services
import android.app.DownloadManager import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.os.Binder
import android.net.Uri import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.github.libretube.R 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_CHANNEL_ID
import com.github.libretube.constants.DOWNLOAD_FAILURE_NOTIFICATION_ID import com.github.libretube.constants.DOWNLOAD_PROGRESS_NOTIFICATION_ID
import com.github.libretube.constants.DOWNLOAD_SUCCESS_NOTIFICATION_ID import com.github.libretube.constants.IntentData
import com.github.libretube.enums.DownloadType import com.github.libretube.db.DatabaseHolder.Companion.Database
import com.github.libretube.extensions.TAG 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
import com.github.libretube.util.DownloadHelper.getNotificationId
import com.github.libretube.util.ImageHelper
import java.io.File 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() { class DownloadService : Service() {
private lateinit var videoName: String private val binder = LocalBinder()
private lateinit var videoUrl: String private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private lateinit var audioUrl: String private val jobMain = SupervisorJob()
private var downloadType: DownloadType = DownloadType.NONE private val scope = CoroutineScope(dispatcher + jobMain)
private var videoDownloadId: Long? = null private lateinit var notificationManager: NotificationManager
private var audioDownloadId: Long? = null 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() { override fun onCreate() {
super.onCreate() super.onCreate()
IS_DOWNLOAD_RUNNING = true IS_DOWNLOAD_RUNNING = true
notifyForeground()
sendBroadcast(Intent(ACTION_SERVICE_STARTED))
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
videoName = intent?.getStringExtra("videoName")!! when (intent?.action) {
videoUrl = intent.getStringExtra("videoUrl")!! ACTION_DOWNLOAD_RESUME -> resume(intent.getIntExtra("id", -1))
audioUrl = intent.getStringExtra("audioUrl")!! ACTION_DOWNLOAD_PAUSE -> pause(intent.getIntExtra("id", -1))
downloadType = when {
videoUrl != "" && audioUrl != "" -> DownloadType.AUDIO_VIDEO
audioUrl != "" -> DownloadType.AUDIO
videoUrl != "" -> DownloadType.VIDEO
else -> DownloadType.NONE
} }
if (downloadType != DownloadType.NONE) { val videoId = intent?.getStringExtra(IntentData.videoId) ?: return START_NOT_STICKY
downloadManager() val fileName = intent.getStringExtra(IntentData.fileName) ?: videoId
} else { val videoFormat = intent.getStringExtra(IntentData.videoFormat)
onDestroy() 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) url.getContentLength().let { size ->
val audioDownloadDir = DownloadHelper.getDownloadDir(this, DownloadHelper.AUDIO_DIR) if (size > 0 && size != item.downloadSize) {
item.downloadSize = size
query {
Database.downloadDao().updateDownloadItem(item)
}
}
}
// start download
try { try {
registerReceiver( // Set start range where last downloading was held.
onDownloadComplete, val con = CronetHelper.getCronetEngine().openConnection(url) as HttpURLConnection
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) con.requestMethod = "GET"
) con.setRequestProperty("Range", "bytes=$totalRead-")
if (downloadType in listOf(DownloadType.VIDEO, DownloadType.AUDIO_VIDEO)) { con.connectTimeout = DownloadHelper.DEFAULT_TIMEOUT
videoDownloadId = downloadManagerRequest( con.readTimeout = DownloadHelper.DEFAULT_TIMEOUT
"[${getString(R.string.video)}] $videoName",
getString(R.string.downloading), withContext(Dispatchers.IO) {
videoUrl, // Retry connecting to server for n times.
Uri.fromFile( for (i in 1..DownloadHelper.DEFAULT_RETRY) {
File(videoDownloadDir, videoName) 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( // If link is expired try to regenerate using available info.
"[${getString(R.string.audio)}] $videoName", if (con.responseCode == 403) {
getString(R.string.downloading), regenerateLink(item)
audioUrl, con.disconnect()
Uri.fromFile( downloadFile(item)
File(audioDownloadDir, videoName) 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") val sink: BufferedSink = file.sink(true).buffer()
downloadFailedNotification() 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) { * Pause downloading job for given [id]. If no downloads are active, stop the service.
// Fetching the download id received with the broadcast */
// Checking if the received broadcast is for our enqueued download by matching download id fun pause(id: Int) {
val downloadId = intent.getLongExtra( downloadQueue[id] = false
DownloadManager.EXTRA_DOWNLOAD_ID,
-1 // 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 return NotificationCompat
.Builder(this, DOWNLOAD_CHANNEL_ID)
downloadSucceededNotification() .setContentTitle("[${item.type}] ${item.fileName}")
onDestroy() .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( private fun setResumeNotification(
title: String, notificationBuilder: NotificationCompat.Builder,
descriptionText: String, item: DownloadItem
url: String, ) {
destination: Uri notificationBuilder
): Long { .setSmallIcon(android.R.drawable.stat_sys_download)
val request: DownloadManager.Request = .setWhen(System.currentTimeMillis())
DownloadManager.Request(Uri.parse(url)) .setOngoing(true)
.setTitle(title) // Title of the Download Notification .clearActions()
.setDescription(descriptionText) // Description of the Download Notification .addAction(getPauseAction(item.id))
.setDestinationUri(destination)
.setAllowedOverMetered(true) // Set if download is allowed on Mobile network
.setAllowedOverRoaming(true)
val downloadManager: DownloadManager = notificationManager.notify(item.getNotificationId(), notificationBuilder.build())
applicationContext.getSystemService(DOWNLOAD_SERVICE) as DownloadManager
return downloadManager.enqueue(request)
} }
private fun downloadFailedNotification() { private fun setPauseNotification(
val builder = NotificationCompat.Builder(this, DOWNLOAD_CHANNEL_ID) notificationBuilder: NotificationCompat.Builder,
.setSmallIcon(R.drawable.ic_download) item: DownloadItem,
.setContentTitle(resources.getString(R.string.downloadfailed)) isCompleted: Boolean = false
.setContentText(getString(R.string.fail)) ) {
.setPriority(NotificationCompat.PRIORITY_HIGH) notificationBuilder
.setProgress(0, 0, false)
.setOngoing(false)
.clearActions()
with(NotificationManagerCompat.from(this)) { if (isCompleted) {
// notificationId is a unique int for each notification that you must define notificationBuilder
notify(DOWNLOAD_FAILURE_NOTIFICATION_ID, builder.build()) .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() { private fun getResumeAction(id: Int): NotificationCompat.Action {
Log.i(TAG(), "Download succeeded") val intent = Intent(this, NotificationReceiver::class.java)
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)
with(NotificationManagerCompat.from(this)) { intent.action = ACTION_DOWNLOAD_RESUME
// notificationId is a unique int for each notification that you must define intent.putExtra("id", id)
notify(DOWNLOAD_SUCCESS_NOTIFICATION_ID, builder.build())
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() { override fun onDestroy() {
try { downloadQueue.clear()
unregisterReceiver(onDownloadComplete)
} catch (e: Exception) {
e.printStackTrace()
}
IS_DOWNLOAD_RUNNING = false IS_DOWNLOAD_RUNNING = false
sendBroadcast(Intent(ACTION_SERVICE_STOPPED))
stopService(Intent(this, DownloadService::class.java))
super.onDestroy() 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 { 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 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.services.ClosingService
import com.github.libretube.ui.base.BaseActivity import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.ui.dialogs.ErrorDialog 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.fragments.PlayerFragment
import com.github.libretube.ui.models.PlayerViewModel import com.github.libretube.ui.models.PlayerViewModel
import com.github.libretube.ui.models.SearchViewModel import com.github.libretube.ui.models.SearchViewModel
@ -423,6 +424,14 @@ class MainActivity : BaseActivity() {
navController.navigate(R.id.subscriptionsFragment) navController.navigate(R.id.subscriptionsFragment)
"library" -> "library" ->
navController.navigate(R.id.libraryFragment) 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.constants.IntentData
import com.github.libretube.databinding.ActivityOfflinePlayerBinding import com.github.libretube.databinding.ActivityOfflinePlayerBinding
import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding 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.base.BaseActivity
import com.github.libretube.ui.extensions.setAspectRatio import com.github.libretube.ui.extensions.setAspectRatio
import com.github.libretube.ui.models.PlayerViewModel import com.github.libretube.ui.models.PlayerViewModel
import com.github.libretube.util.DownloadHelper
import com.github.libretube.util.PlayerHelper import com.github.libretube.util.PlayerHelper
import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
@ -33,7 +35,7 @@ import java.io.File
class OfflinePlayerActivity : BaseActivity() { class OfflinePlayerActivity : BaseActivity() {
private lateinit var binding: ActivityOfflinePlayerBinding private lateinit var binding: ActivityOfflinePlayerBinding
private lateinit var fileName: String private lateinit var videoId: String
private lateinit var player: ExoPlayer private lateinit var player: ExoPlayer
private lateinit var playerView: StyledPlayerView private lateinit var playerView: StyledPlayerView
private lateinit var playerBinding: ExoStyledPlayerControlViewBinding private lateinit var playerBinding: ExoStyledPlayerControlViewBinding
@ -46,7 +48,7 @@ class OfflinePlayerActivity : BaseActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
fileName = intent?.getStringExtra(IntentData.fileName)!! videoId = intent?.getStringExtra(IntentData.videoId)!!
binding = ActivityOfflinePlayerBinding.inflate(layoutInflater) binding = ActivityOfflinePlayerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
@ -96,15 +98,17 @@ class OfflinePlayerActivity : BaseActivity() {
} }
private fun playVideo() { private fun playVideo() {
val videoUri = File( val downloadFiles = awaitQuery {
DownloadHelper.getDownloadDir(this, DownloadHelper.VIDEO_DIR), Database.downloadDao().findById(videoId).downloadItems
fileName }
).toUri()
val audioUri = File( val video = downloadFiles.firstOrNull { it.type == FileType.VIDEO }
DownloadHelper.getDownloadDir(this, DownloadHelper.AUDIO_DIR), val audio = downloadFiles.firstOrNull { it.type == FileType.AUDIO }
fileName val subtitle = downloadFiles.firstOrNull { it.type == FileType.SUBTITLE }
).toUri()
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( setMediaSource(
videoUri, videoUri,

View File

@ -1,24 +1,29 @@
package com.github.libretube.ui.adapters package com.github.libretube.ui.adapters
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent import android.content.Intent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.DownloadedMediaRowBinding import com.github.libretube.databinding.DownloadedMediaRowBinding
import com.github.libretube.extensions.formatShort import com.github.libretube.db.DatabaseHolder
import com.github.libretube.obj.DownloadedFile 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.activities.OfflinePlayerActivity
import com.github.libretube.ui.viewholders.DownloadsViewHolder import com.github.libretube.ui.viewholders.DownloadsViewHolder
import com.github.libretube.util.DownloadHelper import com.github.libretube.util.ImageHelper
import com.github.libretube.util.TextUtils
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.io.File import java.io.File
class DownloadsAdapter( class DownloadsAdapter(
private val files: MutableList<DownloadedFile> private val context: Context,
private val downloads: MutableList<DownloadWithItems>,
private val toogleDownload: (DownloadWithItems) -> Boolean
) : RecyclerView.Adapter<DownloadsViewHolder>() { ) : RecyclerView.Adapter<DownloadsViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadsViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadsViewHolder {
val binding = DownloadedMediaRowBinding.inflate( val binding = DownloadedMediaRowBinding.inflate(
@ -31,24 +36,56 @@ class DownloadsAdapter(
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: DownloadsViewHolder, position: Int) { override fun onBindViewHolder(holder: DownloadsViewHolder, position: Int) {
val file = files[position] val download = downloads[position].download
val items = downloads[position].downloadItems
holder.binding.apply { holder.binding.apply {
fileName.text = file.name title.text = download.title
fileSize.text = "${file.size / (1024 * 1024)} MiB" uploaderName.text = download.uploader
videoInfo.text = download.uploadDate
file.metadata?.let { val downloadSize = items.sumOf { it.downloadSize }
uploaderName.text = it.uploader val currentSize = items.sumOf { File(it.path).length() }
videoInfo.text = it.views.formatShort() + " " +
root.context.getString(R.string.views_placeholder) + if (downloadSize == -1L) {
TextUtils.SEPARATOR + it.uploadDate 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 { root.setOnClickListener {
val intent = Intent(root.context, OfflinePlayerActivity::class.java).also { val intent = Intent(root.context, OfflinePlayerActivity::class.java)
it.putExtra(IntentData.fileName, file.name) intent.putExtra(IntentData.videoId, download.videoId)
}
root.context.startActivity(intent) root.context.startActivity(intent)
} }
@ -61,27 +98,18 @@ class DownloadsAdapter(
) { _, index -> ) { _, index ->
when (index) { when (index) {
0 -> { 0 -> {
val audioDir = DownloadHelper.getDownloadDir( items.map { File(it.path) }.forEach { file ->
root.context, if (file.exists()) {
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()) {
try { try {
f.delete() file.delete()
} catch (e: Exception) { } catch (_: Exception) { }
e.printStackTrace()
}
} }
} }
files.removeAt(position) query {
DatabaseHolder.Database.downloadDao().deleteDownload(download)
}
downloads.removeAt(position)
notifyItemRemoved(position) notifyItemRemoved(position)
notifyItemRangeChanged(position, itemCount) notifyItemRangeChanged(position, itemCount)
} }
@ -95,6 +123,6 @@ class DownloadsAdapter(
} }
override fun getItemCount(): Int { override fun getItemCount(): Int {
return files.size return downloads.size
} }
} }

View File

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

View File

@ -1,21 +1,62 @@
package com.github.libretube.ui.fragments 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.Bundle
import android.os.IBinder
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.size import androidx.core.view.size
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.R
import com.github.libretube.databinding.FragmentDownloadsBinding 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.adapters.DownloadsAdapter
import com.github.libretube.ui.base.BaseFragment 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.DownloadHelper
import com.github.libretube.util.ImageHelper import java.io.File
import com.github.libretube.util.MetadataHelper import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class DownloadsFragment : BaseFragment() { class DownloadsFragment : BaseFragment() {
private lateinit var binding: FragmentDownloadsBinding 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( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -29,36 +70,118 @@ class DownloadsFragment : BaseFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val files = DownloadHelper.getDownloadedFiles(requireContext()) awaitQuery {
downloads.addAll(Database.downloadDao().getAll())
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
}
} }
if (downloads.isEmpty()) return
binding.downloadsEmpty.visibility = View.GONE binding.downloadsEmpty.visibility = View.GONE
binding.downloads.visibility = View.VISIBLE binding.downloads.visibility = View.VISIBLE
binding.downloads.layoutManager = LinearLayoutManager(context) 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( binding.downloads.adapter?.registerAdapterDataObserver(
object : RecyclerView.AdapterDataObserver() { object : RecyclerView.AdapterDataObserver() {
override fun onChanged() { override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
if (binding.downloads.size == 0) { if (binding.downloads.size == 0) {
binding.downloads.visibility = View.GONE binding.downloads.visibility = View.GONE
binding.downloadsEmpty.visibility = View.VISIBLE 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 package com.github.libretube.util
import android.content.Context import android.content.Context
import android.content.Intent
import android.os.Build 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 import java.io.File
object DownloadHelper { object DownloadHelper {
const val VIDEO_DIR = "video" const val VIDEO_DIR = "video"
const val AUDIO_DIR = "audio" const val AUDIO_DIR = "audio"
const val SUBTITLE_DIR = "subtitle"
const val METADATA_DIR = "metadata" const val METADATA_DIR = "metadata"
const val THUMBNAIL_DIR = "thumbnail" 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 { fun getOfflineStorageDir(context: Context): File {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return context.filesDir if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return context.filesDir
@ -30,28 +38,41 @@ object DownloadHelper {
} }
} }
private fun File.toDownloadedFile(): DownloadedFile { fun getMaxConcurrentDownloads(): Int {
return DownloadedFile( return PreferenceHelper.getString(
name = this.name, PreferenceKeys.MAX_CONCURRENT_DOWNLOADS,
size = this.length() "6"
) ).toFloat().toInt()
} }
fun getDownloadedFiles(context: Context): MutableList<DownloadedFile> { fun startDownloadService(
val videoFiles = getDownloadDir(context, VIDEO_DIR).listFiles().orEmpty() context: Context,
val audioFiles = getDownloadDir(context, AUDIO_DIR).listFiles().orEmpty().toMutableList() 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 { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
audioFiles.removeIf { audioFile -> audioFile.name == it.name } context.startForegroundService(intent)
files.add(it.toDownloadedFile()) } else {
context.startService(intent)
} }
}
audioFiles.forEach { fun DownloadItem.getNotificationId(): Int {
files.add(it.toDownloadedFile()) return Int.MAX_VALUE - id
}
return files
} }
} }

View File

@ -55,15 +55,12 @@ object ImageHelper {
if (!DataSaverMode.isEnabled(target.context)) target.load(url, imageLoader) 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) val request = ImageRequest.Builder(context)
.data(url) .data(url)
.target { result -> .target { result ->
val bitmap = (result as BitmapDrawable).bitmap val bitmap = (result as BitmapDrawable).bitmap
val file = File( val file = File(path)
DownloadHelper.getDownloadDir(context, DownloadHelper.THUMBNAIL_DIR),
fileName
)
saveImage(context, bitmap, Uri.fromFile(file)) saveImage(context, bitmap, Uri.fromFile(file))
} }
.build() .build()
@ -71,11 +68,8 @@ object ImageHelper {
imageLoader.enqueue(request) imageLoader.enqueue(request)
} }
fun getDownloadedImage(context: Context, fileName: String): Bitmap? { fun getDownloadedImage(context: Context, path: String): Bitmap? {
val file = File( val file = File(path)
DownloadHelper.getDownloadDir(context, DownloadHelper.THUMBNAIL_DIR),
fileName
)
if (!file.exists()) return null if (!file.exists()) return null
return getImage(context, Uri.fromFile(file)) return getImage(context, Uri.fromFile(file))
} }

View File

@ -17,6 +17,11 @@ object TextUtils {
*/ */
const val EMAIL_REGEX = "^[A-Za-z](.*)([@]{1})(.{1,})(\\.)(.{1,})" 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 { fun toTwoDecimalsString(num: Int): String {
return if (num >= 10) num.toString() else "0$num" 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:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorControlNormal" android:tint="?attr/colorControlNormal"
android:viewportWidth="400" android:viewportWidth="24"
android:viewportHeight="400"> android:viewportHeight="24">
<path <path
android:fillColor="#FF000000" android:fillColor="@android:color/white"
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:pathData="M20.13,5.41l-1.41,-1.41l-9.19,9.19l-4.25,-4.24l-1.41,1.41l5.66,5.66z" />
android:strokeWidth="27"
android:strokeColor="#000000" />
<path <path
android:fillColor="#FF000000" android:fillColor="@android:color/white"
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:pathData="M5,18h14v2h-14z" />
android:strokeWidth="27"
android:strokeColor="#000000" />
</vector> </vector>

View File

@ -58,6 +58,12 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginVertical="10dp" /> 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 <Button
android:id="@+id/download" android:id="@+id/download"
style="@style/CustomDialogButton" style="@style/CustomDialogButton"

View File

@ -23,6 +23,34 @@
android:scaleType="fitXY" android:scaleType="fitXY"
tools:src="@tools:sample/backgrounds/scenic" /> 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> </com.google.android.material.card.MaterialCardView>
<LinearLayout <LinearLayout
@ -33,7 +61,7 @@
android:orientation="vertical"> android:orientation="vertical">
<TextView <TextView
android:id="@+id/fileName" android:id="@+id/title"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginVertical="2dp" android:layout_marginVertical="2dp"
@ -58,8 +86,9 @@
<TextView <TextView
android:id="@+id/fileSize" android:id="@+id/fileSize"
android:layout_width="wrap_content" android:layout_width="64dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" /> android:layout_gravity="bottom"
android:textSize="13sp"/>
</LinearLayout> </LinearLayout>

View File

@ -182,9 +182,17 @@
<string name="playerVideoFormat">Video format for player</string> <string name="playerVideoFormat">Video format for player</string>
<string name="no_audio">No audio</string> <string name="no_audio">No audio</string>
<string name="no_video">No video</string> <string name="no_video">No video</string>
<string name="no_subtitle">No subtitle</string>
<string name="audio">Audio</string> <string name="audio">Audio</string>
<string name="video">Video</string> <string name="video">Video</string>
<string name="downloading">Downloading…</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="player_autoplay">Autoplay</string>
<string name="hideTrendingPage">Hide trending page</string> <string name="hideTrendingPage">Hide trending page</string>
<string name="instance_frontend_url">URL to instance frontend</string> <string name="instance_frontend_url">URL to instance frontend</string>

View File

@ -13,6 +13,15 @@
app:key="data_saver_mode_key" app:key="data_saver_mode_key"
app:title="@string/data_saver_mode" /> 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 <ListPreference
android:entries="@array/cacheSize" android:entries="@array/cacheSize"
android:entryValues="@array/cacheSizeValues" android:entryValues="@array/cacheSizeValues"