mirror of
https://github.com/libre-tube/LibreTube.git
synced 2025-04-29 00:10:32 +05:30
Merge pull request #2469 from Kruna1Pate1/feat/new-downloader
New custom downloader
This commit is contained in:
commit
3a4a0b1809
470
app/schemas/com.github.libretube.db.AppDatabase/10.json
Normal file
470
app/schemas/com.github.libretube.db.AppDatabase/10.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
@ -352,6 +352,11 @@
|
||||
android:name=".services.BackgroundMode"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".receivers.NotificationReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
@ -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))
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
51
app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt
Normal file
51
app/src/main/java/com/github/libretube/db/dao/DownloadDao.kt
Normal 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)
|
||||
}
|
15
app/src/main/java/com/github/libretube/db/obj/Download.kt
Normal file
15
app/src/main/java/com/github/libretube/db/obj/Download.kt
Normal 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
|
||||
)
|
@ -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
|
||||
)
|
@ -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>
|
||||
)
|
7
app/src/main/java/com/github/libretube/enums/FileType.kt
Normal file
7
app/src/main/java/com/github/libretube/enums/FileType.kt
Normal file
@ -0,0 +1,7 @@
|
||||
package com.github.libretube.enums
|
||||
|
||||
enum class FileType {
|
||||
AUDIO,
|
||||
VIDEO,
|
||||
SUBTITLE
|
||||
}
|
@ -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
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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(), "_")
|
||||
}
|
@ -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
|
||||
}
|
16
app/src/main/java/com/github/libretube/obj/DownloadStatus.kt
Normal file
16
app/src/main/java/com/github/libretube/obj/DownloadStatus.kt
Normal 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()
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -1,182 +1,524 @@
|
||||
package com.github.libretube.services
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.github.libretube.R
|
||||
import com.github.libretube.api.CronetHelper
|
||||
import com.github.libretube.api.RetrofitInstance
|
||||
import com.github.libretube.constants.DOWNLOAD_CHANNEL_ID
|
||||
import com.github.libretube.constants.DOWNLOAD_FAILURE_NOTIFICATION_ID
|
||||
import com.github.libretube.constants.DOWNLOAD_SUCCESS_NOTIFICATION_ID
|
||||
import com.github.libretube.enums.DownloadType
|
||||
import com.github.libretube.extensions.TAG
|
||||
import com.github.libretube.constants.DOWNLOAD_PROGRESS_NOTIFICATION_ID
|
||||
import com.github.libretube.constants.IntentData
|
||||
import com.github.libretube.db.DatabaseHolder.Companion.Database
|
||||
import com.github.libretube.db.obj.Download
|
||||
import com.github.libretube.db.obj.DownloadItem
|
||||
import com.github.libretube.enums.FileType
|
||||
import com.github.libretube.extensions.awaitQuery
|
||||
import com.github.libretube.extensions.formatAsFileSize
|
||||
import com.github.libretube.extensions.getContentLength
|
||||
import com.github.libretube.extensions.query
|
||||
import com.github.libretube.extensions.toDownloadItems
|
||||
import com.github.libretube.extensions.toastFromMainThread
|
||||
import com.github.libretube.obj.DownloadStatus
|
||||
import com.github.libretube.receivers.NotificationReceiver
|
||||
import com.github.libretube.receivers.NotificationReceiver.Companion.ACTION_DOWNLOAD_PAUSE
|
||||
import com.github.libretube.receivers.NotificationReceiver.Companion.ACTION_DOWNLOAD_RESUME
|
||||
import com.github.libretube.ui.activities.MainActivity
|
||||
import com.github.libretube.util.DownloadHelper
|
||||
import com.github.libretube.util.DownloadHelper.getNotificationId
|
||||
import com.github.libretube.util.ImageHelper
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.URL
|
||||
import java.util.concurrent.Executors
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.BufferedSink
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
|
||||
/**
|
||||
* Download service with custom implementation of downloading using [HttpURLConnection].
|
||||
*/
|
||||
class DownloadService : Service() {
|
||||
|
||||
private lateinit var videoName: String
|
||||
private lateinit var videoUrl: String
|
||||
private lateinit var audioUrl: String
|
||||
private var downloadType: DownloadType = DownloadType.NONE
|
||||
private val binder = LocalBinder()
|
||||
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
private val jobMain = SupervisorJob()
|
||||
private val scope = CoroutineScope(dispatcher + jobMain)
|
||||
|
||||
private var videoDownloadId: Long? = null
|
||||
private var audioDownloadId: Long? = null
|
||||
private lateinit var notificationManager: NotificationManager
|
||||
private lateinit var summaryNotificationBuilder: NotificationCompat.Builder
|
||||
|
||||
private val jobs = mutableMapOf<Int, Job>()
|
||||
private val downloadQueue = mutableMapOf<Int, Boolean>()
|
||||
private val _downloadFlow = MutableSharedFlow<Pair<Int, DownloadStatus>>()
|
||||
val downloadFlow: SharedFlow<Pair<Int, DownloadStatus>> = _downloadFlow
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
IS_DOWNLOAD_RUNNING = true
|
||||
notifyForeground()
|
||||
sendBroadcast(Intent(ACTION_SERVICE_STARTED))
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
videoName = intent?.getStringExtra("videoName")!!
|
||||
videoUrl = intent.getStringExtra("videoUrl")!!
|
||||
audioUrl = intent.getStringExtra("audioUrl")!!
|
||||
|
||||
downloadType = when {
|
||||
videoUrl != "" && audioUrl != "" -> DownloadType.AUDIO_VIDEO
|
||||
audioUrl != "" -> DownloadType.AUDIO
|
||||
videoUrl != "" -> DownloadType.VIDEO
|
||||
else -> DownloadType.NONE
|
||||
when (intent?.action) {
|
||||
ACTION_DOWNLOAD_RESUME -> resume(intent.getIntExtra("id", -1))
|
||||
ACTION_DOWNLOAD_PAUSE -> pause(intent.getIntExtra("id", -1))
|
||||
}
|
||||
|
||||
if (downloadType != DownloadType.NONE) {
|
||||
downloadManager()
|
||||
} else {
|
||||
onDestroy()
|
||||
}
|
||||
val videoId = intent?.getStringExtra(IntentData.videoId) ?: return START_NOT_STICKY
|
||||
val fileName = intent.getStringExtra(IntentData.fileName) ?: videoId
|
||||
val videoFormat = intent.getStringExtra(IntentData.videoFormat)
|
||||
val videoQuality = intent.getStringExtra(IntentData.videoQuality)
|
||||
val audioFormat = intent.getStringExtra(IntentData.audioFormat)
|
||||
val audioQuality = intent.getStringExtra(IntentData.audioQuality)
|
||||
val subtitleCode = intent.getStringExtra(IntentData.subtitleCode)
|
||||
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
private fun downloadManager() {
|
||||
// initialize and create the directories to download into
|
||||
|
||||
val videoDownloadDir = DownloadHelper.getDownloadDir(this, DownloadHelper.VIDEO_DIR)
|
||||
val audioDownloadDir = DownloadHelper.getDownloadDir(this, DownloadHelper.AUDIO_DIR)
|
||||
|
||||
// start download
|
||||
scope.launch {
|
||||
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)
|
||||
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 ?: ""
|
||||
)
|
||||
)
|
||||
}
|
||||
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)
|
||||
)
|
||||
streams.thumbnailUrl?.let { url ->
|
||||
ImageHelper.downloadImage(
|
||||
this@DownloadService,
|
||||
url,
|
||||
File(
|
||||
DownloadHelper.getDownloadDir(
|
||||
this@DownloadService,
|
||||
DownloadHelper.THUMBNAIL_DIR
|
||||
),
|
||||
fileName
|
||||
).absolutePath
|
||||
)
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e(TAG(), "download error $e")
|
||||
downloadFailedNotification()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
val downloadItems = streams.toDownloadItems(
|
||||
videoId,
|
||||
fileName,
|
||||
videoFormat,
|
||||
videoQuality,
|
||||
audioFormat,
|
||||
audioQuality,
|
||||
subtitleCode
|
||||
)
|
||||
if (downloadId == audioDownloadId) {
|
||||
audioDownloadId = null
|
||||
} else if (downloadId == videoDownloadId) videoDownloadId = null
|
||||
|
||||
if (audioDownloadId != null || videoDownloadId != null) return
|
||||
|
||||
downloadSucceededNotification()
|
||||
onDestroy()
|
||||
downloadItems.forEach { start(it) }
|
||||
} catch (e: Exception) {
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
val downloadManager: DownloadManager =
|
||||
applicationContext.getSystemService(DOWNLOAD_SERVICE) as DownloadManager
|
||||
return downloadManager.enqueue(request)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
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)
|
||||
/**
|
||||
* 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
|
||||
|
||||
with(NotificationManagerCompat.from(this)) {
|
||||
// notificationId is a unique int for each notification that you must define
|
||||
notify(DOWNLOAD_FAILURE_NOTIFICATION_ID, builder.build())
|
||||
item.id = awaitQuery {
|
||||
Database.downloadDao().insertDownloadItem(item)
|
||||
}.toInt()
|
||||
|
||||
jobs[item.id] = scope.launch {
|
||||
downloadFile(item)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
/**
|
||||
* 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)
|
||||
|
||||
with(NotificationManagerCompat.from(this)) {
|
||||
// notificationId is a unique int for each notification that you must define
|
||||
notify(DOWNLOAD_SUCCESS_NOTIFICATION_ID, builder.build())
|
||||
url.getContentLength().let { size ->
|
||||
if (size > 0 && size != item.downloadSize) {
|
||||
item.downloadSize = size
|
||||
query {
|
||||
Database.downloadDao().updateDownloadItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 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 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
|
||||
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 setResumeNotification(
|
||||
notificationBuilder: NotificationCompat.Builder,
|
||||
item: DownloadItem
|
||||
) {
|
||||
notificationBuilder
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.setOngoing(true)
|
||||
.clearActions()
|
||||
.addAction(getPauseAction(item.id))
|
||||
|
||||
notificationManager.notify(item.getNotificationId(), notificationBuilder.build())
|
||||
}
|
||||
|
||||
private fun setPauseNotification(
|
||||
notificationBuilder: NotificationCompat.Builder,
|
||||
item: DownloadItem,
|
||||
isCompleted: Boolean = false
|
||||
) {
|
||||
notificationBuilder
|
||||
.setProgress(0, 0, false)
|
||||
.setOngoing(false)
|
||||
.clearActions()
|
||||
|
||||
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 getResumeAction(id: Int): NotificationCompat.Action {
|
||||
val intent = Intent(this, NotificationReceiver::class.java)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -1,24 +1,29 @@
|
||||
package com.github.libretube.ui.adapters
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.libretube.R
|
||||
import com.github.libretube.constants.IntentData
|
||||
import com.github.libretube.databinding.DownloadedMediaRowBinding
|
||||
import com.github.libretube.extensions.formatShort
|
||||
import com.github.libretube.obj.DownloadedFile
|
||||
import com.github.libretube.db.DatabaseHolder
|
||||
import com.github.libretube.db.obj.DownloadWithItems
|
||||
import com.github.libretube.extensions.formatAsFileSize
|
||||
import com.github.libretube.extensions.query
|
||||
import com.github.libretube.ui.activities.OfflinePlayerActivity
|
||||
import com.github.libretube.ui.viewholders.DownloadsViewHolder
|
||||
import com.github.libretube.util.DownloadHelper
|
||||
import com.github.libretube.util.TextUtils
|
||||
import com.github.libretube.util.ImageHelper
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import java.io.File
|
||||
|
||||
class DownloadsAdapter(
|
||||
private val files: MutableList<DownloadedFile>
|
||||
private val context: Context,
|
||||
private val downloads: MutableList<DownloadWithItems>,
|
||||
private val toogleDownload: (DownloadWithItems) -> Boolean
|
||||
) : RecyclerView.Adapter<DownloadsViewHolder>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadsViewHolder {
|
||||
val binding = DownloadedMediaRowBinding.inflate(
|
||||
@ -31,24 +36,56 @@ class DownloadsAdapter(
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBindViewHolder(holder: DownloadsViewHolder, position: Int) {
|
||||
val file = files[position]
|
||||
val download = downloads[position].download
|
||||
val items = downloads[position].downloadItems
|
||||
holder.binding.apply {
|
||||
fileName.text = file.name
|
||||
fileSize.text = "${file.size / (1024 * 1024)} MiB"
|
||||
title.text = download.title
|
||||
uploaderName.text = download.uploader
|
||||
videoInfo.text = download.uploadDate
|
||||
|
||||
file.metadata?.let {
|
||||
uploaderName.text = it.uploader
|
||||
videoInfo.text = it.views.formatShort() + " " +
|
||||
root.context.getString(R.string.views_placeholder) +
|
||||
TextUtils.SEPARATOR + it.uploadDate
|
||||
val downloadSize = items.sumOf { it.downloadSize }
|
||||
val currentSize = items.sumOf { File(it.path).length() }
|
||||
|
||||
if (downloadSize == -1L) {
|
||||
progressBar.isIndeterminate = true
|
||||
} else {
|
||||
progressBar.max = downloadSize.toInt()
|
||||
progressBar.progress = currentSize.toInt()
|
||||
}
|
||||
|
||||
thumbnailImage.setImageBitmap(file.thumbnail)
|
||||
val totalSizeInfo = if (downloadSize > 0) {
|
||||
downloadSize.formatAsFileSize()
|
||||
} else {
|
||||
context.getString(R.string.unknown)
|
||||
}
|
||||
if (downloadSize > currentSize) {
|
||||
downloadOverlay.visibility = View.VISIBLE
|
||||
resumePauseBtn.setImageResource(R.drawable.ic_download)
|
||||
fileSize.text = "${currentSize.formatAsFileSize()} / $totalSizeInfo"
|
||||
} else {
|
||||
downloadOverlay.visibility = View.GONE
|
||||
fileSize.text = totalSizeInfo
|
||||
}
|
||||
|
||||
download.thumbnailPath?.let { path ->
|
||||
thumbnailImage.setImageBitmap(ImageHelper.getDownloadedImage(context, path))
|
||||
}
|
||||
|
||||
progressBar.setOnClickListener {
|
||||
val isDownloading = toogleDownload(downloads[position])
|
||||
|
||||
resumePauseBtn.setImageResource(
|
||||
if (isDownloading) {
|
||||
R.drawable.ic_pause
|
||||
} else {
|
||||
R.drawable.ic_download
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
root.setOnClickListener {
|
||||
val intent = Intent(root.context, OfflinePlayerActivity::class.java).also {
|
||||
it.putExtra(IntentData.fileName, file.name)
|
||||
}
|
||||
val intent = Intent(root.context, OfflinePlayerActivity::class.java)
|
||||
intent.putExtra(IntentData.videoId, download.videoId)
|
||||
root.context.startActivity(intent)
|
||||
}
|
||||
|
||||
@ -61,27 +98,18 @@ class DownloadsAdapter(
|
||||
) { _, index ->
|
||||
when (index) {
|
||||
0 -> {
|
||||
val audioDir = DownloadHelper.getDownloadDir(
|
||||
root.context,
|
||||
DownloadHelper.AUDIO_DIR
|
||||
)
|
||||
val videoDir = DownloadHelper.getDownloadDir(
|
||||
root.context,
|
||||
DownloadHelper.VIDEO_DIR
|
||||
)
|
||||
|
||||
listOf(audioDir, videoDir).forEach {
|
||||
val f = File(it, file.name)
|
||||
if (f.exists()) {
|
||||
items.map { File(it.path) }.forEach { file ->
|
||||
if (file.exists()) {
|
||||
try {
|
||||
f.delete()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
file.delete()
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
files.removeAt(position)
|
||||
query {
|
||||
DatabaseHolder.Database.downloadDao().deleteDownload(download)
|
||||
}
|
||||
downloads.removeAt(position)
|
||||
notifyItemRemoved(position)
|
||||
notifyItemRangeChanged(position, itemCount)
|
||||
}
|
||||
@ -95,6 +123,6 @@ class DownloadsAdapter(
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return files.size
|
||||
return downloads.size
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
package com.github.libretube.ui.dialogs
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.text.InputFilter
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.ArrayAdapter
|
||||
@ -15,10 +15,8 @@ import com.github.libretube.api.RetrofitInstance
|
||||
import com.github.libretube.api.obj.Streams
|
||||
import com.github.libretube.databinding.DialogDownloadBinding
|
||||
import com.github.libretube.extensions.TAG
|
||||
import com.github.libretube.extensions.sanitize
|
||||
import com.github.libretube.services.DownloadService
|
||||
import com.github.libretube.util.ImageHelper
|
||||
import com.github.libretube.util.MetadataHelper
|
||||
import com.github.libretube.util.DownloadHelper
|
||||
import com.github.libretube.util.TextUtils
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import java.io.IOException
|
||||
import retrofit2.HttpException
|
||||
@ -41,6 +39,25 @@ class DownloadDialog(
|
||||
binding.videoSpinner.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
binding.fileName.filters += InputFilter { source, start, end, _, _, _ ->
|
||||
if (source.isNullOrBlank()) {
|
||||
return@InputFilter null
|
||||
}
|
||||
|
||||
// Extract actual source
|
||||
val actualSource = source.subSequence(start, end)
|
||||
// Filter out unsupported characters
|
||||
val filtered = actualSource.filterNot {
|
||||
TextUtils.RESERVED_CHARS.contains(it, true)
|
||||
}
|
||||
// Check if something was filtered out
|
||||
return@InputFilter if (actualSource.length != filtered.length) {
|
||||
filtered
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return MaterialAlertDialogBuilder(requireContext())
|
||||
.setView(binding.root)
|
||||
.show()
|
||||
@ -68,37 +85,45 @@ class DownloadDialog(
|
||||
binding.fileName.setText(streams.title.toString())
|
||||
|
||||
val vidName = arrayListOf<String>()
|
||||
val videoUrl = arrayListOf<String>()
|
||||
|
||||
// add empty selection
|
||||
vidName.add(getString(R.string.no_video))
|
||||
videoUrl.add("")
|
||||
|
||||
// add all available video streams
|
||||
for (vid in streams.videoStreams!!) {
|
||||
if (vid.url != null) {
|
||||
val name = vid.quality + " " + vid.format
|
||||
vidName.add(name)
|
||||
videoUrl.add(vid.url!!)
|
||||
}
|
||||
}
|
||||
|
||||
val audioName = arrayListOf<String>()
|
||||
val audioUrl = arrayListOf<String>()
|
||||
|
||||
// add empty selection
|
||||
audioName.add(getString(R.string.no_audio))
|
||||
audioUrl.add("")
|
||||
|
||||
// add all available audio streams
|
||||
for (audio in streams.audioStreams!!) {
|
||||
if (audio.url != null) {
|
||||
val name = audio.quality + " " + audio.format
|
||||
audioName.add(name)
|
||||
audioUrl.add(audio.url!!)
|
||||
}
|
||||
}
|
||||
|
||||
val subtitleName = arrayListOf<String>()
|
||||
|
||||
// add empty selection
|
||||
subtitleName.add(getString(R.string.no_subtitle))
|
||||
|
||||
// add all available subtitles
|
||||
for (subtitle in streams.subtitles!!) {
|
||||
if (subtitle.url != null) {
|
||||
subtitleName.add(subtitle.name.toString())
|
||||
}
|
||||
}
|
||||
|
||||
if (subtitleName.size == 1) binding.subtitleSpinner.visibility = View.GONE
|
||||
|
||||
// initialize the video sources
|
||||
val videoArrayAdapter = ArrayAdapter(
|
||||
requireContext(),
|
||||
@ -109,6 +134,7 @@ class DownloadDialog(
|
||||
binding.videoSpinner.adapter = videoArrayAdapter
|
||||
if (binding.videoSpinner.size >= 1) binding.videoSpinner.setSelection(1)
|
||||
if (binding.audioSpinner.size >= 1) binding.audioSpinner.setSelection(1)
|
||||
if (binding.subtitleSpinner.size >= 1) binding.subtitleSpinner.setSelection(1)
|
||||
|
||||
// initialize the audio sources
|
||||
val audioArrayAdapter = ArrayAdapter(
|
||||
@ -120,48 +146,55 @@ class DownloadDialog(
|
||||
binding.audioSpinner.adapter = audioArrayAdapter
|
||||
if (binding.audioSpinner.size >= 1) binding.audioSpinner.setSelection(1)
|
||||
|
||||
// initialize the subtitle sources
|
||||
val subtitleArrayAdapter = ArrayAdapter(
|
||||
requireContext(),
|
||||
android.R.layout.simple_spinner_item,
|
||||
subtitleName
|
||||
)
|
||||
subtitleArrayAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
binding.subtitleSpinner.adapter = subtitleArrayAdapter
|
||||
if (binding.subtitleSpinner.size >= 1) binding.subtitleSpinner.setSelection(1)
|
||||
|
||||
binding.download.setOnClickListener {
|
||||
if (binding.fileName.text.toString().isEmpty()) {
|
||||
Toast.makeText(context, R.string.invalid_filename, Toast.LENGTH_SHORT).show()
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
val vidUrl = videoUrl[binding.videoSpinner.selectedItemPosition]
|
||||
val audUrl = audioUrl[binding.audioSpinner.selectedItemPosition]
|
||||
val videoPosition = binding.videoSpinner.selectedItemPosition - 1
|
||||
val audioPosition = binding.audioSpinner.selectedItemPosition - 1
|
||||
val subtitlePosition = binding.subtitleSpinner.selectedItemPosition - 1
|
||||
|
||||
if (audUrl == "" && vidUrl == "") {
|
||||
if (videoPosition == -1 && audioPosition == -1 && subtitlePosition == -1) {
|
||||
Toast.makeText(context, R.string.nothing_selected, Toast.LENGTH_SHORT).show()
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
val fileName = binding.fileName.text.toString().sanitize()
|
||||
|
||||
val metadataHelper = MetadataHelper(requireContext())
|
||||
metadataHelper.createMetadata(fileName, streams)
|
||||
streams.thumbnailUrl?.let { thumbnailUrl ->
|
||||
ImageHelper.downloadImage(
|
||||
requireContext(),
|
||||
thumbnailUrl,
|
||||
fileName
|
||||
)
|
||||
val videoStream = when (videoPosition) {
|
||||
-1 -> null
|
||||
else -> streams.videoStreams[videoPosition]
|
||||
}
|
||||
val audioStream = when (audioPosition) {
|
||||
-1 -> null
|
||||
else -> streams.audioStreams[audioPosition]
|
||||
}
|
||||
val subtitle = when (subtitlePosition) {
|
||||
-1 -> null
|
||||
else -> streams.subtitles[subtitlePosition]
|
||||
}
|
||||
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
|
||||
intent.putExtra(
|
||||
"videoName",
|
||||
fileName
|
||||
)
|
||||
intent.putExtra(
|
||||
"videoUrl",
|
||||
vidUrl
|
||||
)
|
||||
intent.putExtra(
|
||||
"audioUrl",
|
||||
audUrl
|
||||
DownloadHelper.startDownloadService(
|
||||
context = requireContext(),
|
||||
videoId = videoId,
|
||||
fileName = binding.fileName.text.toString(),
|
||||
videoFormat = videoStream?.format,
|
||||
videoQuality = videoStream?.quality,
|
||||
audioFormat = audioStream?.format,
|
||||
audioQuality = audioStream?.quality,
|
||||
subtitleCode = subtitle?.code
|
||||
)
|
||||
|
||||
context?.startService(intent)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
@ -1,21 +1,62 @@
|
||||
package com.github.libretube.ui.fragments
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.size
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.libretube.R
|
||||
import com.github.libretube.databinding.FragmentDownloadsBinding
|
||||
import com.github.libretube.db.DatabaseHolder.Companion.Database
|
||||
import com.github.libretube.db.obj.DownloadWithItems
|
||||
import com.github.libretube.extensions.awaitQuery
|
||||
import com.github.libretube.extensions.formatAsFileSize
|
||||
import com.github.libretube.obj.DownloadStatus
|
||||
import com.github.libretube.receivers.DownloadReceiver
|
||||
import com.github.libretube.services.DownloadService
|
||||
import com.github.libretube.ui.adapters.DownloadsAdapter
|
||||
import com.github.libretube.ui.base.BaseFragment
|
||||
import com.github.libretube.ui.viewholders.DownloadsViewHolder
|
||||
import com.github.libretube.util.DownloadHelper
|
||||
import com.github.libretube.util.ImageHelper
|
||||
import com.github.libretube.util.MetadataHelper
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DownloadsFragment : BaseFragment() {
|
||||
private lateinit var binding: FragmentDownloadsBinding
|
||||
private var binder: DownloadService.LocalBinder? = null
|
||||
private val downloads = mutableListOf<DownloadWithItems>()
|
||||
private val downloadReceiver = DownloadReceiver()
|
||||
|
||||
private val serviceConnection = object : ServiceConnection {
|
||||
var isBound = false
|
||||
var job: Job? = null
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, iBinder: IBinder?) {
|
||||
binder = iBinder as DownloadService.LocalBinder
|
||||
isBound = true
|
||||
job?.cancel()
|
||||
job = lifecycleScope.launch {
|
||||
binder?.getService()?.downloadFlow?.collectLatest {
|
||||
updateProgress(it.first, it.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
binder = null
|
||||
isBound = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
@ -29,36 +70,118 @@ class DownloadsFragment : BaseFragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val files = DownloadHelper.getDownloadedFiles(requireContext())
|
||||
|
||||
if (files.isEmpty()) return
|
||||
|
||||
val metadataHelper = MetadataHelper(requireContext())
|
||||
files.forEach {
|
||||
metadataHelper.getMetadata(it.name)?.let { streams ->
|
||||
it.metadata = streams
|
||||
}
|
||||
ImageHelper.getDownloadedImage(requireContext(), it.name)?.let { bitmap ->
|
||||
it.thumbnail = bitmap
|
||||
}
|
||||
awaitQuery {
|
||||
downloads.addAll(Database.downloadDao().getAll())
|
||||
}
|
||||
if (downloads.isEmpty()) return
|
||||
|
||||
binding.downloadsEmpty.visibility = View.GONE
|
||||
binding.downloads.visibility = View.VISIBLE
|
||||
|
||||
binding.downloads.layoutManager = LinearLayoutManager(context)
|
||||
binding.downloads.adapter = DownloadsAdapter(files)
|
||||
|
||||
binding.downloads.adapter = DownloadsAdapter(requireContext(), downloads) {
|
||||
var isDownloading = false
|
||||
val ids = it.downloadItems
|
||||
.filter { item -> File(item.path).length() < item.downloadSize }
|
||||
.map { item -> item.id }
|
||||
|
||||
if (!serviceConnection.isBound) {
|
||||
DownloadHelper.startDownloadService(requireContext())
|
||||
bindDownloadService(ids.toIntArray())
|
||||
return@DownloadsAdapter true
|
||||
}
|
||||
|
||||
binder?.getService()?.let { service ->
|
||||
isDownloading = ids.any { id -> service.isDownloading(id) }
|
||||
|
||||
ids.forEach { id ->
|
||||
if (isDownloading) {
|
||||
service.pause(id)
|
||||
} else {
|
||||
service.resume(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return@DownloadsAdapter isDownloading.not()
|
||||
}
|
||||
|
||||
binding.downloads.adapter?.registerAdapterDataObserver(
|
||||
object : RecyclerView.AdapterDataObserver() {
|
||||
override fun onChanged() {
|
||||
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
|
||||
if (binding.downloads.size == 0) {
|
||||
binding.downloads.visibility = View.GONE
|
||||
binding.downloadsEmpty.visibility = View.VISIBLE
|
||||
}
|
||||
super.onChanged()
|
||||
super.onItemRangeRemoved(positionStart, itemCount)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
if (DownloadService.IS_DOWNLOAD_RUNNING) {
|
||||
val intent = Intent(requireContext(), DownloadService::class.java)
|
||||
context?.bindService(intent, serviceConnection, 0)
|
||||
}
|
||||
super.onStart()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val filter = IntentFilter()
|
||||
filter.addAction(DownloadService.ACTION_SERVICE_STARTED)
|
||||
filter.addAction(DownloadService.ACTION_SERVICE_STOPPED)
|
||||
context?.registerReceiver(downloadReceiver, filter)
|
||||
}
|
||||
|
||||
fun bindDownloadService(ids: IntArray? = null) {
|
||||
if (serviceConnection.isBound) return
|
||||
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
intent.putExtra("ids", ids)
|
||||
context?.bindService(intent, serviceConnection, 0)
|
||||
}
|
||||
|
||||
fun updateProgress(id: Int, status: DownloadStatus) {
|
||||
val index = downloads.indexOfFirst {
|
||||
it.downloadItems.any { item -> item.id == id }
|
||||
}
|
||||
val view = binding.downloads.findViewHolderForAdapterPosition(index) as? DownloadsViewHolder
|
||||
|
||||
view?.binding?.apply {
|
||||
when (status) {
|
||||
DownloadStatus.Paused -> {
|
||||
resumePauseBtn.setImageResource(R.drawable.ic_download)
|
||||
}
|
||||
DownloadStatus.Completed -> {
|
||||
downloadOverlay.visibility = View.GONE
|
||||
}
|
||||
is DownloadStatus.Progress -> {
|
||||
downloadOverlay.visibility = View.VISIBLE
|
||||
resumePauseBtn.setImageResource(R.drawable.ic_pause)
|
||||
if (progressBar.isIndeterminate) return
|
||||
progressBar.incrementProgressBy(status.progress.toInt())
|
||||
val progressInfo = progressBar.progress.formatAsFileSize() +
|
||||
" /\n" + progressBar.max.formatAsFileSize()
|
||||
fileSize.text = progressInfo
|
||||
}
|
||||
is DownloadStatus.Error -> {
|
||||
resumePauseBtn.setImageResource(R.drawable.ic_restart)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
context?.unregisterReceiver(downloadReceiver)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (serviceConnection.isBound) {
|
||||
context?.unbindService(serviceConnection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,23 @@
|
||||
package com.github.libretube.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import com.github.libretube.obj.DownloadedFile
|
||||
import com.github.libretube.constants.IntentData
|
||||
import com.github.libretube.constants.PreferenceKeys
|
||||
import com.github.libretube.db.obj.DownloadItem
|
||||
import com.github.libretube.services.DownloadService
|
||||
import java.io.File
|
||||
|
||||
object DownloadHelper {
|
||||
const val VIDEO_DIR = "video"
|
||||
const val AUDIO_DIR = "audio"
|
||||
const val SUBTITLE_DIR = "subtitle"
|
||||
const val METADATA_DIR = "metadata"
|
||||
const val THUMBNAIL_DIR = "thumbnail"
|
||||
const val DOWNLOAD_CHUNK_SIZE = 8L * 1024
|
||||
const val DEFAULT_TIMEOUT = 15 * 1000
|
||||
const val DEFAULT_RETRY = 3
|
||||
|
||||
fun getOfflineStorageDir(context: Context): File {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return context.filesDir
|
||||
@ -30,28 +38,41 @@ object DownloadHelper {
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.toDownloadedFile(): DownloadedFile {
|
||||
return DownloadedFile(
|
||||
name = this.name,
|
||||
size = this.length()
|
||||
)
|
||||
fun getMaxConcurrentDownloads(): Int {
|
||||
return PreferenceHelper.getString(
|
||||
PreferenceKeys.MAX_CONCURRENT_DOWNLOADS,
|
||||
"6"
|
||||
).toFloat().toInt()
|
||||
}
|
||||
|
||||
fun getDownloadedFiles(context: Context): MutableList<DownloadedFile> {
|
||||
val videoFiles = getDownloadDir(context, VIDEO_DIR).listFiles().orEmpty()
|
||||
val audioFiles = getDownloadDir(context, AUDIO_DIR).listFiles().orEmpty().toMutableList()
|
||||
fun startDownloadService(
|
||||
context: Context,
|
||||
videoId: String? = null,
|
||||
fileName: String? = null,
|
||||
videoFormat: String? = null,
|
||||
videoQuality: String? = null,
|
||||
audioFormat: String? = null,
|
||||
audioQuality: String? = null,
|
||||
subtitleCode: String? = null
|
||||
) {
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
|
||||
val files = mutableListOf<DownloadedFile>()
|
||||
intent.putExtra(IntentData.videoId, videoId)
|
||||
intent.putExtra(IntentData.fileName, fileName)
|
||||
intent.putExtra(IntentData.videoFormat, videoFormat)
|
||||
intent.putExtra(IntentData.videoQuality, videoQuality)
|
||||
intent.putExtra(IntentData.audioFormat, audioFormat)
|
||||
intent.putExtra(IntentData.audioQuality, audioQuality)
|
||||
intent.putExtra(IntentData.subtitleCode, subtitleCode)
|
||||
|
||||
videoFiles.forEach {
|
||||
audioFiles.removeIf { audioFile -> audioFile.name == it.name }
|
||||
files.add(it.toDownloadedFile())
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
audioFiles.forEach {
|
||||
files.add(it.toDownloadedFile())
|
||||
}
|
||||
|
||||
return files
|
||||
fun DownloadItem.getNotificationId(): Int {
|
||||
return Int.MAX_VALUE - id
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
|
26
app/src/main/res/drawable/circular_progress.xml
Normal file
26
app/src/main/res/drawable/circular_progress.xml
Normal file
@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item android:id="@android:id/background">
|
||||
<shape
|
||||
android:shape="ring"
|
||||
android:thickness="2.5dp"
|
||||
android:useLevel="false">
|
||||
<solid android:color="#4e4e4e" />
|
||||
</shape>
|
||||
</item>
|
||||
|
||||
<item android:id="@android:id/progress">
|
||||
<rotate android:fromDegrees="270"
|
||||
android:toDegrees="270">
|
||||
<shape
|
||||
android:shape="ring"
|
||||
android:thickness="2.5dp"
|
||||
android:useLevel="true">
|
||||
<solid android:color="?android:colorAccent" />
|
||||
<corners android:radius="20dp" />
|
||||
</shape>
|
||||
</rotate>
|
||||
</item>
|
||||
|
||||
</layer-list>
|
@ -2,16 +2,12 @@
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="400"
|
||||
android:viewportHeight="400">
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M200,15.89C98.47,15.89 15.89,98.48 15.89,200 15.89,301.52 98.47,384.11 200,384.11 301.52,384.11 384.11,301.52 384.11,200 384.11,98.48 301.52,15.89 200,15.89ZM200,359.97C111.79,359.97 40.03,288.2 40.03,200c0,-88.21 71.76,-159.96 159.96,-159.96 88.21,0 159.97,71.76 159.97,159.96 0,88.2 -71.76,159.97 -159.97,159.97z"
|
||||
android:strokeWidth="27"
|
||||
android:strokeColor="#000000" />
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20.13,5.41l-1.41,-1.41l-9.19,9.19l-4.25,-4.24l-1.41,1.41l5.66,5.66z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m266.65,139.73 l-94.94,94.93 -38.38,-38.37c-4.72,-4.71 -12.36,-4.71 -17.07,0 -4.72,4.72 -4.72,12.36 0,17.07l46.91,46.91c2.36,2.35 5.45,3.53 8.53,3.53 3.09,0 6.19,-1.18 8.54,-3.54 0.01,-0.01 0.01,-0.02 0.02,-0.03L283.73,156.8c4.72,-4.71 4.72,-12.36 0,-17.07 -4.72,-4.72 -12.36,-4.72 -17.07,0z"
|
||||
android:strokeWidth="27"
|
||||
android:strokeColor="#000000" />
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M5,18h14v2h-14z" />
|
||||
</vector>
|
||||
|
@ -58,6 +58,12 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginVertical="10dp" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/subtitle_spinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginVertical="10dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/download"
|
||||
style="@style/CustomDialogButton"
|
||||
|
@ -23,6 +23,34 @@
|
||||
android:scaleType="fitXY"
|
||||
tools:src="@tools:sample/backgrounds/scenic" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/downloadOverlay"
|
||||
android:layout_width="140dp"
|
||||
android:layout_height="80dp"
|
||||
android:background="#BF000000">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="52dp"
|
||||
android:layout_height="52dp"
|
||||
android:indeterminateOnly="false"
|
||||
android:progressDrawable="@drawable/circular_progress"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/resumePauseBtn"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:src="@drawable/ic_download"
|
||||
app:layout_constraintBottom_toBottomOf="@id/progressBar"
|
||||
app:layout_constraintLeft_toLeftOf="@id/progressBar"
|
||||
app:layout_constraintRight_toRightOf="@id/progressBar"
|
||||
app:layout_constraintTop_toTopOf="@id/progressBar" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<LinearLayout
|
||||
@ -33,7 +61,7 @@
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/fileName"
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginVertical="2dp"
|
||||
@ -58,8 +86,9 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/fileSize"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center" />
|
||||
android:layout_gravity="bottom"
|
||||
android:textSize="13sp"/>
|
||||
|
||||
</LinearLayout>
|
@ -182,9 +182,17 @@
|
||||
<string name="playerVideoFormat">Video format for player</string>
|
||||
<string name="no_audio">No audio</string>
|
||||
<string name="no_video">No video</string>
|
||||
<string name="no_subtitle">No subtitle</string>
|
||||
<string name="audio">Audio</string>
|
||||
<string name="video">Video</string>
|
||||
<string name="downloading">Downloading…</string>
|
||||
<string name="download_paused">Download paused</string>
|
||||
<string name="download_completed">Download completed</string>
|
||||
<string name="concurrent_downloads">Max concurrent downloads</string>
|
||||
<string name="concurrent_downloads_limit_reached">Max concurrent downloads limit reached.</string>
|
||||
<string name="unknown">Unknown</string>
|
||||
<string name="pause">Pause</string>
|
||||
<string name="resume">Resume</string>
|
||||
<string name="player_autoplay">Autoplay</string>
|
||||
<string name="hideTrendingPage">Hide trending page</string>
|
||||
<string name="instance_frontend_url">URL to instance frontend</string>
|
||||
|
@ -13,6 +13,15 @@
|
||||
app:key="data_saver_mode_key"
|
||||
app:title="@string/data_saver_mode" />
|
||||
|
||||
<com.github.libretube.ui.views.SliderPreference
|
||||
android:icon="@drawable/ic_download"
|
||||
android:key="max_concurrent_downloads"
|
||||
android:title="@string/concurrent_downloads"
|
||||
app:defValue="6"
|
||||
app:stepSize="1"
|
||||
app:valueFrom="1"
|
||||
app:valueTo="20" />
|
||||
|
||||
<ListPreference
|
||||
android:entries="@array/cacheSize"
|
||||
android:entryValues="@array/cacheSizeValues"
|
||||
|
Loading…
x
Reference in New Issue
Block a user