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:name=".services.BackgroundMode"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".receivers.NotificationReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
@ -77,7 +77,7 @@ class LibreTubeApp : Application() {
|
|||||||
private fun initializeNotificationChannels() {
|
private fun initializeNotificationChannels() {
|
||||||
val downloadChannel = NotificationChannelCompat.Builder(
|
val downloadChannel = NotificationChannelCompat.Builder(
|
||||||
DOWNLOAD_CHANNEL_ID,
|
DOWNLOAD_CHANNEL_ID,
|
||||||
NotificationManagerCompat.IMPORTANCE_NONE
|
NotificationManagerCompat.IMPORTANCE_LOW
|
||||||
)
|
)
|
||||||
.setName(getString(R.string.download_channel_name))
|
.setName(getString(R.string.download_channel_name))
|
||||||
.setDescription(getString(R.string.download_channel_description))
|
.setDescription(getString(R.string.download_channel_description))
|
||||||
|
@ -43,6 +43,7 @@ const val PLAYER_NOTIFICATION_ID = 1
|
|||||||
const val DOWNLOAD_PENDING_NOTIFICATION_ID = 2
|
const val DOWNLOAD_PENDING_NOTIFICATION_ID = 2
|
||||||
const val DOWNLOAD_FAILURE_NOTIFICATION_ID = 3
|
const val DOWNLOAD_FAILURE_NOTIFICATION_ID = 3
|
||||||
const val DOWNLOAD_SUCCESS_NOTIFICATION_ID = 4
|
const val DOWNLOAD_SUCCESS_NOTIFICATION_ID = 4
|
||||||
|
const val DOWNLOAD_PROGRESS_NOTIFICATION_ID = 5
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notification Channel IDs
|
* Notification Channel IDs
|
||||||
|
@ -10,5 +10,11 @@ object IntentData {
|
|||||||
const val fileName = "fileName"
|
const val fileName = "fileName"
|
||||||
const val keepQueue = "keepQueue"
|
const val keepQueue = "keepQueue"
|
||||||
const val playlistType = "playlistType"
|
const val playlistType = "playlistType"
|
||||||
|
const val videoFormat = "videoFormate"
|
||||||
|
const val videoQuality = "videoQuality"
|
||||||
|
const val audioFormat = "audioFormate"
|
||||||
|
const val audioQuality = "audioQuality"
|
||||||
|
const val subtitleCode = "subtitleCode"
|
||||||
|
const val downloading = "downloading"
|
||||||
const val openAudioPlayer = "openAudioPlayer"
|
const val openAudioPlayer = "openAudioPlayer"
|
||||||
}
|
}
|
||||||
|
@ -127,6 +127,7 @@ object PreferenceKeys {
|
|||||||
const val SHARE_WITH_TIME_CODE = "share_with_time_code"
|
const val SHARE_WITH_TIME_CODE = "share_with_time_code"
|
||||||
const val CONFIRM_UNSUBSCRIBE = "confirm_unsubscribing"
|
const val CONFIRM_UNSUBSCRIBE = "confirm_unsubscribing"
|
||||||
const val CLEAR_BOOKMARKS = "clear_bookmarks"
|
const val CLEAR_BOOKMARKS = "clear_bookmarks"
|
||||||
|
const val MAX_CONCURRENT_DOWNLOADS = "max_concurrent_downloads"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* History
|
* History
|
||||||
|
@ -4,6 +4,7 @@ import androidx.room.AutoMigration
|
|||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import com.github.libretube.db.dao.CustomInstanceDao
|
import com.github.libretube.db.dao.CustomInstanceDao
|
||||||
|
import com.github.libretube.db.dao.DownloadDao
|
||||||
import com.github.libretube.db.dao.LocalPlaylistsDao
|
import com.github.libretube.db.dao.LocalPlaylistsDao
|
||||||
import com.github.libretube.db.dao.LocalSubscriptionDao
|
import com.github.libretube.db.dao.LocalSubscriptionDao
|
||||||
import com.github.libretube.db.dao.PlaylistBookmarkDao
|
import com.github.libretube.db.dao.PlaylistBookmarkDao
|
||||||
@ -11,6 +12,8 @@ import com.github.libretube.db.dao.SearchHistoryDao
|
|||||||
import com.github.libretube.db.dao.WatchHistoryDao
|
import com.github.libretube.db.dao.WatchHistoryDao
|
||||||
import com.github.libretube.db.dao.WatchPositionDao
|
import com.github.libretube.db.dao.WatchPositionDao
|
||||||
import com.github.libretube.db.obj.CustomInstance
|
import com.github.libretube.db.obj.CustomInstance
|
||||||
|
import com.github.libretube.db.obj.Download
|
||||||
|
import com.github.libretube.db.obj.DownloadItem
|
||||||
import com.github.libretube.db.obj.LocalPlaylist
|
import com.github.libretube.db.obj.LocalPlaylist
|
||||||
import com.github.libretube.db.obj.LocalPlaylistItem
|
import com.github.libretube.db.obj.LocalPlaylistItem
|
||||||
import com.github.libretube.db.obj.LocalSubscription
|
import com.github.libretube.db.obj.LocalSubscription
|
||||||
@ -28,12 +31,15 @@ import com.github.libretube.db.obj.WatchPosition
|
|||||||
LocalSubscription::class,
|
LocalSubscription::class,
|
||||||
PlaylistBookmark::class,
|
PlaylistBookmark::class,
|
||||||
LocalPlaylist::class,
|
LocalPlaylist::class,
|
||||||
LocalPlaylistItem::class
|
LocalPlaylistItem::class,
|
||||||
|
Download::class,
|
||||||
|
DownloadItem::class
|
||||||
],
|
],
|
||||||
version = 9,
|
version = 10,
|
||||||
autoMigrations = [
|
autoMigrations = [
|
||||||
AutoMigration(from = 7, to = 8),
|
AutoMigration(from = 7, to = 8),
|
||||||
AutoMigration(from = 8, to = 9)
|
AutoMigration(from = 8, to = 9),
|
||||||
|
AutoMigration(from = 9, to = 10)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
@ -71,4 +77,9 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
* Local playlists
|
* Local playlists
|
||||||
*/
|
*/
|
||||||
abstract fun localPlaylistsDao(): LocalPlaylistsDao
|
abstract fun localPlaylistsDao(): LocalPlaylistsDao
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads
|
||||||
|
*/
|
||||||
|
abstract fun downloadDao(): DownloadDao
|
||||||
}
|
}
|
||||||
|
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
|
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
|
package com.github.libretube.services
|
||||||
|
|
||||||
import android.app.DownloadManager
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.os.Binder
|
||||||
import android.net.Uri
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import com.github.libretube.R
|
import com.github.libretube.R
|
||||||
|
import com.github.libretube.api.CronetHelper
|
||||||
|
import com.github.libretube.api.RetrofitInstance
|
||||||
import com.github.libretube.constants.DOWNLOAD_CHANNEL_ID
|
import com.github.libretube.constants.DOWNLOAD_CHANNEL_ID
|
||||||
import com.github.libretube.constants.DOWNLOAD_FAILURE_NOTIFICATION_ID
|
import com.github.libretube.constants.DOWNLOAD_PROGRESS_NOTIFICATION_ID
|
||||||
import com.github.libretube.constants.DOWNLOAD_SUCCESS_NOTIFICATION_ID
|
import com.github.libretube.constants.IntentData
|
||||||
import com.github.libretube.enums.DownloadType
|
import com.github.libretube.db.DatabaseHolder.Companion.Database
|
||||||
import com.github.libretube.extensions.TAG
|
import com.github.libretube.db.obj.Download
|
||||||
|
import com.github.libretube.db.obj.DownloadItem
|
||||||
|
import com.github.libretube.enums.FileType
|
||||||
|
import com.github.libretube.extensions.awaitQuery
|
||||||
|
import com.github.libretube.extensions.formatAsFileSize
|
||||||
|
import com.github.libretube.extensions.getContentLength
|
||||||
|
import com.github.libretube.extensions.query
|
||||||
|
import com.github.libretube.extensions.toDownloadItems
|
||||||
|
import com.github.libretube.extensions.toastFromMainThread
|
||||||
|
import com.github.libretube.obj.DownloadStatus
|
||||||
|
import com.github.libretube.receivers.NotificationReceiver
|
||||||
|
import com.github.libretube.receivers.NotificationReceiver.Companion.ACTION_DOWNLOAD_PAUSE
|
||||||
|
import com.github.libretube.receivers.NotificationReceiver.Companion.ACTION_DOWNLOAD_RESUME
|
||||||
|
import com.github.libretube.ui.activities.MainActivity
|
||||||
import com.github.libretube.util.DownloadHelper
|
import com.github.libretube.util.DownloadHelper
|
||||||
|
import com.github.libretube.util.DownloadHelper.getNotificationId
|
||||||
|
import com.github.libretube.util.ImageHelper
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okio.BufferedSink
|
||||||
|
import okio.buffer
|
||||||
|
import okio.sink
|
||||||
|
import okio.source
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download service with custom implementation of downloading using [HttpURLConnection].
|
||||||
|
*/
|
||||||
class DownloadService : Service() {
|
class DownloadService : Service() {
|
||||||
|
|
||||||
private lateinit var videoName: String
|
private val binder = LocalBinder()
|
||||||
private lateinit var videoUrl: String
|
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||||
private lateinit var audioUrl: String
|
private val jobMain = SupervisorJob()
|
||||||
private var downloadType: DownloadType = DownloadType.NONE
|
private val scope = CoroutineScope(dispatcher + jobMain)
|
||||||
|
|
||||||
private var videoDownloadId: Long? = null
|
private lateinit var notificationManager: NotificationManager
|
||||||
private var audioDownloadId: Long? = null
|
private lateinit var summaryNotificationBuilder: NotificationCompat.Builder
|
||||||
|
|
||||||
|
private val jobs = mutableMapOf<Int, Job>()
|
||||||
|
private val downloadQueue = mutableMapOf<Int, Boolean>()
|
||||||
|
private val _downloadFlow = MutableSharedFlow<Pair<Int, DownloadStatus>>()
|
||||||
|
val downloadFlow: SharedFlow<Pair<Int, DownloadStatus>> = _downloadFlow
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
IS_DOWNLOAD_RUNNING = true
|
IS_DOWNLOAD_RUNNING = true
|
||||||
|
notifyForeground()
|
||||||
|
sendBroadcast(Intent(ACTION_SERVICE_STARTED))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
videoName = intent?.getStringExtra("videoName")!!
|
when (intent?.action) {
|
||||||
videoUrl = intent.getStringExtra("videoUrl")!!
|
ACTION_DOWNLOAD_RESUME -> resume(intent.getIntExtra("id", -1))
|
||||||
audioUrl = intent.getStringExtra("audioUrl")!!
|
ACTION_DOWNLOAD_PAUSE -> pause(intent.getIntExtra("id", -1))
|
||||||
|
|
||||||
downloadType = when {
|
|
||||||
videoUrl != "" && audioUrl != "" -> DownloadType.AUDIO_VIDEO
|
|
||||||
audioUrl != "" -> DownloadType.AUDIO
|
|
||||||
videoUrl != "" -> DownloadType.VIDEO
|
|
||||||
else -> DownloadType.NONE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (downloadType != DownloadType.NONE) {
|
val videoId = intent?.getStringExtra(IntentData.videoId) ?: return START_NOT_STICKY
|
||||||
downloadManager()
|
val fileName = intent.getStringExtra(IntentData.fileName) ?: videoId
|
||||||
} else {
|
val videoFormat = intent.getStringExtra(IntentData.videoFormat)
|
||||||
onDestroy()
|
val videoQuality = intent.getStringExtra(IntentData.videoQuality)
|
||||||
|
val audioFormat = intent.getStringExtra(IntentData.audioFormat)
|
||||||
|
val audioQuality = intent.getStringExtra(IntentData.audioQuality)
|
||||||
|
val subtitleCode = intent.getStringExtra(IntentData.subtitleCode)
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val streams = RetrofitInstance.api.getStreams(videoId)
|
||||||
|
|
||||||
|
awaitQuery {
|
||||||
|
Database.downloadDao().insertDownload(
|
||||||
|
Download(
|
||||||
|
videoId = videoId,
|
||||||
|
title = streams.title ?: "",
|
||||||
|
thumbnailPath = File(
|
||||||
|
DownloadHelper.getDownloadDir(
|
||||||
|
this@DownloadService,
|
||||||
|
DownloadHelper.THUMBNAIL_DIR
|
||||||
|
),
|
||||||
|
fileName
|
||||||
|
).absolutePath,
|
||||||
|
description = streams.description ?: "",
|
||||||
|
uploadDate = streams.uploadDate,
|
||||||
|
uploader = streams.uploader ?: ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
streams.thumbnailUrl?.let { url ->
|
||||||
|
ImageHelper.downloadImage(
|
||||||
|
this@DownloadService,
|
||||||
|
url,
|
||||||
|
File(
|
||||||
|
DownloadHelper.getDownloadDir(
|
||||||
|
this@DownloadService,
|
||||||
|
DownloadHelper.THUMBNAIL_DIR
|
||||||
|
),
|
||||||
|
fileName
|
||||||
|
).absolutePath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadItems = streams.toDownloadItems(
|
||||||
|
videoId,
|
||||||
|
fileName,
|
||||||
|
videoFormat,
|
||||||
|
videoQuality,
|
||||||
|
audioFormat,
|
||||||
|
audioQuality,
|
||||||
|
subtitleCode
|
||||||
|
)
|
||||||
|
downloadItems.forEach { start(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return super.onStartCommand(intent, flags, startId)
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? {
|
/**
|
||||||
TODO("Not yet implemented")
|
* Initiate download [Job] using [DownloadItem] by creating file according to [FileType]
|
||||||
|
* for the requested file.
|
||||||
|
*/
|
||||||
|
private fun start(item: DownloadItem) {
|
||||||
|
val file: File = when (item.type) {
|
||||||
|
FileType.AUDIO -> {
|
||||||
|
val audioDownloadDir = DownloadHelper.getDownloadDir(
|
||||||
|
this,
|
||||||
|
DownloadHelper.AUDIO_DIR
|
||||||
|
)
|
||||||
|
File(audioDownloadDir, item.fileName)
|
||||||
|
}
|
||||||
|
FileType.VIDEO -> {
|
||||||
|
val videoDownloadDir = DownloadHelper.getDownloadDir(
|
||||||
|
this,
|
||||||
|
DownloadHelper.VIDEO_DIR
|
||||||
|
)
|
||||||
|
File(videoDownloadDir, item.fileName)
|
||||||
|
}
|
||||||
|
FileType.SUBTITLE -> {
|
||||||
|
val subtitleDownloadDir = DownloadHelper.getDownloadDir(
|
||||||
|
this,
|
||||||
|
DownloadHelper.SUBTITLE_DIR
|
||||||
|
)
|
||||||
|
File(subtitleDownloadDir, item.fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file.createNewFile()
|
||||||
|
item.path = file.absolutePath
|
||||||
|
|
||||||
|
item.id = awaitQuery {
|
||||||
|
Database.downloadDao().insertDownloadItem(item)
|
||||||
|
}.toInt()
|
||||||
|
|
||||||
|
jobs[item.id] = scope.launch {
|
||||||
|
downloadFile(item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadManager() {
|
/**
|
||||||
// initialize and create the directories to download into
|
* Download file and emit [DownloadStatus] to the collectors of [downloadFlow]
|
||||||
|
* and notification.
|
||||||
|
*/
|
||||||
|
private suspend fun downloadFile(item: DownloadItem) {
|
||||||
|
downloadQueue[item.id] = true
|
||||||
|
val notificationBuilder = getNotificationBuilder(item)
|
||||||
|
setResumeNotification(notificationBuilder, item)
|
||||||
|
val file = File(item.path)
|
||||||
|
var totalRead = file.length()
|
||||||
|
val url = URL(item.url ?: return)
|
||||||
|
|
||||||
val videoDownloadDir = DownloadHelper.getDownloadDir(this, DownloadHelper.VIDEO_DIR)
|
url.getContentLength().let { size ->
|
||||||
val audioDownloadDir = DownloadHelper.getDownloadDir(this, DownloadHelper.AUDIO_DIR)
|
if (size > 0 && size != item.downloadSize) {
|
||||||
|
item.downloadSize = size
|
||||||
|
query {
|
||||||
|
Database.downloadDao().updateDownloadItem(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// start download
|
|
||||||
try {
|
try {
|
||||||
registerReceiver(
|
// Set start range where last downloading was held.
|
||||||
onDownloadComplete,
|
val con = CronetHelper.getCronetEngine().openConnection(url) as HttpURLConnection
|
||||||
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
con.requestMethod = "GET"
|
||||||
)
|
con.setRequestProperty("Range", "bytes=$totalRead-")
|
||||||
if (downloadType in listOf(DownloadType.VIDEO, DownloadType.AUDIO_VIDEO)) {
|
con.connectTimeout = DownloadHelper.DEFAULT_TIMEOUT
|
||||||
videoDownloadId = downloadManagerRequest(
|
con.readTimeout = DownloadHelper.DEFAULT_TIMEOUT
|
||||||
"[${getString(R.string.video)}] $videoName",
|
|
||||||
getString(R.string.downloading),
|
withContext(Dispatchers.IO) {
|
||||||
videoUrl,
|
// Retry connecting to server for n times.
|
||||||
Uri.fromFile(
|
for (i in 1..DownloadHelper.DEFAULT_RETRY) {
|
||||||
File(videoDownloadDir, videoName)
|
try {
|
||||||
)
|
con.connect()
|
||||||
)
|
break
|
||||||
|
} catch (_: SocketTimeoutException) {
|
||||||
|
val message = getString(R.string.downloadfailed) + " " + i
|
||||||
|
_downloadFlow.emit(item.id to DownloadStatus.Error(message))
|
||||||
|
toastFromMainThread(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (downloadType in listOf(DownloadType.AUDIO, DownloadType.AUDIO_VIDEO)) {
|
|
||||||
audioDownloadId = downloadManagerRequest(
|
// If link is expired try to regenerate using available info.
|
||||||
"[${getString(R.string.audio)}] $videoName",
|
if (con.responseCode == 403) {
|
||||||
getString(R.string.downloading),
|
regenerateLink(item)
|
||||||
audioUrl,
|
con.disconnect()
|
||||||
Uri.fromFile(
|
downloadFile(item)
|
||||||
File(audioDownloadDir, videoName)
|
return
|
||||||
)
|
} else if (con.responseCode !in 200..299) {
|
||||||
)
|
val message = getString(R.string.downloadfailed) + ": " + con.responseMessage
|
||||||
|
_downloadFlow.emit(item.id to DownloadStatus.Error(message))
|
||||||
|
toastFromMainThread(message)
|
||||||
|
con.disconnect()
|
||||||
|
pause(item.id)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
Log.e(TAG(), "download error $e")
|
val sink: BufferedSink = file.sink(true).buffer()
|
||||||
downloadFailedNotification()
|
val sourceByte = con.inputStream.source()
|
||||||
|
|
||||||
|
var lastTime = System.currentTimeMillis() / 1000
|
||||||
|
var lastRead: Long = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if downloading is still active and read next bytes.
|
||||||
|
while (downloadQueue[item.id] == true &&
|
||||||
|
sourceByte
|
||||||
|
.read(sink.buffer, DownloadHelper.DOWNLOAD_CHUNK_SIZE)
|
||||||
|
.also { lastRead = it } != -1L
|
||||||
|
) {
|
||||||
|
sink.emit()
|
||||||
|
totalRead += lastRead
|
||||||
|
_downloadFlow.emit(
|
||||||
|
item.id to DownloadStatus.Progress(
|
||||||
|
lastRead,
|
||||||
|
totalRead,
|
||||||
|
item.downloadSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (item.downloadSize != -1L &&
|
||||||
|
System.currentTimeMillis() / 1000 > lastTime
|
||||||
|
) {
|
||||||
|
notificationBuilder
|
||||||
|
.setContentText(
|
||||||
|
totalRead.formatAsFileSize() + " / " +
|
||||||
|
item.downloadSize.formatAsFileSize()
|
||||||
|
)
|
||||||
|
.setProgress(
|
||||||
|
item.downloadSize.toInt(),
|
||||||
|
totalRead.toInt(),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
notificationManager.notify(
|
||||||
|
item.getNotificationId(),
|
||||||
|
notificationBuilder.build()
|
||||||
|
)
|
||||||
|
lastTime = System.currentTimeMillis() / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: CancellationException) {
|
||||||
|
} catch (e: Exception) {
|
||||||
|
toastFromMainThread("${getString(R.string.download)}: ${e.message}")
|
||||||
|
_downloadFlow.emit(item.id to DownloadStatus.Error(e.message.toString(), e))
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
sink.flush()
|
||||||
|
sink.close()
|
||||||
|
sourceByte.close()
|
||||||
|
con.disconnect()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
|
val completed = when {
|
||||||
|
totalRead < item.downloadSize -> {
|
||||||
|
_downloadFlow.emit(item.id to DownloadStatus.Paused)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
_downloadFlow.emit(item.id to DownloadStatus.Completed)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPauseNotification(notificationBuilder, item, completed)
|
||||||
|
pause(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume download which may have been paused.
|
||||||
|
*/
|
||||||
|
fun resume(id: Int) {
|
||||||
|
// If file is already downloading then avoid new download job.
|
||||||
|
if (downloadQueue[id] == true) return
|
||||||
|
|
||||||
|
if (downloadQueue.values.count { it } >= DownloadHelper.getMaxConcurrentDownloads()) {
|
||||||
|
toastFromMainThread(getString(R.string.concurrent_downloads_limit_reached))
|
||||||
|
scope.launch {
|
||||||
|
_downloadFlow.emit(id to DownloadStatus.Paused)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadItem = awaitQuery {
|
||||||
|
Database.downloadDao().findDownloadItemById(id)
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
downloadFile(downloadItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val onDownloadComplete: BroadcastReceiver = object : BroadcastReceiver() {
|
/**
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
* Pause downloading job for given [id]. If no downloads are active, stop the service.
|
||||||
// Fetching the download id received with the broadcast
|
*/
|
||||||
// Checking if the received broadcast is for our enqueued download by matching download id
|
fun pause(id: Int) {
|
||||||
val downloadId = intent.getLongExtra(
|
downloadQueue[id] = false
|
||||||
DownloadManager.EXTRA_DOWNLOAD_ID,
|
|
||||||
-1
|
// Stop the service if no downloads are active.
|
||||||
|
if (downloadQueue.none { it.value }) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
stopForeground(STOP_FOREGROUND_DETACH)
|
||||||
|
}
|
||||||
|
sendBroadcast(Intent(ACTION_SERVICE_STOPPED))
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate stream url using available info format and quality.
|
||||||
|
*/
|
||||||
|
private suspend fun regenerateLink(item: DownloadItem) {
|
||||||
|
val streams = RetrofitInstance.api.getStreams(item.videoId)
|
||||||
|
val stream = when (item.type) {
|
||||||
|
FileType.AUDIO -> streams.audioStreams
|
||||||
|
FileType.VIDEO -> streams.videoStreams
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
stream?.find { it.format == item.format && it.quality == item.quality }?.let {
|
||||||
|
item.url = it.url
|
||||||
|
}
|
||||||
|
query {
|
||||||
|
Database.downloadDao().updateDownloadItem(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the file downloading or not.
|
||||||
|
*/
|
||||||
|
fun isDownloading(id: Int): Boolean {
|
||||||
|
return downloadQueue[id] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyForeground() {
|
||||||
|
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|
||||||
|
summaryNotificationBuilder = NotificationCompat
|
||||||
|
.Builder(this, DOWNLOAD_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle(getString(R.string.downloading))
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.setGroup(DOWNLOAD_NOTIFICATION_GROUP)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setGroupSummary(true)
|
||||||
|
|
||||||
|
startForeground(DOWNLOAD_PROGRESS_NOTIFICATION_ID, summaryNotificationBuilder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getNotificationBuilder(item: DownloadItem): NotificationCompat.Builder {
|
||||||
|
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
|
||||||
|
} else {
|
||||||
|
PendingIntent.FLAG_CANCEL_CURRENT
|
||||||
|
}
|
||||||
|
|
||||||
|
val activityIntent =
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this@DownloadService,
|
||||||
|
0,
|
||||||
|
Intent(this@DownloadService, MainActivity::class.java).apply {
|
||||||
|
putExtra("fragmentToOpen", "downloads")
|
||||||
|
},
|
||||||
|
flags
|
||||||
)
|
)
|
||||||
if (downloadId == audioDownloadId) {
|
|
||||||
audioDownloadId = null
|
|
||||||
} else if (downloadId == videoDownloadId) videoDownloadId = null
|
|
||||||
|
|
||||||
if (audioDownloadId != null || videoDownloadId != null) return
|
return NotificationCompat
|
||||||
|
.Builder(this, DOWNLOAD_CHANNEL_ID)
|
||||||
downloadSucceededNotification()
|
.setContentTitle("[${item.type}] ${item.fileName}")
|
||||||
onDestroy()
|
.setProgress(0, 0, true)
|
||||||
}
|
.setOngoing(true)
|
||||||
|
.setContentIntent(activityIntent)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
||||||
|
.setGroup(DOWNLOAD_NOTIFICATION_GROUP)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadManagerRequest(
|
private fun setResumeNotification(
|
||||||
title: String,
|
notificationBuilder: NotificationCompat.Builder,
|
||||||
descriptionText: String,
|
item: DownloadItem
|
||||||
url: String,
|
) {
|
||||||
destination: Uri
|
notificationBuilder
|
||||||
): Long {
|
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
val request: DownloadManager.Request =
|
.setWhen(System.currentTimeMillis())
|
||||||
DownloadManager.Request(Uri.parse(url))
|
.setOngoing(true)
|
||||||
.setTitle(title) // Title of the Download Notification
|
.clearActions()
|
||||||
.setDescription(descriptionText) // Description of the Download Notification
|
.addAction(getPauseAction(item.id))
|
||||||
.setDestinationUri(destination)
|
|
||||||
.setAllowedOverMetered(true) // Set if download is allowed on Mobile network
|
|
||||||
.setAllowedOverRoaming(true)
|
|
||||||
|
|
||||||
val downloadManager: DownloadManager =
|
notificationManager.notify(item.getNotificationId(), notificationBuilder.build())
|
||||||
applicationContext.getSystemService(DOWNLOAD_SERVICE) as DownloadManager
|
|
||||||
return downloadManager.enqueue(request)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadFailedNotification() {
|
private fun setPauseNotification(
|
||||||
val builder = NotificationCompat.Builder(this, DOWNLOAD_CHANNEL_ID)
|
notificationBuilder: NotificationCompat.Builder,
|
||||||
.setSmallIcon(R.drawable.ic_download)
|
item: DownloadItem,
|
||||||
.setContentTitle(resources.getString(R.string.downloadfailed))
|
isCompleted: Boolean = false
|
||||||
.setContentText(getString(R.string.fail))
|
) {
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
notificationBuilder
|
||||||
|
.setProgress(0, 0, false)
|
||||||
|
.setOngoing(false)
|
||||||
|
.clearActions()
|
||||||
|
|
||||||
with(NotificationManagerCompat.from(this)) {
|
if (isCompleted) {
|
||||||
// notificationId is a unique int for each notification that you must define
|
notificationBuilder
|
||||||
notify(DOWNLOAD_FAILURE_NOTIFICATION_ID, builder.build())
|
.setSmallIcon(R.drawable.ic_done)
|
||||||
|
.setContentText(getString(R.string.download_completed))
|
||||||
|
} else {
|
||||||
|
notificationBuilder
|
||||||
|
.setSmallIcon(R.drawable.ic_pause)
|
||||||
|
.setContentText(getString(R.string.download_paused))
|
||||||
|
.addAction(getResumeAction(item.id))
|
||||||
}
|
}
|
||||||
|
notificationManager.notify(item.getNotificationId(), notificationBuilder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadSucceededNotification() {
|
private fun getResumeAction(id: Int): NotificationCompat.Action {
|
||||||
Log.i(TAG(), "Download succeeded")
|
val intent = Intent(this, NotificationReceiver::class.java)
|
||||||
val builder = NotificationCompat.Builder(this, DOWNLOAD_CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_download)
|
|
||||||
.setContentTitle(resources.getString(R.string.success))
|
|
||||||
.setContentText(getString(R.string.downloadsucceeded))
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
||||||
|
|
||||||
with(NotificationManagerCompat.from(this)) {
|
intent.action = ACTION_DOWNLOAD_RESUME
|
||||||
// notificationId is a unique int for each notification that you must define
|
intent.putExtra("id", id)
|
||||||
notify(DOWNLOAD_SUCCESS_NOTIFICATION_ID, builder.build())
|
|
||||||
|
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
(PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
} else {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return NotificationCompat.Action.Builder(
|
||||||
|
R.drawable.ic_play,
|
||||||
|
getString(R.string.resume),
|
||||||
|
PendingIntent.getBroadcast(this, id, intent, flags)
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPauseAction(id: Int): NotificationCompat.Action {
|
||||||
|
val intent = Intent(this, NotificationReceiver::class.java)
|
||||||
|
|
||||||
|
intent.action = ACTION_DOWNLOAD_PAUSE
|
||||||
|
intent.putExtra("id", id)
|
||||||
|
|
||||||
|
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
(PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
} else {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
}
|
||||||
|
|
||||||
|
return NotificationCompat.Action.Builder(
|
||||||
|
R.drawable.ic_pause,
|
||||||
|
getString(R.string.pause),
|
||||||
|
PendingIntent.getBroadcast(this, id, intent, flags)
|
||||||
|
).build()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
try {
|
downloadQueue.clear()
|
||||||
unregisterReceiver(onDownloadComplete)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
|
|
||||||
IS_DOWNLOAD_RUNNING = false
|
IS_DOWNLOAD_RUNNING = false
|
||||||
|
sendBroadcast(Intent(ACTION_SERVICE_STOPPED))
|
||||||
stopService(Intent(this, DownloadService::class.java))
|
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder {
|
||||||
|
val ids = intent?.getIntArrayExtra("ids")
|
||||||
|
ids?.forEach { id -> resume(id) }
|
||||||
|
return binder
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class LocalBinder : Binder() {
|
||||||
|
fun getService(): DownloadService = this@DownloadService
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val DOWNLOAD_NOTIFICATION_GROUP = "download_notification_group"
|
||||||
|
const val ACTION_SERVICE_STARTED =
|
||||||
|
"com.github.libretube.services.DownloadService.ACTION_SERVICE_STARTED"
|
||||||
|
const val ACTION_SERVICE_STOPPED =
|
||||||
|
"com.github.libretube.services.DownloadService.ACTION_SERVICE_STOPPED"
|
||||||
var IS_DOWNLOAD_RUNNING = false
|
var IS_DOWNLOAD_RUNNING = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -35,6 +35,7 @@ import com.github.libretube.extensions.toID
|
|||||||
import com.github.libretube.services.ClosingService
|
import com.github.libretube.services.ClosingService
|
||||||
import com.github.libretube.ui.base.BaseActivity
|
import com.github.libretube.ui.base.BaseActivity
|
||||||
import com.github.libretube.ui.dialogs.ErrorDialog
|
import com.github.libretube.ui.dialogs.ErrorDialog
|
||||||
|
import com.github.libretube.ui.fragments.DownloadsFragment
|
||||||
import com.github.libretube.ui.fragments.PlayerFragment
|
import com.github.libretube.ui.fragments.PlayerFragment
|
||||||
import com.github.libretube.ui.models.PlayerViewModel
|
import com.github.libretube.ui.models.PlayerViewModel
|
||||||
import com.github.libretube.ui.models.SearchViewModel
|
import com.github.libretube.ui.models.SearchViewModel
|
||||||
@ -423,6 +424,14 @@ class MainActivity : BaseActivity() {
|
|||||||
navController.navigate(R.id.subscriptionsFragment)
|
navController.navigate(R.id.subscriptionsFragment)
|
||||||
"library" ->
|
"library" ->
|
||||||
navController.navigate(R.id.libraryFragment)
|
navController.navigate(R.id.libraryFragment)
|
||||||
|
"downloads" ->
|
||||||
|
navController.navigate(R.id.downloadsFragment)
|
||||||
|
}
|
||||||
|
if (intent?.getBooleanExtra(IntentData.downloading, false) == true) {
|
||||||
|
(supportFragmentManager.fragments.find { it is NavHostFragment })
|
||||||
|
?.childFragmentManager?.fragments?.forEach { fragment ->
|
||||||
|
(fragment as? DownloadsFragment)?.bindDownloadService()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,10 +17,12 @@ import androidx.core.view.WindowInsetsControllerCompat
|
|||||||
import com.github.libretube.constants.IntentData
|
import com.github.libretube.constants.IntentData
|
||||||
import com.github.libretube.databinding.ActivityOfflinePlayerBinding
|
import com.github.libretube.databinding.ActivityOfflinePlayerBinding
|
||||||
import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding
|
import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding
|
||||||
|
import com.github.libretube.db.DatabaseHolder.Companion.Database
|
||||||
|
import com.github.libretube.enums.FileType
|
||||||
|
import com.github.libretube.extensions.awaitQuery
|
||||||
import com.github.libretube.ui.base.BaseActivity
|
import com.github.libretube.ui.base.BaseActivity
|
||||||
import com.github.libretube.ui.extensions.setAspectRatio
|
import com.github.libretube.ui.extensions.setAspectRatio
|
||||||
import com.github.libretube.ui.models.PlayerViewModel
|
import com.github.libretube.ui.models.PlayerViewModel
|
||||||
import com.github.libretube.util.DownloadHelper
|
|
||||||
import com.github.libretube.util.PlayerHelper
|
import com.github.libretube.util.PlayerHelper
|
||||||
import com.google.android.exoplayer2.ExoPlayer
|
import com.google.android.exoplayer2.ExoPlayer
|
||||||
import com.google.android.exoplayer2.MediaItem
|
import com.google.android.exoplayer2.MediaItem
|
||||||
@ -33,7 +35,7 @@ import java.io.File
|
|||||||
|
|
||||||
class OfflinePlayerActivity : BaseActivity() {
|
class OfflinePlayerActivity : BaseActivity() {
|
||||||
private lateinit var binding: ActivityOfflinePlayerBinding
|
private lateinit var binding: ActivityOfflinePlayerBinding
|
||||||
private lateinit var fileName: String
|
private lateinit var videoId: String
|
||||||
private lateinit var player: ExoPlayer
|
private lateinit var player: ExoPlayer
|
||||||
private lateinit var playerView: StyledPlayerView
|
private lateinit var playerView: StyledPlayerView
|
||||||
private lateinit var playerBinding: ExoStyledPlayerControlViewBinding
|
private lateinit var playerBinding: ExoStyledPlayerControlViewBinding
|
||||||
@ -46,7 +48,7 @@ class OfflinePlayerActivity : BaseActivity() {
|
|||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
fileName = intent?.getStringExtra(IntentData.fileName)!!
|
videoId = intent?.getStringExtra(IntentData.videoId)!!
|
||||||
|
|
||||||
binding = ActivityOfflinePlayerBinding.inflate(layoutInflater)
|
binding = ActivityOfflinePlayerBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
@ -96,15 +98,17 @@ class OfflinePlayerActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun playVideo() {
|
private fun playVideo() {
|
||||||
val videoUri = File(
|
val downloadFiles = awaitQuery {
|
||||||
DownloadHelper.getDownloadDir(this, DownloadHelper.VIDEO_DIR),
|
Database.downloadDao().findById(videoId).downloadItems
|
||||||
fileName
|
}
|
||||||
).toUri()
|
|
||||||
|
|
||||||
val audioUri = File(
|
val video = downloadFiles.firstOrNull { it.type == FileType.VIDEO }
|
||||||
DownloadHelper.getDownloadDir(this, DownloadHelper.AUDIO_DIR),
|
val audio = downloadFiles.firstOrNull { it.type == FileType.AUDIO }
|
||||||
fileName
|
val subtitle = downloadFiles.firstOrNull { it.type == FileType.SUBTITLE }
|
||||||
).toUri()
|
|
||||||
|
val videoUri = video?.path?.let { File(it).toUri() }
|
||||||
|
val audioUri = audio?.path?.let { File(it).toUri() }
|
||||||
|
val subtitleUri = subtitle?.path?.let { File(it).toUri() }
|
||||||
|
|
||||||
setMediaSource(
|
setMediaSource(
|
||||||
videoUri,
|
videoUri,
|
||||||
|
@ -1,24 +1,29 @@
|
|||||||
package com.github.libretube.ui.adapters
|
package com.github.libretube.ui.adapters
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.github.libretube.R
|
import com.github.libretube.R
|
||||||
import com.github.libretube.constants.IntentData
|
import com.github.libretube.constants.IntentData
|
||||||
import com.github.libretube.databinding.DownloadedMediaRowBinding
|
import com.github.libretube.databinding.DownloadedMediaRowBinding
|
||||||
import com.github.libretube.extensions.formatShort
|
import com.github.libretube.db.DatabaseHolder
|
||||||
import com.github.libretube.obj.DownloadedFile
|
import com.github.libretube.db.obj.DownloadWithItems
|
||||||
|
import com.github.libretube.extensions.formatAsFileSize
|
||||||
|
import com.github.libretube.extensions.query
|
||||||
import com.github.libretube.ui.activities.OfflinePlayerActivity
|
import com.github.libretube.ui.activities.OfflinePlayerActivity
|
||||||
import com.github.libretube.ui.viewholders.DownloadsViewHolder
|
import com.github.libretube.ui.viewholders.DownloadsViewHolder
|
||||||
import com.github.libretube.util.DownloadHelper
|
import com.github.libretube.util.ImageHelper
|
||||||
import com.github.libretube.util.TextUtils
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class DownloadsAdapter(
|
class DownloadsAdapter(
|
||||||
private val files: MutableList<DownloadedFile>
|
private val context: Context,
|
||||||
|
private val downloads: MutableList<DownloadWithItems>,
|
||||||
|
private val toogleDownload: (DownloadWithItems) -> Boolean
|
||||||
) : RecyclerView.Adapter<DownloadsViewHolder>() {
|
) : RecyclerView.Adapter<DownloadsViewHolder>() {
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadsViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadsViewHolder {
|
||||||
val binding = DownloadedMediaRowBinding.inflate(
|
val binding = DownloadedMediaRowBinding.inflate(
|
||||||
@ -31,24 +36,56 @@ class DownloadsAdapter(
|
|||||||
|
|
||||||
@SuppressLint("SetTextI18n")
|
@SuppressLint("SetTextI18n")
|
||||||
override fun onBindViewHolder(holder: DownloadsViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: DownloadsViewHolder, position: Int) {
|
||||||
val file = files[position]
|
val download = downloads[position].download
|
||||||
|
val items = downloads[position].downloadItems
|
||||||
holder.binding.apply {
|
holder.binding.apply {
|
||||||
fileName.text = file.name
|
title.text = download.title
|
||||||
fileSize.text = "${file.size / (1024 * 1024)} MiB"
|
uploaderName.text = download.uploader
|
||||||
|
videoInfo.text = download.uploadDate
|
||||||
|
|
||||||
file.metadata?.let {
|
val downloadSize = items.sumOf { it.downloadSize }
|
||||||
uploaderName.text = it.uploader
|
val currentSize = items.sumOf { File(it.path).length() }
|
||||||
videoInfo.text = it.views.formatShort() + " " +
|
|
||||||
root.context.getString(R.string.views_placeholder) +
|
if (downloadSize == -1L) {
|
||||||
TextUtils.SEPARATOR + it.uploadDate
|
progressBar.isIndeterminate = true
|
||||||
|
} else {
|
||||||
|
progressBar.max = downloadSize.toInt()
|
||||||
|
progressBar.progress = currentSize.toInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
thumbnailImage.setImageBitmap(file.thumbnail)
|
val totalSizeInfo = if (downloadSize > 0) {
|
||||||
|
downloadSize.formatAsFileSize()
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.unknown)
|
||||||
|
}
|
||||||
|
if (downloadSize > currentSize) {
|
||||||
|
downloadOverlay.visibility = View.VISIBLE
|
||||||
|
resumePauseBtn.setImageResource(R.drawable.ic_download)
|
||||||
|
fileSize.text = "${currentSize.formatAsFileSize()} / $totalSizeInfo"
|
||||||
|
} else {
|
||||||
|
downloadOverlay.visibility = View.GONE
|
||||||
|
fileSize.text = totalSizeInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
download.thumbnailPath?.let { path ->
|
||||||
|
thumbnailImage.setImageBitmap(ImageHelper.getDownloadedImage(context, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
progressBar.setOnClickListener {
|
||||||
|
val isDownloading = toogleDownload(downloads[position])
|
||||||
|
|
||||||
|
resumePauseBtn.setImageResource(
|
||||||
|
if (isDownloading) {
|
||||||
|
R.drawable.ic_pause
|
||||||
|
} else {
|
||||||
|
R.drawable.ic_download
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
root.setOnClickListener {
|
root.setOnClickListener {
|
||||||
val intent = Intent(root.context, OfflinePlayerActivity::class.java).also {
|
val intent = Intent(root.context, OfflinePlayerActivity::class.java)
|
||||||
it.putExtra(IntentData.fileName, file.name)
|
intent.putExtra(IntentData.videoId, download.videoId)
|
||||||
}
|
|
||||||
root.context.startActivity(intent)
|
root.context.startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,27 +98,18 @@ class DownloadsAdapter(
|
|||||||
) { _, index ->
|
) { _, index ->
|
||||||
when (index) {
|
when (index) {
|
||||||
0 -> {
|
0 -> {
|
||||||
val audioDir = DownloadHelper.getDownloadDir(
|
items.map { File(it.path) }.forEach { file ->
|
||||||
root.context,
|
if (file.exists()) {
|
||||||
DownloadHelper.AUDIO_DIR
|
|
||||||
)
|
|
||||||
val videoDir = DownloadHelper.getDownloadDir(
|
|
||||||
root.context,
|
|
||||||
DownloadHelper.VIDEO_DIR
|
|
||||||
)
|
|
||||||
|
|
||||||
listOf(audioDir, videoDir).forEach {
|
|
||||||
val f = File(it, file.name)
|
|
||||||
if (f.exists()) {
|
|
||||||
try {
|
try {
|
||||||
f.delete()
|
file.delete()
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) { }
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
files.removeAt(position)
|
query {
|
||||||
|
DatabaseHolder.Database.downloadDao().deleteDownload(download)
|
||||||
|
}
|
||||||
|
downloads.removeAt(position)
|
||||||
notifyItemRemoved(position)
|
notifyItemRemoved(position)
|
||||||
notifyItemRangeChanged(position, itemCount)
|
notifyItemRangeChanged(position, itemCount)
|
||||||
}
|
}
|
||||||
@ -95,6 +123,6 @@ class DownloadsAdapter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
override fun getItemCount(): Int {
|
||||||
return files.size
|
return downloads.size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
package com.github.libretube.ui.dialogs
|
package com.github.libretube.ui.dialogs
|
||||||
|
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.text.InputFilter
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
@ -15,10 +15,8 @@ import com.github.libretube.api.RetrofitInstance
|
|||||||
import com.github.libretube.api.obj.Streams
|
import com.github.libretube.api.obj.Streams
|
||||||
import com.github.libretube.databinding.DialogDownloadBinding
|
import com.github.libretube.databinding.DialogDownloadBinding
|
||||||
import com.github.libretube.extensions.TAG
|
import com.github.libretube.extensions.TAG
|
||||||
import com.github.libretube.extensions.sanitize
|
import com.github.libretube.util.DownloadHelper
|
||||||
import com.github.libretube.services.DownloadService
|
import com.github.libretube.util.TextUtils
|
||||||
import com.github.libretube.util.ImageHelper
|
|
||||||
import com.github.libretube.util.MetadataHelper
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
@ -41,6 +39,25 @@ class DownloadDialog(
|
|||||||
binding.videoSpinner.visibility = View.VISIBLE
|
binding.videoSpinner.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.fileName.filters += InputFilter { source, start, end, _, _, _ ->
|
||||||
|
if (source.isNullOrBlank()) {
|
||||||
|
return@InputFilter null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract actual source
|
||||||
|
val actualSource = source.subSequence(start, end)
|
||||||
|
// Filter out unsupported characters
|
||||||
|
val filtered = actualSource.filterNot {
|
||||||
|
TextUtils.RESERVED_CHARS.contains(it, true)
|
||||||
|
}
|
||||||
|
// Check if something was filtered out
|
||||||
|
return@InputFilter if (actualSource.length != filtered.length) {
|
||||||
|
filtered
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return MaterialAlertDialogBuilder(requireContext())
|
return MaterialAlertDialogBuilder(requireContext())
|
||||||
.setView(binding.root)
|
.setView(binding.root)
|
||||||
.show()
|
.show()
|
||||||
@ -68,37 +85,45 @@ class DownloadDialog(
|
|||||||
binding.fileName.setText(streams.title.toString())
|
binding.fileName.setText(streams.title.toString())
|
||||||
|
|
||||||
val vidName = arrayListOf<String>()
|
val vidName = arrayListOf<String>()
|
||||||
val videoUrl = arrayListOf<String>()
|
|
||||||
|
|
||||||
// add empty selection
|
// add empty selection
|
||||||
vidName.add(getString(R.string.no_video))
|
vidName.add(getString(R.string.no_video))
|
||||||
videoUrl.add("")
|
|
||||||
|
|
||||||
// add all available video streams
|
// add all available video streams
|
||||||
for (vid in streams.videoStreams!!) {
|
for (vid in streams.videoStreams!!) {
|
||||||
if (vid.url != null) {
|
if (vid.url != null) {
|
||||||
val name = vid.quality + " " + vid.format
|
val name = vid.quality + " " + vid.format
|
||||||
vidName.add(name)
|
vidName.add(name)
|
||||||
videoUrl.add(vid.url!!)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val audioName = arrayListOf<String>()
|
val audioName = arrayListOf<String>()
|
||||||
val audioUrl = arrayListOf<String>()
|
|
||||||
|
|
||||||
// add empty selection
|
// add empty selection
|
||||||
audioName.add(getString(R.string.no_audio))
|
audioName.add(getString(R.string.no_audio))
|
||||||
audioUrl.add("")
|
|
||||||
|
|
||||||
// add all available audio streams
|
// add all available audio streams
|
||||||
for (audio in streams.audioStreams!!) {
|
for (audio in streams.audioStreams!!) {
|
||||||
if (audio.url != null) {
|
if (audio.url != null) {
|
||||||
val name = audio.quality + " " + audio.format
|
val name = audio.quality + " " + audio.format
|
||||||
audioName.add(name)
|
audioName.add(name)
|
||||||
audioUrl.add(audio.url!!)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val subtitleName = arrayListOf<String>()
|
||||||
|
|
||||||
|
// add empty selection
|
||||||
|
subtitleName.add(getString(R.string.no_subtitle))
|
||||||
|
|
||||||
|
// add all available subtitles
|
||||||
|
for (subtitle in streams.subtitles!!) {
|
||||||
|
if (subtitle.url != null) {
|
||||||
|
subtitleName.add(subtitle.name.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtitleName.size == 1) binding.subtitleSpinner.visibility = View.GONE
|
||||||
|
|
||||||
// initialize the video sources
|
// initialize the video sources
|
||||||
val videoArrayAdapter = ArrayAdapter(
|
val videoArrayAdapter = ArrayAdapter(
|
||||||
requireContext(),
|
requireContext(),
|
||||||
@ -109,6 +134,7 @@ class DownloadDialog(
|
|||||||
binding.videoSpinner.adapter = videoArrayAdapter
|
binding.videoSpinner.adapter = videoArrayAdapter
|
||||||
if (binding.videoSpinner.size >= 1) binding.videoSpinner.setSelection(1)
|
if (binding.videoSpinner.size >= 1) binding.videoSpinner.setSelection(1)
|
||||||
if (binding.audioSpinner.size >= 1) binding.audioSpinner.setSelection(1)
|
if (binding.audioSpinner.size >= 1) binding.audioSpinner.setSelection(1)
|
||||||
|
if (binding.subtitleSpinner.size >= 1) binding.subtitleSpinner.setSelection(1)
|
||||||
|
|
||||||
// initialize the audio sources
|
// initialize the audio sources
|
||||||
val audioArrayAdapter = ArrayAdapter(
|
val audioArrayAdapter = ArrayAdapter(
|
||||||
@ -120,48 +146,55 @@ class DownloadDialog(
|
|||||||
binding.audioSpinner.adapter = audioArrayAdapter
|
binding.audioSpinner.adapter = audioArrayAdapter
|
||||||
if (binding.audioSpinner.size >= 1) binding.audioSpinner.setSelection(1)
|
if (binding.audioSpinner.size >= 1) binding.audioSpinner.setSelection(1)
|
||||||
|
|
||||||
|
// initialize the subtitle sources
|
||||||
|
val subtitleArrayAdapter = ArrayAdapter(
|
||||||
|
requireContext(),
|
||||||
|
android.R.layout.simple_spinner_item,
|
||||||
|
subtitleName
|
||||||
|
)
|
||||||
|
subtitleArrayAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||||
|
binding.subtitleSpinner.adapter = subtitleArrayAdapter
|
||||||
|
if (binding.subtitleSpinner.size >= 1) binding.subtitleSpinner.setSelection(1)
|
||||||
|
|
||||||
binding.download.setOnClickListener {
|
binding.download.setOnClickListener {
|
||||||
if (binding.fileName.text.toString().isEmpty()) {
|
if (binding.fileName.text.toString().isEmpty()) {
|
||||||
Toast.makeText(context, R.string.invalid_filename, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, R.string.invalid_filename, Toast.LENGTH_SHORT).show()
|
||||||
return@setOnClickListener
|
return@setOnClickListener
|
||||||
}
|
}
|
||||||
|
|
||||||
val vidUrl = videoUrl[binding.videoSpinner.selectedItemPosition]
|
val videoPosition = binding.videoSpinner.selectedItemPosition - 1
|
||||||
val audUrl = audioUrl[binding.audioSpinner.selectedItemPosition]
|
val audioPosition = binding.audioSpinner.selectedItemPosition - 1
|
||||||
|
val subtitlePosition = binding.subtitleSpinner.selectedItemPosition - 1
|
||||||
|
|
||||||
if (audUrl == "" && vidUrl == "") {
|
if (videoPosition == -1 && audioPosition == -1 && subtitlePosition == -1) {
|
||||||
Toast.makeText(context, R.string.nothing_selected, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, R.string.nothing_selected, Toast.LENGTH_SHORT).show()
|
||||||
return@setOnClickListener
|
return@setOnClickListener
|
||||||
}
|
}
|
||||||
|
|
||||||
val fileName = binding.fileName.text.toString().sanitize()
|
val videoStream = when (videoPosition) {
|
||||||
|
-1 -> null
|
||||||
val metadataHelper = MetadataHelper(requireContext())
|
else -> streams.videoStreams[videoPosition]
|
||||||
metadataHelper.createMetadata(fileName, streams)
|
}
|
||||||
streams.thumbnailUrl?.let { thumbnailUrl ->
|
val audioStream = when (audioPosition) {
|
||||||
ImageHelper.downloadImage(
|
-1 -> null
|
||||||
requireContext(),
|
else -> streams.audioStreams[audioPosition]
|
||||||
thumbnailUrl,
|
}
|
||||||
fileName
|
val subtitle = when (subtitlePosition) {
|
||||||
)
|
-1 -> null
|
||||||
|
else -> streams.subtitles[subtitlePosition]
|
||||||
}
|
}
|
||||||
|
|
||||||
val intent = Intent(context, DownloadService::class.java)
|
DownloadHelper.startDownloadService(
|
||||||
|
context = requireContext(),
|
||||||
intent.putExtra(
|
videoId = videoId,
|
||||||
"videoName",
|
fileName = binding.fileName.text.toString(),
|
||||||
fileName
|
videoFormat = videoStream?.format,
|
||||||
)
|
videoQuality = videoStream?.quality,
|
||||||
intent.putExtra(
|
audioFormat = audioStream?.format,
|
||||||
"videoUrl",
|
audioQuality = audioStream?.quality,
|
||||||
vidUrl
|
subtitleCode = subtitle?.code
|
||||||
)
|
|
||||||
intent.putExtra(
|
|
||||||
"audioUrl",
|
|
||||||
audUrl
|
|
||||||
)
|
)
|
||||||
|
|
||||||
context?.startService(intent)
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,21 +1,62 @@
|
|||||||
package com.github.libretube.ui.fragments
|
package com.github.libretube.ui.fragments
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.ServiceConnection
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.IBinder
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.core.view.size
|
import androidx.core.view.size
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.github.libretube.R
|
||||||
import com.github.libretube.databinding.FragmentDownloadsBinding
|
import com.github.libretube.databinding.FragmentDownloadsBinding
|
||||||
|
import com.github.libretube.db.DatabaseHolder.Companion.Database
|
||||||
|
import com.github.libretube.db.obj.DownloadWithItems
|
||||||
|
import com.github.libretube.extensions.awaitQuery
|
||||||
|
import com.github.libretube.extensions.formatAsFileSize
|
||||||
|
import com.github.libretube.obj.DownloadStatus
|
||||||
|
import com.github.libretube.receivers.DownloadReceiver
|
||||||
|
import com.github.libretube.services.DownloadService
|
||||||
import com.github.libretube.ui.adapters.DownloadsAdapter
|
import com.github.libretube.ui.adapters.DownloadsAdapter
|
||||||
import com.github.libretube.ui.base.BaseFragment
|
import com.github.libretube.ui.base.BaseFragment
|
||||||
|
import com.github.libretube.ui.viewholders.DownloadsViewHolder
|
||||||
import com.github.libretube.util.DownloadHelper
|
import com.github.libretube.util.DownloadHelper
|
||||||
import com.github.libretube.util.ImageHelper
|
import java.io.File
|
||||||
import com.github.libretube.util.MetadataHelper
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class DownloadsFragment : BaseFragment() {
|
class DownloadsFragment : BaseFragment() {
|
||||||
private lateinit var binding: FragmentDownloadsBinding
|
private lateinit var binding: FragmentDownloadsBinding
|
||||||
|
private var binder: DownloadService.LocalBinder? = null
|
||||||
|
private val downloads = mutableListOf<DownloadWithItems>()
|
||||||
|
private val downloadReceiver = DownloadReceiver()
|
||||||
|
|
||||||
|
private val serviceConnection = object : ServiceConnection {
|
||||||
|
var isBound = false
|
||||||
|
var job: Job? = null
|
||||||
|
|
||||||
|
override fun onServiceConnected(name: ComponentName?, iBinder: IBinder?) {
|
||||||
|
binder = iBinder as DownloadService.LocalBinder
|
||||||
|
isBound = true
|
||||||
|
job?.cancel()
|
||||||
|
job = lifecycleScope.launch {
|
||||||
|
binder?.getService()?.downloadFlow?.collectLatest {
|
||||||
|
updateProgress(it.first, it.second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
binder = null
|
||||||
|
isBound = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
@ -29,36 +70,118 @@ class DownloadsFragment : BaseFragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
val files = DownloadHelper.getDownloadedFiles(requireContext())
|
awaitQuery {
|
||||||
|
downloads.addAll(Database.downloadDao().getAll())
|
||||||
if (files.isEmpty()) return
|
|
||||||
|
|
||||||
val metadataHelper = MetadataHelper(requireContext())
|
|
||||||
files.forEach {
|
|
||||||
metadataHelper.getMetadata(it.name)?.let { streams ->
|
|
||||||
it.metadata = streams
|
|
||||||
}
|
|
||||||
ImageHelper.getDownloadedImage(requireContext(), it.name)?.let { bitmap ->
|
|
||||||
it.thumbnail = bitmap
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (downloads.isEmpty()) return
|
||||||
|
|
||||||
binding.downloadsEmpty.visibility = View.GONE
|
binding.downloadsEmpty.visibility = View.GONE
|
||||||
binding.downloads.visibility = View.VISIBLE
|
binding.downloads.visibility = View.VISIBLE
|
||||||
|
|
||||||
binding.downloads.layoutManager = LinearLayoutManager(context)
|
binding.downloads.layoutManager = LinearLayoutManager(context)
|
||||||
binding.downloads.adapter = DownloadsAdapter(files)
|
|
||||||
|
binding.downloads.adapter = DownloadsAdapter(requireContext(), downloads) {
|
||||||
|
var isDownloading = false
|
||||||
|
val ids = it.downloadItems
|
||||||
|
.filter { item -> File(item.path).length() < item.downloadSize }
|
||||||
|
.map { item -> item.id }
|
||||||
|
|
||||||
|
if (!serviceConnection.isBound) {
|
||||||
|
DownloadHelper.startDownloadService(requireContext())
|
||||||
|
bindDownloadService(ids.toIntArray())
|
||||||
|
return@DownloadsAdapter true
|
||||||
|
}
|
||||||
|
|
||||||
|
binder?.getService()?.let { service ->
|
||||||
|
isDownloading = ids.any { id -> service.isDownloading(id) }
|
||||||
|
|
||||||
|
ids.forEach { id ->
|
||||||
|
if (isDownloading) {
|
||||||
|
service.pause(id)
|
||||||
|
} else {
|
||||||
|
service.resume(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return@DownloadsAdapter isDownloading.not()
|
||||||
|
}
|
||||||
|
|
||||||
binding.downloads.adapter?.registerAdapterDataObserver(
|
binding.downloads.adapter?.registerAdapterDataObserver(
|
||||||
object : RecyclerView.AdapterDataObserver() {
|
object : RecyclerView.AdapterDataObserver() {
|
||||||
override fun onChanged() {
|
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
|
||||||
if (binding.downloads.size == 0) {
|
if (binding.downloads.size == 0) {
|
||||||
binding.downloads.visibility = View.GONE
|
binding.downloads.visibility = View.GONE
|
||||||
binding.downloadsEmpty.visibility = View.VISIBLE
|
binding.downloadsEmpty.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
super.onChanged()
|
super.onItemRangeRemoved(positionStart, itemCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
if (DownloadService.IS_DOWNLOAD_RUNNING) {
|
||||||
|
val intent = Intent(requireContext(), DownloadService::class.java)
|
||||||
|
context?.bindService(intent, serviceConnection, 0)
|
||||||
|
}
|
||||||
|
super.onStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
val filter = IntentFilter()
|
||||||
|
filter.addAction(DownloadService.ACTION_SERVICE_STARTED)
|
||||||
|
filter.addAction(DownloadService.ACTION_SERVICE_STOPPED)
|
||||||
|
context?.registerReceiver(downloadReceiver, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bindDownloadService(ids: IntArray? = null) {
|
||||||
|
if (serviceConnection.isBound) return
|
||||||
|
|
||||||
|
val intent = Intent(context, DownloadService::class.java)
|
||||||
|
intent.putExtra("ids", ids)
|
||||||
|
context?.bindService(intent, serviceConnection, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProgress(id: Int, status: DownloadStatus) {
|
||||||
|
val index = downloads.indexOfFirst {
|
||||||
|
it.downloadItems.any { item -> item.id == id }
|
||||||
|
}
|
||||||
|
val view = binding.downloads.findViewHolderForAdapterPosition(index) as? DownloadsViewHolder
|
||||||
|
|
||||||
|
view?.binding?.apply {
|
||||||
|
when (status) {
|
||||||
|
DownloadStatus.Paused -> {
|
||||||
|
resumePauseBtn.setImageResource(R.drawable.ic_download)
|
||||||
|
}
|
||||||
|
DownloadStatus.Completed -> {
|
||||||
|
downloadOverlay.visibility = View.GONE
|
||||||
|
}
|
||||||
|
is DownloadStatus.Progress -> {
|
||||||
|
downloadOverlay.visibility = View.VISIBLE
|
||||||
|
resumePauseBtn.setImageResource(R.drawable.ic_pause)
|
||||||
|
if (progressBar.isIndeterminate) return
|
||||||
|
progressBar.incrementProgressBy(status.progress.toInt())
|
||||||
|
val progressInfo = progressBar.progress.formatAsFileSize() +
|
||||||
|
" /\n" + progressBar.max.formatAsFileSize()
|
||||||
|
fileSize.text = progressInfo
|
||||||
|
}
|
||||||
|
is DownloadStatus.Error -> {
|
||||||
|
resumePauseBtn.setImageResource(R.drawable.ic_restart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
context?.unregisterReceiver(downloadReceiver)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
if (serviceConnection.isBound) {
|
||||||
|
context?.unbindService(serviceConnection)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,23 @@
|
|||||||
package com.github.libretube.util
|
package com.github.libretube.util
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import com.github.libretube.obj.DownloadedFile
|
import com.github.libretube.constants.IntentData
|
||||||
|
import com.github.libretube.constants.PreferenceKeys
|
||||||
|
import com.github.libretube.db.obj.DownloadItem
|
||||||
|
import com.github.libretube.services.DownloadService
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
object DownloadHelper {
|
object DownloadHelper {
|
||||||
const val VIDEO_DIR = "video"
|
const val VIDEO_DIR = "video"
|
||||||
const val AUDIO_DIR = "audio"
|
const val AUDIO_DIR = "audio"
|
||||||
|
const val SUBTITLE_DIR = "subtitle"
|
||||||
const val METADATA_DIR = "metadata"
|
const val METADATA_DIR = "metadata"
|
||||||
const val THUMBNAIL_DIR = "thumbnail"
|
const val THUMBNAIL_DIR = "thumbnail"
|
||||||
|
const val DOWNLOAD_CHUNK_SIZE = 8L * 1024
|
||||||
|
const val DEFAULT_TIMEOUT = 15 * 1000
|
||||||
|
const val DEFAULT_RETRY = 3
|
||||||
|
|
||||||
fun getOfflineStorageDir(context: Context): File {
|
fun getOfflineStorageDir(context: Context): File {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return context.filesDir
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return context.filesDir
|
||||||
@ -30,28 +38,41 @@ object DownloadHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun File.toDownloadedFile(): DownloadedFile {
|
fun getMaxConcurrentDownloads(): Int {
|
||||||
return DownloadedFile(
|
return PreferenceHelper.getString(
|
||||||
name = this.name,
|
PreferenceKeys.MAX_CONCURRENT_DOWNLOADS,
|
||||||
size = this.length()
|
"6"
|
||||||
)
|
).toFloat().toInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDownloadedFiles(context: Context): MutableList<DownloadedFile> {
|
fun startDownloadService(
|
||||||
val videoFiles = getDownloadDir(context, VIDEO_DIR).listFiles().orEmpty()
|
context: Context,
|
||||||
val audioFiles = getDownloadDir(context, AUDIO_DIR).listFiles().orEmpty().toMutableList()
|
videoId: String? = null,
|
||||||
|
fileName: String? = null,
|
||||||
|
videoFormat: String? = null,
|
||||||
|
videoQuality: String? = null,
|
||||||
|
audioFormat: String? = null,
|
||||||
|
audioQuality: String? = null,
|
||||||
|
subtitleCode: String? = null
|
||||||
|
) {
|
||||||
|
val intent = Intent(context, DownloadService::class.java)
|
||||||
|
|
||||||
val files = mutableListOf<DownloadedFile>()
|
intent.putExtra(IntentData.videoId, videoId)
|
||||||
|
intent.putExtra(IntentData.fileName, fileName)
|
||||||
|
intent.putExtra(IntentData.videoFormat, videoFormat)
|
||||||
|
intent.putExtra(IntentData.videoQuality, videoQuality)
|
||||||
|
intent.putExtra(IntentData.audioFormat, audioFormat)
|
||||||
|
intent.putExtra(IntentData.audioQuality, audioQuality)
|
||||||
|
intent.putExtra(IntentData.subtitleCode, subtitleCode)
|
||||||
|
|
||||||
videoFiles.forEach {
|
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
|
||||||
audioFiles.removeIf { audioFile -> audioFile.name == it.name }
|
context.startForegroundService(intent)
|
||||||
files.add(it.toDownloadedFile())
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
audioFiles.forEach {
|
fun DownloadItem.getNotificationId(): Int {
|
||||||
files.add(it.toDownloadedFile())
|
return Int.MAX_VALUE - id
|
||||||
}
|
|
||||||
|
|
||||||
return files
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -55,15 +55,12 @@ object ImageHelper {
|
|||||||
if (!DataSaverMode.isEnabled(target.context)) target.load(url, imageLoader)
|
if (!DataSaverMode.isEnabled(target.context)) target.load(url, imageLoader)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun downloadImage(context: Context, url: String, fileName: String) {
|
fun downloadImage(context: Context, url: String, path: String) {
|
||||||
val request = ImageRequest.Builder(context)
|
val request = ImageRequest.Builder(context)
|
||||||
.data(url)
|
.data(url)
|
||||||
.target { result ->
|
.target { result ->
|
||||||
val bitmap = (result as BitmapDrawable).bitmap
|
val bitmap = (result as BitmapDrawable).bitmap
|
||||||
val file = File(
|
val file = File(path)
|
||||||
DownloadHelper.getDownloadDir(context, DownloadHelper.THUMBNAIL_DIR),
|
|
||||||
fileName
|
|
||||||
)
|
|
||||||
saveImage(context, bitmap, Uri.fromFile(file))
|
saveImage(context, bitmap, Uri.fromFile(file))
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
@ -71,11 +68,8 @@ object ImageHelper {
|
|||||||
imageLoader.enqueue(request)
|
imageLoader.enqueue(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDownloadedImage(context: Context, fileName: String): Bitmap? {
|
fun getDownloadedImage(context: Context, path: String): Bitmap? {
|
||||||
val file = File(
|
val file = File(path)
|
||||||
DownloadHelper.getDownloadDir(context, DownloadHelper.THUMBNAIL_DIR),
|
|
||||||
fileName
|
|
||||||
)
|
|
||||||
if (!file.exists()) return null
|
if (!file.exists()) return null
|
||||||
return getImage(context, Uri.fromFile(file))
|
return getImage(context, Uri.fromFile(file))
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,11 @@ object TextUtils {
|
|||||||
*/
|
*/
|
||||||
const val EMAIL_REGEX = "^[A-Za-z](.*)([@]{1})(.{1,})(\\.)(.{1,})"
|
const val EMAIL_REGEX = "^[A-Za-z](.*)([@]{1})(.{1,})(\\.)(.{1,})"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserved characters by unix which can not be used for file name.
|
||||||
|
*/
|
||||||
|
const val RESERVED_CHARS = "?:\"*|/\\<>\u0000"
|
||||||
|
|
||||||
fun toTwoDecimalsString(num: Int): String {
|
fun toTwoDecimalsString(num: Int): String {
|
||||||
return if (num >= 10) num.toString() else "0$num"
|
return if (num >= 10) num.toString() else "0$num"
|
||||||
}
|
}
|
||||||
|
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:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
android:tint="?attr/colorControlNormal"
|
android:tint="?attr/colorControlNormal"
|
||||||
android:viewportWidth="400"
|
android:viewportWidth="24"
|
||||||
android:viewportHeight="400">
|
android:viewportHeight="24">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FF000000"
|
android:fillColor="@android:color/white"
|
||||||
android:pathData="M200,15.89C98.47,15.89 15.89,98.48 15.89,200 15.89,301.52 98.47,384.11 200,384.11 301.52,384.11 384.11,301.52 384.11,200 384.11,98.48 301.52,15.89 200,15.89ZM200,359.97C111.79,359.97 40.03,288.2 40.03,200c0,-88.21 71.76,-159.96 159.96,-159.96 88.21,0 159.97,71.76 159.97,159.96 0,88.2 -71.76,159.97 -159.97,159.97z"
|
android:pathData="M20.13,5.41l-1.41,-1.41l-9.19,9.19l-4.25,-4.24l-1.41,1.41l5.66,5.66z" />
|
||||||
android:strokeWidth="27"
|
|
||||||
android:strokeColor="#000000" />
|
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FF000000"
|
android:fillColor="@android:color/white"
|
||||||
android:pathData="m266.65,139.73 l-94.94,94.93 -38.38,-38.37c-4.72,-4.71 -12.36,-4.71 -17.07,0 -4.72,4.72 -4.72,12.36 0,17.07l46.91,46.91c2.36,2.35 5.45,3.53 8.53,3.53 3.09,0 6.19,-1.18 8.54,-3.54 0.01,-0.01 0.01,-0.02 0.02,-0.03L283.73,156.8c4.72,-4.71 4.72,-12.36 0,-17.07 -4.72,-4.72 -12.36,-4.72 -17.07,0z"
|
android:pathData="M5,18h14v2h-14z" />
|
||||||
android:strokeWidth="27"
|
|
||||||
android:strokeColor="#000000" />
|
|
||||||
</vector>
|
</vector>
|
||||||
|
@ -58,6 +58,12 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginVertical="10dp" />
|
android:layout_marginVertical="10dp" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/subtitle_spinner"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginVertical="10dp" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/download"
|
android:id="@+id/download"
|
||||||
style="@style/CustomDialogButton"
|
style="@style/CustomDialogButton"
|
||||||
|
@ -23,6 +23,34 @@
|
|||||||
android:scaleType="fitXY"
|
android:scaleType="fitXY"
|
||||||
tools:src="@tools:sample/backgrounds/scenic" />
|
tools:src="@tools:sample/backgrounds/scenic" />
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:id="@+id/downloadOverlay"
|
||||||
|
android:layout_width="140dp"
|
||||||
|
android:layout_height="80dp"
|
||||||
|
android:background="#BF000000">
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressBar"
|
||||||
|
android:layout_width="52dp"
|
||||||
|
android:layout_height="52dp"
|
||||||
|
android:indeterminateOnly="false"
|
||||||
|
android:progressDrawable="@drawable/circular_progress"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/resumePauseBtn"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:src="@drawable/ic_download"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/progressBar"
|
||||||
|
app:layout_constraintLeft_toLeftOf="@id/progressBar"
|
||||||
|
app:layout_constraintRight_toRightOf="@id/progressBar"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/progressBar" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
@ -33,7 +61,7 @@
|
|||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/fileName"
|
android:id="@+id/title"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginVertical="2dp"
|
android:layout_marginVertical="2dp"
|
||||||
@ -58,8 +86,9 @@
|
|||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/fileSize"
|
android:id="@+id/fileSize"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="64dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center" />
|
android:layout_gravity="bottom"
|
||||||
|
android:textSize="13sp"/>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
@ -182,9 +182,17 @@
|
|||||||
<string name="playerVideoFormat">Video format for player</string>
|
<string name="playerVideoFormat">Video format for player</string>
|
||||||
<string name="no_audio">No audio</string>
|
<string name="no_audio">No audio</string>
|
||||||
<string name="no_video">No video</string>
|
<string name="no_video">No video</string>
|
||||||
|
<string name="no_subtitle">No subtitle</string>
|
||||||
<string name="audio">Audio</string>
|
<string name="audio">Audio</string>
|
||||||
<string name="video">Video</string>
|
<string name="video">Video</string>
|
||||||
<string name="downloading">Downloading…</string>
|
<string name="downloading">Downloading…</string>
|
||||||
|
<string name="download_paused">Download paused</string>
|
||||||
|
<string name="download_completed">Download completed</string>
|
||||||
|
<string name="concurrent_downloads">Max concurrent downloads</string>
|
||||||
|
<string name="concurrent_downloads_limit_reached">Max concurrent downloads limit reached.</string>
|
||||||
|
<string name="unknown">Unknown</string>
|
||||||
|
<string name="pause">Pause</string>
|
||||||
|
<string name="resume">Resume</string>
|
||||||
<string name="player_autoplay">Autoplay</string>
|
<string name="player_autoplay">Autoplay</string>
|
||||||
<string name="hideTrendingPage">Hide trending page</string>
|
<string name="hideTrendingPage">Hide trending page</string>
|
||||||
<string name="instance_frontend_url">URL to instance frontend</string>
|
<string name="instance_frontend_url">URL to instance frontend</string>
|
||||||
|
@ -13,6 +13,15 @@
|
|||||||
app:key="data_saver_mode_key"
|
app:key="data_saver_mode_key"
|
||||||
app:title="@string/data_saver_mode" />
|
app:title="@string/data_saver_mode" />
|
||||||
|
|
||||||
|
<com.github.libretube.ui.views.SliderPreference
|
||||||
|
android:icon="@drawable/ic_download"
|
||||||
|
android:key="max_concurrent_downloads"
|
||||||
|
android:title="@string/concurrent_downloads"
|
||||||
|
app:defValue="6"
|
||||||
|
app:stepSize="1"
|
||||||
|
app:valueFrom="1"
|
||||||
|
app:valueTo="20" />
|
||||||
|
|
||||||
<ListPreference
|
<ListPreference
|
||||||
android:entries="@array/cacheSize"
|
android:entries="@array/cacheSize"
|
||||||
android:entryValues="@array/cacheSizeValues"
|
android:entryValues="@array/cacheSizeValues"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user