Add in app download functionality (#1404)

* Add initial code for client internal downloads

* Add download to internal storage

* Extend ExoPlayer to be able to play downloads

* Store Download Information separately

* Working Downloads Fragment

* Add openDownloads to NativeInterface

* Add ability to remove downloads

* Improve Download Method Check

* Add detail to DownloadItem

* Add option in settings to choose between external and internal download

* Update layout dimensions

* Fix order of deletion

* Improve download speed

* Improve exception handling for missing files

* Improve layout and localization

* Move media and subtitle download to ExoPlayer Download Service

* Resolve detekt errors

* Abstract JellyfinMediaSource

* Rename KILOBYTE const to better communicate it's function

* Add support for selecting audio and encoded subtitle stream

* Flatten DownloadItem & DownloadEntity into LocalJellyfinMediaSource

* Resolve detekt issues

* Inline `prepareDownloadStreams` in QueueManager

* Fix build issue

* Fix some more linting

* Switch to view binding

* Remove obsolete files

* Adjust layout

* Finally fix detekt

* Fix discard of some parallel downloads

* Fix source error on HLS content

* Move byte size conversion to separate file

* Improve download notifications

* Fix detekt

* Fix download cache

---------

Co-authored-by: Emily Ehlert <ehlertto@hu-berlin.de>
Co-authored-by: Ben Martin <ben@brmartin.co.uk>
This commit is contained in:
Emily Ehlert
2025-05-07 08:09:57 +02:00
committed by GitHub
co-authored by Emily Ehlert Ben Martin
parent 8744f39c64
commit 215a97aa39
46 changed files with 1744 additions and 188 deletions
+4
View File
@@ -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.androidx.room)
alias(libs.plugins.detekt)
@@ -110,6 +111,7 @@ dependencies {
// Kotlin
implementation(libs.bundles.coroutines)
implementation(libs.kotlin.serialization.json)
// Core
implementation(libs.bundles.koin)
@@ -143,6 +145,7 @@ dependencies {
}
}
implementation(libs.okhttp)
implementation(libs.okio)
implementation(libs.coil)
implementation(libs.cronet.embedded)
@@ -159,6 +162,7 @@ dependencies {
// Room
implementation(libs.bundles.androidx.room)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
// Monitoring
@@ -28,10 +28,10 @@
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
],
"autoGenerate": true
]
},
"indices": [
{
@@ -82,10 +82,10 @@
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
],
"autoGenerate": true
]
},
"indices": [
{
@@ -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')"
]
}
}
+15
View File
@@ -13,9 +13,11 @@
android:required="false"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
@@ -26,6 +28,8 @@
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
@@ -83,6 +87,17 @@
</intent-filter>
</service>
<service android:name="org.jellyfin.mobile.downloads.JellyfinDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync">
<!-- This is needed for Scheduler -->
<intent-filter>
<action android:name="androidx.media3.exoplayer.downloadService.action.RESTART"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</service>
<receiver
android:name="androidx.mediarouter.media.MediaTransferReceiver"
android:exported="true"
@@ -71,6 +71,10 @@ window.NativeShell = {
window.NativeInterface.openClientSettings();
},
openDownloads() {
window.NativeInterface.openDownloads();
},
selectServer() {
window.NativeInterface.openServerSelection();
},
@@ -187,6 +187,7 @@ class MainActivity : AppCompatActivity() {
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
for (fragment in supportFragmentManager.fragments) {
if (fragment is PlayerFragment && fragment.isVisible) {
fragment.onUserLeaveHint()
@@ -3,6 +3,8 @@ package org.jellyfin.mobile.app
import android.content.Context
import androidx.core.net.toUri
import coil.ImageLoader
import com.google.android.exoplayer2.database.DatabaseProvider
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
import com.google.android.exoplayer2.ext.cronet.CronetDataSource
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
import com.google.android.exoplayer2.extractor.ts.TsExtractor
@@ -16,6 +18,10 @@ import com.google.android.exoplayer2.upstream.DataSpec
import com.google.android.exoplayer2.upstream.DefaultDataSource
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.upstream.ResolvingDataSource
import com.google.android.exoplayer2.upstream.cache.Cache
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor
import com.google.android.exoplayer2.upstream.cache.SimpleCache
import com.google.android.exoplayer2.util.Util
import kotlinx.coroutines.channels.Channel
import okhttp3.OkHttpClient
@@ -23,6 +29,7 @@ import org.chromium.net.CronetEngine
import org.chromium.net.CronetProvider
import org.jellyfin.mobile.MainViewModel
import org.jellyfin.mobile.bridge.NativePlayer
import org.jellyfin.mobile.downloads.DownloadsViewModel
import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.player.audio.car.LibraryBrowser
import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
@@ -33,6 +40,7 @@ import org.jellyfin.mobile.player.ui.PlayerFragment
import org.jellyfin.mobile.setup.ConnectionHelper
import org.jellyfin.mobile.utils.Constants
import org.jellyfin.mobile.utils.PermissionRequestHelper
import org.jellyfin.mobile.utils.extractId
import org.jellyfin.mobile.utils.isLowRamDevice
import org.jellyfin.mobile.webapp.RemoteVolumeProvider
import org.jellyfin.mobile.webapp.WebViewFragment
@@ -44,6 +52,7 @@ import org.koin.androidx.fragment.dsl.fragment
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
import java.io.File
import java.util.concurrent.Executors
const val PLAYER_EVENT_CHANNEL = "PlayerEventChannel"
@@ -70,6 +79,7 @@ val applicationModule = module {
// ViewModels
viewModel { MainViewModel(get(), get()) }
viewModel { DownloadsViewModel() }
// Fragments
fragment { WebViewFragment() }
@@ -84,6 +94,19 @@ val applicationModule = module {
single { QualityOptionsProvider() }
// ExoPlayer factories
single<DatabaseProvider> {
val dbProvider = StandaloneDatabaseProvider(get<Context>())
dbProvider
}
single<Cache> {
val downloadPath = File(get<Context>().filesDir, Constants.DOWNLOAD_PATH)
if (!downloadPath.exists()) {
downloadPath.mkdirs()
}
val cache = SimpleCache(downloadPath, NoOpCacheEvictor(), get())
cache
}
single<DataSource.Factory> {
val context: Context = get()
val apiClient: ApiClient = get()
@@ -131,6 +154,19 @@ val applicationModule = module {
}
}
}
single<CacheDataSource.Factory> {
// Create a read-only cache data source factory using the download cache.
CacheDataSource.Factory()
.setCache(get())
.setUpstreamDataSourceFactory(get<DataSource.Factory>())
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
.setCacheWriteDataSinkFactory(null)
.setCacheKeyFactory { spec ->
spec.uri.extractId()
}
}
single<MediaSource.Factory> {
val context: Context = get()
val extractorsFactory = DefaultExtractorsFactory().apply {
@@ -142,11 +178,11 @@ val applicationModule = module {
},
)
}
DefaultMediaSourceFactory(get<DataSource.Factory>(), extractorsFactory)
DefaultMediaSourceFactory(get<CacheDataSource.Factory>(), extractorsFactory)
}
single { ProgressiveMediaSource.Factory(get()) }
single { HlsMediaSource.Factory(get<DataSource.Factory>()) }
single { SingleSampleMediaSource.Factory(get()) }
single { ProgressiveMediaSource.Factory(get<CacheDataSource.Factory>()) }
single { HlsMediaSource.Factory(get<CacheDataSource.Factory>()) }
single { SingleSampleMediaSource.Factory(get<CacheDataSource.Factory>()) }
// Media components
single { LibraryBrowser(get(), get()) }
@@ -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)
@@ -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)
@@ -9,9 +9,11 @@ val databaseModule = module {
Room.databaseBuilder(androidApplication(), JellyfinDatabase::class.java, "jellyfin")
.addMigrations()
.fallbackToDestructiveMigrationFrom(1)
.fallbackToDestructiveMigrationFrom(2)
.fallbackToDestructiveMigrationOnDowngrade()
.build()
}
single { get<JellyfinDatabase>().serverDao }
single { get<JellyfinDatabase>().userDao }
single { get<JellyfinDatabase>().downloadDao }
}
@@ -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
}
@@ -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<List<DownloadEntity>>
@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
}
@@ -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)
}
@@ -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<DownloadEntity>() {
override fun areItemsTheSame(oldItem: DownloadEntity, newItem: DownloadEntity): Boolean {
return oldItem.itemId == newItem.itemId
}
override fun areContentsTheSame(oldItem: DownloadEntity, newItem: DownloadEntity): Boolean {
return oldItem == newItem
}
}
@@ -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;
}
@@ -0,0 +1,63 @@
package org.jellyfin.mobile.downloads
import android.content.Context
import com.google.android.exoplayer2.database.DatabaseProvider
import com.google.android.exoplayer2.offline.DownloadManager
import com.google.android.exoplayer2.ui.DownloadNotificationHelper
import com.google.android.exoplayer2.upstream.DataSource
import com.google.android.exoplayer2.upstream.cache.Cache
import org.jellyfin.mobile.utils.Constants.DOWNLOAD_NOTIFICATION_CHANNEL_ID
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.util.concurrent.Executors
object DownloadServiceUtil : KoinComponent {
private const val DOWNLOAD_THREADS = 6
private val context: Context by inject()
private val databaseProvider: DatabaseProvider by inject()
private val downloadCache: Cache by inject()
private val dataSourceFactory: DataSource.Factory by inject()
private var downloadManager: DownloadManager? = null
private var downloadNotificationHelper: DownloadNotificationHelper? = null
private var downloadTracker: DownloadTracker? = null
@Synchronized
fun getDownloadNotificationHelper(
context: Context?,
): DownloadNotificationHelper {
if (downloadNotificationHelper == null) {
downloadNotificationHelper =
DownloadNotificationHelper(context!!, DOWNLOAD_NOTIFICATION_CHANNEL_ID)
}
return downloadNotificationHelper!!
}
@Synchronized
fun getDownloadManager(): DownloadManager {
ensureDownloadManagerInitialized(context)
return downloadManager!!
}
@Synchronized
fun getDownloadTracker(): DownloadTracker {
ensureDownloadManagerInitialized(context)
return downloadTracker!!
}
@Synchronized
private fun ensureDownloadManagerInitialized(context: Context) {
if (downloadManager == null) {
downloadManager =
DownloadManager(
context,
databaseProvider,
downloadCache,
dataSourceFactory,
Executors.newFixedThreadPool(DOWNLOAD_THREADS),
)
downloadTracker =
DownloadTracker(downloadManager!!)
}
}
}
@@ -0,0 +1,82 @@
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<Listener> = CopyOnWriteArraySet()
private val downloads: HashMap<Uri, Download> = 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()
}
}
}
}
@@ -0,0 +1,317 @@
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.extractId
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 downloadUri = downloadURL.toUri()
val cacheKey = downloadURL.toUri().extractId()
val downloadRequest = DownloadRequest.Builder(contentId, downloadUri)
.setData(jellyfinMediaSource.item!!.name!!.encodeToByteArray())
.setCustomCacheKey(cacheKey)
.build()
DownloadService.sendAddDownload(
context,
JellyfinDownloadService::class.java,
downloadRequest,
false,
)
}
private 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 = apiClient.createUrl(it.deliveryUrl).toUri()
val subtitleCacheKey: String = subtitleDownloadURL.extractId()
val downloadRequest = DownloadRequest.Builder("$contentId:${it.index}", subtitleDownloadURL)
.setCustomCacheKey(subtitleCacheKey)
.build()
DownloadService.sendAddDownload(
context,
JellyfinDownloadService::class.java,
downloadRequest,
false,
)
}
}
private suspend fun storeDownloadSpecs(jellyfinMediaSource: JellyfinMediaSource) =
LocalJellyfinMediaSource(
jellyfinMediaSource,
downloadFolder.canonicalPath,
downloadURL,
downloadTracker.getDownloadSize(downloadURL.toUri()),
).also {
downloadDao.insert(DownloadEntity(it))
}
private suspend fun downloadExternalMediaFile(jellyfinMediaSource: JellyfinMediaSource) {
if (!AndroidVersion.isAtLeastQ) {
@Suppress("MagicNumber")
val granted = withTimeout(2 * 60 * 1000) {
suspendCoroutine { continuation ->
mainActivity.requestPermission(WRITE_EXTERNAL_STORAGE) { requestPermissionsResult ->
continuation.resume(requestPermissionsResult[WRITE_EXTERNAL_STORAGE] == PERMISSION_GRANTED)
}
}
}
if (!granted) {
throw IOException(context.getString(R.string.download_no_storage_permission))
}
}
val downloadRequest = DownloadManager.Request(downloadURL.toUri())
.setTitle(jellyfinMediaSource.name)
.setDescription(context.getString(R.string.downloading))
.setDestinationUri(Uri.fromFile(File(appPreferences.downloadLocation, filename)))
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
context.getSystemService<DownloadManager>()?.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
}
}
@@ -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<DownloadEntity, DownloadsAdapter.DownloadViewHolder>(
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)
}
}
}
@@ -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))
}
}
@@ -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<DownloadEntity>())
// 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<DownloadEntity> ->
_downloads.update { downloads }
}
}
}
}
@@ -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<Download>, 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)
}
}
}
@@ -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()
}
@@ -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<ActivityEvent>(
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<DownloadsFragment>()
}
is ActivityEvent.CastMessage -> {
val action = event.action
chromecast.execute(
@@ -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
@@ -95,7 +96,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<ExoPlayer?>()
@@ -143,23 +144,24 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
// Load display preferences
viewModelScope.launch {
var customPrefs: Map<String, String?>? = 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,
)
}
viewModelScope.launch {
@@ -268,12 +270,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)
@@ -301,11 +306,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(
@@ -329,7 +334,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
@@ -359,7 +364,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 {
@@ -394,7 +399,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,
@@ -492,6 +497,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
audioManager.setStreamVolume(stream, scaled, 0)
}
@Deprecated("Deprecated in Java")
@SuppressLint("SwitchIntDef")
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
val player = playerOrNull ?: return
@@ -146,6 +146,7 @@ class MediaService : MediaBrowserServiceCompat() {
}
override fun onDestroy() {
super.onDestroy()
mediaSession.run {
isActive = false
release()
@@ -381,6 +382,7 @@ class MediaService : MediaBrowserServiceCompat() {
* Listen for events from ExoPlayer.
*/
private inner class PlayerEventListener : Player.Listener {
@Deprecated("Deprecated in Java")
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
when (playbackState) {
Player.STATE_BUFFERING,
@@ -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)
@@ -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<ApiClient>().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
@@ -148,16 +154,31 @@ class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinCom
}
}
private suspend fun loadImage(mediaSource: JellyfinMediaSource): Bitmap? {
val height = context.resources.getDimensionPixelSize(R.dimen.media_notification_height)
val imageUrl = imageApi.getItemImageUrl(
itemId = mediaSource.itemId,
imageType = ImageType.PRIMARY,
fillHeight = height,
tag = mediaSource.item?.imageTags?.get(ImageType.PRIMARY),
)
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 height = context.resources.getDimensionPixelSize(R.dimen.media_notification_height)
val imageUrl = imageApi.getItemImageUrl(
itemId = mediaSource.itemId,
imageType = ImageType.PRIMARY,
fillHeight = height,
tag = mediaSource.item?.imageTags?.get(ImageType.PRIMARY),
)
val imageRequest = ImageRequest.Builder(context).data(imageUrl).build()
imageLoader.execute(imageRequest).drawable?.toBitmap()
}
}
private fun generateAction(playerNotificationAction: PlayerNotificationAction): Notification.Action {
@@ -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
@@ -30,6 +34,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(
@@ -48,8 +53,7 @@ class QueueManager(
val currentMediaSource: LiveData<JellyfinMediaSource>
get() = _currentMediaSource
inline val currentMediaSourceOrNull: JellyfinMediaSource?
get() = currentMediaSource.value
fun getCurrentMediaSourceOrNull(): JellyfinMediaSource? = currentMediaSource.value
/**
* Handle initial playback options from fragment.
@@ -66,25 +70,52 @@ 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<DownloadDao>()
.get(mediaSourceId)
?.asMediaSource(startTimeMs, audioStreamIndex, subtitleStreamIndex)
?.also { jellyfinMediaSource ->
_currentMediaSource.value = jellyfinMediaSource
// Load new media source
viewModel.load(jellyfinMediaSource, prepareStreams(jellyfinMediaSource), playWhenReady)
}
return null
}
/**
* Play a specific media item specified by [itemId] and [mediaSourceId].
*
* @return an error of type [PlayerException] or null on success.
*/
private suspend fun startPlayback(
private suspend fun startRemotePlayback(
itemId: UUID,
mediaSourceId: String?,
maxStreamingBitrate: Int?,
@@ -103,8 +134,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
@@ -122,23 +153,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,
@@ -156,9 +193,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,
@@ -169,13 +206,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
}
@@ -187,7 +229,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<MediaSource> = 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 {
@@ -282,6 +336,37 @@ 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<MediaSource> {
val downloadDir: String = File(fileUri).parent
val factory = get<SingleSampleMediaSource.Factory>()
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.
*
@@ -289,18 +374,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
}
@@ -313,18 +407,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
}
}
@@ -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?,
)
@@ -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<LocalJellyfinMediaSource> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("JellyfinMediaSource") {
element<String>("itemId")
element<BaseItemDto?>("item", isOptional = true)
element<MediaSourceInfo>("sourceInfo")
element<String>("playSessionId")
element<String>("downloadFolderUri")
element<String>("downloadedFileUri")
element<Long>("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" },
)
}
}
@@ -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,
)
}
@@ -8,6 +8,7 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.operations.MediaInfoApi
import org.jellyfin.sdk.api.operations.UserLibraryApi
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 +28,10 @@ class MediaSourceResolver(private val apiClient: ApiClient) {
audioStreamIndex: Int? = null,
subtitleStreamIndex: Int? = null,
autoOpenLiveStream: Boolean = true,
): Result<JellyfinMediaSource> {
): Result<RemoteJellyfinMediaSource> {
// Load media source info
val playSessionId: String
val mediaSourceInfo = try {
val mediaSourceInfo: MediaSourceInfo = try {
val response by mediaInfoApi.getPostedPlaybackInfo(
itemId = itemId,
data = PlaybackInfoDto(
@@ -58,6 +59,7 @@ class MediaSourceResolver(private val apiClient: ApiClient) {
}
// Load additional item info if possible
val item = try {
userLibraryApi.getItem(itemId).content
} catch (e: ApiClientException) {
@@ -67,16 +69,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) {
@@ -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)")
}
}
@@ -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)
@@ -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 {
@@ -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)
}
}
@@ -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(
@@ -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"
}
@@ -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;
}
@@ -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,25 +17,30 @@ 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.jellyfin.sdk.model.serializer.toUUID
import org.koin.android.ext.android.get
import timber.log.Timber
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.time.Duration.Companion.minutes
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)
@@ -60,25 +63,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) {
val granted = withTimeout(2.minutes.inWholeMilliseconds) {
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 +92,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)
downloadFile(downloadRequest, downloadMethod)
}
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 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)
}
}
}
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
}
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<DownloadManager>()?.enqueue(request)
}
fun Activity.isAutoRotateOn() = Settings.System.getInt(contentResolver, ACCELEROMETER_ROTATION, 0) == 1
@@ -165,3 +197,19 @@ fun Context.getDownloadsPaths(): List<String> = ArrayList<String>().apply {
val Context.isLowRamDevice: Boolean
get() = getSystemService<ActivityManager>()!!.isLowRamDevice
fun Uri.extractId(): String {
val 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}"
}
return item
}
@@ -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])
}
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:baselineAligned="false"
android:padding="16dp">
<ImageView
android:id="@+id/imageViewThumbnail"
android:layout_width="0dp"
android:layout_weight="1.5"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
android:contentDescription="@string/thumbnail_desc" />
<LinearLayout
android:layout_width="0dp"
android:layout_weight="2"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:paddingHorizontal="16dp">
<TextView
android:id="@+id/textViewName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="21sp" />
<TextView
android:id="@+id/textViewDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/textViewFileSize"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/theme_background">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="?colorPrimary"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="0dp"
android:layout_height="0dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
+16
View File
@@ -119,4 +119,20 @@
<string name="pref_category_downloads">Downloads</string>
<string name="pref_download_location">Download location</string>
<string name="view_downloads">View downloads</string>
<string name="downloads">Downloads</string>
<string name="download_notifications_description">Download notifications</string>
<string name="failed_thumbnail">Failed to download thumbnail</string>
<string name="failed_information">Failed to retrieve media information</string>
<string name="failed_network_method_check">Please check your allowed download methods</string>
<string name="confirm_deletion">Confirm deletion</string>
<string name="confirm_deletion_desc">Do you want to delete %1$s?</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="thumbnail_desc">Thumbnail</string>
<string name="store_videos_in_internal_storage">Store videos in internal storage</string>
<string name="stored_videos_in_internal_storage_desc">Videos such as movies and TV shows will be stored in the app\'s internal storage. Allowing access from within the app.</string>
<string name="tv_show_desc">%1$s - S%2$dE%3$d</string>
<string name="downloading_desc">Downloading %1$d titles</string>
<string name="downloaded">Downloaded %1$s</string>
</resources>
+6
View File
@@ -8,6 +8,7 @@ android-junit5 = "1.11.3.0"
# KotlinX
coroutines = "1.10.1"
serialization-json = "1.6.3"
# Core
koin = "4.0.2"
@@ -36,6 +37,7 @@ compose-material = "1.7.6"
# Network
jellyfin-sdk = "1.6.6"
okhttp = "4.12.0"
okio = "3.9.0"
coil = "2.7.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" }
androidx-room = { id = "androidx.room", version.ref = "androidx-room" }
@@ -74,6 +77,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" }
@@ -111,6 +115,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" }
@@ -130,6 +135,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" }