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