diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 57715853f..4ca8e00c8 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.ksp)
alias(libs.plugins.kotlin.parcelize)
+ alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.detekt)
alias(libs.plugins.android.junit5)
@@ -108,6 +109,7 @@ dependencies {
// Kotlin
implementation(libs.bundles.coroutines)
+ implementation(libs.kotlin.serialization.json)
// Core
implementation(libs.bundles.koin)
@@ -141,6 +143,7 @@ dependencies {
}
}
implementation(libs.okhttp)
+ implementation(libs.okio)
implementation(libs.coil)
implementation(libs.cronet.embedded)
@@ -157,6 +160,7 @@ dependencies {
// Room
implementation(libs.bundles.androidx.room)
+ implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
// Monitoring
diff --git a/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/2.json b/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/2.json
index 24946ccca..3be8c458d 100644
--- a/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/2.json
+++ b/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/2.json
@@ -28,10 +28,10 @@
}
],
"primaryKey": {
+ "autoGenerate": true,
"columnNames": [
"id"
- ],
- "autoGenerate": true
+ ]
},
"indices": [
{
@@ -82,10 +82,10 @@
}
],
"primaryKey": {
+ "autoGenerate": true,
"columnNames": [
"id"
- ],
- "autoGenerate": true
+ ]
},
"indices": [
{
diff --git a/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/3.json b/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/3.json
new file mode 100644
index 000000000..fcbf09077
--- /dev/null
+++ b/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/3.json
@@ -0,0 +1,159 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 3,
+ "identityHash": "26b179bda28d76008389cab4ce8cb631",
+ "entities": [
+ {
+ "tableName": "Server",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hostname` TEXT NOT NULL, `last_used_timestamp` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hostname",
+ "columnName": "hostname",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUsedTimestamp",
+ "columnName": "last_used_timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_Server_hostname",
+ "unique": true,
+ "columnNames": [
+ "hostname"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Server_hostname` ON `${TABLE_NAME}` (`hostname`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "User",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `server_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `access_token` TEXT, `last_login_timestamp` INTEGER NOT NULL, FOREIGN KEY(`server_id`) REFERENCES `Server`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "server_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accessToken",
+ "columnName": "access_token",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "lastLoginTimestamp",
+ "columnName": "last_login_timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_User_server_id_user_id",
+ "unique": true,
+ "columnNames": [
+ "server_id",
+ "user_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_User_server_id_user_id` ON `${TABLE_NAME}` (`server_id`, `user_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "Server",
+ "onDelete": "NO ACTION",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "server_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "Download",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`item_id` TEXT NOT NULL, `media_source` TEXT NOT NULL, PRIMARY KEY(`item_id`))",
+ "fields": [
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mediaSource",
+ "columnName": "media_source",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "item_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_Download_item_id",
+ "unique": true,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Download_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ }
+ ],
+ "foreignKeys": []
+ }
+ ],
+ "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, '26b179bda28d76008389cab4ce8cb631')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 39533e909..13076208b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -13,9 +13,11 @@
android:required="false"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
+
+
+
+
@@ -84,6 +88,17 @@
+
+
+
+
+
+
+
+
+
{
+ single {
+ val dbProvider = StandaloneDatabaseProvider(get())
+ dbProvider
+ }
+ single {
+ val downloadPath = File(get().filesDir, Constants.DOWNLOAD_PATH)
+ if (!downloadPath.exists()) {
+ downloadPath.mkdirs()
+ }
+ val cache = SimpleCache(downloadPath, NoOpCacheEvictor(), get())
+ cache
+ }
+
+ single {
val context: Context = get()
val provider = CronetProvider.getAllProviders(context).firstOrNull { provider: CronetProvider ->
@@ -104,6 +126,31 @@ val applicationModule = module {
DefaultDataSource.Factory(context, baseDataSourceFactory)
}
+
+ single {
+ // Create a read-only cache data source factory using the download cache.
+ CacheDataSource.Factory()
+ .setCache(get())
+ .setUpstreamDataSourceFactory(get())
+ .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
+ .setCacheWriteDataSinkFactory(null)
+ .setCacheKeyFactory { spec ->
+ val uri = spec.uri.toString()
+ val idRegex = Regex("""/([a-f0-9]{32}|[a-f0-9-]{36})/""")
+ val idResult = idRegex.find(uri)
+ val itemId = idResult?.groups?.get(1)?.value.toString()
+ var item = itemId.toUUID().toString()
+
+ val subtitleRegex = Regex("""Subtitles/(\d+)/\d+/Stream.subrip|/(\d+).subrip""")
+ val subtitleResult = subtitleRegex.find(uri)
+ if (subtitleResult != null) {
+ item += ":${subtitleResult.groups[1]?.value ?: subtitleResult.groups[2]?.value}"
+ }
+
+ item
+ }
+ }
+
single {
val context: Context = get()
val extractorsFactory = DefaultExtractorsFactory().apply {
@@ -115,11 +162,11 @@ val applicationModule = module {
},
)
}
- DefaultMediaSourceFactory(get(), extractorsFactory)
+ DefaultMediaSourceFactory(get(), extractorsFactory)
}
- single { ProgressiveMediaSource.Factory(get()) }
- single { HlsMediaSource.Factory(get()) }
- single { SingleSampleMediaSource.Factory(get()) }
+ single { ProgressiveMediaSource.Factory(get()) }
+ single { HlsMediaSource.Factory(get()) }
+ single { SingleSampleMediaSource.Factory(get()) }
// Media components
single { LibraryBrowser(get(), get()) }
diff --git a/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt b/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt
index e4d02a523..69504a710 100644
--- a/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt
+++ b/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt
@@ -90,6 +90,16 @@ class AppPreferences(context: Context) {
}
}
+ var downloadToInternal: Boolean?
+ get() = sharedPreferences.getBoolean(Constants.PREF_DOWNLOAD_INTERNAL, true)
+ set(value) {
+ if (value != null) {
+ sharedPreferences.edit {
+ putBoolean(Constants.PREF_DOWNLOAD_METHOD, value)
+ }
+ }
+ }
+
val musicNotificationAlwaysDismissible: Boolean
get() = sharedPreferences.getBoolean(Constants.PREF_MUSIC_NOTIFICATION_ALWAYS_DISMISSIBLE, false)
diff --git a/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt b/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt
index 9b70aa3fc..15685ab36 100644
--- a/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt
+++ b/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt
@@ -149,6 +149,11 @@ class NativeInterface(private val context: Context) : KoinComponent {
emitEvent(ActivityEvent.OpenSettings)
}
+ @JavascriptInterface
+ fun openDownloads() {
+ emitEvent(ActivityEvent.OpenDownloads)
+ }
+
@JavascriptInterface
fun openServerSelection() {
emitEvent(ActivityEvent.SelectServer)
diff --git a/app/src/main/java/org/jellyfin/mobile/data/DatabaseModule.kt b/app/src/main/java/org/jellyfin/mobile/data/DatabaseModule.kt
index 0b44e3c56..1e6a46e71 100644
--- a/app/src/main/java/org/jellyfin/mobile/data/DatabaseModule.kt
+++ b/app/src/main/java/org/jellyfin/mobile/data/DatabaseModule.kt
@@ -9,9 +9,11 @@ val databaseModule = module {
Room.databaseBuilder(androidApplication(), JellyfinDatabase::class.java, "jellyfin")
.addMigrations()
.fallbackToDestructiveMigrationFrom(1)
+ .fallbackToDestructiveMigrationFrom(2)
.fallbackToDestructiveMigrationOnDowngrade()
.build()
}
single { get().serverDao }
single { get().userDao }
+ single { get().downloadDao }
}
diff --git a/app/src/main/java/org/jellyfin/mobile/data/JellyfinDatabase.kt b/app/src/main/java/org/jellyfin/mobile/data/JellyfinDatabase.kt
index 660f777b9..81864820f 100644
--- a/app/src/main/java/org/jellyfin/mobile/data/JellyfinDatabase.kt
+++ b/app/src/main/java/org/jellyfin/mobile/data/JellyfinDatabase.kt
@@ -2,13 +2,16 @@ package org.jellyfin.mobile.data
import androidx.room.Database
import androidx.room.RoomDatabase
+import org.jellyfin.mobile.data.dao.DownloadDao
import org.jellyfin.mobile.data.dao.ServerDao
import org.jellyfin.mobile.data.dao.UserDao
+import org.jellyfin.mobile.data.entity.DownloadEntity
import org.jellyfin.mobile.data.entity.ServerEntity
import org.jellyfin.mobile.data.entity.UserEntity
-@Database(entities = [ServerEntity::class, UserEntity::class], version = 2)
+@Database(entities = [ServerEntity::class, UserEntity::class, DownloadEntity::class], version = 3)
abstract class JellyfinDatabase : RoomDatabase() {
abstract val serverDao: ServerDao
abstract val userDao: UserDao
+ abstract val downloadDao: DownloadDao
}
diff --git a/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt b/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt
new file mode 100644
index 000000000..8bd926019
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt
@@ -0,0 +1,27 @@
+package org.jellyfin.mobile.data.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+import org.jellyfin.mobile.data.entity.DownloadEntity
+import org.jellyfin.mobile.data.entity.DownloadEntity.Key.TABLE_NAME
+
+@Dao
+interface DownloadDao {
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(entity: DownloadEntity): Long
+
+ @Query("DELETE FROM $TABLE_NAME WHERE item_id LIKE :downloadId")
+ suspend fun delete(downloadId: String)
+
+ @Query("SELECT * FROM $TABLE_NAME ORDER BY item_id DESC")
+ fun getAllDownloads(): Flow>
+
+ @Query("SELECT * FROM $TABLE_NAME WHERE item_id LIKE :downloadId")
+ suspend fun get(downloadId: String): DownloadEntity?
+
+ @Query("SELECT EXISTS(SELECT * FROM $TABLE_NAME WHERE item_id LIKE :downloadId)")
+ suspend fun downloadExists(downloadId: String): Boolean
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt b/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt
new file mode 100644
index 000000000..40411e1a8
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt
@@ -0,0 +1,88 @@
+package org.jellyfin.mobile.data.entity
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Ignore
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import androidx.room.TypeConverter
+import androidx.room.TypeConverters
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.Json.Default.decodeFromString
+import org.jellyfin.mobile.data.entity.DownloadEntity.Key.ITEM_ID
+import org.jellyfin.mobile.data.entity.DownloadEntity.Key.TABLE_NAME
+import org.jellyfin.mobile.player.source.LocalJellyfinMediaSource
+import org.jellyfin.mobile.utils.Constants
+import org.jellyfin.mobile.utils.extensions.toFileSize
+import java.io.File
+
+@Entity(
+ tableName = TABLE_NAME,
+ indices = [
+ Index(value = [ITEM_ID], unique = true),
+ ],
+)
+@TypeConverters(LocalJellyfinMediaSourceConverter::class)
+data class DownloadEntity(
+ @PrimaryKey
+ @ColumnInfo(name = ITEM_ID)
+ val itemId: String,
+ @ColumnInfo(name = MEDIA_SOURCE)
+ val mediaSource: LocalJellyfinMediaSource,
+) {
+ /**
+ * Converts the [mediaSource] string to a [LocalJellyfinMediaSource] object.
+ *
+ * @param startTimeMs The start time in milliseconds. If null, the default start time is used.
+ * @param audioStreamIndex The index of the audio stream to select. If null, the default audio stream is used.
+ * @param subtitleStreamIndex The index of the subtitle stream to select. If -1, subtitles are disabled. If null, the default subtitle stream is used.
+ */
+ fun asMediaSource(
+ startTimeMs: Long? = null,
+ audioStreamIndex: Int? = null,
+ subtitleStreamIndex: Int? = null,
+ ): LocalJellyfinMediaSource = mediaSource
+ .also { localJellyfinMediaSource ->
+ startTimeMs
+ ?.let { localJellyfinMediaSource.startTimeMs = it }
+ audioStreamIndex
+ ?.let { localJellyfinMediaSource.mediaStreams[it] }
+ ?.let(localJellyfinMediaSource::selectAudioStream)
+ subtitleStreamIndex
+ ?.run {
+ takeUnless { it == -1 }
+ ?.let { localJellyfinMediaSource.mediaStreams[it] }
+ ?: localJellyfinMediaSource.selectSubtitleStream(null)
+ }
+ }
+
+ constructor(mediaSource: LocalJellyfinMediaSource) :
+ this(mediaSource.id, mediaSource)
+
+ @Ignore
+ val thumbnail: Bitmap? = BitmapFactory.decodeFile(
+ File(mediaSource.localDirectoryUri, Constants.DOWNLOAD_THUMBNAIL_FILENAME).canonicalPath,
+ )
+
+ @Ignore
+ val fileSize: String = mediaSource.downloadSize.toFileSize()
+
+ companion object Key {
+ const val BYTES_PER_BINARY_UNIT: Int = 1024
+ const val TABLE_NAME: String = "Download"
+ const val ID: String = "id"
+ const val ITEM_ID: String = "item_id"
+ const val MEDIA_SOURCE: String = "media_source"
+ }
+}
+
+class LocalJellyfinMediaSourceConverter {
+ @TypeConverter
+ fun toLocalJellyfinMediaSource(value: String): LocalJellyfinMediaSource = decodeFromString(value)
+
+ @TypeConverter
+ fun fromLocalJellyfinMediaSource(value: LocalJellyfinMediaSource): String = Json.encodeToString(value)
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadDiffCallback.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadDiffCallback.kt
new file mode 100644
index 000000000..d9c0d0eb7
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadDiffCallback.kt
@@ -0,0 +1,14 @@
+package org.jellyfin.mobile.downloads
+
+import androidx.recyclerview.widget.DiffUtil
+import org.jellyfin.mobile.data.entity.DownloadEntity
+
+class DownloadDiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: DownloadEntity, newItem: DownloadEntity): Boolean {
+ return oldItem.itemId == newItem.itemId
+ }
+
+ override fun areContentsTheSame(oldItem: DownloadEntity, newItem: DownloadEntity): Boolean {
+ return oldItem == newItem
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadMethod.java b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadMethod.java
new file mode 100644
index 000000000..d40ec6dcb
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadMethod.java
@@ -0,0 +1,14 @@
+package org.jellyfin.mobile.downloads;
+
+import static org.jellyfin.mobile.downloads.DownloadMethod.MOBILE_AND_ROAMING;
+import static org.jellyfin.mobile.downloads.DownloadMethod.MOBILE_DATA;
+import static org.jellyfin.mobile.downloads.DownloadMethod.WIFI_ONLY;
+
+import androidx.annotation.IntDef;
+
+@IntDef({WIFI_ONLY, MOBILE_DATA, MOBILE_AND_ROAMING})
+public @interface DownloadMethod {
+ int WIFI_ONLY = 0;
+ int MOBILE_DATA = 1;
+ int MOBILE_AND_ROAMING = 2;
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt
new file mode 100644
index 000000000..23601dd27
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt
@@ -0,0 +1,64 @@
+package org.jellyfin.mobile.downloads
+
+import android.content.Context
+import com.google.android.exoplayer2.database.DatabaseProvider
+import com.google.android.exoplayer2.offline.DefaultDownloadIndex
+import com.google.android.exoplayer2.offline.DefaultDownloaderFactory
+import com.google.android.exoplayer2.offline.DownloadManager
+import com.google.android.exoplayer2.ui.DownloadNotificationHelper
+import com.google.android.exoplayer2.upstream.cache.CacheDataSource
+import org.jellyfin.mobile.utils.Constants.DOWNLOAD_NOTIFICATION_CHANNEL_ID
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import java.util.concurrent.Executors
+
+object DownloadServiceUtil : KoinComponent {
+ private const val DOWNLOAD_THREADS = 6
+
+ private val context: Context by inject()
+ private val databaseProvider: DatabaseProvider by inject()
+ private val downloadDataCache: CacheDataSource.Factory by inject()
+ private var downloadManager: DownloadManager? = null
+ private var downloadNotificationHelper: DownloadNotificationHelper? = null
+ private var downloadTracker: DownloadTracker? = null
+
+ @Synchronized
+ fun getDownloadNotificationHelper(
+ context: Context?,
+ ): DownloadNotificationHelper {
+ if (downloadNotificationHelper == null) {
+ downloadNotificationHelper =
+ DownloadNotificationHelper(context!!, DOWNLOAD_NOTIFICATION_CHANNEL_ID)
+ }
+ return downloadNotificationHelper!!
+ }
+
+ @Synchronized
+ fun getDownloadManager(): DownloadManager {
+ ensureDownloadManagerInitialized(context)
+ return downloadManager!!
+ }
+
+ @Synchronized
+ fun getDownloadTracker(): DownloadTracker {
+ ensureDownloadManagerInitialized(context)
+ return downloadTracker!!
+ }
+
+ @Synchronized
+ private fun ensureDownloadManagerInitialized(context: Context) {
+ if (downloadManager == null) {
+ downloadManager =
+ DownloadManager(
+ context,
+ DefaultDownloadIndex(databaseProvider),
+ DefaultDownloaderFactory(
+ downloadDataCache,
+ Executors.newFixedThreadPool(DOWNLOAD_THREADS),
+ ),
+ )
+ downloadTracker =
+ DownloadTracker(downloadManager!!)
+ }
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt
new file mode 100644
index 000000000..acf3bb2ac
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt
@@ -0,0 +1,78 @@
+package org.jellyfin.mobile.downloads
+
+import android.net.Uri
+import com.google.android.exoplayer2.offline.Download
+import com.google.android.exoplayer2.offline.DownloadIndex
+import com.google.android.exoplayer2.offline.DownloadManager
+import com.google.common.base.Preconditions
+import timber.log.Timber
+import java.io.IOException
+import java.util.concurrent.CopyOnWriteArraySet
+
+class DownloadTracker(downloadManager: DownloadManager) {
+ interface Listener {
+ fun onDownloadsChanged()
+ }
+
+ private val listeners: CopyOnWriteArraySet = CopyOnWriteArraySet()
+ private val downloads: HashMap = HashMap()
+ private val downloadIndex: DownloadIndex
+
+ init {
+ downloadIndex = downloadManager.downloadIndex
+ downloadManager.addListener(DownloadManagerListener())
+ loadDownloads()
+ }
+
+ fun addListener(listener: Listener?) {
+ listeners.add(Preconditions.checkNotNull(listener))
+ }
+
+ fun removeListener(listener: Listener) {
+ listeners.remove(listener)
+ }
+
+ fun isDownloaded(uri: Uri): Boolean {
+ val download = downloads[uri]
+ return download != null && download.state == Download.STATE_COMPLETED
+ }
+
+ fun getDownloadSize(uri: Uri): Long {
+ val download = downloads[uri]
+ return download?.bytesDownloaded ?: 0
+ }
+
+ fun isFailed(uri: Uri): Boolean {
+ val download = downloads[uri]
+ return download != null && download.state == Download.STATE_FAILED
+ }
+
+ private fun loadDownloads() {
+ try {
+ downloadIndex.getDownloads().use { loadedDownloads ->
+ while (loadedDownloads.moveToNext()) {
+ val download = loadedDownloads.download
+ downloads[download.request.uri] = download
+ }
+ }
+ } catch (e: IOException) {
+ Timber.e(e, "Failed to load downloads")
+ }
+ }
+
+ private inner class DownloadManagerListener : DownloadManager.Listener {
+ override fun onDownloadChanged(downloadManager: DownloadManager, download: Download, finalException: Exception?) {
+ downloads[download.request.uri] = download
+ for (listener in listeners) {
+ listener.onDownloadsChanged()
+ }
+ }
+
+ override fun onDownloadRemoved(downloadManager: DownloadManager, download: Download) {
+ downloads.remove(download.request.uri)
+ for (listener in listeners) {
+ listener.onDownloadsChanged()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt
new file mode 100644
index 000000000..d16f23bc6
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt
@@ -0,0 +1,308 @@
+package org.jellyfin.mobile.downloads
+
+import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
+import android.annotation.SuppressLint
+import android.app.DownloadManager
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.content.pm.PackageManager.PERMISSION_GRANTED
+import android.graphics.Bitmap
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.Uri
+import android.os.Build
+import android.os.Build.VERSION_CODES.P
+import android.util.AndroidException
+import androidx.annotation.RequiresApi
+import androidx.core.content.getSystemService
+import androidx.core.graphics.drawable.toBitmap
+import androidx.core.net.toUri
+import coil.ImageLoader
+import coil.request.ImageRequest
+import com.google.android.exoplayer2.offline.DownloadRequest
+import com.google.android.exoplayer2.offline.DownloadService
+import com.google.android.exoplayer2.scheduler.Requirements
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+import okio.buffer
+import okio.sink
+import org.jellyfin.mobile.MainActivity
+import org.jellyfin.mobile.R
+import org.jellyfin.mobile.app.AppPreferences
+import org.jellyfin.mobile.data.dao.DownloadDao
+import org.jellyfin.mobile.data.entity.DownloadEntity
+import org.jellyfin.mobile.downloads.DownloadServiceUtil.getDownloadTracker
+import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
+import org.jellyfin.mobile.player.source.JellyfinMediaSource
+import org.jellyfin.mobile.player.source.LocalJellyfinMediaSource
+import org.jellyfin.mobile.player.source.MediaSourceResolver
+import org.jellyfin.mobile.utils.AndroidVersion
+import org.jellyfin.mobile.utils.Constants
+import org.jellyfin.mobile.utils.requestPermission
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.imageApi
+import org.jellyfin.sdk.model.UUID
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.ImageType
+import org.jellyfin.sdk.model.serializer.toUUID
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.get
+import org.koin.core.component.inject
+import java.io.File
+import java.io.IOException
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+
+class DownloadUtils(
+ val context: Context,
+ private val filename: String,
+ private val downloadURL: String,
+ private val downloadMethod: Int,
+) : KoinComponent {
+ private val mainActivity: MainActivity = context as MainActivity
+ private val downloadFolder: File
+ private val itemId: String
+ private val itemUUID: UUID
+ private val contentId: String
+ private val downloadDao: DownloadDao by inject()
+ private val apiClient: ApiClient = get()
+ private val imageLoader: ImageLoader by inject()
+ private val mediaSourceResolver: MediaSourceResolver by inject()
+ private val deviceProfileBuilder: DeviceProfileBuilder by inject()
+ private val deviceProfile = deviceProfileBuilder.getDeviceProfile()
+ private val notificationManager: NotificationManager? by lazy { context.getSystemService() }
+ private val connectivityManager: ConnectivityManager? by lazy { context.getSystemService() }
+ private val appPreferences: AppPreferences by inject()
+ private var downloadTracker: DownloadTracker = getDownloadTracker()
+
+ init {
+ val regex = Regex("""Items/([a-f0-9]{32})/Download""")
+ val matchResult = regex.find(downloadURL)
+ itemId = matchResult?.groups?.get(1)?.value.toString()
+ itemUUID = itemId.toUUID()
+ contentId = itemUUID.toString()
+ downloadFolder = File(context.filesDir, "/Downloads/$itemId/")
+ downloadFolder.mkdirs()
+ }
+
+ suspend fun download() {
+ createDownloadNotificationChannel()
+ checkForDownloadMethod()
+ val jellyfinMediaSource = retrieveJellyfinMediaSource()
+ val isDestinationInternal = jellyfinMediaSource.getIsDestinationInternal()
+ if (isDestinationInternal) {
+ if (checkIfDownloadExists(itemId)) {
+ removeDownloadRemains(jellyfinMediaSource)
+ } else {
+ downloadFiles(jellyfinMediaSource)
+ }
+ } else {
+ downloadExternalMediaFile(jellyfinMediaSource)
+ }
+ }
+
+ @SuppressLint("InlinedApi")
+ private fun checkForDownloadMethod() {
+ val validConnection = when (downloadMethod) {
+ DownloadMethod.WIFI_ONLY -> {
+ setUnmeteredRequirement()
+ !isNetworkMetered()
+ }
+ DownloadMethod.MOBILE_DATA -> {
+ if (Build.VERSION.SDK_INT < P) {
+ !isNetworkRoamingCompat()
+ } else {
+ !isNetworkRoaming()
+ }
+ }
+ else -> true
+ }
+
+ if (!validConnection) throw IOException(context.getString(R.string.failed_network_method_check))
+ }
+
+ private fun setUnmeteredRequirement() {
+ DownloadService.sendSetRequirements(
+ context,
+ JellyfinDownloadService::class.java,
+ Requirements(Requirements.NETWORK_UNMETERED),
+ false,
+ )
+ }
+
+ private fun isNetworkMetered(): Boolean = connectivityManager?.isActiveNetworkMetered ?: false
+
+ private fun isNetworkRoamingCompat(): Boolean = connectivityManager?.activeNetworkInfo?.isRoaming ?: throw AndroidException()
+
+ @RequiresApi(P)
+ private fun isNetworkRoaming(): Boolean {
+ val network: Network = connectivityManager?.activeNetwork ?: throw AndroidException()
+ val capabilities: NetworkCapabilities = connectivityManager?.getNetworkCapabilities(network) ?: throw AndroidException()
+ return !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING)
+ }
+
+ private suspend fun checkIfDownloadExists(itemId: String) = downloadDao.downloadExists(itemId)
+
+ private suspend fun retrieveJellyfinMediaSource() = mediaSourceResolver.resolveMediaSource(
+ itemId = itemUUID,
+ mediaSourceId = itemId,
+ deviceProfile = deviceProfile,
+ ).getOrElse { throw IOException(context.getString(R.string.failed_information)) }
+
+ // Only download shows and movies to internal storage
+ private fun JellyfinMediaSource.getIsDestinationInternal() =
+ appPreferences.downloadToInternal == true && item?.type in listOf(
+ BaseItemKind.EPISODE,
+ BaseItemKind.MOVIE,
+ BaseItemKind.VIDEO,
+ )
+
+ private suspend fun downloadFiles(jellyfinMediaSource: JellyfinMediaSource) {
+ val jellyfinDownloadTracker = JellyfinDownloadTracker(jellyfinMediaSource)
+ downloadTracker.addListener(jellyfinDownloadTracker)
+ downloadMediaFile(jellyfinMediaSource)
+ downloadThumbnail()
+ downloadExternalSubtitles(jellyfinMediaSource)
+ }
+
+ private fun downloadMediaFile(jellyfinMediaSource: JellyfinMediaSource) {
+ val downloadRequest = DownloadRequest.Builder(contentId, downloadURL.toUri())
+ .setData(jellyfinMediaSource.item!!.name!!.encodeToByteArray())
+ .build()
+ DownloadService.sendAddDownload(
+ context,
+ JellyfinDownloadService::class.java,
+ downloadRequest,
+ false,
+ )
+ }
+
+ private suspend fun downloadThumbnail() {
+ val size = context.resources.getDimensionPixelSize(R.dimen.media_notification_height)
+
+ val imageUrl = apiClient.imageApi.getItemImageUrl(
+ itemId = itemUUID,
+ imageType = ImageType.PRIMARY,
+ maxWidth = size,
+ maxHeight = size,
+ )
+ val imageRequest = ImageRequest.Builder(context).data(imageUrl).build()
+ val bitmap: Bitmap = imageLoader.execute(imageRequest).drawable?.toBitmap() ?: throw IOException(
+ context.getString(R.string.failed_thumbnail),
+ )
+
+ val thumbnailFile = File(downloadFolder, Constants.DOWNLOAD_THUMBNAIL_FILENAME)
+ val sink = thumbnailFile.sink().buffer()
+ bitmap.compress(Bitmap.CompressFormat.JPEG, THUMBNAIL_DOWNLOAD_QUALITY, sink.outputStream())
+ withContext(Dispatchers.IO) {
+ sink.close()
+ }
+ }
+
+ private fun downloadExternalSubtitles(jellyfinMediaSource: JellyfinMediaSource) {
+ jellyfinMediaSource.externalSubtitleStreams.forEach {
+ val subtitleDownloadURL: String = apiClient.createUrl(it.deliveryUrl)
+ val downloadRequest = DownloadRequest.Builder("$contentId:${it.index}", subtitleDownloadURL.toUri()).build()
+ DownloadService.sendAddDownload(
+ context,
+ JellyfinDownloadService::class.java,
+ downloadRequest,
+ false,
+ )
+ }
+ }
+
+ private suspend fun storeDownloadSpecs(jellyfinMediaSource: JellyfinMediaSource) =
+ LocalJellyfinMediaSource(
+ jellyfinMediaSource,
+ downloadFolder.canonicalPath,
+ downloadURL,
+ downloadTracker.getDownloadSize(downloadURL.toUri()),
+ ).also {
+ downloadDao.insert(DownloadEntity(it))
+ }
+
+ private suspend fun downloadExternalMediaFile(jellyfinMediaSource: JellyfinMediaSource) {
+ if (!AndroidVersion.isAtLeastQ) {
+ @Suppress("MagicNumber")
+ val granted = withTimeout(2 * 60 * 1000 /* 2 minutes */) {
+ suspendCoroutine { continuation ->
+ mainActivity.requestPermission(WRITE_EXTERNAL_STORAGE) { requestPermissionsResult ->
+ continuation.resume(requestPermissionsResult[WRITE_EXTERNAL_STORAGE] == PERMISSION_GRANTED)
+ }
+ }
+ }
+
+ if (!granted) {
+ throw IOException(context.getString(R.string.download_no_storage_permission))
+ }
+ }
+
+ val downloadRequest = DownloadManager.Request(downloadURL.toUri())
+ .setTitle(jellyfinMediaSource.name)
+ .setDescription(context.getString(R.string.downloading))
+ .setDestinationUri(Uri.fromFile(File(appPreferences.downloadLocation, filename)))
+ .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+
+ context.getSystemService()?.enqueue(downloadRequest)
+ }
+
+ private fun removeDownloadRemains(jellyfinMediaSource: JellyfinMediaSource) {
+ downloadFolder.deleteRecursively()
+
+ // Remove media file
+ DownloadService.sendRemoveDownload(
+ context,
+ JellyfinDownloadService::class.java,
+ contentId,
+ false,
+ )
+
+ // Remove subtitles
+ jellyfinMediaSource.externalSubtitleStreams.forEach {
+ DownloadService.sendRemoveDownload(
+ context,
+ JellyfinDownloadService::class.java,
+ "$contentId:${it.index}",
+ false,
+ )
+ }
+ }
+
+ private fun createDownloadNotificationChannel() {
+ if (AndroidVersion.isAtLeastO) {
+ val notificationChannel = NotificationChannel(
+ Constants.DOWNLOAD_NOTIFICATION_CHANNEL_ID,
+ context.getString(R.string.downloads),
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply {
+ description = context.getString(R.string.download_notifications_description)
+ }
+ notificationManager?.createNotificationChannel(notificationChannel)
+ }
+ }
+
+ private inner class JellyfinDownloadTracker(val jellyfinMediaSource: JellyfinMediaSource) : DownloadTracker.Listener {
+ override fun onDownloadsChanged() {
+ if (downloadTracker.isDownloaded(downloadURL.toUri())) {
+ runBlocking {
+ withContext(Dispatchers.IO) {
+ storeDownloadSpecs(jellyfinMediaSource)
+ }
+ }
+ downloadTracker.removeListener(this)
+ } else if (downloadTracker.isFailed(downloadURL.toUri())) {
+ removeDownloadRemains(jellyfinMediaSource)
+ downloadTracker.removeListener(this)
+ }
+ }
+ }
+
+ private companion object {
+ const val THUMBNAIL_DOWNLOAD_QUALITY = 80
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsAdapter.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsAdapter.kt
new file mode 100644
index 000000000..f258b9e54
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsAdapter.kt
@@ -0,0 +1,62 @@
+package org.jellyfin.mobile.downloads
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import org.jellyfin.mobile.R
+import org.jellyfin.mobile.data.entity.DownloadEntity
+import org.jellyfin.mobile.databinding.DownloadItemBinding
+import org.jellyfin.sdk.model.api.BaseItemDto
+
+class DownloadsAdapter(
+ private val onItemClick: (DownloadEntity) -> Unit,
+ private val onItemHold: (DownloadEntity) -> Unit,
+) : ListAdapter(
+ DownloadDiffCallback(),
+) {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadViewHolder {
+ val binding = DownloadItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return DownloadViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+
+ inner class DownloadViewHolder(private val binding: DownloadItemBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun bind(downloadEntity: DownloadEntity) {
+ if (downloadEntity.thumbnail == null) {
+ onItemHold(downloadEntity)
+ return
+ }
+
+ val context = itemView.context
+
+ val mediaItem: BaseItemDto? = downloadEntity.mediaSource.item
+ binding.textViewName.text = downloadEntity.mediaSource.name
+ binding.textViewDescription.text = when {
+ mediaItem?.seriesName != null -> context.getString(
+ R.string.tv_show_desc,
+ mediaItem.seriesName,
+ mediaItem.parentIndexNumber,
+ mediaItem.indexNumber,
+ )
+ mediaItem?.productionYear != null -> mediaItem.productionYear.toString()
+ else -> downloadEntity.mediaSource.id
+ }
+ binding.textViewFileSize.text = downloadEntity.fileSize
+
+ itemView.setOnClickListener {
+ onItemClick(downloadEntity)
+ }
+ itemView.setOnLongClickListener {
+ onItemHold(downloadEntity)
+ true
+ }
+
+ binding.imageViewThumbnail.setImageBitmap(downloadEntity.thumbnail)
+ }
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsFragment.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsFragment.kt
new file mode 100644
index 000000000..5d8aef057
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsFragment.kt
@@ -0,0 +1,74 @@
+package org.jellyfin.mobile.downloads
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import kotlinx.coroutines.launch
+import org.jellyfin.mobile.R
+import org.jellyfin.mobile.data.entity.DownloadEntity
+import org.jellyfin.mobile.databinding.FragmentDownloadsBinding
+import org.jellyfin.mobile.events.ActivityEvent
+import org.jellyfin.mobile.events.ActivityEventHandler
+import org.jellyfin.mobile.player.interaction.PlayOptions
+import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins
+import org.jellyfin.mobile.utils.extensions.requireMainActivity
+import org.jellyfin.mobile.utils.withThemedContext
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+
+class DownloadsFragment : Fragment(), KoinComponent {
+ private val viewModel: DownloadsViewModel by inject()
+ private val activityEventHandler: ActivityEventHandler by inject()
+ private lateinit var adapter: DownloadsAdapter
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ val localInflater = inflater.withThemedContext(requireContext(), R.style.AppTheme_Settings)
+ val binding = FragmentDownloadsBinding.inflate(localInflater, container, false)
+ binding.root.applyWindowInsetsAsMargins()
+ binding.toolbar.setTitle(R.string.downloads)
+
+ requireMainActivity().apply {
+ setSupportActionBar(binding.toolbar)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ }
+
+ adapter = DownloadsAdapter(
+ onItemClick = { download -> onDownloadItemClick(download) },
+ onItemHold = { download -> onDownloadItemHold(download) },
+ )
+ binding.recyclerView.adapter = adapter
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.downloads.collect { downloads ->
+ adapter.submitList(downloads)
+ }
+ }
+ }
+
+ return binding.root
+ }
+
+ private fun onDownloadItemClick(download: DownloadEntity) {
+ val playOptions = PlayOptions(
+ ids = listOf(download.mediaSource.itemId),
+ mediaSourceId = download.mediaSource.id,
+ startIndex = 0,
+ startPositionTicks = null,
+ audioStreamIndex = 1,
+ subtitleStreamIndex = -1,
+ playFromDownloads = true,
+ )
+ activityEventHandler.emit(ActivityEvent.LaunchNativePlayer(playOptions))
+ }
+
+ private fun onDownloadItemHold(download: DownloadEntity) {
+ val itemMissing = download.thumbnail == null
+ activityEventHandler.emit(ActivityEvent.RemoveDownload(download.mediaSource, force = itemMissing))
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsViewModel.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsViewModel.kt
new file mode 100644
index 000000000..ee76aed8c
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsViewModel.kt
@@ -0,0 +1,37 @@
+package org.jellyfin.mobile.downloads
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.jellyfin.mobile.data.dao.DownloadDao
+import org.jellyfin.mobile.data.entity.DownloadEntity
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+
+class DownloadsViewModel : ViewModel(), KoinComponent {
+
+ private val downloadDao: DownloadDao by inject()
+
+ // This is a mutable state flow that will be used internally in the viewmodel, empty list is given as initial value.
+ private val _downloads = MutableStateFlow(emptyList())
+
+ // Immutable state flow that you expose to your UI
+ val downloads = _downloads.asStateFlow()
+
+ init {
+ getAllDownloads()
+ }
+
+ private fun getAllDownloads() {
+ viewModelScope.launch { // this: CoroutineScope
+ downloadDao.getAllDownloads().flowOn(Dispatchers.IO).collect { downloads: List ->
+ _downloads.update { downloads }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt b/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt
new file mode 100644
index 000000000..1b335b830
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt
@@ -0,0 +1,93 @@
+package org.jellyfin.mobile.downloads
+
+import android.app.Notification
+import android.content.Context
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import com.google.android.exoplayer2.offline.Download
+import com.google.android.exoplayer2.offline.DownloadManager
+import com.google.android.exoplayer2.offline.DownloadService
+import com.google.android.exoplayer2.scheduler.PlatformScheduler
+import com.google.android.exoplayer2.scheduler.Scheduler
+import com.google.android.exoplayer2.ui.DownloadNotificationHelper
+import com.google.android.exoplayer2.util.NotificationUtil
+import com.google.android.exoplayer2.util.Util
+import org.jellyfin.mobile.R
+import org.jellyfin.mobile.utils.Constants
+import org.jellyfin.mobile.utils.extensions.toFileSize
+
+class JellyfinDownloadService : DownloadService(
+ Constants.DOWNLOAD_NOTIFICATION_ID,
+ DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
+) {
+ private val jobId = 1
+
+ override fun getDownloadManager(): DownloadManager {
+ val downloadManager: DownloadManager = DownloadServiceUtil.getDownloadManager()
+ val downloadNotificationHelper: DownloadNotificationHelper =
+ DownloadServiceUtil.getDownloadNotificationHelper(this)
+ downloadManager.addListener(
+ TerminalStateNotificationHelper(
+ this,
+ downloadNotificationHelper,
+ Constants.DOWNLOAD_NOTIFICATION_ID + 1,
+ ),
+ )
+ return downloadManager
+ }
+
+ override fun getScheduler(): Scheduler? {
+ return if (Util.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) PlatformScheduler(this, jobId) else null
+ }
+
+ @Suppress("MagicNumber")
+ override fun getForegroundNotification(downloads: MutableList, notMetRequirements: Int): Notification =
+ NotificationCompat.Builder(this, Constants.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(getString(R.string.downloading))
+ .setContentInfo(getString(R.string.downloading_desc, downloads.size))
+ .setOngoing(true)
+ .setProgress(100, downloads.map { it.percentDownloaded }.average().toInt(), false)
+ .build()
+
+ private class TerminalStateNotificationHelper(
+ context: Context,
+ private val notificationHelper: DownloadNotificationHelper,
+ private var nextNotificationId: Int,
+ ) : DownloadManager.Listener {
+ private val context: Context = context.applicationContext
+
+ override fun onDownloadChanged(
+ downloadManager: DownloadManager,
+ download: Download,
+ finalException: Exception?,
+ ) {
+ if (download.request.data.isEmpty()) {
+ // Do not display download complete notification for external subtitles
+ // Can be identified by request data being empty
+ return
+ }
+ val notification = when (download.state) {
+ Download.STATE_COMPLETED -> {
+ NotificationCompat.Builder(context, Constants.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(
+ context.getString(R.string.downloaded, Util.fromUtf8Bytes(download.request.data)),
+ )
+ .setContentInfo(download.bytesDownloaded.toFileSize())
+ .build()
+ }
+ Download.STATE_FAILED -> {
+ notificationHelper.buildDownloadFailedNotification(
+ context,
+ R.drawable.ic_notification,
+ null,
+ Util.fromUtf8Bytes(download.request.data),
+ )
+ }
+ else -> return
+ }
+ NotificationUtil.setNotification(context, nextNotificationId++, notification)
+ }
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt b/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt
index e5a9ec5ca..eccd69c12 100644
--- a/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt
+++ b/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt
@@ -2,6 +2,7 @@ package org.jellyfin.mobile.events
import android.net.Uri
import org.jellyfin.mobile.player.interaction.PlayOptions
+import org.jellyfin.mobile.player.source.LocalJellyfinMediaSource
import org.json.JSONArray
sealed class ActivityEvent {
@@ -9,9 +10,11 @@ sealed class ActivityEvent {
class LaunchNativePlayer(val playOptions: PlayOptions) : ActivityEvent()
class OpenUrl(val uri: String) : ActivityEvent()
class DownloadFile(val uri: Uri, val title: String, val filename: String) : ActivityEvent()
+ class RemoveDownload(val download: LocalJellyfinMediaSource, val force: Boolean = false) : ActivityEvent()
class CastMessage(val action: String, val args: JSONArray) : ActivityEvent()
data object RequestBluetoothPermission : ActivityEvent()
data object OpenSettings : ActivityEvent()
data object SelectServer : ActivityEvent()
data object ExitApp : ActivityEvent()
+ data object OpenDownloads : ActivityEvent()
}
diff --git a/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt b/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt
index 349751b00..e2475e9be 100644
--- a/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt
+++ b/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt
@@ -14,11 +14,13 @@ import kotlinx.coroutines.launch
import org.jellyfin.mobile.MainActivity
import org.jellyfin.mobile.R
import org.jellyfin.mobile.bridge.JavascriptCallback
+import org.jellyfin.mobile.downloads.DownloadsFragment
import org.jellyfin.mobile.player.ui.PlayerFragment
import org.jellyfin.mobile.player.ui.PlayerFullscreenHelper
import org.jellyfin.mobile.settings.SettingsFragment
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.extensions.addFragment
+import org.jellyfin.mobile.utils.removeDownload
import org.jellyfin.mobile.utils.requestDownload
import org.jellyfin.mobile.webapp.WebappFunctionChannel
import timber.log.Timber
@@ -27,8 +29,8 @@ class ActivityEventHandler(
private val webappFunctionChannel: WebappFunctionChannel,
) {
private val eventsFlow = MutableSharedFlow(
- extraBufferCapacity = 1,
- onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ extraBufferCapacity = 10,
+ onBufferOverflow = BufferOverflow.SUSPEND,
)
fun MainActivity.subscribe() {
@@ -74,9 +76,17 @@ class ActivityEventHandler(
}
is ActivityEvent.DownloadFile -> {
lifecycleScope.launch {
- with(event) { requestDownload(uri, title, filename) }
+ with(event) { requestDownload(uri, filename) }
}
}
+ is ActivityEvent.RemoveDownload -> {
+ lifecycleScope.launch {
+ with(event) { removeDownload(download, force) }
+ }
+ }
+ ActivityEvent.OpenDownloads -> {
+ supportFragmentManager.addFragment()
+ }
is ActivityEvent.CastMessage -> {
val action = event.action
chromecast.execute(
diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt
index e3306fa47..25d24eabe 100644
--- a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt
+++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt
@@ -40,6 +40,7 @@ import org.jellyfin.mobile.player.interaction.PlayerMediaSessionCallback
import org.jellyfin.mobile.player.interaction.PlayerNotificationHelper
import org.jellyfin.mobile.player.queue.QueueManager
import org.jellyfin.mobile.player.source.JellyfinMediaSource
+import org.jellyfin.mobile.player.source.RemoteJellyfinMediaSource
import org.jellyfin.mobile.player.ui.DecoderType
import org.jellyfin.mobile.player.ui.DisplayPreferences
import org.jellyfin.mobile.player.ui.PlayState
@@ -91,7 +92,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
val trackSelectionHelper = TrackSelectionHelper(this, trackSelector)
val queueManager = QueueManager(this)
val mediaSourceOrNull: JellyfinMediaSource?
- get() = queueManager.currentMediaSourceOrNull
+ get() = queueManager.getCurrentMediaSourceOrNull()
// ExoPlayer
private val _player = MutableLiveData()
@@ -138,23 +139,24 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
// Load display preferences
viewModelScope.launch {
+ var customPrefs: Map? = null
try {
val displayPreferencesDto by displayPreferencesApi.getDisplayPreferences(
displayPreferencesId = Constants.DISPLAY_PREFERENCES_ID_USER_SETTINGS,
client = Constants.DISPLAY_PREFERENCES_CLIENT_EMBY,
)
- val customPrefs = displayPreferencesDto.customPrefs
-
- displayPreferences = DisplayPreferences(
- skipBackLength = customPrefs[Constants.DISPLAY_PREFERENCES_SKIP_BACK_LENGTH]?.toLongOrNull()
- ?: Constants.DEFAULT_SEEK_TIME_MS,
- skipForwardLength = customPrefs[Constants.DISPLAY_PREFERENCES_SKIP_FORWARD_LENGTH]?.toLongOrNull()
- ?: Constants.DEFAULT_SEEK_TIME_MS,
- )
+ customPrefs = displayPreferencesDto.customPrefs
} catch (e: ApiClientException) {
- Timber.e(e, "Failed to load display preferences")
+ Timber.e(e, "Failed to load display preferences from API")
}
+
+ displayPreferences = DisplayPreferences(
+ skipBackLength = customPrefs?.get(Constants.DISPLAY_PREFERENCES_SKIP_BACK_LENGTH)?.toLongOrNull()
+ ?: Constants.DEFAULT_SEEK_TIME_MS,
+ skipForwardLength = customPrefs?.get(Constants.DISPLAY_PREFERENCES_SKIP_FORWARD_LENGTH)?.toLongOrNull()
+ ?: Constants.DEFAULT_SEEK_TIME_MS,
+ )
}
// Subscribe to player events from webapp
@@ -254,12 +256,15 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
mediaSession.setMetadata(jellyfinMediaSource.toMediaMetadata())
- viewModelScope.launch {
- player.reportPlaybackStart(jellyfinMediaSource)
+ if (jellyfinMediaSource is RemoteJellyfinMediaSource) {
+ viewModelScope.launch {
+ player.reportPlaybackStart(jellyfinMediaSource)
+ }
}
}
private fun startProgressUpdates() {
+ if (mediaSourceOrNull != null && mediaSourceOrNull !is RemoteJellyfinMediaSource) return
progressUpdateJob = viewModelScope.launch {
while (true) {
delay(Constants.PLAYER_TIME_UPDATE_RATE)
@@ -287,11 +292,11 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
}
analyticsCollector = buildAnalyticsCollector()
setupPlayer()
- queueManager.currentMediaSourceOrNull?.startTimeMs = playedTime
+ queueManager.getCurrentMediaSourceOrNull()?.startTimeMs = playedTime
queueManager.tryRestartPlayback()
}
- private suspend fun Player.reportPlaybackStart(mediaSource: JellyfinMediaSource) {
+ private suspend fun Player.reportPlaybackStart(mediaSource: RemoteJellyfinMediaSource) {
try {
playStateApi.reportPlaybackStart(
PlaybackStartInfo(
@@ -314,7 +319,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
}
private suspend fun Player.reportPlaybackState() {
- val mediaSource = mediaSourceOrNull ?: return
+ val mediaSource = mediaSourceOrNull as? RemoteJellyfinMediaSource ?: return
val playbackPositionMillis = currentPosition
if (playbackState != Player.STATE_ENDED) {
val stream = AudioManager.STREAM_MUSIC
@@ -343,7 +348,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
}
private fun reportPlaybackStop() {
- val mediaSource = mediaSourceOrNull ?: return
+ val mediaSource = mediaSourceOrNull as? RemoteJellyfinMediaSource ?: return
val player = playerOrNull ?: return
val hasFinished = player.playbackState == Player.STATE_ENDED
val lastPositionTicks = when {
@@ -378,7 +383,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
}
}
- suspend fun stopTranscoding(mediaSource: JellyfinMediaSource) {
+ suspend fun stopTranscoding(mediaSource: RemoteJellyfinMediaSource) {
if (mediaSource.playMethod == PlayMethod.TRANSCODE) {
hlsSegmentApi.stopEncodingProcess(
deviceId = apiClient.deviceInfo.id,
diff --git a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayOptions.kt b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayOptions.kt
index 70ace19a7..582940eab 100644
--- a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayOptions.kt
+++ b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayOptions.kt
@@ -17,6 +17,7 @@ data class PlayOptions(
val startPositionTicks: Long?,
val audioStreamIndex: Int?,
val subtitleStreamIndex: Int?,
+ val playFromDownloads: Boolean?,
) : Parcelable {
companion object {
fun fromJson(json: JSONObject): PlayOptions? = try {
@@ -33,6 +34,7 @@ data class PlayOptions(
startPositionTicks = json.optLong("startPositionTicks").takeIf { it > 0 },
audioStreamIndex = json.optString("audioStreamIndex").toIntOrNull(),
subtitleStreamIndex = json.optString("subtitleStreamIndex").toIntOrNull(),
+ playFromDownloads = false,
)
} catch (e: JSONException) {
Timber.e(e, "Failed to parse playback options: %s", json)
diff --git a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt
index 5b8ca57db..ec644c2a0 100644
--- a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt
+++ b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt
@@ -8,6 +8,7 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Bitmap
+import android.graphics.BitmapFactory
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.toBitmap
@@ -22,8 +23,11 @@ import org.jellyfin.mobile.BuildConfig
import org.jellyfin.mobile.MainActivity
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.AppPreferences
+import org.jellyfin.mobile.data.dao.DownloadDao
import org.jellyfin.mobile.player.PlayerViewModel
import org.jellyfin.mobile.player.source.JellyfinMediaSource
+import org.jellyfin.mobile.player.source.LocalJellyfinMediaSource
+import org.jellyfin.mobile.player.source.RemoteJellyfinMediaSource
import org.jellyfin.mobile.utils.AndroidVersion
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.Constants.VIDEO_PLAYER_NOTIFICATION_ID
@@ -35,6 +39,7 @@ import org.jellyfin.sdk.model.api.ImageType
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
+import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinComponent {
@@ -43,6 +48,7 @@ class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinCom
private val notificationManager: NotificationManager? by lazy { context.getSystemService() }
private val imageApi: ImageApi = get().imageApi
private val imageLoader: ImageLoader by inject()
+ private val downloadDao: DownloadDao by inject()
private val receiverRegistered = AtomicBoolean(false)
val allowBackgroundAudio: Boolean
@@ -66,7 +72,7 @@ class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinCom
fun postNotification() {
val nm = notificationManager ?: return
val player = viewModel.playerOrNull ?: return
- val currentMediaSource = viewModel.queueManager.currentMediaSourceOrNull ?: return
+ val currentMediaSource = viewModel.queueManager.getCurrentMediaSourceOrNull() ?: return
val hasPrevious = viewModel.queueManager.hasPrevious()
val hasNext = viewModel.queueManager.hasNext()
val playbackState = player.playbackState
@@ -143,17 +149,30 @@ class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinCom
}
}
- private suspend fun loadImage(mediaSource: JellyfinMediaSource): Bitmap? {
- val size = context.resources.getDimensionPixelSize(R.dimen.media_notification_height)
-
- val imageUrl = imageApi.getItemImageUrl(
- itemId = mediaSource.itemId,
- imageType = ImageType.PRIMARY,
- maxWidth = size,
- maxHeight = size,
- )
- val imageRequest = ImageRequest.Builder(context).data(imageUrl).build()
- return imageLoader.execute(imageRequest).drawable?.toBitmap()
+ private suspend fun loadImage(mediaSource: JellyfinMediaSource) = when (mediaSource) {
+ is LocalJellyfinMediaSource -> {
+ val downloadFolder = File(
+ downloadDao
+ .get(mediaSource.id)
+ .let(::requireNotNull)
+ .asMediaSource()
+ .localDirectoryUri,
+ )
+ val thumbnailFile = File(downloadFolder, Constants.DOWNLOAD_THUMBNAIL_FILENAME)
+ BitmapFactory.decodeFile(thumbnailFile.canonicalPath)
+ }
+ is RemoteJellyfinMediaSource -> {
+ val size = context.resources.getDimensionPixelSize(R.dimen.media_notification_height)
+
+ val imageUrl = imageApi.getItemImageUrl(
+ itemId = mediaSource.itemId,
+ imageType = ImageType.PRIMARY,
+ maxWidth = size,
+ maxHeight = size,
+ )
+ val imageRequest = ImageRequest.Builder(context).data(imageUrl).build()
+ imageLoader.execute(imageRequest).drawable?.toBitmap()
+ }
}
private fun generateAction(playerNotificationAction: PlayerNotificationAction): Notification.Action {
diff --git a/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt b/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt
index b24829f47..a149092a1 100644
--- a/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt
+++ b/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt
@@ -2,6 +2,7 @@ package org.jellyfin.mobile.player.queue
import android.net.Uri
import androidx.annotation.CheckResult
+import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.android.exoplayer2.MediaItem
@@ -10,13 +11,16 @@ import com.google.android.exoplayer2.source.MergingMediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.SingleSampleMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource
+import org.jellyfin.mobile.data.dao.DownloadDao
import org.jellyfin.mobile.player.PlayerException
import org.jellyfin.mobile.player.PlayerViewModel
import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
import org.jellyfin.mobile.player.interaction.PlayOptions
import org.jellyfin.mobile.player.source.ExternalSubtitleStream
import org.jellyfin.mobile.player.source.JellyfinMediaSource
+import org.jellyfin.mobile.player.source.LocalJellyfinMediaSource
import org.jellyfin.mobile.player.source.MediaSourceResolver
+import org.jellyfin.mobile.player.source.RemoteJellyfinMediaSource
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.videosApi
@@ -29,6 +33,7 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
+import java.io.File
import java.util.UUID
class QueueManager(
@@ -47,8 +52,7 @@ class QueueManager(
val currentMediaSource: LiveData
get() = _currentMediaSource
- inline val currentMediaSourceOrNull: JellyfinMediaSource?
- get() = currentMediaSource.value
+ fun getCurrentMediaSourceOrNull(): JellyfinMediaSource? = currentMediaSource.value
/**
* Handle initial playback options from fragment.
@@ -65,16 +69,43 @@ class QueueManager(
else -> playOptions.mediaSourceId?.toUUIDOrNull()
} ?: return PlayerException.InvalidPlayOptions()
- startPlayback(
- itemId = itemId,
- mediaSourceId = playOptions.mediaSourceId,
- maxStreamingBitrate = null,
- startTimeTicks = playOptions.startPositionTicks,
- audioStreamIndex = playOptions.audioStreamIndex,
- subtitleStreamIndex = playOptions.subtitleStreamIndex,
- playWhenReady = true,
- )
+ when (playOptions.playFromDownloads) {
+ true -> playOptions.mediaSourceId?.let {
+ startDownloadPlayback(
+ mediaSourceId = it,
+ playWhenReady = true,
+ )
+ }
+ else -> startRemotePlayback(
+ itemId = itemId,
+ mediaSourceId = playOptions.mediaSourceId,
+ maxStreamingBitrate = null,
+ startTimeTicks = playOptions.startPositionTicks,
+ audioStreamIndex = playOptions.audioStreamIndex,
+ subtitleStreamIndex = playOptions.subtitleStreamIndex,
+ playWhenReady = true,
+ )
+ }
+
+ return null
+ }
+ private suspend fun startDownloadPlayback(
+ mediaSourceId: String,
+ startTimeMs: Long? = null,
+ audioStreamIndex: Int? = null,
+ subtitleStreamIndex: Int? = null,
+ playWhenReady: Boolean = true,
+ ): PlayerException? {
+ get()
+ .get(mediaSourceId)
+ ?.asMediaSource(startTimeMs, audioStreamIndex, subtitleStreamIndex)
+ ?.also { jellyfinMediaSource ->
+ _currentMediaSource.value = jellyfinMediaSource
+
+ // Load new media source
+ viewModel.load(jellyfinMediaSource, prepareStreams(jellyfinMediaSource), playWhenReady)
+ }
return null
}
@@ -83,7 +114,7 @@ class QueueManager(
*
* @return an error of type [PlayerException] or null on success.
*/
- private suspend fun startPlayback(
+ private suspend fun startRemotePlayback(
itemId: UUID,
mediaSourceId: String?,
maxStreamingBitrate: Int?,
@@ -102,8 +133,8 @@ class QueueManager(
subtitleStreamIndex = subtitleStreamIndex,
).onSuccess { jellyfinMediaSource ->
// Ensure transcoding of the current element is stopped
- currentMediaSourceOrNull?.let { oldMediaSource ->
- viewModel.stopTranscoding(oldMediaSource)
+ getCurrentMediaSourceOrNull()?.let { oldMediaSource ->
+ viewModel.stopTranscoding(oldMediaSource as RemoteJellyfinMediaSource)
}
_currentMediaSource.value = jellyfinMediaSource
@@ -121,23 +152,29 @@ class QueueManager(
* Reinitialize current media source without changing settings
*/
fun tryRestartPlayback() {
- val currentMediaSource = currentMediaSourceOrNull ?: return
-
- viewModel.load(currentMediaSource, prepareStreams(currentMediaSource), playWhenReady = true)
+ with(getCurrentMediaSourceOrNull()) {
+ when (this) {
+ is LocalJellyfinMediaSource -> prepareStreams(this)
+ is RemoteJellyfinMediaSource -> prepareStreams(this)
+ null -> return
+ }.let {
+ viewModel.load(this, it, playWhenReady = true)
+ }
+ }
}
/**
* Change the maximum bitrate to the specified value.
*/
suspend fun changeBitrate(bitrate: Int?): Boolean {
- val currentMediaSource = currentMediaSourceOrNull ?: return false
+ val currentMediaSource = getCurrentMediaSourceOrNull() as? RemoteJellyfinMediaSource ?: return false
// Bitrate didn't change, ignore
if (currentMediaSource.maxStreamingBitrate == bitrate) return true
val currentPlayState = viewModel.getStateAndPause() ?: return false
- return startPlayback(
+ return startRemotePlayback(
itemId = currentMediaSource.itemId,
mediaSourceId = currentMediaSource.id,
maxStreamingBitrate = bitrate,
@@ -155,9 +192,9 @@ class QueueManager(
suspend fun previous(): Boolean {
if (!hasPrevious()) return false
- val currentMediaSource = currentMediaSourceOrNull ?: return false
+ val currentMediaSource = getCurrentMediaSourceOrNull() as? RemoteJellyfinMediaSource ?: return false
- startPlayback(
+ startRemotePlayback(
itemId = currentQueue[--currentQueueIndex],
mediaSourceId = null,
maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
@@ -168,13 +205,18 @@ class QueueManager(
suspend fun next(): Boolean {
if (!hasNext()) return false
- val currentMediaSource = currentMediaSourceOrNull ?: return false
-
- startPlayback(
- itemId = currentQueue[++currentQueueIndex],
- mediaSourceId = null,
- maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
- )
+ when (val currentMediaSource = getCurrentMediaSourceOrNull()) {
+ is LocalJellyfinMediaSource -> startDownloadPlayback(
+ mediaSourceId = currentMediaSource.id,
+ playWhenReady = true,
+ )
+ is RemoteJellyfinMediaSource -> startRemotePlayback(
+ itemId = currentQueue[++currentQueueIndex],
+ mediaSourceId = null,
+ maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
+ )
+ null -> return false
+ }
return true
}
@@ -186,7 +228,19 @@ class QueueManager(
* a [MergingMediaSource] containing the mentioned media stream and all external subtitle streams.
*/
@CheckResult
- private fun prepareStreams(source: JellyfinMediaSource): MediaSource {
+ private fun prepareStreams(source: LocalJellyfinMediaSource): MediaSource {
+ val videoSource: MediaSource = createDownloadVideoMediaSource(source.id, source.remoteFileUri)
+ val subtitleSources: Array = createDownloadExternalSubtitleMediaSources(
+ source,
+ source.remoteFileUri,
+ )
+ return when {
+ subtitleSources.isNotEmpty() -> MergingMediaSource(videoSource, *subtitleSources)
+ else -> videoSource
+ }
+ }
+
+ private fun prepareStreams(source: RemoteJellyfinMediaSource): MediaSource {
val videoSource = createVideoMediaSource(source)
val subtitleSources = createExternalSubtitleMediaSources(source)
return when {
@@ -281,6 +335,34 @@ class QueueManager(
}.toTypedArray()
}
+ @CheckResult
+ private fun createDownloadVideoMediaSource(mediaSourceId: String, fileUri: String): MediaSource {
+ val mediaSourceFactory: ProgressiveMediaSource.Factory = get()
+
+ val mediaItem = MediaItem.Builder()
+ .setMediaId(mediaSourceId)
+ .setUri(fileUri.toUri())
+ .build()
+
+ return mediaSourceFactory.createMediaSource(mediaItem)
+ }
+
+ @CheckResult
+ private fun createDownloadExternalSubtitleMediaSources(source: JellyfinMediaSource, fileUri: String): Array {
+ val downloadDir: String = File(fileUri).parent
+ val factory = get()
+ return source.externalSubtitleStreams.map { stream ->
+ val uri: Uri = File(downloadDir, "${stream.index}.subrip").toUri()
+ val mediaItem = MediaItem.SubtitleConfiguration.Builder(uri).apply {
+ setId("${ExternalSubtitleStream.ID_PREFIX}${stream.index}")
+ setLabel(stream.displayTitle)
+ setMimeType(stream.mimeType)
+ setLanguage(stream.language)
+ }.build()
+ factory.createMediaSource(mediaItem, source.runTimeMs)
+ }.toTypedArray()
+ }
+
/**
* Switch to the specified [audio stream][stream] and restart playback, for example while transcoding.
*
@@ -288,18 +370,27 @@ class QueueManager(
*/
suspend fun selectAudioStreamAndRestartPlayback(stream: MediaStream): Boolean {
require(stream.type == MediaStreamType.AUDIO)
- val currentMediaSource = currentMediaSourceOrNull ?: return false
val currentPlayState = viewModel.getStateAndPause() ?: return false
- startPlayback(
- itemId = currentMediaSource.itemId,
- mediaSourceId = currentMediaSource.id,
- maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
- startTimeTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND,
- audioStreamIndex = stream.index,
- subtitleStreamIndex = currentMediaSource.selectedSubtitleStreamIndex,
- playWhenReady = currentPlayState.playWhenReady,
- )
+ when (val currentMediaSource = getCurrentMediaSourceOrNull()) {
+ is LocalJellyfinMediaSource -> startDownloadPlayback(
+ mediaSourceId = currentMediaSource.id,
+ startTimeMs = currentPlayState.position,
+ audioStreamIndex = stream.index,
+ subtitleStreamIndex = currentMediaSource.selectedSubtitleStreamIndex,
+ playWhenReady = currentPlayState.playWhenReady,
+ )
+ is RemoteJellyfinMediaSource -> startRemotePlayback(
+ itemId = currentMediaSource.itemId,
+ mediaSourceId = currentMediaSource.id,
+ maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
+ startTimeTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND,
+ audioStreamIndex = stream.index,
+ subtitleStreamIndex = currentMediaSource.selectedSubtitleStreamIndex,
+ playWhenReady = currentPlayState.playWhenReady,
+ )
+ null -> return false
+ }
return true
}
@@ -312,18 +403,27 @@ class QueueManager(
*/
suspend fun selectSubtitleStreamAndRestartPlayback(stream: MediaStream?): Boolean {
require(stream == null || stream.type == MediaStreamType.SUBTITLE)
- val currentMediaSource = currentMediaSourceOrNull ?: return false
val currentPlayState = viewModel.getStateAndPause() ?: return false
- startPlayback(
- itemId = currentMediaSource.itemId,
- mediaSourceId = currentMediaSource.id,
- maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
- startTimeTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND,
- audioStreamIndex = currentMediaSource.selectedAudioStreamIndex,
- subtitleStreamIndex = stream?.index ?: -1, // -1 disables subtitles, null would select the default subtitle
- playWhenReady = currentPlayState.playWhenReady,
- )
+ when (val mediaSource = getCurrentMediaSourceOrNull()) {
+ is LocalJellyfinMediaSource -> startDownloadPlayback(
+ mediaSourceId = mediaSource.id,
+ startTimeMs = currentPlayState.position,
+ audioStreamIndex = mediaSource.selectedAudioStreamIndex,
+ subtitleStreamIndex = stream?.index ?: -1, // -1 disables subtitles, null would select the default subtitle
+ playWhenReady = currentPlayState.playWhenReady,
+ )
+ is RemoteJellyfinMediaSource -> startRemotePlayback(
+ itemId = mediaSource.itemId,
+ mediaSourceId = mediaSource.id,
+ maxStreamingBitrate = mediaSource.maxStreamingBitrate,
+ startTimeTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND,
+ audioStreamIndex = mediaSource.selectedAudioStreamIndex,
+ subtitleStreamIndex = stream?.index ?: -1, // -1 disables subtitles, null would select the default subtitle
+ playWhenReady = currentPlayState.playWhenReady,
+ )
+ null -> return false
+ }
return true
}
}
diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt
index e30989743..e7df6e3f6 100644
--- a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt
+++ b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt
@@ -10,27 +10,20 @@ import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import java.util.UUID
-class JellyfinMediaSource(
+sealed class JellyfinMediaSource(
val itemId: UUID,
val item: BaseItemDto?,
val sourceInfo: MediaSourceInfo,
val playSessionId: String,
- val liveStreamId: String?,
- val maxStreamingBitrate: Int?,
- private var startTimeTicks: Long? = null,
- audioStreamIndex: Int? = null,
- subtitleStreamIndex: Int? = null,
+ playbackDetails: PlaybackDetails?,
) {
val id: String = requireNotNull(sourceInfo.id) { "Media source has no id" }
val name: String = item?.name ?: sourceInfo.name.orEmpty()
- val playMethod: PlayMethod = when {
- sourceInfo.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
- sourceInfo.supportsDirectStream -> PlayMethod.DIRECT_STREAM
- sourceInfo.supportsTranscoding -> PlayMethod.TRANSCODE
- else -> throw IllegalArgumentException("No play method found for $name ($itemId)")
- }
+ abstract val playMethod: PlayMethod
+ var startTimeTicks: Long? = playbackDetails?.startTimeTicks
+ private set
var startTimeMs: Long
get() = (startTimeTicks ?: 0L) / Constants.TICKS_PER_MILLISECOND
set(value) {
@@ -73,13 +66,13 @@ class JellyfinMediaSource(
}
MediaStreamType.AUDIO -> {
audio += mediaStream
- if (mediaStream.index == (audioStreamIndex ?: sourceInfo.defaultAudioStreamIndex)) {
+ if (mediaStream.index == (playbackDetails?.audioStreamIndex ?: sourceInfo.defaultAudioStreamIndex)) {
selectedAudioStream = mediaStream
}
}
MediaStreamType.SUBTITLE -> {
subtitles += mediaStream
- if (mediaStream.index == (subtitleStreamIndex ?: sourceInfo.defaultSubtitleStreamIndex)) {
+ if (mediaStream.index == (playbackDetails?.subtitleStreamIndex ?: sourceInfo.defaultSubtitleStreamIndex)) {
selectedSubtitleStream = mediaStream
}
@@ -110,6 +103,7 @@ class JellyfinMediaSource(
audioStreams = audio
subtitleStreams = subtitles
externalSubtitleStreams = externalSubtitles
+ this.startTimeTicks = startTimeTicks
}
/**
@@ -164,3 +158,9 @@ class JellyfinMediaSource(
throw IllegalArgumentException("Invalid media stream")
}
}
+
+data class PlaybackDetails(
+ val startTimeTicks: Long?,
+ val audioStreamIndex: Int?,
+ val subtitleStreamIndex: Int?,
+)
diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSourceSerializer.kt b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSourceSerializer.kt
new file mode 100644
index 000000000..a983258e5
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSourceSerializer.kt
@@ -0,0 +1,76 @@
+package org.jellyfin.mobile.player.source
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.descriptors.buildClassSerialDescriptor
+import kotlinx.serialization.descriptors.element
+import kotlinx.serialization.encoding.CompositeDecoder
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.encoding.decodeStructure
+import kotlinx.serialization.encoding.encodeStructure
+import org.jellyfin.sdk.model.api.BaseItemDto
+import org.jellyfin.sdk.model.api.MediaSourceInfo
+import org.jellyfin.sdk.model.serializer.toUUID
+import java.util.UUID
+
+class JellyfinMediaSourceSerializer : KSerializer {
+ override val descriptor: SerialDescriptor = buildClassSerialDescriptor("JellyfinMediaSource") {
+ element("itemId")
+ element("item", isOptional = true)
+ element("sourceInfo")
+ element("playSessionId")
+ element("downloadFolderUri")
+ element("downloadedFileUri")
+ element("downloadSize")
+ }
+
+ @SuppressWarnings("MagicNumber")
+ override fun serialize(encoder: Encoder, value: LocalJellyfinMediaSource): Unit =
+ encoder.encodeStructure(descriptor) {
+ encodeStringElement(descriptor, 0, value.itemId.toString())
+ encodeNullableSerializableElement(descriptor, 1, BaseItemDto.serializer(), value.item)
+ encodeSerializableElement(descriptor, 2, MediaSourceInfo.serializer(), value.sourceInfo)
+ encodeStringElement(descriptor, 3, value.playSessionId)
+ encodeStringElement(descriptor, 4, value.localDirectoryUri)
+ encodeStringElement(descriptor, 5, value.remoteFileUri)
+ encodeLongElement(descriptor, 6, value.downloadSize)
+ }
+
+ @SuppressWarnings("MagicNumber")
+ override fun deserialize(decoder: Decoder): LocalJellyfinMediaSource =
+ decoder.decodeStructure(descriptor) {
+ var itemId: UUID? = null
+ var item: BaseItemDto? = null
+ var sourceInfo: MediaSourceInfo? = null
+ var playSessionId: String? = null
+ var downloadFolderUri: String? = null
+ var downloadedFileUri: String? = null
+ var downloadSize: Long? = null
+
+ while (true) {
+ when (val index = decodeElementIndex(descriptor)) {
+ 0 -> itemId = decodeStringElement(descriptor, 0).toUUID()
+ 1 -> item = decodeNullableSerializableElement(descriptor, 1, BaseItemDto.serializer())
+ 2 -> sourceInfo = decodeSerializableElement(descriptor, 2, MediaSourceInfo.serializer())
+ 3 -> playSessionId = decodeStringElement(descriptor, 3)
+ 4 -> downloadFolderUri = decodeStringElement(descriptor, 4)
+ 5 -> downloadedFileUri = decodeStringElement(descriptor, 5)
+ 6 -> downloadSize = decodeLongElement(descriptor, 6)
+ CompositeDecoder.DECODE_DONE -> break
+ else -> throw SerializationException("Unknown index $index")
+ }
+ }
+
+ LocalJellyfinMediaSource(
+ itemId = requireNotNull(itemId) { "Media source has no id" },
+ item = item,
+ sourceInfo = requireNotNull(sourceInfo) { "Media source has no source info" },
+ playSessionId = requireNotNull(playSessionId) { "Media source has no play session id" },
+ localDirectoryUri = requireNotNull(downloadFolderUri) { "Media source has no download folder uri" },
+ remoteFileUri = requireNotNull(downloadedFileUri) { "Media source has no downloaded file uri" },
+ downloadSize = requireNotNull(downloadSize) { "Media source has no download size" },
+ )
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/LocalJellyfinMediaSource.kt b/app/src/main/java/org/jellyfin/mobile/player/source/LocalJellyfinMediaSource.kt
new file mode 100644
index 000000000..f01508017
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/player/source/LocalJellyfinMediaSource.kt
@@ -0,0 +1,32 @@
+package org.jellyfin.mobile.player.source
+
+import kotlinx.serialization.Serializable
+import org.jellyfin.sdk.model.api.BaseItemDto
+import org.jellyfin.sdk.model.api.MediaSourceInfo
+import org.jellyfin.sdk.model.api.PlayMethod
+import java.util.UUID
+
+@Serializable(with = JellyfinMediaSourceSerializer::class)
+class LocalJellyfinMediaSource(
+ itemId: UUID,
+ item: BaseItemDto?,
+ sourceInfo: MediaSourceInfo,
+ playSessionId: String,
+ playbackDetails: PlaybackDetails? = null,
+ val localDirectoryUri: String,
+ val remoteFileUri: String,
+ val downloadSize: Long,
+) : JellyfinMediaSource(itemId, item, sourceInfo, playSessionId, playbackDetails) {
+ override val playMethod: PlayMethod = PlayMethod.DIRECT_PLAY
+
+ constructor(source: JellyfinMediaSource, downloadFolder: String, downloadUrl: String, downloadSize: Long) : this(
+ source.itemId,
+ source.item,
+ source.sourceInfo,
+ source.playSessionId,
+ PlaybackDetails(source.startTimeTicks, source.selectedAudioStreamIndex, source.selectedSubtitleStreamIndex),
+ downloadFolder,
+ downloadUrl,
+ downloadSize,
+ )
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt b/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt
index 51fcbd8b7..924f59594 100644
--- a/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt
+++ b/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt
@@ -7,7 +7,9 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.MediaInfoApi
+import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.DeviceProfile
+import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
@@ -27,10 +29,10 @@ class MediaSourceResolver(private val apiClient: ApiClient) {
audioStreamIndex: Int? = null,
subtitleStreamIndex: Int? = null,
autoOpenLiveStream: Boolean = true,
- ): Result {
+ ): Result {
// Load media source info
val playSessionId: String
- val mediaSourceInfo = try {
+ val mediaSourceInfo: MediaSourceInfo = try {
val response by mediaInfoApi.getPostedPlaybackInfo(
itemId = itemId,
data = PlaybackInfoDto(
@@ -59,7 +61,7 @@ class MediaSourceResolver(private val apiClient: ApiClient) {
}
// Load additional item info if possible
- val item = try {
+ val item: BaseItemDto? = try {
val response by itemsApi.getItemsByUserId(ids = listOf(itemId))
response.items?.firstOrNull()
} catch (e: ApiClientException) {
@@ -69,16 +71,14 @@ class MediaSourceResolver(private val apiClient: ApiClient) {
// Create JellyfinMediaSource
return try {
- val source = JellyfinMediaSource(
+ val source = RemoteJellyfinMediaSource(
itemId = itemId,
item = item,
sourceInfo = mediaSourceInfo,
playSessionId = playSessionId,
liveStreamId = mediaSourceInfo.liveStreamId,
maxStreamingBitrate = maxStreamingBitrate,
- startTimeTicks = startTimeTicks,
- audioStreamIndex = audioStreamIndex,
- subtitleStreamIndex = subtitleStreamIndex,
+ playbackDetails = PlaybackDetails(startTimeTicks, audioStreamIndex, subtitleStreamIndex),
)
Result.success(source)
} catch (e: IllegalArgumentException) {
diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/RemoteJellyfinMediaSource.kt b/app/src/main/java/org/jellyfin/mobile/player/source/RemoteJellyfinMediaSource.kt
new file mode 100644
index 000000000..dc1e8c58c
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/player/source/RemoteJellyfinMediaSource.kt
@@ -0,0 +1,23 @@
+package org.jellyfin.mobile.player.source
+
+import org.jellyfin.sdk.model.api.BaseItemDto
+import org.jellyfin.sdk.model.api.MediaSourceInfo
+import org.jellyfin.sdk.model.api.PlayMethod
+import java.util.UUID
+
+class RemoteJellyfinMediaSource(
+ itemId: UUID,
+ item: BaseItemDto?,
+ sourceInfo: MediaSourceInfo,
+ playSessionId: String,
+ val liveStreamId: String?,
+ val maxStreamingBitrate: Int?,
+ playbackDetails: PlaybackDetails?,
+) : JellyfinMediaSource(itemId, item, sourceInfo, playSessionId, playbackDetails) {
+ override val playMethod: PlayMethod = when {
+ sourceInfo.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
+ sourceInfo.supportsDirectStream -> PlayMethod.DIRECT_STREAM
+ sourceInfo.supportsTranscoding -> PlayMethod.TRANSCODE
+ else -> throw IllegalArgumentException("No play method found for $name ($itemId)")
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt
index 0768a371b..09f31873a 100644
--- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt
+++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt
@@ -15,6 +15,8 @@ import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider
import org.jellyfin.mobile.player.source.JellyfinMediaSource
+import org.jellyfin.mobile.player.source.LocalJellyfinMediaSource
+import org.jellyfin.mobile.player.source.RemoteJellyfinMediaSource
import org.jellyfin.sdk.model.api.MediaStream
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -130,10 +132,11 @@ class PlayerMenus(
val height = videoStream?.height
val width = videoStream?.width
- if (height != null && width != null) {
- buildQualityMenu(qualityMenu.menu, mediaSource.maxStreamingBitrate, width, height)
- } else {
- qualityButton.isVisible = false
+ when (mediaSource) {
+ is LocalJellyfinMediaSource -> qualityButton.isVisible = false
+ is RemoteJellyfinMediaSource -> if (height != null && width != null) {
+ buildQualityMenu(qualityMenu.menu, mediaSource.maxStreamingBitrate, width, height)
+ }
}
val playMethod = context.getString(R.string.playback_info_play_method, mediaSource.playMethod)
diff --git a/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt b/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt
index 3df148885..2bb67b4e3 100644
--- a/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt
+++ b/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt
@@ -24,9 +24,9 @@ import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.AppPreferences
import org.jellyfin.mobile.databinding.FragmentSettingsBinding
+import org.jellyfin.mobile.downloads.DownloadMethod
import org.jellyfin.mobile.utils.BackPressInterceptor
import org.jellyfin.mobile.utils.Constants
-import org.jellyfin.mobile.utils.DownloadMethod
import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins
import org.jellyfin.mobile.utils.extensions.requireMainActivity
import org.jellyfin.mobile.utils.getDownloadsPaths
@@ -225,6 +225,12 @@ class SettingsFragment : Fragment(), BackPressInterceptor {
.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
.absolutePath
}
+
+ checkBox(Constants.PREF_DOWNLOAD_INTERNAL) {
+ titleRes = R.string.store_videos_in_internal_storage
+ summaryRes = R.string.stored_videos_in_internal_storage_desc
+ defaultValue = true
+ }
}
companion object {
diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ConnectScreen.kt b/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ConnectScreen.kt
index 8c02bfde9..56855598d 100644
--- a/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ConnectScreen.kt
+++ b/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ConnectScreen.kt
@@ -3,23 +3,32 @@ package org.jellyfin.mobile.ui.screens.connect
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.jellyfin.mobile.MainViewModel
import org.jellyfin.mobile.R
+import org.jellyfin.mobile.events.ActivityEvent
+import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.ui.utils.CenterRow
+import org.koin.compose.koinInject
@Composable
fun ConnectScreen(
mainViewModel: MainViewModel,
showExternalConnectionError: Boolean,
+ activityEventHandler: ActivityEventHandler = koinInject(),
) {
Surface(color = MaterialTheme.colors.background) {
Column(
@@ -34,6 +43,10 @@ fun ConnectScreen(
mainViewModel.switchServer(hostname)
},
)
+ StyledTextButton(
+ onClick = { activityEventHandler.emit(ActivityEvent.OpenDownloads) },
+ text = stringResource(R.string.view_downloads),
+ )
}
}
}
@@ -52,3 +65,22 @@ fun LogoHeader() {
)
}
}
+
+@Stable
+@Composable
+fun StyledTextButton(
+ text: String,
+ enabled: Boolean = true,
+ onClick: () -> Unit,
+) {
+ TextButton(
+ onClick = onClick,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ enabled = enabled,
+ colors = ButtonDefaults.buttonColors(),
+ ) {
+ Text(text = text)
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ServerSelection.kt b/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ServerSelection.kt
index dd32a64be..47a9afc8a 100644
--- a/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ServerSelection.kt
+++ b/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ServerSelection.kt
@@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material.ButtonDefaults
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
@@ -27,7 +26,6 @@ import androidx.compose.material.ListItem
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
-import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.runtime.Composable
@@ -268,25 +266,6 @@ private fun AnimatedErrorText(
}
}
-@Stable
-@Composable
-private fun StyledTextButton(
- text: String,
- enabled: Boolean = true,
- onClick: () -> Unit,
-) {
- TextButton(
- onClick = onClick,
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 4.dp),
- enabled = enabled,
- colors = ButtonDefaults.buttonColors(),
- ) {
- Text(text = text)
- }
-}
-
@Stable
@Composable
private fun ServerDiscoveryList(
diff --git a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt
index 57158181c..47f15d3a2 100644
--- a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt
+++ b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt
@@ -42,6 +42,7 @@ object Constants {
const val PREF_EXTERNAL_PLAYER_APP = "pref_external_player_app"
const val PREF_SUBTITLE_STYLE = "pref_subtitle_style"
const val PREF_DOWNLOAD_LOCATION = "pref_download_location"
+ const val PREF_DOWNLOAD_INTERNAL = "pref_download_internal"
// InputManager commands
const val PLAYBACK_MANAGER_COMMAND_PLAY = "unpause"
@@ -60,6 +61,7 @@ object Constants {
else -> 0
}
const val MEDIA_NOTIFICATION_CHANNEL_ID = "org.jellyfin.mobile.media.NOW_PLAYING"
+ const val DOWNLOAD_NOTIFICATION_CHANNEL_ID = "org.jellyfin.mobile.download.DOWNLOAD_PROGRESS"
// Music player constants
const val SUPPORTED_MUSIC_PLAYER_PLAYBACK_ACTIONS: Long = PlaybackState.ACTION_PLAY_PAUSE or
@@ -125,6 +127,7 @@ object Constants {
PlaybackState.ACTION_FAST_FORWARD or
PlaybackState.ACTION_STOP
const val VIDEO_PLAYER_NOTIFICATION_ID = 99
+ const val DOWNLOAD_NOTIFICATION_ID = 80
// Video player intent extras
const val EXTRA_MEDIA_PLAY_OPTIONS = "org.jellyfin.mobile.MEDIA_PLAY_OPTIONS"
@@ -145,4 +148,6 @@ object Constants {
// Misc
const val PERCENT_MAX = 100
+ const val DOWNLOAD_PATH = "/MediaCache/"
+ const val DOWNLOAD_THUMBNAIL_FILENAME = "thumbnail.jpg"
}
diff --git a/app/src/main/java/org/jellyfin/mobile/utils/DownloadMethod.java b/app/src/main/java/org/jellyfin/mobile/utils/DownloadMethod.java
deleted file mode 100644
index f36b1b4d8..000000000
--- a/app/src/main/java/org/jellyfin/mobile/utils/DownloadMethod.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.jellyfin.mobile.utils;
-
-import static org.jellyfin.mobile.utils.DownloadMethod.MOBILE_AND_ROAMING;
-import static org.jellyfin.mobile.utils.DownloadMethod.MOBILE_DATA;
-import static org.jellyfin.mobile.utils.DownloadMethod.WIFI_ONLY;
-
-import androidx.annotation.IntDef;
-
-@IntDef({WIFI_ONLY, MOBILE_DATA, MOBILE_AND_ROAMING})
-public @interface DownloadMethod {
- int WIFI_ONLY = 0;
- int MOBILE_DATA = 1;
- int MOBILE_AND_ROAMING = 2;
-}
diff --git a/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt
index ee450d47a..c5c211a0a 100644
--- a/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt
+++ b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt
@@ -1,17 +1,15 @@
package org.jellyfin.mobile.utils
-import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
+import android.Manifest
import android.app.Activity
import android.app.ActivityManager
import android.app.AlertDialog
-import android.app.DownloadManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
-import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.net.Uri
import android.os.Environment
import android.os.PowerManager
@@ -19,24 +17,29 @@ import android.provider.Settings
import android.provider.Settings.System.ACCELEROMETER_ROTATION
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.getSystemService
+import com.google.android.exoplayer2.offline.DownloadService
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.suspendCancellableCoroutine
-import kotlinx.coroutines.withTimeout
import org.jellyfin.mobile.BuildConfig
import org.jellyfin.mobile.MainActivity
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.AppPreferences
+import org.jellyfin.mobile.data.dao.DownloadDao
+import org.jellyfin.mobile.data.entity.DownloadEntity
+import org.jellyfin.mobile.downloads.DownloadMethod
+import org.jellyfin.mobile.downloads.DownloadUtils
+import org.jellyfin.mobile.downloads.JellyfinDownloadService
+import org.jellyfin.mobile.player.source.LocalJellyfinMediaSource
import org.jellyfin.mobile.settings.ExternalPlayerPackage
import org.jellyfin.mobile.webapp.WebViewFragment
import org.koin.android.ext.android.get
import timber.log.Timber
import java.io.File
import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
fun WebViewFragment.requestNoBatteryOptimizations(rootView: CoordinatorLayout) {
if (AndroidVersion.isAtLeastM) {
- val powerManager: PowerManager = requireContext().getSystemService(Activity.POWER_SERVICE) as PowerManager
+ val powerManager = requireContext().getSystemService(Activity.POWER_SERVICE) as PowerManager
if (
!appPreferences.ignoreBatteryOptimizations &&
!powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)
@@ -59,26 +62,9 @@ fun WebViewFragment.requestNoBatteryOptimizations(rootView: CoordinatorLayout) {
}
}
-suspend fun MainActivity.requestDownload(uri: Uri, title: String, filename: String) {
+suspend fun MainActivity.requestDownload(uri: Uri, filename: String) {
val appPreferences: AppPreferences = get()
- // Storage permission for downloads isn't necessary from Android 10 onwards
- if (!AndroidVersion.isAtLeastQ) {
- @Suppress("MagicNumber")
- val granted = withTimeout(2 * 60 * 1000 /* 2 minutes */) {
- suspendCoroutine { continuation ->
- requestPermission(WRITE_EXTERNAL_STORAGE) { requestPermissionsResult ->
- continuation.resume(requestPermissionsResult[WRITE_EXTERNAL_STORAGE] == PERMISSION_GRANTED)
- }
- }
- }
-
- if (!granted) {
- toast(R.string.download_no_storage_permission)
- return
- }
- }
-
val downloadMethod = appPreferences.downloadMethod ?: suspendCancellableCoroutine { continuation ->
AlertDialog.Builder(this)
.setTitle(R.string.network_title)
@@ -105,22 +91,67 @@ suspend fun MainActivity.requestDownload(uri: Uri, title: String, filename: Stri
.show()
}
- val downloadRequest = DownloadManager.Request(uri)
- .setTitle(title)
- .setDescription(getString(R.string.downloading))
- .setDestinationUri(Uri.fromFile(File(appPreferences.downloadLocation, filename)))
- .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+ val permissionResult: Boolean = suspendCancellableCoroutine { continuation ->
+ requestPermission("android.permission.POST_NOTIFICATIONS") { permissionsMap ->
+ if (permissionsMap[Manifest.permission.POST_NOTIFICATIONS] == PackageManager.PERMISSION_GRANTED) {
+ continuation.resume(true)
+ } else {
+ continuation.cancel(null)
+ }
+ }
+ }
- downloadFile(downloadRequest, downloadMethod)
+ if (permissionResult) {
+ val downloadUtils = DownloadUtils(this, filename, uri.toString(), downloadMethod)
+ downloadUtils.download()
+ }
}
+suspend fun MainActivity.removeDownload(download: LocalJellyfinMediaSource, force: Boolean = false) {
+ if (!force) {
+ val confirmation = suspendCancellableCoroutine { continuation ->
+ AlertDialog.Builder(this)
+ .setTitle(getString(R.string.confirm_deletion))
+ .setMessage(getString(R.string.confirm_deletion_desc, download.name))
+ .setPositiveButton(getString(R.string.yes)) { _, _ ->
+ continuation.resume(true)
+ }
+ .setNegativeButton(getString(R.string.no)) { _, _ ->
+ continuation.cancel(null)
+ }
+ .setOnDismissListener {
+ continuation.cancel(null)
+ }
+ .setCancelable(false)
+ .show()
+ }
+
+ if (!confirmation) return
+ }
-private fun Context.downloadFile(request: DownloadManager.Request, @DownloadMethod downloadMethod: Int) {
- require(downloadMethod >= 0) { "Download method hasn't been set" }
- request.apply {
- setAllowedOverMetered(downloadMethod >= DownloadMethod.MOBILE_DATA)
- setAllowedOverRoaming(downloadMethod == DownloadMethod.MOBILE_AND_ROAMING)
+ val downloadDao: DownloadDao = get()
+ val downloadEntity: DownloadEntity = requireNotNull(downloadDao.get(download.id))
+ val downloadDir = File(downloadEntity.mediaSource.localDirectoryUri)
+ downloadDao.delete(download.id)
+ downloadDir.deleteRecursively()
+
+ val contentId = download.itemId.toString()
+ // Remove media file
+ DownloadService.sendRemoveDownload(
+ this,
+ JellyfinDownloadService::class.java,
+ contentId,
+ false,
+ )
+
+ // Remove subtitles
+ download.externalSubtitleStreams.forEach {
+ DownloadService.sendRemoveDownload(
+ this,
+ JellyfinDownloadService::class.java,
+ "$contentId:${it.index}",
+ false,
+ )
}
- getSystemService()?.enqueue(request)
}
fun Activity.isAutoRotateOn() = Settings.System.getInt(contentResolver, ACCELEROMETER_ROTATION, 0) == 1
diff --git a/app/src/main/java/org/jellyfin/mobile/utils/extensions/Long.kt b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Long.kt
new file mode 100644
index 000000000..77c5252c6
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Long.kt
@@ -0,0 +1,21 @@
+@file:Suppress("NOTHING_TO_INLINE")
+
+package org.jellyfin.mobile.utils.extensions
+
+import androidx.annotation.CheckResult
+import org.jellyfin.mobile.data.entity.DownloadEntity.Key.BYTES_PER_BINARY_UNIT
+import java.util.Locale
+
+@CheckResult
+inline fun Long.toFileSize(): String {
+ val units = arrayOf("B", "KB", "MB", "GB", "TB")
+ var size = this.toDouble()
+ var unitIndex = 0
+
+ while (size >= BYTES_PER_BINARY_UNIT && unitIndex < units.lastIndex) {
+ size /= BYTES_PER_BINARY_UNIT
+ unitIndex++
+ }
+
+ return "%.1f %s".format(Locale.ROOT, size, units[unitIndex])
+}
diff --git a/app/src/main/res/layout/download_item.xml b/app/src/main/res/layout/download_item.xml
new file mode 100644
index 000000000..a64202fd1
--- /dev/null
+++ b/app/src/main/res/layout/download_item.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_downloads.xml b/app/src/main/res/layout/fragment_downloads.xml
new file mode 100644
index 000000000..b13e6a4e5
--- /dev/null
+++ b/app/src/main/res/layout/fragment_downloads.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 330a048d8..3c0eb18b8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -119,4 +119,20 @@
Downloads
Download location
+ View downloads
+ Downloads
+ Download notifications
+ Failed to download thumbnail
+ Failed to retrieve media information
+ Please check your allowed download methods
+ Confirm deletion
+ Do you want to delete %1$s?
+ Yes
+ No
+ Thumbnail
+ Store videos in internal storage
+ Videos such as movies and TV shows will be stored in the app\'s internal storage. Allowing access from within the app.
+ %1$s - S%2$dE%3$d
+ Downloading %1$d titles
+ Downloaded %1$s
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 10ee45725..833ef3e81 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -8,6 +8,7 @@ android-junit5 = "1.10.0.0"
# KotlinX
coroutines = "1.8.1"
+serialization-json = "1.6.3"
# Core
koin = "3.5.6"
@@ -36,6 +37,7 @@ compose-material = "1.6.8"
# Network
jellyfin-sdk = "1.4.7"
okhttp = "4.12.0"
+okio = "3.9.0"
coil = "2.6.0"
cronet-embedded = "119.6045.31"
@@ -64,6 +66,7 @@ androidx-test-espresso = "3.6.1"
android-app = { id = "com.android.application", version.ref = "android-plugin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "kotlin-ksp" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
android-junit5 = { id = "de.mannodermaus.android-junit5", version.ref = "android-junit5" }
@@ -73,6 +76,7 @@ detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
# KotlinX
coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization-json" }
# Core
koin = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
@@ -110,6 +114,7 @@ compose-material-icons-extended = { group = "androidx.compose.material", name =
# Network
jellyfin-sdk = { group = "org.jellyfin.sdk", name = "jellyfin-core", version.ref = "jellyfin-sdk" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+okio = { group = "com.squareup.okio", name = "okio", version.ref = "okio" }
coil = { group = "io.coil-kt", name = "coil-base", version.ref = "coil" }
cronet-embedded = { group = "org.chromium.net", name = "cronet-embedded", version.ref = "cronet-embedded" }
@@ -129,6 +134,7 @@ jellyfin-exoplayer-ffmpegextension = { group = "org.jellyfin.exoplayer", name =
# Room
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidx-room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidx-room" }
+androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "androidx-room" }
# Monitoring
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }