diff --git a/app/schemas/com.github.libretube.db.AppDatabase/10.json b/app/schemas/com.github.libretube.db.AppDatabase/10.json
new file mode 100644
index 000000000..632e2b7d0
--- /dev/null
+++ b/app/schemas/com.github.libretube.db.AppDatabase/10.json
@@ -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')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 29fb3eb0b..06d4a6c8f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -352,6 +352,11 @@
android:name=".services.BackgroundMode"
android:enabled="true"
android:exported="false" />
+
+
diff --git a/app/src/main/java/com/github/libretube/LibreTubeApp.kt b/app/src/main/java/com/github/libretube/LibreTubeApp.kt
index dbd4db631..2fab6a40f 100644
--- a/app/src/main/java/com/github/libretube/LibreTubeApp.kt
+++ b/app/src/main/java/com/github/libretube/LibreTubeApp.kt
@@ -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))
diff --git a/app/src/main/java/com/github/libretube/constants/Constants.kt b/app/src/main/java/com/github/libretube/constants/Constants.kt
index 25da67196..464a0dec0 100644
--- a/app/src/main/java/com/github/libretube/constants/Constants.kt
+++ b/app/src/main/java/com/github/libretube/constants/Constants.kt
@@ -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
diff --git a/app/src/main/java/com/github/libretube/constants/IntentData.kt b/app/src/main/java/com/github/libretube/constants/IntentData.kt
index 148770945..aa763177c 100644
--- a/app/src/main/java/com/github/libretube/constants/IntentData.kt
+++ b/app/src/main/java/com/github/libretube/constants/IntentData.kt
@@ -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"
}
diff --git a/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt b/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt
index cbe237d9c..56d9070d4 100644
--- a/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt
+++ b/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt
@@ -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
diff --git a/app/src/main/java/com/github/libretube/db/AppDatabase.kt b/app/src/main/java/com/github/libretube/db/AppDatabase.kt
index 9342e51b4..91326a615 100644
--- a/app/src/main/java/com/github/libretube/db/AppDatabase.kt
+++ b/app/src/main/java/com/github/libretube/db/AppDatabase.kt
@@ -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
}
diff --git a/app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt b/app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt
new file mode 100644
index 000000000..1bd6023f4
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt
@@ -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
+
+ @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)
+}
diff --git a/app/src/main/java/com/github/libretube/db/obj/Download.kt b/app/src/main/java/com/github/libretube/db/obj/Download.kt
new file mode 100644
index 000000000..2ec979ddd
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/db/obj/Download.kt
@@ -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
+)
diff --git a/app/src/main/java/com/github/libretube/db/obj/DownloadItem.kt b/app/src/main/java/com/github/libretube/db/obj/DownloadItem.kt
new file mode 100644
index 000000000..aadc27e1b
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/db/obj/DownloadItem.kt
@@ -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
+)
diff --git a/app/src/main/java/com/github/libretube/db/obj/DownloadWithItems.kt b/app/src/main/java/com/github/libretube/db/obj/DownloadWithItems.kt
new file mode 100644
index 000000000..1357a8dec
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/db/obj/DownloadWithItems.kt
@@ -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
+)
diff --git a/app/src/main/java/com/github/libretube/enums/FileType.kt b/app/src/main/java/com/github/libretube/enums/FileType.kt
new file mode 100644
index 000000000..e81da8aa7
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/enums/FileType.kt
@@ -0,0 +1,7 @@
+package com.github.libretube.enums
+
+enum class FileType {
+ AUDIO,
+ VIDEO,
+ SUBTITLE
+}
diff --git a/app/src/main/java/com/github/libretube/extensions/ContentLength.kt b/app/src/main/java/com/github/libretube/extensions/ContentLength.kt
new file mode 100644
index 000000000..b6f0a7752
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/extensions/ContentLength.kt
@@ -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
+}
diff --git a/app/src/main/java/com/github/libretube/extensions/FormatFileSize.kt b/app/src/main/java/com/github/libretube/extensions/FormatFileSize.kt
new file mode 100644
index 000000000..b068964d7
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/extensions/FormatFileSize.kt
@@ -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))
+ }
+}
diff --git a/app/src/main/java/com/github/libretube/extensions/Normalize.kt b/app/src/main/java/com/github/libretube/extensions/Normalize.kt
index 0550a62c0..650e35a5d 100644
--- a/app/src/main/java/com/github/libretube/extensions/Normalize.kt
+++ b/app/src/main/java/com/github/libretube/extensions/Normalize.kt
@@ -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
+}
diff --git a/app/src/main/java/com/github/libretube/extensions/Sanitize.kt b/app/src/main/java/com/github/libretube/extensions/Sanitize.kt
deleted file mode 100644
index 7c7e9cad4..000000000
--- a/app/src/main/java/com/github/libretube/extensions/Sanitize.kt
+++ /dev/null
@@ -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(), "_")
-}
diff --git a/app/src/main/java/com/github/libretube/extensions/ToDownloadItems.kt b/app/src/main/java/com/github/libretube/extensions/ToDownloadItems.kt
new file mode 100644
index 000000000..497daaf34
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/extensions/ToDownloadItems.kt
@@ -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 {
+ val items = mutableListOf()
+
+ 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
+}
diff --git a/app/src/main/java/com/github/libretube/obj/DownloadStatus.kt b/app/src/main/java/com/github/libretube/obj/DownloadStatus.kt
new file mode 100644
index 000000000..9125caa66
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/obj/DownloadStatus.kt
@@ -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()
+}
diff --git a/app/src/main/java/com/github/libretube/receivers/DownloadReceiver.kt b/app/src/main/java/com/github/libretube/receivers/DownloadReceiver.kt
new file mode 100644
index 000000000..fcaac60ed
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/receivers/DownloadReceiver.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/com/github/libretube/receivers/NotificationReceiver.kt b/app/src/main/java/com/github/libretube/receivers/NotificationReceiver.kt
new file mode 100644
index 000000000..e7e21f9a1
--- /dev/null
+++ b/app/src/main/java/com/github/libretube/receivers/NotificationReceiver.kt
@@ -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"
+ }
+}
diff --git a/app/src/main/java/com/github/libretube/services/DownloadService.kt b/app/src/main/java/com/github/libretube/services/DownloadService.kt
index 7972ce033..6e9f5acac 100644
--- a/app/src/main/java/com/github/libretube/services/DownloadService.kt
+++ b/app/src/main/java/com/github/libretube/services/DownloadService.kt
@@ -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()
+ private val downloadQueue = mutableMapOf()
+ private val _downloadFlow = MutableSharedFlow>()
+ val downloadFlow: SharedFlow> = _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
}
}
diff --git a/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt
index 93ef77c57..66d3a6174 100644
--- a/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt
+++ b/app/src/main/java/com/github/libretube/ui/activities/MainActivity.kt
@@ -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()
+ }
}
}
diff --git a/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt
index 102ccead5..092b48a78 100644
--- a/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt
+++ b/app/src/main/java/com/github/libretube/ui/activities/OfflinePlayerActivity.kt
@@ -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,
diff --git a/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt
index 5a94d9f54..a61a25b07 100644
--- a/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt
+++ b/app/src/main/java/com/github/libretube/ui/adapters/DownloadsAdapter.kt
@@ -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
+ private val context: Context,
+ private val downloads: MutableList,
+ private val toogleDownload: (DownloadWithItems) -> Boolean
) : RecyclerView.Adapter() {
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
}
}
diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/DownloadDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/DownloadDialog.kt
index 3cdeb9732..0ca9b30be 100644
--- a/app/src/main/java/com/github/libretube/ui/dialogs/DownloadDialog.kt
+++ b/app/src/main/java/com/github/libretube/ui/dialogs/DownloadDialog.kt
@@ -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()
- val videoUrl = arrayListOf()
// 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()
- val audioUrl = arrayListOf()
// 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()
+
+ // 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()
}
}
diff --git a/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt
index fb7812ca9..77bb48329 100644
--- a/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt
+++ b/app/src/main/java/com/github/libretube/ui/fragments/DownloadsFragment.kt
@@ -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()
+ 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)
+ }
+ }
}
diff --git a/app/src/main/java/com/github/libretube/util/DownloadHelper.kt b/app/src/main/java/com/github/libretube/util/DownloadHelper.kt
index 041452811..a789f09bd 100644
--- a/app/src/main/java/com/github/libretube/util/DownloadHelper.kt
+++ b/app/src/main/java/com/github/libretube/util/DownloadHelper.kt
@@ -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 {
- 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()
+ 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
}
}
diff --git a/app/src/main/java/com/github/libretube/util/ImageHelper.kt b/app/src/main/java/com/github/libretube/util/ImageHelper.kt
index 73329fbd5..f5aaca9b6 100644
--- a/app/src/main/java/com/github/libretube/util/ImageHelper.kt
+++ b/app/src/main/java/com/github/libretube/util/ImageHelper.kt
@@ -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))
}
diff --git a/app/src/main/java/com/github/libretube/util/TextUtils.kt b/app/src/main/java/com/github/libretube/util/TextUtils.kt
index e3b774b17..4d5f2f9e3 100644
--- a/app/src/main/java/com/github/libretube/util/TextUtils.kt
+++ b/app/src/main/java/com/github/libretube/util/TextUtils.kt
@@ -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"
}
diff --git a/app/src/main/res/drawable/circular_progress.xml b/app/src/main/res/drawable/circular_progress.xml
new file mode 100644
index 000000000..2aab83901
--- /dev/null
+++ b/app/src/main/res/drawable/circular_progress.xml
@@ -0,0 +1,26 @@
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_done.xml b/app/src/main/res/drawable/ic_done.xml
index 160f0a052..2ce2c8440 100644
--- a/app/src/main/res/drawable/ic_done.xml
+++ b/app/src/main/res/drawable/ic_done.xml
@@ -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">
+ 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" />
+ android:fillColor="@android:color/white"
+ android:pathData="M5,18h14v2h-14z" />
diff --git a/app/src/main/res/layout/dialog_download.xml b/app/src/main/res/layout/dialog_download.xml
index e82a19532..ebfbcf6d3 100644
--- a/app/src/main/res/layout/dialog_download.xml
+++ b/app/src/main/res/layout/dialog_download.xml
@@ -58,6 +58,12 @@
android:layout_height="wrap_content"
android:layout_marginVertical="10dp" />
+
+
+
+
+
+
+
+
+
+ android:layout_gravity="bottom"
+ android:textSize="13sp"/>
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9c8c18b4e..05695ef13 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -182,9 +182,17 @@
Video format for player
No audio
No video
+ No subtitle
Audio
Video
Downloading…
+ Download paused
+ Download completed
+ Max concurrent downloads
+ Max concurrent downloads limit reached.
+ Unknown
+ Pause
+ Resume
Autoplay
Hide trending page
URL to instance frontend
diff --git a/app/src/main/res/xml/advanced_settings.xml b/app/src/main/res/xml/advanced_settings.xml
index 17c17d43f..0ea5e57e1 100644
--- a/app/src/main/res/xml/advanced_settings.xml
+++ b/app/src/main/res/xml/advanced_settings.xml
@@ -13,6 +13,15 @@
app:key="data_saver_mode_key"
app:title="@string/data_saver_mode" />
+
+