diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e9c48410..f1a98fd1 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -121,6 +121,8 @@ dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.androidx.activity)
implementation(libs.androidx.fragment)
+ implementation(libs.androidx.documentfile)
+ implementation(libs.androidx.work.runtime)
coreLibraryDesugaring(libs.androiddesugarlibs)
// Lifecycle
diff --git a/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/5.json b/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/5.json
new file mode 100644
index 00000000..9e6b3556
--- /dev/null
+++ b/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/5.json
@@ -0,0 +1,239 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 5,
+ "identityHash": "bb9e921c1ce696cf9a83c92fff1903e1",
+ "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`)"
+ }
+ ]
+ },
+ {
+ "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"
+ },
+ {
+ "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}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `server_id` INTEGER NOT NULL, `user_id` INTEGER NOT NULL, `item_id` TEXT NOT NULL, `path` TEXT NOT NULL, `item` TEXT NOT NULL, `status` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, FOREIGN KEY(`server_id`) REFERENCES `server`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`) REFERENCES `user`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "server_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "item_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "item",
+ "columnName": "item",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "modifiedAt",
+ "columnName": "modified_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_download_server_id",
+ "unique": false,
+ "columnNames": [
+ "server_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_download_server_id` ON `${TABLE_NAME}` (`server_id`)"
+ },
+ {
+ "name": "index_download_user_id",
+ "unique": false,
+ "columnNames": [
+ "user_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_download_user_id` ON `${TABLE_NAME}` (`user_id`)"
+ },
+ {
+ "name": "index_download_item_id",
+ "unique": false,
+ "columnNames": [
+ "item_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_download_item_id` ON `${TABLE_NAME}` (`item_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "server",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "server_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "user",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "user_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "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, 'bb9e921c1ce696cf9a83c92fff1903e1')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e4d28642..3fad6f35 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -79,15 +79,9 @@
android:foregroundServiceType="mediaPlayback" />
-
-
-
-
-
-
+ android:name="androidx.work.impl.foreground.SystemForegroundService"
+ android:foregroundServiceType="dataSync"
+ tools:node="merge" />
+
+
diff --git a/app/src/main/java/org/jellyfin/mobile/MainViewModel.kt b/app/src/main/java/org/jellyfin/mobile/MainViewModel.kt
index 5d8348ea..185ef6a6 100644
--- a/app/src/main/java/org/jellyfin/mobile/MainViewModel.kt
+++ b/app/src/main/java/org/jellyfin/mobile/MainViewModel.kt
@@ -8,6 +8,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.jellyfin.mobile.app.ApiClientController
import org.jellyfin.mobile.data.entity.ServerEntity
+import org.jellyfin.mobile.data.entity.UserEntity
+import java.util.UUID
class MainViewModel(
app: Application,
@@ -16,15 +18,25 @@ class MainViewModel(
private val _serverState: MutableStateFlow = MutableStateFlow(ServerState.Pending)
val serverState: StateFlow get() = _serverState
+ private val _userState: MutableStateFlow = MutableStateFlow(UserState.Pending)
+ val userState: StateFlow get() = _userState
+
init {
viewModelScope.launch {
refreshServer()
+ refreshUser()
}
}
suspend fun switchServer(hostname: String) {
apiClientController.setupServer(hostname)
refreshServer()
+ refreshUser()
+ }
+
+ suspend fun setupUser(serverId: Long, userId: UUID, accessToken: String) {
+ apiClientController.setupUser(serverId, userId, accessToken)
+ refreshUser()
}
private suspend fun refreshServer() {
@@ -32,11 +44,17 @@ class MainViewModel(
_serverState.value = serverEntity?.let { entity -> ServerState.Available(entity) } ?: ServerState.Unset
}
+ private suspend fun refreshUser() {
+ val userEntity = apiClientController.loadSavedUser()
+ _userState.value = userEntity?.let { entity -> UserState.Available(entity) } ?: UserState.Unset
+ }
+
/**
* Temporarily unset the selected server to be able to connect to a different one
*/
fun resetServer() {
_serverState.value = ServerState.Unset
+ _userState.value = UserState.Unset
}
}
@@ -47,3 +65,11 @@ sealed class ServerState {
object Unset : ServerState()
class Available(override val server: ServerEntity) : ServerState()
}
+
+sealed class UserState {
+ open val user: UserEntity? = null
+
+ object Pending : UserState()
+ object Unset : UserState()
+ class Available(override val user: UserEntity) : UserState()
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt b/app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt
index e2a83e12..56f5a3f4 100644
--- a/app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt
+++ b/app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt
@@ -5,6 +5,8 @@ import kotlinx.coroutines.withContext
import org.jellyfin.mobile.data.dao.ServerDao
import org.jellyfin.mobile.data.dao.UserDao
import org.jellyfin.mobile.data.entity.ServerEntity
+import org.jellyfin.mobile.data.entity.ServerUser
+import org.jellyfin.mobile.data.entity.UserEntity
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.DeviceInfo
@@ -46,7 +48,12 @@ class ApiClientController(
return server
}
- suspend fun loadSavedServerUser() {
+ suspend fun loadSavedUser(): UserEntity? = withContext(Dispatchers.IO) {
+ val userId = appPreferences.currentUserId ?: return@withContext null
+ userDao.getUser(userId)
+ }
+
+ suspend fun loadSavedServerUser(): ServerUser? {
val serverUser = withContext(Dispatchers.IO) {
val serverId = appPreferences.currentServerId ?: return@withContext null
val userId = appPreferences.currentUserId ?: return@withContext null
@@ -60,6 +67,8 @@ class ApiClientController(
} else {
resetApiClientUser()
}
+
+ return serverUser
}
suspend fun loadPreviouslyUsedServers(): List = withContext(Dispatchers.IO) {
@@ -86,4 +95,14 @@ class ApiClientController(
deviceInfo = baseDeviceInfo,
)
}
+
+ fun getApiClient(server: Long, user: Long): ApiClient {
+ val serverUser = userDao.getServerUser(server, user) ?: error("Invalid server user combination (server=$server, user=$user)")
+
+ return jellyfin.createApi(
+ baseUrl = serverUser.server.hostname,
+ accessToken = serverUser.user.accessToken,
+ deviceInfo = baseDeviceInfo.copy(id = baseDeviceInfo.id + serverUser.user.userId),
+ )
+ }
}
diff --git a/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt b/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt
index a7eecd20..ad4f777f 100644
--- a/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt
+++ b/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt
@@ -21,13 +21,18 @@ import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ts.TsExtractor
+import androidx.work.WorkManager
import coil3.ImageLoader
import kotlinx.coroutines.channels.Channel
import okhttp3.OkHttpClient
import org.jellyfin.mobile.MainViewModel
import org.jellyfin.mobile.bridge.MediaSegments
import org.jellyfin.mobile.bridge.NativePlayer
+import org.jellyfin.mobile.downloads.DownloadManager
+import org.jellyfin.mobile.downloads.DownloadNotificationManager
+import org.jellyfin.mobile.downloads.DownloadQueue
import org.jellyfin.mobile.downloads.DownloadsViewModel
+import org.jellyfin.mobile.downloads.FileDownloader
import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
import org.jellyfin.mobile.player.interaction.PlayerEvent
@@ -62,6 +67,7 @@ val applicationModule = module {
single { PermissionRequestHelper() }
single { RemoteVolumeProvider(get()) }
single(named(PLAYER_EVENT_CHANNEL)) { Channel() }
+ factory { WorkManager.getInstance(get()) }
// Controllers
single { ApiClientController(get(), get(), get(), get(), get()) }
@@ -145,7 +151,7 @@ val applicationModule = module {
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
.setCacheWriteDataSinkFactory(null)
.setCacheKeyFactory { spec ->
- spec.uri.extractId()
+ spec.key ?: spec.uri.extractId()
}
}
@@ -165,4 +171,10 @@ val applicationModule = module {
single { ProgressiveMediaSource.Factory(get()) }
single { HlsMediaSource.Factory(get()) }
single { SingleSampleMediaSource.Factory(get()) }
+
+ single(createdAtStart = true) { StorageManager(get(), get()) }
+ single { DownloadManager(get(), get(), get(), get(), get()) }
+ single { DownloadNotificationManager(get()) }
+ single { DownloadQueue(get(), get(), get(), get(), get(), get()) }
+ single { FileDownloader(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 9e2d166e..bac6ccd7 100644
--- a/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt
+++ b/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt
@@ -2,16 +2,15 @@ package org.jellyfin.mobile.app
import android.content.Context
import android.content.SharedPreferences
-import android.os.Environment
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
import androidx.core.content.edit
+import org.jellyfin.mobile.downloads.DownloadMethod
import org.jellyfin.mobile.player.mediasegments.MediaSegmentAction
import org.jellyfin.mobile.player.mediasegments.toMediaSegmentActionsString
import org.jellyfin.mobile.settings.ExternalPlayerPackage
import org.jellyfin.mobile.settings.VideoPlayerType
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.sdk.model.api.MediaSegmentType
-import java.io.File
class AppPreferences(context: Context) {
private val sharedPreferences: SharedPreferences =
@@ -57,48 +56,24 @@ class AppPreferences(context: Context) {
}
}
- var downloadMethod: Int?
- get() = sharedPreferences.getInt(Constants.PREF_DOWNLOAD_METHOD, -1).takeIf { it >= 0 }
+ var downloadMethod: DownloadMethod?
+ get() = DownloadMethod.fromInt(sharedPreferences.getInt(Constants.PREF_DOWNLOAD_METHOD, -1))
set(value) {
if (value != null) {
sharedPreferences.edit {
- putInt(Constants.PREF_DOWNLOAD_METHOD, value)
+ putInt(Constants.PREF_DOWNLOAD_METHOD, value.intValue)
}
}
}
- var downloadLocation: String
- get() {
- val savedStorage = sharedPreferences.getString(Constants.PREF_DOWNLOAD_LOCATION, null)
- if (savedStorage != null) {
- if (File(savedStorage).parentFile?.isDirectory == true) {
- // Saved location is still valid
- return savedStorage
- } else {
- // Reset download option if corrupt
- sharedPreferences.edit {
- remove(Constants.PREF_DOWNLOAD_LOCATION)
- }
- }
- }
-
- // Return default storage location
- return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath
- }
+ var storageLocation: String?
+ get() = sharedPreferences.getString(Constants.PREF_STORAGE_LOCATION, null)
set(value) {
sharedPreferences.edit {
- if (File(value).parentFile?.isDirectory == true) {
- putString(Constants.PREF_DOWNLOAD_LOCATION, value)
- }
- }
- }
-
- var downloadToInternal: Boolean?
- get() = sharedPreferences.getBoolean(Constants.PREF_DOWNLOAD_INTERNAL, true)
- set(value) {
- if (value != null) {
- sharedPreferences.edit {
- putBoolean(Constants.PREF_DOWNLOAD_METHOD, value)
+ if (value == null) {
+ remove(Constants.PREF_STORAGE_LOCATION)
+ } else {
+ putString(Constants.PREF_STORAGE_LOCATION, value)
}
}
}
diff --git a/app/src/main/java/org/jellyfin/mobile/app/StorageManager.kt b/app/src/main/java/org/jellyfin/mobile/app/StorageManager.kt
new file mode 100644
index 00000000..43b24c01
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/app/StorageManager.kt
@@ -0,0 +1,48 @@
+package org.jellyfin.mobile.app
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Environment
+import androidx.core.net.toUri
+import androidx.documentfile.provider.DocumentFile
+import org.jellyfin.mobile.R
+import java.io.File
+
+class StorageManager(
+ private val context: Context,
+ private val appPreferences: AppPreferences,
+) {
+ private val defaultStorageLocation
+ get() = Environment.getExternalStorageDirectory().absolutePath + File.separator + context.getString(R.string.app_name_short)
+
+ init {
+ ensureNoMedia(getStorageLocation())
+ }
+
+ fun getStorageLocation(): DocumentFile = appPreferences.storageLocation?.toUri()?.let {
+ DocumentFile.fromTreeUri(context, it)
+ } ?: DocumentFile.fromFile(File(defaultStorageLocation))
+
+ fun changeStorageLocation(location: Uri) {
+ if (appPreferences.storageLocation?.toUri() == location) return
+
+ val documentFile = DocumentFile.fromTreeUri(context, location) ?: error("Invalid location $location")
+ context.contentResolver.takePersistableUriPermission(
+ documentFile.uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
+ )
+ ensureNoMedia(documentFile)
+ appPreferences.storageLocation = documentFile.uri.toString()
+ }
+
+ private fun ensureNoMedia(documentFile: DocumentFile) {
+ if (documentFile.findFile(NOMEDIA_FILE) == null) {
+ documentFile.createFile("", NOMEDIA_FILE)
+ }
+ }
+
+ companion object {
+ const val NOMEDIA_FILE = ".nomedia"
+ }
+}
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 e67f3306..c0cc7c07 100644
--- a/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt
+++ b/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt
@@ -6,7 +6,6 @@ import android.content.Intent
import android.media.session.PlaybackState
import android.webkit.JavascriptInterface
import androidx.core.content.ContextCompat
-import androidx.core.net.toUri
import org.jellyfin.mobile.events.ActivityEvent
import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.utils.Constants
@@ -25,6 +24,7 @@ import org.jellyfin.mobile.webapp.RemotePlayerService
import org.jellyfin.mobile.webapp.RemoteVolumeProvider
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
+import org.jellyfin.sdk.model.serializer.toUUID
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
@@ -32,6 +32,7 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
import timber.log.Timber
+import java.util.UUID
@Suppress("unused")
class NativeInterface(private val context: Context) : KoinComponent {
@@ -126,16 +127,16 @@ class NativeInterface(private val context: Context) : KoinComponent {
fun downloadFiles(args: String): Boolean {
try {
val files = JSONArray(args)
+ val itemIds = mutableSetOf()
repeat(files.length()) { index ->
val file = files.getJSONObject(index)
+ val itemId = file.getString("itemId").toUUID()
- val title: String = file.getString("title")
- val filename: String = file.getString("filename")
- val url: String = file.getString("url")
-
- emitEvent(ActivityEvent.DownloadFile(url.toUri(), title, filename))
+ itemIds.add(itemId)
}
+
+ emitEvent(ActivityEvent.DownloadItems(itemIds))
} catch (e: JSONException) {
Timber.e("Download failed: %s", e.message)
return false
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 12bcab37..3013ddf9 100644
--- a/app/src/main/java/org/jellyfin/mobile/data/JellyfinDatabase.kt
+++ b/app/src/main/java/org/jellyfin/mobile/data/JellyfinDatabase.kt
@@ -8,12 +8,14 @@ import androidx.room.TypeConverter
import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteDatabase
+import kotlinx.serialization.json.Json
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
+import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
@@ -24,7 +26,7 @@ import java.util.UUID
UserEntity::class,
DownloadEntity::class,
],
- version = 4,
+ version = 5,
autoMigrations = [
AutoMigration(from = 2, to = 3),
AutoMigration(from = 3, to = 4, spec = JellyfinDatabase.MigrateV4::class),
@@ -44,6 +46,12 @@ abstract class JellyfinDatabase : RoomDatabase() {
@TypeConverter
fun toUuid(value: String?): UUID? = value?.toUUIDOrNull()
+
+ @TypeConverter
+ fun fromBaseItemDto(baseItem: BaseItemDto?): String? = baseItem?.let(Json::encodeToString)
+
+ @TypeConverter
+ fun toBaseItemDto(json: String?): BaseItemDto? = json?.let(Json::decodeFromString)
}
// Migrations
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
index 8bd92601..b7592cd4 100644
--- a/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt
+++ b/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt
@@ -4,24 +4,34 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
+import androidx.room.Update
import kotlinx.coroutines.flow.Flow
import org.jellyfin.mobile.data.entity.DownloadEntity
-import org.jellyfin.mobile.data.entity.DownloadEntity.Key.TABLE_NAME
+import org.jellyfin.sdk.model.UUID
@Dao
interface DownloadDao {
+ @Query("SELECT * FROM download ORDER BY created_at DESC")
+ fun getAllDownloads(): Flow>
+
+ @Query("SELECT * FROM download WHERE status = 'QUEUED' OR status = 'DOWNLOADING' ORDER BY created_at ASC")
+ fun getQueuedDownloads(): List
+
+ @Query("SELECT * FROM download WHERE item_id IN (:itemIds)")
+ fun getDownloadsByItemIds(itemIds: Collection): List
+
+ @Query("SELECT * FROM download WHERE item_id = :itemId")
+ fun getDownloadByItemId(itemId: UUID): DownloadEntity?
+
+ @Query("SELECT * FROM download WHERE id = :id")
+ suspend fun getDownload(id: Long): DownloadEntity?
+
@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)
+ @Update(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun update(entity: DownloadEntity): Int
- @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
+ @Query("DELETE FROM download WHERE id = :id")
+ suspend fun delete(id: Long)
}
diff --git a/app/src/main/java/org/jellyfin/mobile/data/dao/UserDao.kt b/app/src/main/java/org/jellyfin/mobile/data/dao/UserDao.kt
index 033417cf..7a70b324 100644
--- a/app/src/main/java/org/jellyfin/mobile/data/dao/UserDao.kt
+++ b/app/src/main/java/org/jellyfin/mobile/data/dao/UserDao.kt
@@ -44,6 +44,9 @@ interface UserDao {
@Query("SELECT * FROM $TABLE_NAME WHERE $SERVER_ID = :serverId AND $USER_ID = :userId")
fun getByUserId(serverId: Long, userId: UUID): UserEntity?
+ @Query("SELECT * FROM $TABLE_NAME WHERE $ID = :id")
+ fun getUser(id: Long): UserEntity?
+
@Query("SELECT * FROM $TABLE_NAME WHERE $SERVER_ID = :serverId")
fun getAllForServer(serverId: Long): List
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
index 4b1c1552..15e0fead 100644
--- a/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt
+++ b/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt
@@ -2,78 +2,44 @@ package org.jellyfin.mobile.data.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
-import androidx.room.Ignore
+import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
-import androidx.room.TypeConverter
-import androidx.room.TypeConverters
-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.extensions.toFileSize
-import kotlin.time.Duration
+import org.jellyfin.mobile.downloads.DownloadStatus
+import org.jellyfin.sdk.model.api.BaseItemDto
+import java.util.UUID
@Entity(
- tableName = TABLE_NAME,
- indices = [
- Index(value = [ITEM_ID], unique = true),
+ tableName = "download",
+ indices = [Index(value = ["server_id"]), Index(value = ["user_id"]), Index(value = ["item_id"])],
+ foreignKeys = [
+ ForeignKey(
+ entity = ServerEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["server_id"],
+ onDelete = ForeignKey.CASCADE,
+ ),
+ ForeignKey(
+ entity = UserEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["user_id"],
+ onDelete = ForeignKey.CASCADE,
+ ),
],
)
-@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 startTime The start time as a [Duration]. 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(
- startTime: Duration? = null,
- audioStreamIndex: Int? = null,
- subtitleStreamIndex: Int? = null,
- ): LocalJellyfinMediaSource = mediaSource
- .also { localJellyfinMediaSource ->
- startTime
- ?.let { localJellyfinMediaSource.startTime = it }
- audioStreamIndex
- ?.let { localJellyfinMediaSource.mediaStreams[it] }
- ?.let(localJellyfinMediaSource::selectAudioStream)
- subtitleStreamIndex
- ?.run {
- takeUnless { it == -1 }
- ?.let { localJellyfinMediaSource.mediaStreams[it] }
- ?: localJellyfinMediaSource.selectSubtitleStream(null)
- }
- }
+ @PrimaryKey(autoGenerate = true)
+ @ColumnInfo(name = "id") val id: Long = 0L,
- constructor(mediaSource: LocalJellyfinMediaSource) :
- this(mediaSource.id, mediaSource)
+ @ColumnInfo(name = "server_id") val serverId: Long,
+ @ColumnInfo(name = "user_id") val userId: Long,
+ @ColumnInfo(name = "item_id") val itemId: UUID,
- @Ignore
- val fileSize: String = mediaSource.downloadSize.toFileSize()
+ @ColumnInfo(name = "path") val path: String,
+ @ColumnInfo(name = "item") val item: BaseItemDto,
- 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"
- }
-}
+ @ColumnInfo(name = "status") val status: DownloadStatus = DownloadStatus.QUEUED,
-class LocalJellyfinMediaSourceConverter {
- @TypeConverter
- fun toLocalJellyfinMediaSource(value: String): LocalJellyfinMediaSource = decodeFromString(value)
-
- @TypeConverter
- fun fromLocalJellyfinMediaSource(value: LocalJellyfinMediaSource): String = Json.encodeToString(value)
-}
+ @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
+ @ColumnInfo(name = "modified_at") var modifiedAt: Long = System.currentTimeMillis(),
+)
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/ContentRange.kt b/app/src/main/java/org/jellyfin/mobile/downloads/ContentRange.kt
new file mode 100644
index 00000000..6ae3cf67
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/ContentRange.kt
@@ -0,0 +1,44 @@
+package org.jellyfin.mobile.downloads
+
+data class ContentRange(
+ val start: Long,
+ val end: Long,
+ val total: Long,
+) {
+ companion object {
+ fun fromContentLengthHeader(input: String): ContentRange {
+ val value = input.toLongOrNull()
+ requireNotNull(value) { "Invalid content length $input" }
+
+ return ContentRange(0, value, value)
+ }
+
+ fun fromContentRangeHeader(input: String): ContentRange {
+ val parts = input.split(" ")
+ if (parts.size != 2) error("Invalid formatted content range $input")
+
+ val rangeAndTotal = parts[1].split("/")
+
+ if (rangeAndTotal.size != 2) error("Invalid formatted content range $input")
+ val rangePart = rangeAndTotal[0]
+ val totalPart = rangeAndTotal[1]
+
+ val total = totalPart.takeIf { it != "*" }?.toLongOrNull()
+ requireNotNull(total) { "Total size is missing in content range $input" }
+
+ val (start, end) = when (rangePart) {
+ "*" -> 0L to 0L
+ else -> {
+ val dashParts = rangePart.split("-")
+ if (dashParts.size != 2) error("Invalid formatted content range $input")
+ dashParts[0].toLongOrNull() to dashParts[1].toLongOrNull()
+ }
+ }
+
+ requireNotNull(start) { "Start is missing in content range $input" }
+ requireNotNull(end) { "End is missing in content range $input" }
+
+ return ContentRange(start, end, total)
+ }
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadBroadcastReceiver.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadBroadcastReceiver.kt
new file mode 100644
index 00000000..8b8512b4
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadBroadcastReceiver.kt
@@ -0,0 +1,43 @@
+package org.jellyfin.mobile.downloads
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+
+class DownloadBroadcastReceiver : BroadcastReceiver(), KoinComponent {
+ companion object {
+ private const val ACTION_DOWNLOAD_CANCEL = "download_cancel"
+ private const val EXTRA_DOWNLOAD_ID = "download_id"
+
+ fun cancelDownloadIntent(context: Context, downloadId: Long) = Intent(
+ context,
+ DownloadBroadcastReceiver::class.java,
+ ).apply {
+ action = ACTION_DOWNLOAD_CANCEL
+ putExtra(EXTRA_DOWNLOAD_ID, downloadId)
+ }
+ }
+
+ private val downloadManager by inject()
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == ACTION_DOWNLOAD_CANCEL) {
+ val id = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -1L)
+ if (id == -1L) return
+
+ val pendingResult = goAsync()
+ CoroutineScope(SupervisorJob()).launch {
+ try {
+ downloadManager.cancel(id)
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadManager.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadManager.kt
new file mode 100644
index 00000000..960b0281
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadManager.kt
@@ -0,0 +1,118 @@
+package org.jellyfin.mobile.downloads
+
+import android.content.Context
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.jellyfin.mobile.app.AppPreferences
+import org.jellyfin.mobile.app.StorageManager
+import org.jellyfin.mobile.data.dao.DownloadDao
+import org.jellyfin.mobile.data.entity.DownloadEntity
+import org.jellyfin.mobile.data.entity.ServerEntity
+import org.jellyfin.mobile.data.entity.UserEntity
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.itemsApi
+import org.jellyfin.sdk.model.api.ItemFields
+import java.util.UUID
+
+class DownloadManager(
+ private val context: Context,
+ private val api: ApiClient,
+ private val downloadDao: DownloadDao,
+ private val appPreferences: AppPreferences,
+ private val storageManager: StorageManager,
+) {
+ companion object {
+ /**
+ * How many items can be processed at once in [enqueueItems]. If more items are enqueued at once they will be
+ * split into separate download chunks.
+ */
+ private const val ITEMS_BATCH = 25
+ }
+
+ suspend fun enqueueItems(
+ server: ServerEntity,
+ user: UserEntity,
+ items: Collection,
+ ) = withContext(Dispatchers.IO) {
+ for (itemsChunk in items.chunked(ITEMS_BATCH)) {
+ val existingItems = downloadDao.getDownloadsByItemIds(itemsChunk)
+ .filter { it.serverId == server.id }
+ .associateBy { it.itemId }
+
+ val response by api.itemsApi.getItems(
+ ids = itemsChunk,
+ fields = setOf(ItemFields.MEDIA_SOURCES, ItemFields.PATH),
+ )
+
+ // Sanity check, this shouldn't happen really
+ if (response.items.size != itemsChunk.size) {
+ error(
+ "Requested ${itemsChunk.size} items but only got ${response.items.size}. Indicating one or multiple items do not exist.",
+ )
+ }
+
+ for (item in response.items) {
+ var downloadEntity = existingItems[item.id]
+ if (downloadEntity != null) {
+ // If the item already exists we just update the local information for it and requeue it
+ // this will force the download worker to recheck the local file in case it is missing or changed
+ downloadEntity = downloadEntity.copy(
+ item = item,
+ status = DownloadStatus.QUEUED,
+ modifiedAt = System.currentTimeMillis(),
+ )
+ downloadDao.update(downloadEntity)
+ } else {
+ // Otherwise we create a new one
+ downloadEntity = DownloadEntity(
+ serverId = server.id,
+ userId = user.id,
+ itemId = item.id,
+ item = item,
+ path = item.name ?: item.id.toString(),
+ )
+ downloadDao.insert(downloadEntity)
+ }
+ }
+ }
+
+ if (!DownloadWorker.isActive(context)) {
+ DownloadWorker.start(context, appPreferences)
+ }
+ }
+
+ suspend fun resume(downloadEntity: DownloadEntity) = withContext(Dispatchers.IO) {
+ downloadDao.update(
+ downloadEntity.copy(
+ status = DownloadStatus.QUEUED,
+ modifiedAt = System.currentTimeMillis(),
+ ),
+ )
+
+ if (!DownloadWorker.isActive(context)) {
+ DownloadWorker.start(context, appPreferences)
+ }
+ }
+
+ suspend fun cancel(id: Long) = withContext(Dispatchers.IO) {
+ val download = downloadDao.getDownload(id)
+ if (download != null) {
+ DownloadWorker.stop(context)
+ downloadDao.update(download.copy(status = DownloadStatus.CANCELLED))
+ DownloadWorker.start(context, appPreferences)
+ }
+ }
+
+ suspend fun delete(id: Long, deleteFiles: Boolean) = withContext(Dispatchers.IO) {
+ val download = downloadDao.getDownload(id) ?: return@withContext
+
+ if (download.status == DownloadStatus.DOWNLOADING) DownloadWorker.stop(context)
+
+ if (deleteFiles) {
+ val storageLocation = storageManager.getStorageLocation()
+ storageLocation.findFile(download.path)?.delete()
+ }
+
+ downloadDao.delete(id)
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadMethod.java b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadMethod.java
deleted file mode 100644
index d40ec6dc..00000000
--- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadMethod.java
+++ /dev/null
@@ -1,14 +0,0 @@
-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/DownloadMethod.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadMethod.kt
new file mode 100644
index 00000000..9a7a03e0
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadMethod.kt
@@ -0,0 +1,14 @@
+package org.jellyfin.mobile.downloads
+
+enum class DownloadMethod(val intValue: Int) {
+ WIFI_ONLY(0),
+ MOBILE_DATA(1),
+ MOBILE_AND_ROAMING(2),
+ ;
+
+ companion object {
+ val DEFAULT = WIFI_ONLY
+
+ fun fromInt(value: Int): DownloadMethod? = entries.find { it.intValue == value }
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadNotificationManager.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadNotificationManager.kt
new file mode 100644
index 00000000..71e5b555
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadNotificationManager.kt
@@ -0,0 +1,107 @@
+package org.jellyfin.mobile.downloads
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.content.pm.ServiceInfo
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.PendingIntentCompat
+import androidx.core.content.getSystemService
+import androidx.work.ForegroundInfo
+import org.jellyfin.mobile.R
+
+class DownloadNotificationManager(
+ val context: Context,
+) {
+ companion object {
+ const val CHANNEL_ID = "downloads"
+ const val NOTIFICATION_ID = 67
+ }
+
+ private val notificationManager = requireNotNull(context.getSystemService())
+
+ init {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ context.getString(R.string.downloads),
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply {
+ setShowBadge(false)
+ }
+ notificationManager.createNotificationChannel(channel)
+ }
+ }
+
+ fun createForegroundInfo() = ForegroundInfo(
+ 67,
+ NotificationCompat.Builder(context, CHANNEL_ID).apply {
+ setContentTitle("Downloads")
+ setSmallIcon(android.R.drawable.stat_sys_download)
+ }.build(),
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0,
+ )
+
+ fun downloadFile(id: Long, name: String) = NotificationProgressCallback(context, notificationManager, id, name)
+}
+
+class NotificationProgressCallback(
+ private val context: Context,
+ private val notificationManager: NotificationManager,
+ private val downloadId: Long,
+ private val name: String,
+) : FileDownloader.ProgressCallback {
+ private var lastProgress = -1
+
+ private val builder by lazy {
+ NotificationCompat.Builder(context, DownloadNotificationManager.CHANNEL_ID).apply {
+ setContentTitle(context.getString(R.string.downloading_title, name))
+ setSmallIcon(android.R.drawable.stat_sys_download)
+ setPriority(NotificationCompat.PRIORITY_LOW)
+ setOnlyAlertOnce(true)
+ setOngoing(true)
+ setProgress(100, 0, true)
+
+ val cancelPendingIntent = PendingIntentCompat.getBroadcast(
+ context,
+ 0,
+ DownloadBroadcastReceiver.cancelDownloadIntent(context, downloadId),
+ 0,
+ false,
+ )
+ addAction(
+ NotificationCompat.Action.Builder(
+ null,
+ context.getString(R.string.download_cancel),
+ cancelPendingIntent,
+ ).build(),
+ )
+ }
+ }
+
+ override suspend fun onProgress(downloaded: Long, total: Long) {
+ val progress = (downloaded.toFloat() / (total.toFloat()) * 100).toInt().coerceIn(0, 100)
+
+ if (lastProgress == progress) return
+ lastProgress = progress
+
+ if (progress == 100) {
+ builder.apply {
+ setContentText(context.getString(R.string.download_completed))
+ setProgress(0, 0, false)
+ setSmallIcon(android.R.drawable.stat_sys_download_done)
+ }
+ } else {
+ builder.apply {
+ setSubText(context.getString(R.string.download_progress, progress))
+ setProgress(100, progress, false)
+ setOngoing(false)
+ }
+ }
+
+ notificationManager.notify(DownloadNotificationManager.NOTIFICATION_ID, builder.build())
+ }
+
+ suspend fun onEnd() = onProgress(Long.MAX_VALUE, Long.MAX_VALUE)
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadQueue.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadQueue.kt
new file mode 100644
index 00000000..43a333ca
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadQueue.kt
@@ -0,0 +1,101 @@
+package org.jellyfin.mobile.downloads
+
+import android.content.Context
+import androidx.core.net.toUri
+import androidx.documentfile.provider.DocumentFile
+import kotlinx.coroutines.CancellationException
+import okhttp3.OkHttpClient
+import org.jellyfin.mobile.app.ApiClientController
+import org.jellyfin.mobile.app.StorageManager
+import org.jellyfin.mobile.data.dao.DownloadDao
+import org.jellyfin.mobile.data.entity.DownloadEntity
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.libraryApi
+import org.jellyfin.sdk.model.api.BaseItemDto
+
+class DownloadQueue(
+ private val context: Context,
+ private val apiClientController: ApiClientController,
+ private val downloadDao: DownloadDao,
+ private val downloadNotificationManager: DownloadNotificationManager,
+ private val storageManager: StorageManager,
+ okHttpClient: OkHttpClient,
+) {
+ private val _downloader = FileDownloader(okHttpClient)
+ private val _downloads = mutableListOf()
+
+ suspend fun prepare(): Boolean {
+ val queuedDownloads = downloadDao.getQueuedDownloads()
+ _downloads.clear()
+ _downloads.addAll(queuedDownloads)
+ return _downloads.any()
+ }
+
+ suspend fun process() {
+ while (_downloads.any()) {
+ val iterator = _downloads.iterator()
+ while (iterator.hasNext()) {
+ val download = iterator.next()
+ process(download)
+ iterator.remove()
+ }
+
+ // Refetch the queued downloads
+ prepare()
+ }
+ }
+
+ private suspend fun process(download: DownloadEntity) {
+ // Mark as downloading
+ downloadDao.update(download.copy(status = DownloadStatus.DOWNLOADING))
+
+ try {
+ val notificationProgressCallback = downloadNotificationManager.downloadFile(
+ download.id,
+ download.id.toString(),
+ )
+
+ val api = apiClientController.getApiClient(download.serverId, download.userId)
+
+ val storageLocation = storageManager.getStorageLocation()
+ val itemLocation = storageLocation.findFile(download.path) ?: storageLocation.createDirectory(download.path) ?: error("Unable to find or create folder ${download.path}")
+
+ // TODO: Download all mediastreams, thumbnail etc.
+ download(
+ api = api,
+ item = download.item,
+ itemLocation = itemLocation,
+ progressCallback = notificationProgressCallback,
+ )
+
+ notificationProgressCallback.onEnd()
+ downloadDao.update(download.copy(status = DownloadStatus.DOWNLOADED))
+ } catch (_: CancellationException) {
+ downloadDao.update(download.copy(status = DownloadStatus.QUEUED))
+ } catch (error: Throwable) {
+ downloadDao.update(download.copy(status = DownloadStatus.ERROR))
+ throw error
+ }
+ }
+
+ private suspend fun download(
+ api: ApiClient,
+ item: BaseItemDto,
+ itemLocation: DocumentFile,
+ progressCallback: FileDownloader.ProgressCallback,
+ ) {
+ val filename = item.path?.replace(Regex("^.*[\\\\/]"), "") ?: error("Missing item path")
+
+ val fileLocation = itemLocation.findFile(filename) ?: itemLocation.createFile("", filename) ?: error("Unable to find or create file $filename")
+ if (!fileLocation.canRead() || !fileLocation.canWrite()) error("Not allowed to read-write $fileLocation")
+
+ val fileDescriptor = context.contentResolver.openFileDescriptor(fileLocation.uri, "rw") ?: error("Unable to open file descriptor for $fileLocation")
+
+ _downloader.downloadAndSave(
+ api,
+ from = api.libraryApi.getDownloadUrl(item.id).toUri(),
+ to = fileDescriptor,
+ progressCallback = progressCallback,
+ )
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt
deleted file mode 100644
index 5552bc48..00000000
--- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-package org.jellyfin.mobile.downloads
-
-import android.content.Context
-import androidx.media3.database.DatabaseProvider
-import androidx.media3.datasource.DataSource
-import androidx.media3.datasource.cache.Cache
-import androidx.media3.exoplayer.offline.DownloadManager
-import androidx.media3.exoplayer.offline.DownloadNotificationHelper
-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 downloadCache: Cache by inject()
- private val dataSourceFactory: DataSource.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,
- databaseProvider,
- downloadCache,
- dataSourceFactory,
- Executors.newFixedThreadPool(DOWNLOAD_THREADS),
- )
- downloadTracker = DownloadTracker(downloadManager!!)
- }
- }
-}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadStatus.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadStatus.kt
new file mode 100644
index 00000000..8aa3af70
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadStatus.kt
@@ -0,0 +1,9 @@
+package org.jellyfin.mobile.downloads
+
+enum class DownloadStatus {
+ QUEUED,
+ DOWNLOADING,
+ DOWNLOADED,
+ ERROR,
+ CANCELLED,
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt
deleted file mode 100644
index 423961d4..00000000
--- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt
+++ /dev/null
@@ -1,81 +0,0 @@
-package org.jellyfin.mobile.downloads
-
-import android.net.Uri
-import androidx.media3.exoplayer.offline.Download
-import androidx.media3.exoplayer.offline.DownloadIndex
-import androidx.media3.exoplayer.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 = downloadManager.downloadIndex
-
- init {
- 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
deleted file mode 100644
index 30c153be..00000000
--- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt
+++ /dev/null
@@ -1,282 +0,0 @@
-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.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.net.toUri
-import androidx.media3.exoplayer.offline.DownloadRequest
-import androidx.media3.exoplayer.offline.DownloadService
-import androidx.media3.exoplayer.scheduler.Requirements
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.withContext
-import kotlinx.coroutines.withTimeout
-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.extractId
-import org.jellyfin.mobile.utils.requestPermission
-import org.jellyfin.sdk.api.client.ApiClient
-import org.jellyfin.sdk.model.UUID
-import org.jellyfin.sdk.model.api.BaseItemKind
-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 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 == true
-
- 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,
- BaseItemKind.MUSIC_VIDEO,
- )
-
- private fun downloadFiles(jellyfinMediaSource: JellyfinMediaSource) {
- val jellyfinDownloadTracker = JellyfinDownloadTracker(jellyfinMediaSource)
- downloadTracker.addListener(jellyfinDownloadTracker)
- downloadMediaFile(jellyfinMediaSource)
- downloadExternalSubtitles(jellyfinMediaSource)
- }
-
- private fun downloadMediaFile(jellyfinMediaSource: JellyfinMediaSource) {
- val downloadUri = downloadURL.toUri()
- val cacheKey = downloadURL.toUri().extractId()
-
- val downloadRequest = DownloadRequest.Builder(contentId, downloadUri)
- .setData(jellyfinMediaSource.item!!.name!!.encodeToByteArray())
- .setCustomCacheKey(cacheKey)
- .build()
- DownloadService.sendAddDownload(
- context,
- JellyfinDownloadService::class.java,
- downloadRequest,
- false,
- )
- }
-
- private fun downloadExternalSubtitles(jellyfinMediaSource: JellyfinMediaSource) {
- jellyfinMediaSource.externalSubtitleStreams.forEach {
- val subtitleDownloadURL = apiClient.createUrl(it.deliveryUrl).toUri()
- val subtitleCacheKey: String = subtitleDownloadURL.extractId()
-
- val downloadRequest = DownloadRequest.Builder("$contentId:${it.index}", subtitleDownloadURL)
- .setCustomCacheKey(subtitleCacheKey)
- .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) {
- 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.getName(context))
- .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)
- }
- }
- }
-}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadWorker.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadWorker.kt
new file mode 100644
index 00000000..ecda4b53
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadWorker.kt
@@ -0,0 +1,69 @@
+package org.jellyfin.mobile.downloads
+
+import android.content.Context
+import androidx.work.Constraints
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingWorkPolicy
+import androidx.work.ForegroundInfo
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.OutOfQuotaPolicy
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import org.jellyfin.mobile.app.AppPreferences
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+
+class DownloadWorker(
+ context: Context,
+ parameters: WorkerParameters,
+) : CoroutineWorker(context, parameters), KoinComponent {
+ companion object {
+ private val tag = DownloadWorker::class.qualifiedName!!
+
+ fun start(context: Context, appPreferences: AppPreferences) {
+ val downloadMethod = appPreferences.downloadMethod ?: DownloadMethod.DEFAULT
+
+ val request = OneTimeWorkRequestBuilder().apply {
+ addTag(tag)
+ setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+ setConstraints(
+ Constraints.Builder().apply {
+ when (downloadMethod) {
+ DownloadMethod.WIFI_ONLY -> setRequiredNetworkType(NetworkType.UNMETERED)
+ DownloadMethod.MOBILE_DATA -> setRequiredNetworkType(NetworkType.NOT_ROAMING)
+ DownloadMethod.MOBILE_AND_ROAMING -> setRequiredNetworkType(NetworkType.CONNECTED)
+ }
+ }.build(),
+ )
+ }.build()
+
+ WorkManager.getInstance(context).enqueueUniqueWork(tag, ExistingWorkPolicy.REPLACE, request)
+ }
+
+ fun stop(context: Context) {
+ WorkManager.getInstance(context).cancelUniqueWork(tag)
+ }
+
+ fun isActive(context: Context): Boolean = WorkManager.getInstance(context)
+ .getWorkInfosForUniqueWork(tag)
+ .get()
+ .any { workInfo -> workInfo.state == WorkInfo.State.RUNNING }
+ }
+
+ private val downloadNotificationManager by inject()
+ private val downloadQueue by inject()
+
+ override suspend fun getForegroundInfo(): ForegroundInfo = downloadNotificationManager.createForegroundInfo()
+
+ override suspend fun doWork(): Result {
+ val canProcess = downloadQueue.prepare()
+ if (!canProcess) return Result.failure()
+
+ setForeground(getForegroundInfo())
+ downloadQueue.process()
+
+ return Result.success()
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsViewModel.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsViewModel.kt
index 28729379..5e693f28 100644
--- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsViewModel.kt
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsViewModel.kt
@@ -7,38 +7,77 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.jellyfin.mobile.app.StorageManager
import org.jellyfin.mobile.data.dao.DownloadDao
import org.jellyfin.mobile.data.entity.DownloadEntity
import org.jellyfin.mobile.events.ActivityEvent
import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.player.interaction.PlayOptions
+import org.jellyfin.sdk.model.api.MediaType
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class DownloadsViewModel : ViewModel(), KoinComponent {
private val downloadDao: DownloadDao by inject()
+ private val downloadManager: DownloadManager by inject()
private val activityEventHandler: ActivityEventHandler by inject()
+ private val storageManager: StorageManager by inject()
val downloads: StateFlow> = downloadDao
.getAllDownloads()
.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
- fun playDownload(download: DownloadEntity) {
- val playOptions = PlayOptions(
- ids = listOf(download.mediaSource.itemId),
- mediaSourceId = download.mediaSource.id,
- startIndex = 0,
- startPosition = null,
- audioStreamIndex = 1,
- subtitleStreamIndex = -1,
- playFromDownloads = true,
- )
- activityEventHandler.emit(ActivityEvent.LaunchNativePlayer(playOptions))
+ fun openDownload(download: DownloadEntity) {
+ when (download.item.mediaType) {
+ MediaType.VIDEO,
+ MediaType.AUDIO,
+ -> {
+ val playOptions = PlayOptions(
+ ids = listOf(download.itemId),
+ mediaSourceId = download.itemId.toString(),
+ startIndex = 0,
+ startPosition = null,
+ audioStreamIndex = null,
+ subtitleStreamIndex = null,
+ playFromDownloads = true,
+ )
+ activityEventHandler.emit(ActivityEvent.LaunchNativePlayer(playOptions))
+ }
+
+ MediaType.PHOTO,
+ MediaType.BOOK,
+ MediaType.UNKNOWN,
+ -> {
+ viewModelScope.launch {
+ val fileUri = withContext(Dispatchers.IO) {
+ val storageLocation = storageManager.getStorageLocation()
+ val itemLocation = storageLocation.findFile(download.path)
+ if (itemLocation != null && itemLocation.isDirectory) {
+ val filename = download.item.path?.replace(Regex("^.*[\\\\/]"), "")
+ if (filename != null) itemLocation.findFile(filename)?.uri else null
+ } else {
+ null
+ }
+ }
+ fileUri?.let { activityEventHandler.emit(ActivityEvent.OpenUrl(it.toString(), true)) }
+ }
+ }
+ }
}
- fun removeDownload(download: DownloadEntity, force: Boolean = false) {
- activityEventHandler.emit(ActivityEvent.RemoveDownload(download.mediaSource, force))
+ fun download(download: DownloadEntity) {
+ viewModelScope.launch {
+ downloadManager.resume(download)
+ }
+ }
+
+ fun removeDownload(download: DownloadEntity, deleteFiles: Boolean) {
+ viewModelScope.launch {
+ downloadManager.delete(download.id, deleteFiles)
+ }
}
}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/FileDownloader.kt b/app/src/main/java/org/jellyfin/mobile/downloads/FileDownloader.kt
new file mode 100644
index 00000000..ed27b5bd
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/downloads/FileDownloader.kt
@@ -0,0 +1,125 @@
+package org.jellyfin.mobile.downloads
+
+import android.net.Uri
+import android.os.ParcelFileDescriptor
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
+import java.io.IOException
+import kotlin.coroutines.resumeWithException
+
+class FileDownloader(
+ private val okHttpClient: OkHttpClient,
+) {
+ fun interface ProgressCallback {
+ suspend fun onProgress(downloaded: Long, total: Long)
+
+ companion object Empty : ProgressCallback {
+ override suspend fun onProgress(downloaded: Long, total: Long) = Unit
+ }
+ }
+
+ private suspend fun download(
+ api: ApiClient,
+ from: Uri,
+ rangeStart: Long? = null,
+ ): Response {
+ val authorizationHeader = AuthorizationHeaderBuilder.buildHeader(
+ clientName = api.clientInfo.name,
+ clientVersion = api.clientInfo.version,
+ deviceId = api.deviceInfo.id,
+ deviceName = api.deviceInfo.name,
+ accessToken = api.accessToken,
+ )
+
+ val request = Request.Builder().apply {
+ url(from.toString())
+
+ header("Authorization", authorizationHeader)
+ rangeStart?.let { header("Range", "bytes=$rangeStart-") }
+ }.build()
+
+ val response = okHttpClient.newCall(request).await()
+
+ // 416 (Requested Range Not Satisfiable) can happen when we've already fully downloaded the file
+ if (response.code == 416 && rangeStart != null && rangeStart >= response.getContentRange().total) return response
+
+ // Throw for other unsuccessful responses
+ if (!response.isSuccessful) throw IOException("Unexpected response $response")
+
+ return response
+ }
+
+ private suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation ->
+ enqueue(
+ object : Callback {
+ override fun onResponse(call: Call, response: Response) {
+ continuation.resume(response) { cause, response, _ ->
+ response.close()
+ }
+ }
+
+ override fun onFailure(call: Call, e: IOException) {
+ continuation.resumeWithException(e)
+ }
+ },
+ )
+
+ continuation.invokeOnCancellation {
+ cancel()
+ }
+ }
+
+ private fun Response.getContentRange() = when (code) {
+ 200 -> requireNotNull(header("Content-Length")).let(ContentRange::fromContentLengthHeader)
+ 206, 416 -> requireNotNull(header("Content-Range")).let(ContentRange::fromContentRangeHeader)
+ else -> error("Invalid response code $code")
+ }
+
+ private suspend fun save(
+ response: Response,
+ to: ParcelFileDescriptor,
+ progressCallback: ProgressCallback,
+ ) = withContext(Dispatchers.IO) {
+ val contentRange = response.getContentRange()
+
+ val output = ParcelFileDescriptor.AutoCloseOutputStream(to)
+ output.channel.position(contentRange.start)
+
+ val inputStream = response.body?.byteStream() ?: error("Response does not contain a body")
+ inputStream.use { inputStream ->
+ output.use { outputFile ->
+ val buffer = ByteArray(10240)
+ var totalRead = contentRange.start
+ var bytesRead: Int
+ while (inputStream.read(buffer).also { bytesRead = it } != -1) {
+ coroutineContext.ensureActive()
+
+ outputFile.write(buffer, 0, bytesRead)
+ totalRead += bytesRead
+
+ progressCallback.onProgress(totalRead, contentRange.total)
+ }
+ }
+ }
+ }
+
+ suspend fun downloadAndSave(
+ api: ApiClient,
+ from: Uri,
+ to: ParcelFileDescriptor,
+ progressCallback: ProgressCallback = ProgressCallback.Empty,
+ ) {
+ val rangeStart = to.statSize
+ val response = download(api, from, rangeStart)
+ save(response, to, progressCallback)
+ }
+}
diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt b/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt
deleted file mode 100644
index f0811334..00000000
--- a/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt
+++ /dev/null
@@ -1,101 +0,0 @@
-package org.jellyfin.mobile.downloads
-
-import android.app.Notification
-import android.content.Context
-import androidx.core.app.NotificationCompat
-import androidx.media3.common.util.NotificationUtil
-import androidx.media3.common.util.Util
-import androidx.media3.exoplayer.offline.Download
-import androidx.media3.exoplayer.offline.DownloadManager
-import androidx.media3.exoplayer.offline.DownloadNotificationHelper
-import androidx.media3.exoplayer.offline.DownloadService
-import androidx.media3.exoplayer.scheduler.PlatformScheduler
-import androidx.media3.exoplayer.scheduler.Scheduler
-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 PlatformScheduler(this, jobId)
- }
-
- @Suppress("MagicNumber")
- override fun getForegroundNotification(downloads: MutableList, notMetRequirements: Int): Notification {
- val inboxStyle = NotificationCompat.InboxStyle()
-
- downloads.forEach { download ->
- val progress = download.percentDownloaded
- inboxStyle.addLine("${Util.fromUtf8Bytes(download.request.data)} - ${progress.toInt()}%")
- }
-
- return NotificationCompat.Builder(this, Constants.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
- .setSmallIcon(R.drawable.ic_notification)
- .setContentTitle(getString(R.string.downloading))
- .setOngoing(true)
- .setOnlyAlertOnce(true)
- .setPriority(NotificationCompat.PRIORITY_LOW)
- .setStyle(inboxStyle)
- .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 eccd69c1..22a6b572 100644
--- a/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt
+++ b/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt
@@ -1,16 +1,14 @@
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
+import java.util.UUID
sealed class ActivityEvent {
class ChangeFullscreen(val isFullscreen: Boolean) : 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 OpenUrl(val uri: String, val grantReadPermission: Boolean = false) : ActivityEvent()
+ class DownloadItems(val itemIds: Collection) : ActivityEvent()
class CastMessage(val action: String, val args: JSONArray) : ActivityEvent()
data object RequestBluetoothPermission : ActivityEvent()
data object OpenSettings : 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 cc2514d1..f26ee625 100644
--- a/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt
+++ b/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt
@@ -20,7 +20,6 @@ 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
@@ -69,19 +68,15 @@ class ActivityEventHandler(
is ActivityEvent.OpenUrl -> {
try {
val intent = Intent(Intent.ACTION_VIEW, event.uri.toUri())
+ if (event.grantReadPermission) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
startActivity(intent)
} catch (e: ActivityNotFoundException) {
Timber.e("openIntent: %s", e.message)
}
}
- is ActivityEvent.DownloadFile -> {
+ is ActivityEvent.DownloadItems -> {
lifecycleScope.launch {
- with(event) { requestDownload(uri, filename) }
- }
- }
- is ActivityEvent.RemoveDownload -> {
- lifecycleScope.launch {
- with(event) { removeDownload(download, force) }
+ with(event) { requestDownload(itemIds) }
}
}
ActivityEvent.OpenDownloads -> {
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 b19186be..35ddd1cb 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,7 +8,6 @@ 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.lifecycle.viewModelScope
@@ -23,7 +22,6 @@ 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
@@ -39,7 +37,6 @@ 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 {
@@ -48,7 +45,6 @@ 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
@@ -155,17 +151,7 @@ class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinCom
}
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 LocalJellyfinMediaSource -> null
is RemoteJellyfinMediaSource -> {
val height = context.resources.getDimensionPixelSize(R.dimen.media_notification_height)
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 cb89ec9e..4c752f69 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
@@ -11,6 +11,9 @@ import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.jellyfin.mobile.app.StorageManager
import org.jellyfin.mobile.data.dao.DownloadDao
import org.jellyfin.mobile.player.PlayerException
import org.jellyfin.mobile.player.PlayerViewModel
@@ -20,6 +23,7 @@ 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.PlaybackDetails
import org.jellyfin.mobile.player.source.RemoteJellyfinMediaSource
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.videosApi
@@ -44,6 +48,8 @@ class QueueManager(
private val videosApi: VideosApi = apiClient.videosApi
private val mediaSourceResolver: MediaSourceResolver by inject()
private val deviceProfileBuilder: DeviceProfileBuilder by inject()
+ private val storageManager: StorageManager by inject()
+ private val downloadDao: DownloadDao by inject()
private val deviceProfile = deviceProfileBuilder.getDeviceProfile()
private var currentQueue: List = emptyList()
@@ -73,7 +79,7 @@ class QueueManager(
when (playOptions.playFromDownloads) {
true -> playOptions.mediaSourceId?.let {
startDownloadPlayback(
- mediaSourceId = it,
+ itemId = itemId,
playWhenReady = true,
)
}
@@ -92,21 +98,38 @@ class QueueManager(
}
private suspend fun startDownloadPlayback(
- mediaSourceId: String,
+ itemId: UUID,
startTime: Duration? = null,
audioStreamIndex: Int? = null,
subtitleStreamIndex: Int? = null,
playWhenReady: Boolean = true,
): PlayerException? {
- get()
- .get(mediaSourceId)
- ?.asMediaSource(startTime, audioStreamIndex, subtitleStreamIndex)
- ?.also { jellyfinMediaSource ->
- _currentMediaSource.value = jellyfinMediaSource
+ val download = withContext(Dispatchers.IO) {
+ downloadDao.getDownloadByItemId(itemId)
+ } ?: return PlayerException.UnsupportedContent()
+
+ val storageLocation = storageManager.getStorageLocation()
+
+ val filename = download.item.path?.replace(Regex("^.*[\\\\/]"), "") ?: error("Missing item path")
+ val fileLocation = storageLocation.findFile(download.path)?.findFile(filename)?.uri ?: return PlayerException.NetworkFailure()
+
+ val mediaSource = LocalJellyfinMediaSource(
+ itemId = download.itemId,
+ item = download.item,
+ sourceInfo = download.item.mediaSources!!.first(),
+ playSessionId = download.id.toString(),
+ playbackDetails = PlaybackDetails(startTime, audioStreamIndex, subtitleStreamIndex),
+ remoteFileUri = fileLocation.toString(),
+ )
+ startTime?.let { duration -> mediaSource.startTime = duration }
+ audioStreamIndex?.let { index -> mediaSource.selectAudioStream(mediaSource.audioStreams[index]) }
+ subtitleStreamIndex?.let { index -> mediaSource.selectSubtitleStream(mediaSource.subtitleStreams[index]) }
+
+ _currentMediaSource.value = mediaSource
+
+ // Load new media source
+ viewModel.load(mediaSource, prepareStreams(mediaSource), playWhenReady)
- // Load new media source
- viewModel.load(jellyfinMediaSource, prepareStreams(jellyfinMediaSource), playWhenReady)
- }
return null
}
@@ -208,7 +231,7 @@ class QueueManager(
when (val currentMediaSource = getCurrentMediaSourceOrNull()) {
is LocalJellyfinMediaSource -> startDownloadPlayback(
- mediaSourceId = currentMediaSource.id,
+ itemId = currentQueue[++currentQueueIndex],
playWhenReady = true,
)
is RemoteJellyfinMediaSource -> startRemotePlayback(
@@ -343,6 +366,7 @@ class QueueManager(
val mediaItem = MediaItem.Builder()
.setMediaId(mediaSourceId)
.setUri(fileUri.toUri())
+ .setCustomCacheKey(fileUri)
.build()
return mediaSourceFactory.createMediaSource(mediaItem)
@@ -378,7 +402,7 @@ class QueueManager(
when (val currentMediaSource = getCurrentMediaSourceOrNull()) {
is LocalJellyfinMediaSource -> startDownloadPlayback(
- mediaSourceId = currentMediaSource.id,
+ itemId = currentMediaSource.itemId,
startTime = currentPlayState.position,
audioStreamIndex = stream.index,
subtitleStreamIndex = currentMediaSource.selectedSubtitleStreamIndex,
@@ -411,7 +435,7 @@ class QueueManager(
when (val mediaSource = getCurrentMediaSourceOrNull()) {
is LocalJellyfinMediaSource -> startDownloadPlayback(
- mediaSourceId = mediaSource.id,
+ itemId = mediaSource.itemId,
startTime = currentPlayState.position,
audioStreamIndex = mediaSource.selectedAudioStreamIndex,
subtitleStreamIndex = stream?.index ?: -1, // -1 disables subtitles, null would select the default subtitle
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
deleted file mode 100644
index b5d44559..00000000
--- a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSourceSerializer.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-package org.jellyfin.mobile.player.source
-
-import kotlinx.serialization.ExperimentalSerializationApi
-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
-
-@OptIn(ExperimentalSerializationApi::class)
-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
index 5a0dfef4..eb0ecea6 100644
--- a/app/src/main/java/org/jellyfin/mobile/player/source/LocalJellyfinMediaSource.kt
+++ b/app/src/main/java/org/jellyfin/mobile/player/source/LocalJellyfinMediaSource.kt
@@ -1,32 +1,17 @@
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.startTime, source.selectedAudioStreamIndex, source.selectedSubtitleStreamIndex),
- downloadFolder,
- downloadUrl,
- downloadSize,
- )
}
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 5432e5ec..dae914fc 100644
--- a/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt
+++ b/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt
@@ -2,12 +2,12 @@ package org.jellyfin.mobile.settings
import android.content.Intent
import android.os.Bundle
-import android.os.Environment
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import de.Maxr1998.modernpreferences.Preference
import de.Maxr1998.modernpreferences.PreferencesAdapter
@@ -16,6 +16,7 @@ import de.Maxr1998.modernpreferences.helpers.checkBox
import de.Maxr1998.modernpreferences.helpers.defaultOnCheckedChange
import de.Maxr1998.modernpreferences.helpers.defaultOnClick
import de.Maxr1998.modernpreferences.helpers.defaultOnSelectionChange
+import de.Maxr1998.modernpreferences.helpers.onClick
import de.Maxr1998.modernpreferences.helpers.pref
import de.Maxr1998.modernpreferences.helpers.screen
import de.Maxr1998.modernpreferences.helpers.singleChoice
@@ -23,13 +24,13 @@ import de.Maxr1998.modernpreferences.preferences.CheckBoxPreference
import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem
import org.jellyfin.mobile.R
import org.jellyfin.mobile.app.AppPreferences
+import org.jellyfin.mobile.app.StorageManager
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.applyWindowInsetsAsMargins
import org.jellyfin.mobile.utils.extensions.requireMainActivity
-import org.jellyfin.mobile.utils.getDownloadsPaths
import org.jellyfin.mobile.utils.isPackageInstalled
import org.jellyfin.mobile.utils.withThemedContext
import org.koin.android.ext.android.inject
@@ -37,6 +38,20 @@ import org.koin.android.ext.android.inject
class SettingsFragment : Fragment(), BackPressInterceptor {
private val appPreferences: AppPreferences by inject()
+ private val storageManager: StorageManager by inject()
+
+ private val storageLocationPicker = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
+ if (uri != null) {
+ storageManager.changeStorageLocation(uri)
+
+ // Update preference
+ if (::downloadLocationPreference.isInitialized) {
+ downloadLocationPreference.summary = storageManager.getStorageLocation().name
+ downloadLocationPreference.requestRebindAndHighlight()
+ }
+ }
+ }
+
private val settingsAdapter: PreferencesAdapter by lazy { PreferencesAdapter(buildSettingsScreen()) }
private lateinit var startLandscapeVideoInLandscapePreference: CheckBoxPreference
private lateinit var swipeGesturesPreference: CheckBoxPreference
@@ -46,6 +61,7 @@ class SettingsFragment : Fragment(), BackPressInterceptor {
private lateinit var directPlayAssPreference: Preference
private lateinit var networkBufferPreference: Preference
private lateinit var externalPlayerChoicePreference: Preference
+ private lateinit var downloadLocationPreference: Preference
init {
Preference.Config.titleMaxLines = 2
@@ -234,17 +250,17 @@ class SettingsFragment : Fragment(), BackPressInterceptor {
val downloadMethods = listOf(
SelectionItem(
- DownloadMethod.WIFI_ONLY,
+ DownloadMethod.WIFI_ONLY.intValue,
R.string.wifi_only,
R.string.wifi_only_summary,
),
SelectionItem(
- DownloadMethod.MOBILE_DATA,
+ DownloadMethod.MOBILE_DATA.intValue,
R.string.mobile_data,
R.string.mobile_data_summary,
),
SelectionItem(
- DownloadMethod.MOBILE_AND_ROAMING,
+ DownloadMethod.MOBILE_AND_ROAMING.intValue,
R.string.mobile_data_and_roaming,
R.string.mobile_data_and_roaming_summary,
),
@@ -253,20 +269,16 @@ class SettingsFragment : Fragment(), BackPressInterceptor {
titleRes = R.string.network_title
}
- val downloadsDirs = requireContext().getDownloadsPaths().map { path ->
- SelectionItem(path, path, null)
- }
- singleChoice(Constants.PREF_DOWNLOAD_LOCATION, downloadsDirs) {
- titleRes = R.string.pref_download_location
- initialSelection = Environment
- .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
- .absolutePath
- }
+ downloadLocationPreference = pref(Constants.PREF_STORAGE_LOCATION) {
+ val location = storageManager.getStorageLocation()
- checkBox(Constants.PREF_DOWNLOAD_INTERNAL) {
- titleRes = R.string.store_videos_in_internal_storage
- summaryRes = R.string.stored_videos_in_internal_storage_desc
- defaultValue = true
+ titleRes = R.string.pref_download_location
+ summary = location.name
+
+ onClick {
+ storageLocationPicker.launch(location.uri)
+ false
+ }
}
}
diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsList.kt b/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsList.kt
index d20a9802..7e6af204 100644
--- a/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsList.kt
+++ b/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsList.kt
@@ -1,28 +1,51 @@
package org.jellyfin.mobile.ui.screens.downloads
+import android.content.Context
+import android.text.format.Formatter
import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.material.AlertDialog
+import androidx.compose.material.Checkbox
import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.ListItem
import androidx.compose.material.Text
+import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import org.jellyfin.mobile.R
+import org.jellyfin.mobile.app.StorageManager
import org.jellyfin.mobile.data.entity.DownloadEntity
+import org.jellyfin.mobile.downloads.DownloadStatus
import org.jellyfin.mobile.downloads.DownloadsViewModel
+import org.jellyfin.mobile.utils.lengthRecursive
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
+import org.jellyfin.sdk.model.api.BaseItemDto
+import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.koin.compose.koinInject
@@ -32,19 +55,63 @@ fun DownloadsList(
contentPadding: PaddingValues = PaddingValues.Zero,
) {
val downloads by viewModel.downloads.collectAsState()
+ var downloadToRemove by remember { mutableStateOf(null) }
+
+ if (downloadToRemove != null) {
+ val context = LocalContext.current
+ var keepLocalFiles by remember { mutableStateOf(false) }
+
+ AlertDialog(
+ onDismissRequest = { downloadToRemove = null },
+ title = { Text(text = stringResource(R.string.download_remove)) },
+ text = {
+ Column {
+ val name = remember(downloadToRemove, context) {
+ downloadToRemove?.item?.getDownloadName(context).orEmpty()
+ }
+ Text(text = stringResource(R.string.download_remove_description, name))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(top = 8.dp),
+ ) {
+ Checkbox(
+ checked = keepLocalFiles,
+ onCheckedChange = { keepLocalFiles = it },
+ )
+ Text(text = stringResource(R.string.download_remove_keep_local))
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ downloadToRemove?.let { viewModel.removeDownload(it, deleteFiles = !keepLocalFiles) }
+ downloadToRemove = null
+ },
+ ) {
+ Text(text = stringResource(R.string.yes))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { downloadToRemove = null }) {
+ Text(text = stringResource(R.string.download_cancel))
+ }
+ },
+ )
+ }
+
LazyColumn(
contentPadding = contentPadding,
) {
items(
downloads,
- key = DownloadEntity::itemId,
+ key = DownloadEntity::id,
) { download ->
DownloadItem(
download,
- modifier = Modifier.combinedClickable(
- onClick = { viewModel.playDownload(download) },
- onLongClick = { viewModel.removeDownload(download) },
- ),
+ onOpen = { viewModel.openDownload(download) },
+ onDownload = { viewModel.download(download) },
+ onRemove = { downloadToRemove = download },
)
}
}
@@ -54,17 +121,36 @@ fun DownloadsList(
@Composable
fun DownloadItem(
download: DownloadEntity,
+ onOpen: () -> Unit,
+ onDownload: () -> Unit,
+ onRemove: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val apiClient: ApiClient = koinInject()
+ val storageManager: StorageManager = koinInject()
+
+ val fileSize by produceState(initialValue = 0L, download) {
+ value = withContext(Dispatchers.IO) {
+ val itemLocation = storageManager.getStorageLocation().findFile(download.path)
+ itemLocation?.lengthRecursive()
+ }
+ }
ListItem(
- modifier = modifier,
+ modifier = modifier
+ .combinedClickable(
+ onClick = {
+ if (fileSize == null) {
+ onDownload()
+ } else {
+ onOpen()
+ }
+ },
+ onLongClick = { onRemove() },
+ ),
text = {
- val name = remember(download.mediaSource.itemId) {
- download.mediaSource.getName(context)
- }
+ val name = remember(download.item, context) { download.item.getDownloadName(context) }
Text(
text = name,
overflow = TextOverflow.Ellipsis,
@@ -73,9 +159,9 @@ fun DownloadItem(
},
icon = {
val maxSize = LocalResources.current.getDimensionPixelSize(R.dimen.movie_thumbnail_list_size)
- val url = remember(download.mediaSource.itemId) {
+ val url = remember(apiClient, download.itemId, maxSize) {
apiClient.imageApi.getItemImageUrl(
- itemId = download.mediaSource.itemId,
+ itemId = download.itemId,
imageType = ImageType.PRIMARY,
maxWidth = maxSize,
maxHeight = maxSize,
@@ -90,12 +176,52 @@ fun DownloadItem(
)
},
secondaryText = {
- Text(
- text = download.fileSize,
- overflow = TextOverflow.Ellipsis,
- maxLines = 1,
- )
+ if (download.status == DownloadStatus.DOWNLOADING || download.status == DownloadStatus.QUEUED) {
+ LinearProgressIndicator()
+ } else if (fileSize != null) {
+ Text(
+ text = Formatter.formatShortFileSize(context, fileSize!!),
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ )
+ } else {
+ Text(
+ text = stringResource(R.string.download_incomplete),
+ color = Color.Yellow,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ )
+ }
},
singleLineSecondaryText = true,
)
}
+
+private fun BaseItemDto.getDownloadName(context: Context) = buildString {
+ val name = if (
+ type in arrayOf(BaseItemKind.PROGRAM, BaseItemKind.RECORDING) &&
+ (isSeries == true || !episodeTitle.isNullOrEmpty())
+ ) {
+ episodeTitle
+ } else {
+ name
+ }
+
+ val extraInfo = when (type) {
+ BaseItemKind.TV_CHANNEL if !channelNumber.isNullOrEmpty() -> channelNumber
+ BaseItemKind.EPISODE if parentIndexNumber == 0 -> context.getString(R.string.special_episode)
+ in arrayOf(BaseItemKind.EPISODE, BaseItemKind.RECORDING) if indexNumber != null && parentIndexNumber != null ->
+ "S$parentIndexNumber:E${indexNumber}${indexNumberEnd?.let { n -> "-$n" } ?: ""}"
+ else -> ""
+ }
+
+ listOf(seriesName, extraInfo, name)
+ .filter { str -> !str.isNullOrEmpty() }
+ .joinTo(this, separator = " - ")
+
+ if (type == BaseItemKind.MOVIE && productionYear != null) {
+ append(" ($productionYear)")
+ } else if (premiereDate != null) {
+ append(" (${premiereDate!!.year})")
+ }
+}.ifEmpty { name.orEmpty() }
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 3f39fd58..47eec221 100644
--- a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt
+++ b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt
@@ -45,8 +45,7 @@ object Constants {
const val NETWORK_BUFFER_EXTRA_LARGE = "extra_large"
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"
+ const val PREF_STORAGE_LOCATION = "pref_storage_location"
const val PREF_MEDIA_SEGMENT_ACTIONS = "pref_media_segment_actions"
// InputManager commands
@@ -158,5 +157,4 @@ 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/DocumentFileExtensions.kt b/app/src/main/java/org/jellyfin/mobile/utils/DocumentFileExtensions.kt
new file mode 100644
index 00000000..47f033ed
--- /dev/null
+++ b/app/src/main/java/org/jellyfin/mobile/utils/DocumentFileExtensions.kt
@@ -0,0 +1,13 @@
+package org.jellyfin.mobile.utils
+
+import androidx.documentfile.provider.DocumentFile
+
+fun DocumentFile.lengthRecursive(): Long? {
+ if (!exists()) return null
+
+ return if (isDirectory) {
+ listFiles().sumOf { it.lengthRecursive() ?: 0L }
+ } else {
+ length()
+ }
+}
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 c11a483e..a4610883 100644
--- a/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt
+++ b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt
@@ -3,7 +3,6 @@ package org.jellyfin.mobile.utils
import android.Manifest
import android.app.Activity
import android.app.ActivityManager
-import android.app.AlertDialog
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.ActivityNotFoundException
@@ -11,31 +10,26 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
-import android.os.Environment
import android.os.PowerManager
import android.provider.Settings
import android.provider.Settings.System.ACCELEROMETER_ROTATION
+import androidx.appcompat.app.AlertDialog
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.getSystemService
-import androidx.media3.exoplayer.offline.DownloadService
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.suspendCancellableCoroutine
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.DownloadManager
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.jellyfin.sdk.model.serializer.toUUID
import org.koin.android.ext.android.get
import timber.log.Timber
-import java.io.File
+import java.util.UUID
import kotlin.coroutines.resume
fun WebViewFragment.requestNoBatteryOptimizations(rootView: CoordinatorLayout) {
@@ -63,34 +57,11 @@ fun WebViewFragment.requestNoBatteryOptimizations(rootView: CoordinatorLayout) {
}
}
-suspend fun MainActivity.requestDownload(uri: Uri, filename: String) {
- val appPreferences: AppPreferences = get()
+suspend fun MainActivity.requestDownload(itemIds: Collection) {
+ if (itemIds.isEmpty()) return
- val downloadMethod = appPreferences.downloadMethod ?: suspendCancellableCoroutine { continuation ->
- AlertDialog.Builder(this)
- .setTitle(R.string.network_title)
- .setMessage(R.string.network_message)
- .setPositiveButton(R.string.wifi_only) { _, _ ->
- val selectedDownloadMethod = DownloadMethod.WIFI_ONLY
- appPreferences.downloadMethod = selectedDownloadMethod
- continuation.resume(selectedDownloadMethod)
- }
- .setNegativeButton(R.string.mobile_data) { _, _ ->
- val selectedDownloadMethod = DownloadMethod.MOBILE_DATA
- appPreferences.downloadMethod = selectedDownloadMethod
- continuation.resume(selectedDownloadMethod)
- }
- .setNeutralButton(R.string.mobile_data_and_roaming) { _, _ ->
- val selectedDownloadMethod = DownloadMethod.MOBILE_AND_ROAMING
- appPreferences.downloadMethod = selectedDownloadMethod
- continuation.resume(selectedDownloadMethod)
- }
- .setOnDismissListener {
- continuation.cancel(null)
- }
- .setCancelable(false)
- .show()
- }
+ val appPreferences: AppPreferences = get()
+ val downloadManager: DownloadManager = get()
val permissionResult: Boolean = suspendCancellableCoroutine { continuation ->
requestPermission("android.permission.POST_NOTIFICATIONS") { permissionsMap ->
@@ -102,22 +73,26 @@ suspend fun MainActivity.requestDownload(uri: Uri, filename: String) {
}
}
- 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 ->
+ // First time download, ask for network constraint preference
+ if (appPreferences.downloadMethod == null) {
+ suspendCancellableCoroutine { continuation ->
AlertDialog.Builder(this)
- .setTitle(getString(R.string.confirm_deletion))
- .setMessage(getString(R.string.confirm_deletion_desc, download.getName(this)))
- .setPositiveButton(getString(R.string.yes)) { _, _ ->
- continuation.resume(true)
+ .setTitle(R.string.network_title)
+ .setMessage(R.string.network_message)
+ .setPositiveButton(R.string.wifi_only) { _, _ ->
+ val selectedDownloadMethod = DownloadMethod.WIFI_ONLY
+ appPreferences.downloadMethod = selectedDownloadMethod
+ continuation.resume(selectedDownloadMethod)
}
- .setNegativeButton(getString(R.string.no)) { _, _ ->
- continuation.cancel(null)
+ .setNegativeButton(R.string.mobile_data) { _, _ ->
+ val selectedDownloadMethod = DownloadMethod.MOBILE_DATA
+ appPreferences.downloadMethod = selectedDownloadMethod
+ continuation.resume(selectedDownloadMethod)
+ }
+ .setNeutralButton(R.string.mobile_data_and_roaming) { _, _ ->
+ val selectedDownloadMethod = DownloadMethod.MOBILE_AND_ROAMING
+ appPreferences.downloadMethod = selectedDownloadMethod
+ continuation.resume(selectedDownloadMethod)
}
.setOnDismissListener {
continuation.cancel(null)
@@ -125,33 +100,12 @@ suspend fun MainActivity.removeDownload(download: LocalJellyfinMediaSource, forc
.setCancelable(false)
.show()
}
-
- if (!confirmation) return
}
- 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,
- )
+ if (permissionResult) {
+ val server = mainViewModel.serverState.value.server ?: return
+ val user = mainViewModel.userState.value.user ?: return
+ downloadManager.enqueueItems(server, user, itemIds)
}
}
@@ -176,25 +130,6 @@ fun Context.createMediaNotificationChannel(notificationManager: NotificationMana
}
}
-fun Context.getDownloadsPaths(): List = ArrayList().apply {
- for (directory in getExternalFilesDirs(null)) {
- // Ignore currently unavailable shared storage
- if (directory == null) continue
-
- val path = directory.absolutePath
- val androidFolderIndex = path.indexOf("/Android")
- if (androidFolderIndex == -1) continue
-
- val storageDirectory = File(path.substring(0, androidFolderIndex))
- if (storageDirectory.isDirectory) {
- add(File(storageDirectory, Environment.DIRECTORY_DOWNLOADS).absolutePath)
- }
- }
- if (isEmpty()) {
- add(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath)
- }
-}
-
val Context.isLowRamDevice: Boolean
get() = getSystemService()!!.isLowRamDevice
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
deleted file mode 100644
index 77c5252c..00000000
--- a/app/src/main/java/org/jellyfin/mobile/utils/extensions/Long.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-@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/java/org/jellyfin/mobile/webapp/JellyfinWebViewClient.kt b/app/src/main/java/org/jellyfin/mobile/webapp/JellyfinWebViewClient.kt
index 8ef5a513..d0c69643 100644
--- a/app/src/main/java/org/jellyfin/mobile/webapp/JellyfinWebViewClient.kt
+++ b/app/src/main/java/org/jellyfin/mobile/webapp/JellyfinWebViewClient.kt
@@ -11,7 +11,7 @@ import androidx.webkit.WebViewClientCompat
import androidx.webkit.WebViewFeature
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
-import org.jellyfin.mobile.app.ApiClientController
+import org.jellyfin.mobile.MainViewModel
import org.jellyfin.mobile.data.entity.ServerEntity
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.initLocale
@@ -30,7 +30,7 @@ abstract class JellyfinWebViewClient(
private val coroutineScope: CoroutineScope,
private val server: ServerEntity,
private val assetsPathHandler: AssetsPathHandler,
- private val apiClientController: ApiClientController,
+ private val mainViewModel: MainViewModel,
) : WebViewClientCompat() {
abstract fun onConnectedToWebapp()
@@ -67,7 +67,7 @@ abstract class JellyfinWebViewClient(
val storedServer = credentials.getJSONArray("Servers").getJSONObject(0)
val user = storedServer.getString("UserId").toUUID()
val token = storedServer.getString("AccessToken")
- apiClientController.setupUser(server.id, user, token)
+ mainViewModel.setupUser(server.id, user, token)
webView.initLocale(user)
}
null
diff --git a/app/src/main/java/org/jellyfin/mobile/webapp/WebViewFragment.kt b/app/src/main/java/org/jellyfin/mobile/webapp/WebViewFragment.kt
index 02115f4c..1340c259 100644
--- a/app/src/main/java/org/jellyfin/mobile/webapp/WebViewFragment.kt
+++ b/app/src/main/java/org/jellyfin/mobile/webapp/WebViewFragment.kt
@@ -23,8 +23,8 @@ import androidx.lifecycle.lifecycleScope
import androidx.webkit.WebViewAssetLoader.AssetsPathHandler
import androidx.webkit.WebViewCompat
import kotlinx.coroutines.launch
+import org.jellyfin.mobile.MainViewModel
import org.jellyfin.mobile.R
-import org.jellyfin.mobile.app.ApiClientController
import org.jellyfin.mobile.app.AppPreferences
import org.jellyfin.mobile.bridge.ExternalPlayer
import org.jellyfin.mobile.bridge.MediaSegments
@@ -47,10 +47,11 @@ import org.jellyfin.mobile.utils.isOutdated
import org.jellyfin.mobile.utils.requestNoBatteryOptimizations
import org.jellyfin.mobile.utils.runOnUiThread
import org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.activityViewModel
class WebViewFragment : Fragment(), BackPressInterceptor, JellyfinWebChromeClient.FileChooserListener {
val appPreferences: AppPreferences by inject()
- private val apiClientController: ApiClientController by inject()
+ private val mainViewModel: MainViewModel by activityViewModel()
private val webappFunctionChannel: WebappFunctionChannel by inject()
private lateinit var assetsPathHandler: AssetsPathHandler
private lateinit var jellyfinWebViewClient: JellyfinWebViewClient
@@ -90,7 +91,7 @@ class WebViewFragment : Fragment(), BackPressInterceptor, JellyfinWebChromeClien
lifecycleScope,
server,
assetsPathHandler,
- apiClientController,
+ mainViewModel,
) {
override fun onConnectedToWebapp() {
val webViewBinding = webViewBinding ?: return
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 33c9643e..4dcac66b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -142,8 +142,6 @@
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
@@ -153,4 +151,12 @@
Downloading %1$d titles
Downloaded %1$s
Special
+ Downloading %1$s
+ Cancel
+ Download completed
+ %1$d%%
+ Remove download
+ Keep local file(s)
+ Do you want to remove "%1$s" from your downloads?
+ Download incomplete
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index fbd49c39..d2fb20dd 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -17,7 +17,9 @@ androidx-core = "1.17.0"
androidx-core-splashscreen = "1.2.0"
androidx-appcompat = "1.7.1"
androidx-activity = "1.11.0"
+androidx-documentfile = "1.1.0"
androidx-fragment = "1.8.9"
+androidx-work = "2.10.5"
androiddesugarlibs = "2.1.5"
# Lifecycle extensions
@@ -84,7 +86,9 @@ androidx-core = { group = "androidx.core", name = "core", version.ref = "android
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidx-core-splashscreen" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "androidx-activity" }
+androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "androidx-documentfile" }
androidx-fragment = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidx-fragment" }
+androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
androiddesugarlibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androiddesugarlibs" }
# Lifecycle Extensions