mirror of
https://github.com/jellyfin/jellyfin-android.git
synced 2026-09-14 13:11:55 +02:00
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:
co-authored by
Emily Ehlert
Ben Martin
parent
8744f39c64
commit
215a97aa39
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+32
-11
@@ -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])
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user