Items and downloads rework (#329)
* refactor WIP: stop using `BaseItemDto` but use custom items specific to Findroid This will make it easier to support downloaded items * refactor: split `MediaInfoFragment` into `MovieFragment` and `ShowFragment` * feat: add download icons to items * feat WIP: download movies * feat: download movie and play local file * fix: remove `VideoVersionDialogFragment` from `ShowFragment` * feat: select which version you want to download * feat: delete downloaded movie * feat: download progress indicator * refactor: rename JellyfinItems to FindroidItems * feat: offline mode (movies only) * feat: offline mode card * feat: download external files * feat: toggle played on downloads * feat: convert intros to `FindroidIntro` * refactor: add itemId and sourceId to external downloaded subtitle filenames * refactor: simplify `onMediaItemTransition` * refactor: clean up some player item logic * feat: download trickPlay data * refactor: downloading of item to only require the item and a source id * fix: external subtitle title * feat: add `DownloadsFragment` * feat: download episodes * fix: cascade deletion if last item * feat: download intro timestamps * feat WIP: add storage activity * feat: user data in separate table * feat: add buttons to season fragment * fix: improve responsiveness of the watched and favorite buttons * fix: move `ic_database.xml` to main * perf: optimize home fragment by limiting the number of items * fix: database improvements - use compound primary key for FindroidUserDataDto instead of id - set played to false when playback percentage is below 90% - capitalize SQL keywords - update favorite in userdata - set primary key of TrickPlayManifestDto to itemId - prepare to sync data back to server * feat: sync playback progress This includes playback position, played and favorite * fix: use non-transitive r classes * lint: ktlint fix * refactor: centralize item buttons in `item_actions.xml` * feat: show intermediate progress when progress is less than 5 Also remove delete button from item_actions.xml * feat: remove intros * feat: check available storage space before downloading * fix: trailer button * refactor: make indexNumberEnd nullable * feat: add offline mode toggle in settings * fix: download over mobile data and roaming * feat: immediately show spinner when tapping download * revert: season fragment buttons * feat: snackbar in downloads fragment This snackbar is displayed when there is no connection to the server but the app is not in Offline Mode (Offline Mode is required to play content when the server is unavailable) * refactor: make onReceive arguments non nullable * fix: handle download finished / failed when BroadcastReceiver does not work * fix: download multiple episodes * feat: download to external storage (SD card) * fix: reset download button when dialog is dismissed * feat(offline): show "continue watching" episodes on home * fix: watch progress bar on episode item in season * feat(offline): next up items * lint: fix some linting issues * lint: fix some linting issues * lint: fix some linting issues * feat: remove StorageActivity StorageActivity is not ready yet and out of scope for this PR * fix: collection types that are not known crash the media fragment * fix: downloading trick play data * fix: sort downloaded items * fix: navigate back if item is deleted instead of showing error Navigate back based on NullPointerException in loadData method of viewmodels. This may not be the best approach but it works well enough. Navigating back from BottomSheetFragment does not trigger onResume of previous fragment which in turn does not refresh its contents. * fix: play from local storage instead of server when downloaded * fix: missing items * fix: `SyncWorker` using the app JellyfinApi instance instead of it's own * fix: only show downloaded items when navigating from `DownloadsFragment` * fix: make chips horizontal scrollable * feat: migrate database (retain) and downloads (wipe) Also add indexes on seriesId and seasonId * fix: remove temp testing in downloadsMigrated * lint: fix some linting issues * fix: add error handling to downloading item * feat: add "Preparing download" dialog to make sure the user waits for the download to start * refactor: first show dialog then start downloading * fix: add error handling to user configuration in `PlayerViewModel` * feat: allow downloads to be cancelled * fix: "View details" is cut off when text is too long * lint: fix indent
This commit is contained in:
parent
c36705c206
commit
00c84fa9d5
143 changed files with 7395 additions and 3608 deletions
|
@ -82,6 +82,7 @@ dependencies {
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.androidx.constraintlayout)
|
implementation(libs.androidx.constraintlayout)
|
||||||
implementation(libs.androidx.core)
|
implementation(libs.androidx.core)
|
||||||
|
implementation(libs.androidx.hilt.work)
|
||||||
implementation(libs.androidx.lifecycle.runtime)
|
implementation(libs.androidx.lifecycle.runtime)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel)
|
implementation(libs.androidx.lifecycle.viewmodel)
|
||||||
implementation(libs.androidx.media3.exoplayer)
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
|
@ -95,6 +96,7 @@ dependencies {
|
||||||
implementation(libs.androidx.recyclerview.selection)
|
implementation(libs.androidx.recyclerview.selection)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
implementation(libs.androidx.swiperefreshlayout)
|
implementation(libs.androidx.swiperefreshlayout)
|
||||||
|
implementation(libs.androidx.work)
|
||||||
implementation(libs.glide)
|
implementation(libs.glide)
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
kapt(libs.hilt.compiler)
|
kapt(libs.hilt.compiler)
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.wifi" android:required="false" />
|
<uses-feature
|
||||||
|
android:name="android.hardware.wifi"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".BaseApplication"
|
android:name=".BaseApplication"
|
||||||
|
@ -33,6 +37,19 @@
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".utils.DownloadReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
tools:node="remove">
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
|
@ -2,16 +2,26 @@ package dev.jdtech.jellyfin
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
|
import androidx.work.Configuration
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class BaseApplication : Application() {
|
class BaseApplication : Application(), Configuration.Provider {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var appPreferences: AppPreferences
|
lateinit var appPreferences: AppPreferences
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var workerFactory: HiltWorkerFactory
|
||||||
|
|
||||||
|
override fun getWorkManagerConfiguration() =
|
||||||
|
Configuration.Builder()
|
||||||
|
.setWorkerFactory(workerFactory)
|
||||||
|
.build()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,9 @@ import dev.jdtech.jellyfin.adapters.ServerGridAdapter
|
||||||
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
|
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
|
||||||
import dev.jdtech.jellyfin.api.JellyfinApi
|
import dev.jdtech.jellyfin.api.JellyfinApi
|
||||||
import dev.jdtech.jellyfin.core.R as CoreR
|
import dev.jdtech.jellyfin.core.R as CoreR
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||||
import dev.jdtech.jellyfin.models.Server
|
import dev.jdtech.jellyfin.models.Server
|
||||||
import dev.jdtech.jellyfin.models.User
|
import dev.jdtech.jellyfin.models.User
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
@ -27,7 +30,7 @@ fun bindServers(recyclerView: RecyclerView, data: List<Server>?) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@BindingAdapter("items")
|
@BindingAdapter("items")
|
||||||
fun bindItems(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
|
fun bindItems(recyclerView: RecyclerView, data: List<FindroidItem>?) {
|
||||||
val adapter = recyclerView.adapter as ViewItemListAdapter
|
val adapter = recyclerView.adapter as ViewItemListAdapter
|
||||||
adapter.submitList(data)
|
adapter.submitList(data)
|
||||||
}
|
}
|
||||||
|
@ -42,8 +45,21 @@ fun bindItemImage(imageView: ImageView, item: BaseItemDto) {
|
||||||
.posterDescription(item.name)
|
.posterDescription(item.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@BindingAdapter("itemImage")
|
||||||
|
fun bindItemImage(imageView: ImageView, item: FindroidItem) {
|
||||||
|
val itemId = when (item) {
|
||||||
|
is FindroidEpisode -> item.seriesId
|
||||||
|
// is JellyfinSeasonItem && item.imageTags.isNullOrEmpty() -> item.seriesId
|
||||||
|
else -> item.id
|
||||||
|
}
|
||||||
|
|
||||||
|
imageView
|
||||||
|
.loadImage("/items/$itemId/Images/${ImageType.PRIMARY}")
|
||||||
|
.posterDescription(item.name)
|
||||||
|
}
|
||||||
|
|
||||||
@BindingAdapter("itemBackdropImage")
|
@BindingAdapter("itemBackdropImage")
|
||||||
fun bindItemBackdropImage(imageView: ImageView, item: BaseItemDto?) {
|
fun bindItemBackdropImage(imageView: ImageView, item: FindroidItem?) {
|
||||||
if (item == null) return
|
if (item == null) return
|
||||||
|
|
||||||
imageView
|
imageView
|
||||||
|
@ -64,41 +80,21 @@ fun bindPersonImage(imageView: ImageView, person: BaseItemPerson) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@BindingAdapter("homeEpisodes")
|
@BindingAdapter("homeEpisodes")
|
||||||
fun bindHomeEpisodes(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
|
fun bindHomeEpisodes(recyclerView: RecyclerView, data: List<FindroidItem>?) {
|
||||||
val adapter = recyclerView.adapter as HomeEpisodeListAdapter
|
val adapter = recyclerView.adapter as HomeEpisodeListAdapter
|
||||||
adapter.submitList(data)
|
adapter.submitList(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@BindingAdapter("baseItemImage")
|
@BindingAdapter("cardItemImage")
|
||||||
fun bindBaseItemImage(imageView: ImageView, episode: BaseItemDto?) {
|
fun bindCardItemImage(imageView: ImageView, item: FindroidItem) {
|
||||||
if (episode == null) return
|
val imageType = when (item) {
|
||||||
|
is FindroidMovie -> ImageType.BACKDROP
|
||||||
var imageItemId = episode.id
|
else -> ImageType.PRIMARY
|
||||||
var imageType = ImageType.PRIMARY
|
|
||||||
|
|
||||||
if (!episode.imageTags.isNullOrEmpty()) { // TODO: Downloadmetadata currently does not store imagetags, so it always uses the backdrop
|
|
||||||
when (episode.type) {
|
|
||||||
BaseItemKind.MOVIE -> {
|
|
||||||
if (!episode.backdropImageTags.isNullOrEmpty()) {
|
|
||||||
imageType = ImageType.BACKDROP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
if (!episode.imageTags!!.keys.contains(ImageType.PRIMARY)) {
|
|
||||||
imageType = ImageType.BACKDROP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (episode.type == BaseItemKind.EPISODE) {
|
|
||||||
imageItemId = episode.seriesId!!
|
|
||||||
imageType = ImageType.BACKDROP
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
imageView
|
imageView
|
||||||
.loadImage("/items/$imageItemId/Images/$imageType")
|
.loadImage("/items/${item.id}/Images/$imageType")
|
||||||
.posterDescription(episode.name)
|
.posterDescription(item.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@BindingAdapter("seasonPoster")
|
@BindingAdapter("seasonPoster")
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
package dev.jdtech.jellyfin
|
package dev.jdtech.jellyfin
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Environment
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.NavGraph
|
import androidx.navigation.NavGraph
|
||||||
import androidx.navigation.fragment.NavHostFragment
|
import androidx.navigation.fragment.NavHostFragment
|
||||||
|
@ -11,14 +13,20 @@ import androidx.navigation.ui.AppBarConfiguration
|
||||||
import androidx.navigation.ui.NavigationUI
|
import androidx.navigation.ui.NavigationUI
|
||||||
import androidx.navigation.ui.NavigationUiSaveStateControl
|
import androidx.navigation.ui.NavigationUiSaveStateControl
|
||||||
import androidx.navigation.ui.setupActionBarWithNavController
|
import androidx.navigation.ui.setupActionBarWithNavController
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
import com.google.android.material.navigation.NavigationBarView
|
import com.google.android.material.navigation.NavigationBarView
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.core.R as CoreR
|
import dev.jdtech.jellyfin.core.R as CoreR
|
||||||
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||||
import dev.jdtech.jellyfin.databinding.ActivityMainBinding
|
import dev.jdtech.jellyfin.databinding.ActivityMainBinding
|
||||||
import dev.jdtech.jellyfin.utils.loadDownloadLocation
|
|
||||||
import dev.jdtech.jellyfin.viewmodels.MainViewModel
|
import dev.jdtech.jellyfin.viewmodels.MainViewModel
|
||||||
|
import dev.jdtech.jellyfin.work.SyncWorker
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
@ -39,6 +47,26 @@ class MainActivity : AppCompatActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
print("OnCreate LOOOOOL")
|
||||||
|
|
||||||
|
val syncWorkRequest = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||||
|
.setConstraints(
|
||||||
|
Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(
|
||||||
|
NetworkType.CONNECTED
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val workManager = WorkManager.getInstance(applicationContext)
|
||||||
|
|
||||||
|
workManager.beginUniqueWork("syncUserData", ExistingWorkPolicy.KEEP, syncWorkRequest).enqueue()
|
||||||
|
|
||||||
|
if (!appPreferences.downloadsMigrated) {
|
||||||
|
cleanUpOldDownloads()
|
||||||
|
}
|
||||||
|
|
||||||
if (appPreferences.amoledTheme) {
|
if (appPreferences.amoledTheme) {
|
||||||
setTheme(CoreR.style.Theme_FindroidAMOLED)
|
setTheme(CoreR.style.Theme_FindroidAMOLED)
|
||||||
}
|
}
|
||||||
|
@ -62,6 +90,11 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
val navView: NavigationBarView = binding.navView as NavigationBarView
|
val navView: NavigationBarView = binding.navView as NavigationBarView
|
||||||
|
|
||||||
|
if (appPreferences.offlineMode) {
|
||||||
|
navView.menu.clear()
|
||||||
|
navView.inflateMenu(CoreR.menu.bottom_nav_menu_offline)
|
||||||
|
}
|
||||||
|
|
||||||
setSupportActionBar(binding.mainToolbar)
|
setSupportActionBar(binding.mainToolbar)
|
||||||
|
|
||||||
// Passing each menu ID as a set of Ids because each
|
// Passing each menu ID as a set of Ids because each
|
||||||
|
@ -71,7 +104,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
R.id.homeFragment,
|
R.id.homeFragment,
|
||||||
R.id.mediaFragment,
|
R.id.mediaFragment,
|
||||||
R.id.favoriteFragment,
|
R.id.favoriteFragment,
|
||||||
R.id.downloadFragment
|
R.id.downloadsFragment,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -88,8 +121,6 @@ class MainActivity : AppCompatActivity() {
|
||||||
if (destination.id == com.mikepenz.aboutlibraries.R.id.about_libraries_dest) binding.mainToolbar.title =
|
if (destination.id == com.mikepenz.aboutlibraries.R.id.about_libraries_dest) binding.mainToolbar.title =
|
||||||
getString(CoreR.string.app_info)
|
getString(CoreR.string.app_info)
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDownloadLocation(applicationContext)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSupportNavigateUp(): Boolean {
|
override fun onSupportNavigateUp(): Boolean {
|
||||||
|
@ -119,4 +150,25 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temp to remove old downloads, will be removed in a future version
|
||||||
|
*/
|
||||||
|
private fun cleanUpOldDownloads() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val oldDir = applicationContext.getExternalFilesDir(Environment.DIRECTORY_MOVIES)
|
||||||
|
if (oldDir == null) {
|
||||||
|
appPreferences.downloadsMigrated = true
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (file in oldDir.listFiles()!!) {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
|
||||||
|
appPreferences.downloadsMigrated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,25 +6,25 @@ import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import dev.jdtech.jellyfin.databinding.CollectionItemBinding
|
import dev.jdtech.jellyfin.databinding.CollectionItemBinding
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import dev.jdtech.jellyfin.models.FindroidCollection
|
||||||
|
|
||||||
class CollectionListAdapter(
|
class CollectionListAdapter(
|
||||||
private val onClickListener: OnClickListener
|
private val onClickListener: OnClickListener
|
||||||
) : ListAdapter<BaseItemDto, CollectionListAdapter.CollectionViewHolder>(DiffCallback) {
|
) : ListAdapter<FindroidCollection, CollectionListAdapter.CollectionViewHolder>(DiffCallback) {
|
||||||
class CollectionViewHolder(private var binding: CollectionItemBinding) :
|
class CollectionViewHolder(private var binding: CollectionItemBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(collection: BaseItemDto) {
|
fun bind(collection: FindroidCollection) {
|
||||||
binding.collection = collection
|
binding.collection = collection
|
||||||
binding.executePendingBindings()
|
binding.executePendingBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object DiffCallback : DiffUtil.ItemCallback<BaseItemDto>() {
|
companion object DiffCallback : DiffUtil.ItemCallback<FindroidCollection>() {
|
||||||
override fun areItemsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean {
|
override fun areItemsTheSame(oldItem: FindroidCollection, newItem: FindroidCollection): Boolean {
|
||||||
return oldItem.id == newItem.id
|
return oldItem.id == newItem.id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean {
|
override fun areContentsTheSame(oldItem: FindroidCollection, newItem: FindroidCollection): Boolean {
|
||||||
return oldItem == newItem
|
return oldItem == newItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -47,7 +47,7 @@ class CollectionListAdapter(
|
||||||
holder.bind(collection)
|
holder.bind(collection)
|
||||||
}
|
}
|
||||||
|
|
||||||
class OnClickListener(val clickListener: (collection: BaseItemDto) -> Unit) {
|
class OnClickListener(val clickListener: (collection: FindroidCollection) -> Unit) {
|
||||||
fun onClick(collection: BaseItemDto) = clickListener(collection)
|
fun onClick(collection: FindroidCollection) = clickListener(collection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,116 +0,0 @@
|
||||||
package dev.jdtech.jellyfin.adapters
|
|
||||||
|
|
||||||
import android.util.TypedValue
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import dev.jdtech.jellyfin.databinding.EpisodeItemBinding
|
|
||||||
import dev.jdtech.jellyfin.databinding.SeasonHeaderBinding
|
|
||||||
import dev.jdtech.jellyfin.models.DownloadEpisodeItem
|
|
||||||
import dev.jdtech.jellyfin.models.DownloadSeriesMetadata
|
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
|
||||||
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
|
|
||||||
private const val ITEM_VIEW_TYPE_HEADER = 0
|
|
||||||
private const val ITEM_VIEW_TYPE_EPISODE = 1
|
|
||||||
|
|
||||||
class DownloadEpisodeListAdapter(
|
|
||||||
private val onClickListener: OnClickListener,
|
|
||||||
private val downloadSeriesMetadata: DownloadSeriesMetadata
|
|
||||||
) :
|
|
||||||
ListAdapter<DownloadEpisodeItem, RecyclerView.ViewHolder>(DiffCallback) {
|
|
||||||
|
|
||||||
class HeaderViewHolder(private var binding: SeasonHeaderBinding) :
|
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
|
||||||
fun bind(
|
|
||||||
metadata: DownloadSeriesMetadata
|
|
||||||
) {
|
|
||||||
binding.seasonName.text = metadata.name
|
|
||||||
binding.seriesId = metadata.itemId
|
|
||||||
binding.seasonId = metadata.itemId
|
|
||||||
binding.executePendingBindings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class EpisodeViewHolder(private var binding: EpisodeItemBinding) :
|
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
|
||||||
fun bind(episode: BaseItemDto) {
|
|
||||||
binding.episode = episode
|
|
||||||
if (episode.userData?.playedPercentage != null) {
|
|
||||||
binding.progressBar.layoutParams.width = TypedValue.applyDimension(
|
|
||||||
TypedValue.COMPLEX_UNIT_DIP,
|
|
||||||
(episode.userData?.playedPercentage?.times(.84))!!.toFloat(),
|
|
||||||
binding.progressBar.context.resources.displayMetrics
|
|
||||||
).toInt()
|
|
||||||
binding.progressBar.visibility = View.VISIBLE
|
|
||||||
} else {
|
|
||||||
binding.progressBar.visibility = View.GONE
|
|
||||||
}
|
|
||||||
binding.executePendingBindings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object DiffCallback : DiffUtil.ItemCallback<DownloadEpisodeItem>() {
|
|
||||||
override fun areItemsTheSame(oldItem: DownloadEpisodeItem, newItem: DownloadEpisodeItem): Boolean {
|
|
||||||
return oldItem.id == newItem.id
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItem: DownloadEpisodeItem, newItem: DownloadEpisodeItem): Boolean {
|
|
||||||
return oldItem == newItem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
|
||||||
return when (viewType) {
|
|
||||||
ITEM_VIEW_TYPE_HEADER -> {
|
|
||||||
HeaderViewHolder(
|
|
||||||
SeasonHeaderBinding.inflate(
|
|
||||||
LayoutInflater.from(parent.context),
|
|
||||||
parent,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ITEM_VIEW_TYPE_EPISODE -> {
|
|
||||||
EpisodeViewHolder(
|
|
||||||
EpisodeItemBinding.inflate(
|
|
||||||
LayoutInflater.from(parent.context),
|
|
||||||
parent,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> throw ClassCastException("Unknown viewType $viewType")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
|
||||||
when (holder.itemViewType) {
|
|
||||||
ITEM_VIEW_TYPE_HEADER -> {
|
|
||||||
(holder as HeaderViewHolder).bind(downloadSeriesMetadata)
|
|
||||||
}
|
|
||||||
ITEM_VIEW_TYPE_EPISODE -> {
|
|
||||||
val item = getItem(position) as DownloadEpisodeItem.Episode
|
|
||||||
holder.itemView.setOnClickListener {
|
|
||||||
onClickListener.onClick(item.episode)
|
|
||||||
}
|
|
||||||
(holder as EpisodeViewHolder).bind(downloadMetadataToBaseItemDto(item.episode.item!!))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemViewType(position: Int): Int {
|
|
||||||
return when (getItem(position)) {
|
|
||||||
is DownloadEpisodeItem.Header -> ITEM_VIEW_TYPE_HEADER
|
|
||||||
is DownloadEpisodeItem.Episode -> ITEM_VIEW_TYPE_EPISODE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class OnClickListener(val clickListener: (item: PlayerItem) -> Unit) {
|
|
||||||
fun onClick(item: PlayerItem) = clickListener(item)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,71 +0,0 @@
|
||||||
package dev.jdtech.jellyfin.adapters
|
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import dev.jdtech.jellyfin.core.R as CoreR
|
|
||||||
import dev.jdtech.jellyfin.databinding.BaseItemBinding
|
|
||||||
import dev.jdtech.jellyfin.models.DownloadSeriesMetadata
|
|
||||||
import dev.jdtech.jellyfin.utils.downloadSeriesMetadataToBaseItemDto
|
|
||||||
|
|
||||||
class DownloadSeriesListAdapter(
|
|
||||||
private val onClickListener: OnClickListener,
|
|
||||||
private val fixedWidth: Boolean = false,
|
|
||||||
) : ListAdapter<DownloadSeriesMetadata, DownloadSeriesListAdapter.ItemViewHolder>(DiffCallback) {
|
|
||||||
|
|
||||||
class ItemViewHolder(private var binding: BaseItemBinding, private val parent: ViewGroup) :
|
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
|
||||||
fun bind(item: DownloadSeriesMetadata, fixedWidth: Boolean) {
|
|
||||||
binding.item = downloadSeriesMetadataToBaseItemDto(item)
|
|
||||||
binding.itemName.text = item.name
|
|
||||||
binding.itemCount.text = item.episodes.size.toString()
|
|
||||||
if (fixedWidth) {
|
|
||||||
binding.itemLayout.layoutParams.width =
|
|
||||||
parent.resources.getDimension(CoreR.dimen.overview_media_width).toInt()
|
|
||||||
(binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0
|
|
||||||
}
|
|
||||||
binding.executePendingBindings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object DiffCallback : DiffUtil.ItemCallback<DownloadSeriesMetadata>() {
|
|
||||||
override fun areItemsTheSame(
|
|
||||||
oldItem: DownloadSeriesMetadata,
|
|
||||||
newItem: DownloadSeriesMetadata
|
|
||||||
): Boolean {
|
|
||||||
return oldItem.itemId == newItem.itemId
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun areContentsTheSame(
|
|
||||||
oldItem: DownloadSeriesMetadata,
|
|
||||||
newItem: DownloadSeriesMetadata
|
|
||||||
): Boolean {
|
|
||||||
return oldItem == newItem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
|
|
||||||
return ItemViewHolder(
|
|
||||||
BaseItemBinding.inflate(
|
|
||||||
LayoutInflater.from(parent.context),
|
|
||||||
parent,
|
|
||||||
false
|
|
||||||
),
|
|
||||||
parent
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
|
|
||||||
val item = getItem(position)
|
|
||||||
holder.itemView.setOnClickListener {
|
|
||||||
onClickListener.onClick(item)
|
|
||||||
}
|
|
||||||
holder.bind(item, fixedWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
class OnClickListener(val clickListener: (item: DownloadSeriesMetadata) -> Unit) {
|
|
||||||
fun onClick(item: DownloadSeriesMetadata) = clickListener(item)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,66 +0,0 @@
|
||||||
package dev.jdtech.jellyfin.adapters
|
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import dev.jdtech.jellyfin.core.R as CoreR
|
|
||||||
import dev.jdtech.jellyfin.databinding.BaseItemBinding
|
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
|
||||||
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
|
|
||||||
|
|
||||||
class DownloadViewItemListAdapter(
|
|
||||||
private val onClickListener: OnClickListener,
|
|
||||||
private val fixedWidth: Boolean = false,
|
|
||||||
) : ListAdapter<PlayerItem, DownloadViewItemListAdapter.ItemViewHolder>(DiffCallback) {
|
|
||||||
|
|
||||||
class ItemViewHolder(private var binding: BaseItemBinding, private val parent: ViewGroup) :
|
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
|
||||||
fun bind(item: PlayerItem, fixedWidth: Boolean) {
|
|
||||||
val metadata = item.item!!
|
|
||||||
binding.item = downloadMetadataToBaseItemDto(metadata)
|
|
||||||
binding.itemName.text = item.name
|
|
||||||
binding.itemCount.visibility = View.GONE
|
|
||||||
if (fixedWidth) {
|
|
||||||
binding.itemLayout.layoutParams.width = parent.resources.getDimension(CoreR.dimen.overview_media_width).toInt()
|
|
||||||
(binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0
|
|
||||||
}
|
|
||||||
binding.executePendingBindings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object DiffCallback : DiffUtil.ItemCallback<PlayerItem>() {
|
|
||||||
override fun areItemsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean {
|
|
||||||
return oldItem.itemId == newItem.itemId
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean {
|
|
||||||
return oldItem == newItem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
|
|
||||||
return ItemViewHolder(
|
|
||||||
BaseItemBinding.inflate(
|
|
||||||
LayoutInflater.from(parent.context),
|
|
||||||
parent,
|
|
||||||
false
|
|
||||||
),
|
|
||||||
parent
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
|
|
||||||
val item = getItem(position)
|
|
||||||
holder.itemView.setOnClickListener {
|
|
||||||
onClickListener.onClick(item)
|
|
||||||
}
|
|
||||||
holder.bind(item, fixedWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
class OnClickListener(val clickListener: (item: PlayerItem) -> Unit) {
|
|
||||||
fun onClick(item: PlayerItem) = clickListener(item)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,64 +0,0 @@
|
||||||
package dev.jdtech.jellyfin.adapters
|
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import dev.jdtech.jellyfin.databinding.DownloadSectionBinding
|
|
||||||
import dev.jdtech.jellyfin.models.DownloadSection
|
|
||||||
|
|
||||||
class DownloadsListAdapter(
|
|
||||||
private val onClickListener: DownloadViewItemListAdapter.OnClickListener,
|
|
||||||
private val onSeriesClickListener: DownloadSeriesListAdapter.OnClickListener
|
|
||||||
) : ListAdapter<DownloadSection, DownloadsListAdapter.SectionViewHolder>(DiffCallback) {
|
|
||||||
class SectionViewHolder(private var binding: DownloadSectionBinding) :
|
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
|
||||||
fun bind(
|
|
||||||
section: DownloadSection,
|
|
||||||
onClickListener: DownloadViewItemListAdapter.OnClickListener,
|
|
||||||
onSeriesClickListener: DownloadSeriesListAdapter.OnClickListener
|
|
||||||
) {
|
|
||||||
binding.section = section
|
|
||||||
when (section.name) {
|
|
||||||
"Movies" -> {
|
|
||||||
binding.itemsRecyclerView.adapter = DownloadViewItemListAdapter(onClickListener, fixedWidth = true)
|
|
||||||
(binding.itemsRecyclerView.adapter as DownloadViewItemListAdapter).submitList(section.items)
|
|
||||||
}
|
|
||||||
"Shows" -> {
|
|
||||||
binding.itemsRecyclerView.adapter = DownloadSeriesListAdapter(onSeriesClickListener, fixedWidth = true)
|
|
||||||
(binding.itemsRecyclerView.adapter as DownloadSeriesListAdapter).submitList(section.series)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.executePendingBindings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object DiffCallback : DiffUtil.ItemCallback<DownloadSection>() {
|
|
||||||
override fun areItemsTheSame(oldItem: DownloadSection, newItem: DownloadSection): Boolean {
|
|
||||||
return oldItem.id == newItem.id
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun areContentsTheSame(
|
|
||||||
oldItem: DownloadSection,
|
|
||||||
newItem: DownloadSection
|
|
||||||
): Boolean {
|
|
||||||
return oldItem == newItem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionViewHolder {
|
|
||||||
return SectionViewHolder(
|
|
||||||
DownloadSectionBinding.inflate(
|
|
||||||
LayoutInflater.from(parent.context),
|
|
||||||
parent,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: SectionViewHolder, position: Int) {
|
|
||||||
val collection = getItem(position)
|
|
||||||
holder.bind(collection, onClickListener, onSeriesClickListener)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,57 +4,52 @@ import android.util.TypedValue
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import dev.jdtech.jellyfin.databinding.EpisodeItemBinding
|
import dev.jdtech.jellyfin.databinding.EpisodeItemBinding
|
||||||
import dev.jdtech.jellyfin.databinding.SeasonHeaderBinding
|
import dev.jdtech.jellyfin.databinding.SeasonHeaderBinding
|
||||||
import dev.jdtech.jellyfin.models.EpisodeItem
|
import dev.jdtech.jellyfin.models.EpisodeItem
|
||||||
import java.util.UUID
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import dev.jdtech.jellyfin.models.isDownloaded
|
||||||
|
|
||||||
private const val ITEM_VIEW_TYPE_HEADER = 0
|
private const val ITEM_VIEW_TYPE_HEADER = 0
|
||||||
private const val ITEM_VIEW_TYPE_EPISODE = 1
|
private const val ITEM_VIEW_TYPE_EPISODE = 1
|
||||||
|
|
||||||
class EpisodeListAdapter(
|
class EpisodeListAdapter(
|
||||||
private val onClickListener: OnClickListener,
|
private val onClickListener: OnClickListener,
|
||||||
private val seriesId: UUID,
|
|
||||||
private val seriesName: String?,
|
|
||||||
private val seasonId: UUID,
|
|
||||||
private val seasonName: String?
|
|
||||||
) :
|
) :
|
||||||
ListAdapter<EpisodeItem, RecyclerView.ViewHolder>(DiffCallback) {
|
ListAdapter<EpisodeItem, RecyclerView.ViewHolder>(DiffCallback) {
|
||||||
|
|
||||||
class HeaderViewHolder(private var binding: SeasonHeaderBinding) :
|
class HeaderViewHolder(private var binding: SeasonHeaderBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(
|
fun bind(header: EpisodeItem.Header) {
|
||||||
seriesId: UUID,
|
binding.seriesId = header.seriesId
|
||||||
seriesName: String?,
|
binding.seasonId = header.seasonId
|
||||||
seasonId: UUID,
|
binding.seasonName.text = header.seasonName
|
||||||
seasonName: String?
|
binding.seriesName.text = header.seriesName
|
||||||
) {
|
|
||||||
binding.seriesId = seriesId
|
|
||||||
binding.seasonId = seasonId
|
|
||||||
binding.seasonName.text = seasonName
|
|
||||||
binding.seriesName.text = seriesName
|
|
||||||
binding.executePendingBindings()
|
binding.executePendingBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class EpisodeViewHolder(private var binding: EpisodeItemBinding) :
|
class EpisodeViewHolder(private var binding: EpisodeItemBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(episode: BaseItemDto) {
|
fun bind(episode: FindroidEpisode) {
|
||||||
binding.episode = episode
|
binding.episode = episode
|
||||||
if (episode.userData?.playedPercentage != null) {
|
if (episode.playbackPositionTicks > 0) {
|
||||||
binding.progressBar.layoutParams.width = TypedValue.applyDimension(
|
binding.progressBar.layoutParams.width = TypedValue.applyDimension(
|
||||||
TypedValue.COMPLEX_UNIT_DIP,
|
TypedValue.COMPLEX_UNIT_DIP,
|
||||||
(episode.userData?.playedPercentage?.times(.84))!!.toFloat(),
|
(episode.playbackPositionTicks.div(episode.runtimeTicks.toFloat()).times(84)),
|
||||||
binding.progressBar.context.resources.displayMetrics
|
binding.progressBar.context.resources.displayMetrics
|
||||||
).toInt()
|
).toInt()
|
||||||
binding.progressBar.visibility = View.VISIBLE
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
} else {
|
} else {
|
||||||
binding.progressBar.visibility = View.GONE
|
binding.progressBar.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.downloadedIcon.isVisible = episode.isDownloaded()
|
||||||
|
|
||||||
binding.executePendingBindings()
|
binding.executePendingBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -96,7 +91,8 @@ class EpisodeListAdapter(
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
when (holder.itemViewType) {
|
when (holder.itemViewType) {
|
||||||
ITEM_VIEW_TYPE_HEADER -> {
|
ITEM_VIEW_TYPE_HEADER -> {
|
||||||
(holder as HeaderViewHolder).bind(seriesId, seriesName, seasonId, seasonName)
|
val item = getItem(position) as EpisodeItem.Header
|
||||||
|
(holder as HeaderViewHolder).bind(item)
|
||||||
}
|
}
|
||||||
ITEM_VIEW_TYPE_EPISODE -> {
|
ITEM_VIEW_TYPE_EPISODE -> {
|
||||||
val item = getItem(position) as EpisodeItem.Episode
|
val item = getItem(position) as EpisodeItem.Episode
|
||||||
|
@ -115,7 +111,7 @@ class EpisodeListAdapter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class OnClickListener(val clickListener: (item: BaseItemDto) -> Unit) {
|
class OnClickListener(val clickListener: (item: FindroidEpisode) -> Unit) {
|
||||||
fun onClick(item: BaseItemDto) = clickListener(item)
|
fun onClick(item: FindroidEpisode) = clickListener(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,44 +4,57 @@ import android.util.TypedValue
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import dev.jdtech.jellyfin.core.R as CoreR
|
||||||
import dev.jdtech.jellyfin.databinding.HomeEpisodeItemBinding
|
import dev.jdtech.jellyfin.databinding.HomeEpisodeItemBinding
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.isDownloaded
|
||||||
|
|
||||||
class HomeEpisodeListAdapter(private val onClickListener: OnClickListener) : ListAdapter<BaseItemDto, HomeEpisodeListAdapter.EpisodeViewHolder>(DiffCallback) {
|
class HomeEpisodeListAdapter(private val onClickListener: OnClickListener) : ListAdapter<FindroidItem, HomeEpisodeListAdapter.EpisodeViewHolder>(DiffCallback) {
|
||||||
class EpisodeViewHolder(private var binding: HomeEpisodeItemBinding) :
|
class EpisodeViewHolder(
|
||||||
|
private var binding: HomeEpisodeItemBinding,
|
||||||
|
private val parent: ViewGroup
|
||||||
|
) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(episode: BaseItemDto) {
|
fun bind(item: FindroidItem) {
|
||||||
binding.episode = episode
|
binding.item = item
|
||||||
if (episode.userData?.playedPercentage != null) {
|
if (item.playbackPositionTicks > 0) {
|
||||||
binding.progressBar.layoutParams.width = TypedValue.applyDimension(
|
binding.progressBar.layoutParams.width = TypedValue.applyDimension(
|
||||||
TypedValue.COMPLEX_UNIT_DIP,
|
TypedValue.COMPLEX_UNIT_DIP,
|
||||||
(episode.userData?.playedPercentage?.times(2.24))!!.toFloat(), binding.progressBar.context.resources.displayMetrics
|
(item.playbackPositionTicks.div(item.runtimeTicks.toFloat()).times(224)), binding.progressBar.context.resources.displayMetrics
|
||||||
).toInt()
|
).toInt()
|
||||||
binding.progressBar.visibility = View.VISIBLE
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
if (episode.type == BaseItemKind.MOVIE) {
|
binding.downloadedIcon.isVisible = item.isDownloaded()
|
||||||
binding.primaryName.text = episode.name
|
|
||||||
binding.secondaryName.visibility = View.GONE
|
when (item) {
|
||||||
} else if (episode.type == BaseItemKind.EPISODE) {
|
is FindroidMovie -> {
|
||||||
binding.primaryName.text = episode.seriesName
|
binding.primaryName.text = item.name
|
||||||
|
binding.secondaryName.visibility = View.GONE
|
||||||
|
}
|
||||||
|
is FindroidEpisode -> {
|
||||||
|
binding.primaryName.text = item.seriesName
|
||||||
|
binding.secondaryName.text = parent.resources.getString(CoreR.string.episode_name_extended, item.parentIndexNumber, item.indexNumber, item.name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.executePendingBindings()
|
binding.executePendingBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object DiffCallback : DiffUtil.ItemCallback<BaseItemDto>() {
|
companion object DiffCallback : DiffUtil.ItemCallback<FindroidItem>() {
|
||||||
override fun areItemsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean {
|
override fun areItemsTheSame(oldItem: FindroidItem, newItem: FindroidItem): Boolean {
|
||||||
return oldItem.id == newItem.id
|
return oldItem.id == newItem.id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean {
|
override fun areContentsTheSame(oldItem: FindroidItem, newItem: FindroidItem): Boolean {
|
||||||
return oldItem == newItem
|
return oldItem.name == newItem.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -51,7 +64,8 @@ class HomeEpisodeListAdapter(private val onClickListener: OnClickListener) : Lis
|
||||||
LayoutInflater.from(parent.context),
|
LayoutInflater.from(parent.context),
|
||||||
parent,
|
parent,
|
||||||
false
|
false
|
||||||
)
|
),
|
||||||
|
parent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,7 +77,7 @@ class HomeEpisodeListAdapter(private val onClickListener: OnClickListener) : Lis
|
||||||
holder.bind(item)
|
holder.bind(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
class OnClickListener(val clickListener: (item: BaseItemDto) -> Unit) {
|
class OnClickListener(val clickListener: (item: FindroidItem) -> Unit) {
|
||||||
fun onClick(item: BaseItemDto) = clickListener(item)
|
fun onClick(item: FindroidItem) = clickListener(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,42 +3,47 @@ package dev.jdtech.jellyfin.adapters
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import dev.jdtech.jellyfin.core.R as CoreR
|
import dev.jdtech.jellyfin.core.R as CoreR
|
||||||
import dev.jdtech.jellyfin.databinding.BaseItemBinding
|
import dev.jdtech.jellyfin.databinding.BaseItemBinding
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
import dev.jdtech.jellyfin.models.isDownloaded
|
||||||
|
|
||||||
class ViewItemListAdapter(
|
class ViewItemListAdapter(
|
||||||
private val onClickListener: OnClickListener,
|
private val onClickListener: OnClickListener,
|
||||||
private val fixedWidth: Boolean = false,
|
private val fixedWidth: Boolean = false,
|
||||||
) : ListAdapter<BaseItemDto, ViewItemListAdapter.ItemViewHolder>(DiffCallback) {
|
) : ListAdapter<FindroidItem, ViewItemListAdapter.ItemViewHolder>(DiffCallback) {
|
||||||
|
|
||||||
class ItemViewHolder(private var binding: BaseItemBinding, private val parent: ViewGroup) :
|
class ItemViewHolder(private var binding: BaseItemBinding, private val parent: ViewGroup) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(item: BaseItemDto, fixedWidth: Boolean) {
|
fun bind(item: FindroidItem, fixedWidth: Boolean) {
|
||||||
binding.item = item
|
binding.item = item
|
||||||
binding.itemName.text = if (item.type == BaseItemKind.EPISODE) item.seriesName else item.name
|
binding.itemName.text = if (item is FindroidEpisode) item.seriesName else item.name
|
||||||
binding.itemCount.visibility =
|
binding.itemCount.visibility =
|
||||||
if (item.userData?.unplayedItemCount != null && item.userData?.unplayedItemCount!! > 0) View.VISIBLE else View.GONE
|
if (item.unplayedItemCount != null && item.unplayedItemCount!! > 0) View.VISIBLE else View.GONE
|
||||||
if (fixedWidth) {
|
if (fixedWidth) {
|
||||||
binding.itemLayout.layoutParams.width =
|
binding.itemLayout.layoutParams.width =
|
||||||
parent.resources.getDimension(CoreR.dimen.overview_media_width).toInt()
|
parent.resources.getDimension(CoreR.dimen.overview_media_width).toInt()
|
||||||
(binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0
|
(binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.downloadedIcon.isVisible = item.isDownloaded()
|
||||||
|
|
||||||
binding.executePendingBindings()
|
binding.executePendingBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object DiffCallback : DiffUtil.ItemCallback<BaseItemDto>() {
|
companion object DiffCallback : DiffUtil.ItemCallback<FindroidItem>() {
|
||||||
override fun areItemsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean {
|
override fun areItemsTheSame(oldItem: FindroidItem, newItem: FindroidItem): Boolean {
|
||||||
return oldItem.id == newItem.id
|
return oldItem.id == newItem.id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean {
|
override fun areContentsTheSame(oldItem: FindroidItem, newItem: FindroidItem): Boolean {
|
||||||
return oldItem == newItem
|
return oldItem.name == newItem.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,7 +66,7 @@ class ViewItemListAdapter(
|
||||||
holder.bind(item, fixedWidth)
|
holder.bind(item, fixedWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
class OnClickListener(val clickListener: (item: BaseItemDto) -> Unit) {
|
class OnClickListener(val clickListener: (item: FindroidItem) -> Unit) {
|
||||||
fun onClick(item: BaseItemDto) = clickListener(item)
|
fun onClick(item: FindroidItem) = clickListener(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,43 +3,48 @@ package dev.jdtech.jellyfin.adapters
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.paging.PagingDataAdapter
|
import androidx.paging.PagingDataAdapter
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import dev.jdtech.jellyfin.core.R as CoreR
|
import dev.jdtech.jellyfin.core.R as CoreR
|
||||||
import dev.jdtech.jellyfin.databinding.BaseItemBinding
|
import dev.jdtech.jellyfin.databinding.BaseItemBinding
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
import dev.jdtech.jellyfin.models.isDownloaded
|
||||||
|
|
||||||
class ViewItemPagingAdapter(
|
class ViewItemPagingAdapter(
|
||||||
private val onClickListener: OnClickListener,
|
private val onClickListener: OnClickListener,
|
||||||
private val fixedWidth: Boolean = false,
|
private val fixedWidth: Boolean = false,
|
||||||
) : PagingDataAdapter<BaseItemDto, ViewItemPagingAdapter.ItemViewHolder>(DiffCallback) {
|
) : PagingDataAdapter<FindroidItem, ViewItemPagingAdapter.ItemViewHolder>(DiffCallback) {
|
||||||
|
|
||||||
class ItemViewHolder(private var binding: BaseItemBinding, private val parent: ViewGroup) :
|
class ItemViewHolder(private var binding: BaseItemBinding, private val parent: ViewGroup) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(item: BaseItemDto, fixedWidth: Boolean) {
|
fun bind(item: FindroidItem, fixedWidth: Boolean) {
|
||||||
binding.item = item
|
binding.item = item
|
||||||
binding.itemName.text =
|
binding.itemName.text =
|
||||||
if (item.type == BaseItemKind.EPISODE) item.seriesName else item.name
|
if (item is FindroidEpisode) item.seriesName else item.name
|
||||||
binding.itemCount.visibility =
|
binding.itemCount.visibility =
|
||||||
if (item.userData?.unplayedItemCount != null && item.userData?.unplayedItemCount!! > 0) View.VISIBLE else View.GONE
|
if (item.unplayedItemCount != null && item.unplayedItemCount!! > 0) View.VISIBLE else View.GONE
|
||||||
if (fixedWidth) {
|
if (fixedWidth) {
|
||||||
binding.itemLayout.layoutParams.width =
|
binding.itemLayout.layoutParams.width =
|
||||||
parent.resources.getDimension(CoreR.dimen.overview_media_width).toInt()
|
parent.resources.getDimension(CoreR.dimen.overview_media_width).toInt()
|
||||||
(binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0
|
(binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.downloadedIcon.isVisible = item.isDownloaded()
|
||||||
|
|
||||||
binding.executePendingBindings()
|
binding.executePendingBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object DiffCallback : DiffUtil.ItemCallback<BaseItemDto>() {
|
companion object DiffCallback : DiffUtil.ItemCallback<FindroidItem>() {
|
||||||
override fun areItemsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean {
|
override fun areItemsTheSame(oldItem: FindroidItem, newItem: FindroidItem): Boolean {
|
||||||
return oldItem.id == newItem.id
|
return oldItem.id == newItem.id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItem: BaseItemDto, newItem: BaseItemDto): Boolean {
|
override fun areContentsTheSame(oldItem: FindroidItem, newItem: FindroidItem): Boolean {
|
||||||
return oldItem == newItem
|
return oldItem.name == newItem.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +69,7 @@ class ViewItemPagingAdapter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class OnClickListener(val clickListener: (item: BaseItemDto) -> Unit) {
|
class OnClickListener(val clickListener: (item: FindroidItem) -> Unit) {
|
||||||
fun onClick(item: BaseItemDto) = clickListener(item)
|
fun onClick(item: FindroidItem) = clickListener(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import dev.jdtech.jellyfin.core.R as CoreR
|
import dev.jdtech.jellyfin.core.R as CoreR
|
||||||
|
import dev.jdtech.jellyfin.databinding.CardOfflineBinding
|
||||||
import dev.jdtech.jellyfin.databinding.NextUpSectionBinding
|
import dev.jdtech.jellyfin.databinding.NextUpSectionBinding
|
||||||
import dev.jdtech.jellyfin.databinding.ViewItemBinding
|
import dev.jdtech.jellyfin.databinding.ViewItemBinding
|
||||||
import dev.jdtech.jellyfin.models.HomeItem
|
import dev.jdtech.jellyfin.models.HomeItem
|
||||||
|
@ -13,11 +14,13 @@ import dev.jdtech.jellyfin.models.View
|
||||||
|
|
||||||
private const val ITEM_VIEW_TYPE_NEXT_UP = 0
|
private const val ITEM_VIEW_TYPE_NEXT_UP = 0
|
||||||
private const val ITEM_VIEW_TYPE_VIEW = 1
|
private const val ITEM_VIEW_TYPE_VIEW = 1
|
||||||
|
private const val ITEM_VIEW_TYPE_OFFLINE_CARD = 2
|
||||||
|
|
||||||
class ViewListAdapter(
|
class ViewListAdapter(
|
||||||
private val onClickListener: OnClickListener,
|
private val onClickListener: OnClickListener,
|
||||||
private val onItemClickListener: ViewItemListAdapter.OnClickListener,
|
private val onItemClickListener: ViewItemListAdapter.OnClickListener,
|
||||||
private val onNextUpClickListener: HomeEpisodeListAdapter.OnClickListener
|
private val onNextUpClickListener: HomeEpisodeListAdapter.OnClickListener,
|
||||||
|
private val onOnlineClickListener: OnClickListenerOfflineCard
|
||||||
) : ListAdapter<HomeItem, RecyclerView.ViewHolder>(DiffCallback) {
|
) : ListAdapter<HomeItem, RecyclerView.ViewHolder>(DiffCallback) {
|
||||||
|
|
||||||
class ViewViewHolder(private var binding: ViewItemBinding) :
|
class ViewViewHolder(private var binding: ViewItemBinding) :
|
||||||
|
@ -43,11 +46,20 @@ class ViewListAdapter(
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(section: HomeItem.Section, onClickListener: HomeEpisodeListAdapter.OnClickListener) {
|
fun bind(section: HomeItem.Section, onClickListener: HomeEpisodeListAdapter.OnClickListener) {
|
||||||
binding.section = section.homeSection
|
binding.section = section.homeSection
|
||||||
|
binding.sectionName.text = section.homeSection.name.asString(binding.sectionName.context.resources)
|
||||||
binding.itemsRecyclerView.adapter = HomeEpisodeListAdapter(onClickListener)
|
binding.itemsRecyclerView.adapter = HomeEpisodeListAdapter(onClickListener)
|
||||||
binding.executePendingBindings()
|
binding.executePendingBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class OfflineCardViewHolder(private var binding: CardOfflineBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
fun bind(onClickListener: OnClickListenerOfflineCard) {
|
||||||
|
binding.onlineButton.setOnClickListener {
|
||||||
|
onClickListener.onClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object DiffCallback : DiffUtil.ItemCallback<HomeItem>() {
|
companion object DiffCallback : DiffUtil.ItemCallback<HomeItem>() {
|
||||||
override fun areItemsTheSame(oldItem: HomeItem, newItem: HomeItem): Boolean {
|
override fun areItemsTheSame(oldItem: HomeItem, newItem: HomeItem): Boolean {
|
||||||
return oldItem.id == newItem.id
|
return oldItem.id == newItem.id
|
||||||
|
@ -75,6 +87,15 @@ class ViewListAdapter(
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
ITEM_VIEW_TYPE_OFFLINE_CARD -> {
|
||||||
|
OfflineCardViewHolder(
|
||||||
|
CardOfflineBinding.inflate(
|
||||||
|
LayoutInflater.from(parent.context),
|
||||||
|
parent,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> throw ClassCastException("Unknown viewType $viewType")
|
else -> throw ClassCastException("Unknown viewType $viewType")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -89,11 +110,15 @@ class ViewListAdapter(
|
||||||
val view = getItem(position) as HomeItem.ViewItem
|
val view = getItem(position) as HomeItem.ViewItem
|
||||||
(holder as ViewViewHolder).bind(view, onClickListener, onItemClickListener)
|
(holder as ViewViewHolder).bind(view, onClickListener, onItemClickListener)
|
||||||
}
|
}
|
||||||
|
ITEM_VIEW_TYPE_OFFLINE_CARD -> {
|
||||||
|
(holder as OfflineCardViewHolder).bind(onOnlineClickListener)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemViewType(position: Int): Int {
|
override fun getItemViewType(position: Int): Int {
|
||||||
return when (getItem(position)) {
|
return when (getItem(position)) {
|
||||||
|
is HomeItem.OfflineCard -> ITEM_VIEW_TYPE_OFFLINE_CARD
|
||||||
is HomeItem.Libraries -> -1
|
is HomeItem.Libraries -> -1
|
||||||
is HomeItem.Section -> ITEM_VIEW_TYPE_NEXT_UP
|
is HomeItem.Section -> ITEM_VIEW_TYPE_NEXT_UP
|
||||||
is HomeItem.ViewItem -> ITEM_VIEW_TYPE_VIEW
|
is HomeItem.ViewItem -> ITEM_VIEW_TYPE_VIEW
|
||||||
|
@ -103,4 +128,8 @@ class ViewListAdapter(
|
||||||
class OnClickListener(val clickListener: (view: View) -> Unit) {
|
class OnClickListener(val clickListener: (view: View) -> Unit) {
|
||||||
fun onClick(view: View) = clickListener(view)
|
fun onClick(view: View) = clickListener(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class OnClickListenerOfflineCard(val clickListener: () -> Unit) {
|
||||||
|
fun onClick() = clickListener()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
package dev.jdtech.jellyfin.dialogs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Environment
|
||||||
|
import android.os.StatFs
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import dev.jdtech.jellyfin.core.R as CoreR
|
||||||
|
|
||||||
|
fun getStorageSelectionDialog(
|
||||||
|
context: Context,
|
||||||
|
onItemSelected: (which: Int) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
): AlertDialog {
|
||||||
|
val locations = context.getExternalFilesDirs(null).mapNotNull {
|
||||||
|
val locationStringRes = if (Environment.isExternalStorageRemovable(it)) CoreR.string.external else CoreR.string.internal
|
||||||
|
val stat = StatFs(it.path)
|
||||||
|
context.getString(CoreR.string.storage_name, context.getString(locationStringRes), stat.availableBytes.div(1000000))
|
||||||
|
}.toTypedArray()
|
||||||
|
val dialog = MaterialAlertDialogBuilder(context)
|
||||||
|
.setTitle(CoreR.string.select_storage_location)
|
||||||
|
.setItems(locations) { _, which ->
|
||||||
|
onItemSelected(which)
|
||||||
|
}
|
||||||
|
.setOnCancelListener() {
|
||||||
|
onCancel()
|
||||||
|
}.create()
|
||||||
|
return dialog
|
||||||
|
}
|
|
@ -18,10 +18,13 @@ import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter
|
||||||
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
|
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
|
||||||
import dev.jdtech.jellyfin.databinding.FragmentFavoriteBinding
|
import dev.jdtech.jellyfin.databinding.FragmentFavoriteBinding
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidShow
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
import dev.jdtech.jellyfin.viewmodels.CollectionViewModel
|
import dev.jdtech.jellyfin.viewmodels.CollectionViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
@ -41,10 +44,10 @@ class CollectionFragment : Fragment() {
|
||||||
|
|
||||||
binding.favoritesRecyclerView.adapter = FavoritesListAdapter(
|
binding.favoritesRecyclerView.adapter = FavoritesListAdapter(
|
||||||
ViewItemListAdapter.OnClickListener { item ->
|
ViewItemListAdapter.OnClickListener { item ->
|
||||||
navigateToMediaInfoFragment(item)
|
navigateToMediaItem(item)
|
||||||
},
|
},
|
||||||
HomeEpisodeListAdapter.OnClickListener { item ->
|
HomeEpisodeListAdapter.OnClickListener { item ->
|
||||||
navigateToEpisodeBottomSheetFragment(item)
|
navigateToMediaItem(item)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -103,21 +106,31 @@ class CollectionFragment : Fragment() {
|
||||||
checkIfLoginRequired(uiState.error.message)
|
checkIfLoginRequired(uiState.error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
|
private fun navigateToMediaItem(item: FindroidItem) {
|
||||||
findNavController().navigate(
|
when (item) {
|
||||||
CollectionFragmentDirections.actionCollectionFragmentToMediaInfoFragment(
|
is FindroidMovie -> {
|
||||||
item.id,
|
findNavController().navigate(
|
||||||
item.name,
|
CollectionFragmentDirections.actionCollectionFragmentToMovieFragment(
|
||||||
item.type
|
item.id,
|
||||||
)
|
item.name
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
|
}
|
||||||
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {
|
is FindroidShow -> {
|
||||||
findNavController().navigate(
|
findNavController().navigate(
|
||||||
CollectionFragmentDirections.actionCollectionFragmentToEpisodeBottomSheetFragment(
|
CollectionFragmentDirections.actionCollectionFragmentToShowFragment(
|
||||||
episode.id
|
item.id,
|
||||||
)
|
item.name
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is FindroidEpisode -> {
|
||||||
|
findNavController().navigate(
|
||||||
|
CollectionFragmentDirections.actionCollectionFragmentToEpisodeBottomSheetFragment(
|
||||||
|
item.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,117 +0,0 @@
|
||||||
package dev.jdtech.jellyfin.fragments
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.fragment.app.viewModels
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
|
||||||
import androidx.navigation.fragment.findNavController
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import dev.jdtech.jellyfin.adapters.DownloadSeriesListAdapter
|
|
||||||
import dev.jdtech.jellyfin.adapters.DownloadViewItemListAdapter
|
|
||||||
import dev.jdtech.jellyfin.adapters.DownloadsListAdapter
|
|
||||||
import dev.jdtech.jellyfin.databinding.FragmentDownloadBinding
|
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
|
||||||
import dev.jdtech.jellyfin.models.DownloadSeriesMetadata
|
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
|
||||||
import dev.jdtech.jellyfin.viewmodels.DownloadViewModel
|
|
||||||
import java.util.UUID
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class DownloadFragment : Fragment() {
|
|
||||||
|
|
||||||
private lateinit var binding: FragmentDownloadBinding
|
|
||||||
private val viewModel: DownloadViewModel by viewModels()
|
|
||||||
|
|
||||||
private lateinit var errorDialog: ErrorDialogFragment
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View {
|
|
||||||
binding = FragmentDownloadBinding.inflate(inflater, container, false)
|
|
||||||
|
|
||||||
binding.downloadsRecyclerView.adapter =
|
|
||||||
DownloadsListAdapter(
|
|
||||||
DownloadViewItemListAdapter.OnClickListener { item ->
|
|
||||||
navigateToMediaInfoFragment(item)
|
|
||||||
},
|
|
||||||
DownloadSeriesListAdapter.OnClickListener { item ->
|
|
||||||
navigateToDownloadSeriesFragment(item)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
||||||
viewModel.uiState.collect { uiState ->
|
|
||||||
Timber.d("$uiState")
|
|
||||||
when (uiState) {
|
|
||||||
is DownloadViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
|
||||||
is DownloadViewModel.UiState.Loading -> bindUiStateLoading()
|
|
||||||
is DownloadViewModel.UiState.Error -> bindUiStateError(uiState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.errorLayout.errorRetryButton.setOnClickListener {
|
|
||||||
viewModel.loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
|
||||||
errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG)
|
|
||||||
}
|
|
||||||
|
|
||||||
return binding.root
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindUiStateNormal(uiState: DownloadViewModel.UiState.Normal) {
|
|
||||||
uiState.apply {
|
|
||||||
binding.noDownloadsText.isVisible = downloadSections.isEmpty()
|
|
||||||
|
|
||||||
val adapter = binding.downloadsRecyclerView.adapter as DownloadsListAdapter
|
|
||||||
adapter.submitList(downloadSections)
|
|
||||||
}
|
|
||||||
binding.loadingIndicator.isVisible = false
|
|
||||||
binding.downloadsRecyclerView.isVisible = true
|
|
||||||
binding.errorLayout.errorPanel.isVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindUiStateLoading() {
|
|
||||||
binding.loadingIndicator.isVisible = true
|
|
||||||
binding.errorLayout.errorPanel.isVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindUiStateError(uiState: DownloadViewModel.UiState.Error) {
|
|
||||||
errorDialog = ErrorDialogFragment.newInstance(uiState.error)
|
|
||||||
binding.loadingIndicator.isVisible = false
|
|
||||||
binding.downloadsRecyclerView.isVisible = false
|
|
||||||
binding.errorLayout.errorPanel.isVisible = true
|
|
||||||
checkIfLoginRequired(uiState.error.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun navigateToMediaInfoFragment(item: PlayerItem) {
|
|
||||||
findNavController().navigate(
|
|
||||||
DownloadFragmentDirections.actionDownloadFragmentToMediaInfoFragment(
|
|
||||||
UUID.randomUUID(), item.name, item.item!!.type, item, isOffline = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun navigateToDownloadSeriesFragment(series: DownloadSeriesMetadata) {
|
|
||||||
findNavController().navigate(
|
|
||||||
DownloadFragmentDirections.actionDownloadFragmentToDownloadSeriesFragment(
|
|
||||||
seriesMetadata = series, seriesName = series.name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,101 +0,0 @@
|
||||||
package dev.jdtech.jellyfin.fragments
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.fragment.app.viewModels
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
|
||||||
import androidx.navigation.fragment.findNavController
|
|
||||||
import androidx.navigation.fragment.navArgs
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import dev.jdtech.jellyfin.adapters.DownloadEpisodeListAdapter
|
|
||||||
import dev.jdtech.jellyfin.databinding.FragmentDownloadSeriesBinding
|
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
|
||||||
import dev.jdtech.jellyfin.viewmodels.DownloadSeriesViewModel
|
|
||||||
import java.util.UUID
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class DownloadSeriesFragment : Fragment() {
|
|
||||||
|
|
||||||
private lateinit var binding: FragmentDownloadSeriesBinding
|
|
||||||
private val viewModel: DownloadSeriesViewModel by viewModels()
|
|
||||||
|
|
||||||
private lateinit var errorDialog: ErrorDialogFragment
|
|
||||||
|
|
||||||
private val args: DownloadSeriesFragmentArgs by navArgs()
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View {
|
|
||||||
binding = FragmentDownloadSeriesBinding.inflate(inflater, container, false)
|
|
||||||
binding.lifecycleOwner = viewLifecycleOwner
|
|
||||||
return binding.root
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
binding.viewModel = viewModel
|
|
||||||
|
|
||||||
binding.episodesRecyclerView.adapter =
|
|
||||||
DownloadEpisodeListAdapter(
|
|
||||||
DownloadEpisodeListAdapter.OnClickListener { episode ->
|
|
||||||
navigateToEpisodeBottomSheetFragment(episode)
|
|
||||||
},
|
|
||||||
args.seriesMetadata
|
|
||||||
)
|
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
||||||
viewModel.uiState.collect { uiState ->
|
|
||||||
when (uiState) {
|
|
||||||
is DownloadSeriesViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
|
||||||
is DownloadSeriesViewModel.UiState.Loading -> Unit
|
|
||||||
is DownloadSeriesViewModel.UiState.Error -> bindUiStateError(uiState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.errorLayout.errorRetryButton.setOnClickListener {
|
|
||||||
viewModel.loadEpisodes(args.seriesMetadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
|
||||||
errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.loadEpisodes(args.seriesMetadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindUiStateNormal(uiState: DownloadSeriesViewModel.UiState.Normal) {
|
|
||||||
val adapter = binding.episodesRecyclerView.adapter as DownloadEpisodeListAdapter
|
|
||||||
adapter.submitList(uiState.downloadEpisodes)
|
|
||||||
binding.episodesRecyclerView.isVisible = true
|
|
||||||
binding.errorLayout.errorPanel.isVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindUiStateError(uiState: DownloadSeriesViewModel.UiState.Error) {
|
|
||||||
errorDialog = ErrorDialogFragment.newInstance(uiState.error)
|
|
||||||
binding.episodesRecyclerView.isVisible = false
|
|
||||||
binding.errorLayout.errorPanel.isVisible = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun navigateToEpisodeBottomSheetFragment(episode: PlayerItem) {
|
|
||||||
findNavController().navigate(
|
|
||||||
DownloadSeriesFragmentDirections.actionDownloadSeriesFragmentToEpisodeBottomSheetFragment(
|
|
||||||
UUID.randomUUID(),
|
|
||||||
episode,
|
|
||||||
isOffline = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,126 @@
|
||||||
|
package dev.jdtech.jellyfin.fragments
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import dev.jdtech.jellyfin.AppPreferences
|
||||||
|
import dev.jdtech.jellyfin.R
|
||||||
|
import dev.jdtech.jellyfin.adapters.FavoritesListAdapter
|
||||||
|
import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter
|
||||||
|
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
|
||||||
|
import dev.jdtech.jellyfin.core.R as CoreR
|
||||||
|
import dev.jdtech.jellyfin.databinding.FragmentDownloadsBinding
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidShow
|
||||||
|
import dev.jdtech.jellyfin.utils.restart
|
||||||
|
import dev.jdtech.jellyfin.viewmodels.DownloadsViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class DownloadsFragment : Fragment() {
|
||||||
|
private lateinit var binding: FragmentDownloadsBinding
|
||||||
|
private val viewModel: DownloadsViewModel by viewModels()
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var appPreferences: AppPreferences
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
binding = FragmentDownloadsBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
|
binding.downloadsRecyclerView.adapter = FavoritesListAdapter(
|
||||||
|
ViewItemListAdapter.OnClickListener { item ->
|
||||||
|
navigateToMediaItem(item)
|
||||||
|
},
|
||||||
|
HomeEpisodeListAdapter.OnClickListener { item ->
|
||||||
|
navigateToMediaItem(item)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
launch {
|
||||||
|
viewModel.connectionError.collect {
|
||||||
|
Snackbar.make(binding.root, CoreR.string.no_server_connection, Snackbar.LENGTH_INDEFINITE)
|
||||||
|
.setAnchorView(requireActivity().findViewById(R.id.nav_view))
|
||||||
|
.setAction(CoreR.string.offline_mode) {
|
||||||
|
appPreferences.offlineMode = true
|
||||||
|
activity?.restart()
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
viewModel.uiState.collect { uiState ->
|
||||||
|
Timber.d("$uiState")
|
||||||
|
when (uiState) {
|
||||||
|
is DownloadsViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
||||||
|
is DownloadsViewModel.UiState.Loading -> bindUiStateLoading()
|
||||||
|
is DownloadsViewModel.UiState.Error -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
|
||||||
|
viewModel.loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindUiStateNormal(uiState: DownloadsViewModel.UiState.Normal) {
|
||||||
|
binding.loadingIndicator.isVisible = false
|
||||||
|
binding.downloadsRecyclerView.isVisible = true
|
||||||
|
binding.errorLayout.errorPanel.isVisible = false
|
||||||
|
binding.noDownloadsText.isVisible = uiState.sections.isEmpty()
|
||||||
|
val adapter = binding.downloadsRecyclerView.adapter as FavoritesListAdapter
|
||||||
|
adapter.submitList(uiState.sections)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindUiStateLoading() {
|
||||||
|
binding.loadingIndicator.isVisible = true
|
||||||
|
binding.errorLayout.errorPanel.isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToMediaItem(item: FindroidItem) {
|
||||||
|
when (item) {
|
||||||
|
is FindroidMovie -> {
|
||||||
|
findNavController().navigate(
|
||||||
|
DownloadsFragmentDirections.actionDownloadsFragmentToMovieFragment(
|
||||||
|
item.id,
|
||||||
|
item.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is FindroidShow -> {
|
||||||
|
findNavController().navigate(
|
||||||
|
DownloadsFragmentDirections.actionDownloadsFragmentToShowFragment(
|
||||||
|
item.id,
|
||||||
|
item.name,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,13 @@
|
||||||
package dev.jdtech.jellyfin.fragments
|
package dev.jdtech.jellyfin.fragments
|
||||||
|
|
||||||
|
import android.R as AndroidR
|
||||||
|
import android.app.DownloadManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
|
@ -17,20 +20,30 @@ import com.google.android.material.R as MaterialR
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.bindBaseItemImage
|
import dev.jdtech.jellyfin.R
|
||||||
|
import dev.jdtech.jellyfin.bindCardItemImage
|
||||||
import dev.jdtech.jellyfin.core.R as CoreR
|
import dev.jdtech.jellyfin.core.R as CoreR
|
||||||
import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding
|
import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||||
|
import dev.jdtech.jellyfin.dialogs.getStorageSelectionDialog
|
||||||
|
import dev.jdtech.jellyfin.dialogs.getVideoVersionDialog
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidSourceType
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
import dev.jdtech.jellyfin.models.PlayerItem
|
||||||
|
import dev.jdtech.jellyfin.models.UiText
|
||||||
|
import dev.jdtech.jellyfin.models.isDownloaded
|
||||||
|
import dev.jdtech.jellyfin.models.isDownloading
|
||||||
import dev.jdtech.jellyfin.utils.setTintColor
|
import dev.jdtech.jellyfin.utils.setTintColor
|
||||||
import dev.jdtech.jellyfin.utils.setTintColorAttribute
|
import dev.jdtech.jellyfin.utils.setTintColorAttribute
|
||||||
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel
|
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel
|
||||||
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.Date
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.DateTime
|
||||||
import org.jellyfin.sdk.model.api.LocationType
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
@ -41,6 +54,8 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
private val viewModel: EpisodeBottomSheetViewModel by viewModels()
|
private val viewModel: EpisodeBottomSheetViewModel by viewModels()
|
||||||
private val playerViewModel: PlayerViewModel by viewModels()
|
private val playerViewModel: PlayerViewModel by viewModels()
|
||||||
|
|
||||||
|
private lateinit var downloadPreparingDialog: AlertDialog
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
|
@ -48,31 +63,67 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
): View {
|
): View {
|
||||||
binding = EpisodeBottomSheetBinding.inflate(inflater, container, false)
|
binding = EpisodeBottomSheetBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
binding.playButton.setOnClickListener {
|
binding.itemActions.playButton.setOnClickListener {
|
||||||
binding.playButton.setImageResource(android.R.color.transparent)
|
binding.itemActions.playButton.setImageResource(AndroidR.color.transparent)
|
||||||
binding.progressCircular.isVisible = true
|
binding.itemActions.progressCircular.isVisible = true
|
||||||
if (viewModel.canRetry) {
|
playerViewModel.loadPlayerItems(viewModel.item)
|
||||||
binding.playButton.isEnabled = false
|
|
||||||
viewModel.download()
|
|
||||||
return@setOnClickListener
|
|
||||||
}
|
|
||||||
viewModel.item?.let {
|
|
||||||
if (!args.isOffline) {
|
|
||||||
playerViewModel.loadPlayerItems(it)
|
|
||||||
} else {
|
|
||||||
playerViewModel.loadOfflinePlayerItems(viewModel.playerItems[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
viewModel.uiState.collect { uiState ->
|
launch {
|
||||||
Timber.d("$uiState")
|
viewModel.uiState.collect { uiState ->
|
||||||
when (uiState) {
|
Timber.d("$uiState")
|
||||||
is EpisodeBottomSheetViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
when (uiState) {
|
||||||
is EpisodeBottomSheetViewModel.UiState.Loading -> bindUiStateLoading()
|
is EpisodeBottomSheetViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
||||||
is EpisodeBottomSheetViewModel.UiState.Error -> bindUiStateError(uiState)
|
is EpisodeBottomSheetViewModel.UiState.Loading -> bindUiStateLoading()
|
||||||
|
is EpisodeBottomSheetViewModel.UiState.Error -> bindUiStateError(uiState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
launch {
|
||||||
|
viewModel.downloadStatus.collect { (status, progress) ->
|
||||||
|
when (status) {
|
||||||
|
10 -> {
|
||||||
|
downloadPreparingDialog.dismiss()
|
||||||
|
}
|
||||||
|
DownloadManager.STATUS_PENDING -> {
|
||||||
|
binding.itemActions.downloadButton.setImageResource(AndroidR.color.transparent)
|
||||||
|
binding.itemActions.progressDownload.isIndeterminate = true
|
||||||
|
binding.itemActions.progressDownload.isVisible = true
|
||||||
|
}
|
||||||
|
DownloadManager.STATUS_RUNNING -> {
|
||||||
|
binding.itemActions.downloadButton.setImageResource(AndroidR.color.transparent)
|
||||||
|
binding.itemActions.progressDownload.isVisible = true
|
||||||
|
if (progress < 5) {
|
||||||
|
binding.itemActions.progressDownload.isIndeterminate = true
|
||||||
|
} else {
|
||||||
|
binding.itemActions.progressDownload.isIndeterminate = false
|
||||||
|
binding.itemActions.progressDownload.setProgressCompat(progress, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadManager.STATUS_SUCCESSFUL -> {
|
||||||
|
binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_trash)
|
||||||
|
binding.itemActions.progressDownload.isVisible = false
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
binding.itemActions.progressDownload.isVisible = false
|
||||||
|
binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
launch {
|
||||||
|
viewModel.downloadError.collect { uiText ->
|
||||||
|
createErrorDialog(uiText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
launch {
|
||||||
|
viewModel.navigateBack.collect {
|
||||||
|
if (it) findNavController().navigateUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -85,59 +136,80 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!args.isOffline) {
|
binding.seriesName.setOnClickListener {
|
||||||
val episodeId: UUID = args.episodeId
|
navigateToSeries(viewModel.item.seriesId, viewModel.item.seriesName)
|
||||||
|
}
|
||||||
|
|
||||||
binding.checkButton.setOnClickListener {
|
binding.itemActions.checkButton.setOnClickListener {
|
||||||
when (viewModel.played) {
|
val played = viewModel.togglePlayed()
|
||||||
true -> {
|
bindCheckButtonState(played)
|
||||||
viewModel.markAsUnplayed(episodeId)
|
}
|
||||||
binding.checkButton.setTintColorAttribute(MaterialR.attr.colorOnSecondaryContainer, requireActivity().theme)
|
|
||||||
}
|
|
||||||
false -> {
|
|
||||||
viewModel.markAsPlayed(episodeId)
|
|
||||||
binding.checkButton.setTintColor(CoreR.color.red, requireActivity().theme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.favoriteButton.setOnClickListener {
|
binding.itemActions.favoriteButton.setOnClickListener {
|
||||||
when (viewModel.favorite) {
|
val favorite = viewModel.toggleFavorite()
|
||||||
true -> {
|
bindFavoriteButtonState(favorite)
|
||||||
viewModel.unmarkAsFavorite(episodeId)
|
}
|
||||||
binding.favoriteButton.setImageResource(CoreR.drawable.ic_heart)
|
|
||||||
binding.favoriteButton.setTintColorAttribute(MaterialR.attr.colorOnSecondaryContainer, requireActivity().theme)
|
|
||||||
}
|
|
||||||
false -> {
|
|
||||||
viewModel.markAsFavorite(episodeId)
|
|
||||||
binding.favoriteButton.setImageResource(CoreR.drawable.ic_heart_filled)
|
|
||||||
binding.favoriteButton.setTintColor(CoreR.color.red, requireActivity().theme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.downloadButton.setOnClickListener {
|
binding.itemActions.downloadButton.setOnClickListener {
|
||||||
binding.downloadButton.isEnabled = false
|
if (viewModel.item.isDownloaded()) {
|
||||||
viewModel.download()
|
|
||||||
binding.downloadButton.setTintColor(CoreR.color.red, requireActivity().theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.loadEpisode(episodeId)
|
|
||||||
} else {
|
|
||||||
val playerItem = args.playerItem!!
|
|
||||||
viewModel.loadEpisode(playerItem)
|
|
||||||
|
|
||||||
binding.deleteButton.isVisible = true
|
|
||||||
|
|
||||||
binding.deleteButton.setOnClickListener {
|
|
||||||
viewModel.deleteEpisode()
|
viewModel.deleteEpisode()
|
||||||
dismiss()
|
binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download)
|
||||||
findNavController().navigate(CoreR.id.downloadFragment)
|
} else if (viewModel.item.isDownloading()) {
|
||||||
|
createCancelDialog()
|
||||||
|
} else {
|
||||||
|
binding.itemActions.downloadButton.setImageResource(android.R.color.transparent)
|
||||||
|
binding.itemActions.progressDownload.isIndeterminate = true
|
||||||
|
binding.itemActions.progressDownload.isVisible = true
|
||||||
|
if (requireContext().getExternalFilesDirs(null).filterNotNull().size > 1) {
|
||||||
|
val storageDialog = getStorageSelectionDialog(
|
||||||
|
requireContext(),
|
||||||
|
onItemSelected = { storageIndex ->
|
||||||
|
if (viewModel.item.sources.size > 1) {
|
||||||
|
val dialog = getVideoVersionDialog(
|
||||||
|
requireContext(),
|
||||||
|
viewModel.item,
|
||||||
|
onItemSelected = { sourceIndex ->
|
||||||
|
createDownloadPreparingDialog()
|
||||||
|
viewModel.download(sourceIndex, storageIndex)
|
||||||
|
},
|
||||||
|
onCancel = {
|
||||||
|
binding.itemActions.progressDownload.isVisible = false
|
||||||
|
binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
dialog.show()
|
||||||
|
return@getStorageSelectionDialog
|
||||||
|
}
|
||||||
|
createDownloadPreparingDialog()
|
||||||
|
viewModel.download(storageIndex = storageIndex)
|
||||||
|
},
|
||||||
|
onCancel = {
|
||||||
|
binding.itemActions.progressDownload.isVisible = false
|
||||||
|
binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
storageDialog.show()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
if (viewModel.item.sources.size > 1) {
|
||||||
|
val dialog = getVideoVersionDialog(
|
||||||
|
requireContext(),
|
||||||
|
viewModel.item,
|
||||||
|
onItemSelected = { sourceIndex ->
|
||||||
|
createDownloadPreparingDialog()
|
||||||
|
viewModel.download(sourceIndex)
|
||||||
|
},
|
||||||
|
onCancel = {
|
||||||
|
binding.itemActions.progressDownload.isVisible = false
|
||||||
|
binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
dialog.show()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
createDownloadPreparingDialog()
|
||||||
|
viewModel.download()
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.checkButton.isVisible = false
|
|
||||||
binding.favoriteButton.isVisible = false
|
|
||||||
binding.downloadButtonWrapper.isVisible = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return binding.root
|
return binding.root
|
||||||
|
@ -150,50 +222,41 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
|
||||||
|
viewModel.loadEpisode(args.episodeId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun bindUiStateNormal(uiState: EpisodeBottomSheetViewModel.UiState.Normal) {
|
private fun bindUiStateNormal(uiState: EpisodeBottomSheetViewModel.UiState.Normal) {
|
||||||
uiState.apply {
|
uiState.apply {
|
||||||
if (episode.userData?.playedPercentage != null) {
|
val canDownload = episode.canDownload && episode.sources.any { it.type == FindroidSourceType.REMOTE }
|
||||||
|
val canDelete = episode.sources.any { it.type == FindroidSourceType.LOCAL }
|
||||||
|
|
||||||
|
if (episode.playbackPositionTicks > 0) {
|
||||||
binding.progressBar.layoutParams.width = TypedValue.applyDimension(
|
binding.progressBar.layoutParams.width = TypedValue.applyDimension(
|
||||||
TypedValue.COMPLEX_UNIT_DIP,
|
TypedValue.COMPLEX_UNIT_DIP,
|
||||||
(episode.userData?.playedPercentage?.times(1.26))!!.toFloat(),
|
(episode.playbackPositionTicks.div(episode.runtimeTicks).times(1.26)).toFloat(),
|
||||||
context?.resources?.displayMetrics
|
context?.resources?.displayMetrics
|
||||||
).toInt()
|
).toInt()
|
||||||
binding.progressBar.isVisible = true
|
binding.progressBar.isVisible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
val clickable = canPlay && (available || canRetry)
|
val canPlay = episode.canPlay && episode.sources.isNotEmpty()
|
||||||
binding.playButton.isEnabled = clickable
|
binding.itemActions.playButton.isEnabled = canPlay
|
||||||
binding.playButton.alpha = if (!clickable) 0.5F else 1.0F
|
binding.itemActions.playButton.alpha = if (!canPlay) 0.5F else 1.0F
|
||||||
binding.playButton.setImageResource(if (!canRetry) CoreR.drawable.ic_play else CoreR.drawable.ic_rotate_ccw)
|
|
||||||
if (!(available || canRetry)) {
|
bindCheckButtonState(episode.played)
|
||||||
binding.playButton.setImageResource(android.R.color.transparent)
|
|
||||||
binding.progressCircular.isVisible = true
|
bindFavoriteButtonState(episode.favorite)
|
||||||
|
|
||||||
|
if (episode.isDownloaded()) {
|
||||||
|
binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_trash)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check icon
|
when (canDownload || canDelete) {
|
||||||
when (played) {
|
true -> binding.itemActions.downloadButton.isVisible = true
|
||||||
true -> binding.checkButton.setTintColor(CoreR.color.red, requireActivity().theme)
|
false -> binding.itemActions.downloadButton.isVisible = false
|
||||||
false -> binding.checkButton.setTintColorAttribute(MaterialR.attr.colorOnSecondaryContainer, requireActivity().theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Favorite icon
|
|
||||||
val favoriteDrawable = when (favorite) {
|
|
||||||
true -> CoreR.drawable.ic_heart_filled
|
|
||||||
false -> CoreR.drawable.ic_heart
|
|
||||||
}
|
|
||||||
binding.favoriteButton.setImageResource(favoriteDrawable)
|
|
||||||
if (favorite) binding.favoriteButton.setTintColor(CoreR.color.red, requireActivity().theme)
|
|
||||||
|
|
||||||
when (canDownload) {
|
|
||||||
true -> {
|
|
||||||
binding.downloadButtonWrapper.isVisible = true
|
|
||||||
binding.downloadButton.isEnabled = !downloaded
|
|
||||||
|
|
||||||
if (downloaded) binding.downloadButton.setTintColor(CoreR.color.red, requireActivity().theme)
|
|
||||||
}
|
|
||||||
false -> {
|
|
||||||
binding.downloadButtonWrapper.isVisible = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.episodeName.text = getString(
|
binding.episodeName.text = getString(
|
||||||
|
@ -204,18 +267,13 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
)
|
)
|
||||||
binding.seriesName.text = episode.seriesName
|
binding.seriesName.text = episode.seriesName
|
||||||
binding.overview.text = episode.overview
|
binding.overview.text = episode.overview
|
||||||
binding.year.text = dateString
|
binding.year.text = formatDateTime(episode.premiereDate)
|
||||||
binding.playtime.text = runTime
|
binding.playtime.text = getString(CoreR.string.runtime_minutes, episode.runtimeTicks.div(600000000))
|
||||||
binding.communityRating.isVisible = episode.communityRating != null
|
binding.communityRating.isVisible = episode.communityRating != null
|
||||||
binding.communityRating.text = episode.communityRating.toString()
|
binding.communityRating.text = episode.communityRating.toString()
|
||||||
binding.missingIcon.isVisible = episode.locationType == LocationType.VIRTUAL
|
binding.missingIcon.isVisible = false
|
||||||
|
|
||||||
binding.seriesName.setOnClickListener {
|
bindCardItemImage(binding.episodeImage, episode)
|
||||||
if (episode.seriesId != null) {
|
|
||||||
navigateToSeries(episode.seriesId!!, episode.seriesName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bindBaseItemImage(binding.episodeImage, episode)
|
|
||||||
}
|
}
|
||||||
binding.loadingIndicator.isVisible = false
|
binding.loadingIndicator.isVisible = false
|
||||||
}
|
}
|
||||||
|
@ -231,31 +289,92 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
|
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
|
||||||
navigateToPlayerActivity(items.items.toTypedArray())
|
navigateToPlayerActivity(items.items.toTypedArray())
|
||||||
binding.playButton.setImageDrawable(
|
binding.itemActions.playButton.setImageDrawable(
|
||||||
ContextCompat.getDrawable(
|
ContextCompat.getDrawable(
|
||||||
requireActivity(),
|
requireActivity(),
|
||||||
CoreR.drawable.ic_play
|
CoreR.drawable.ic_play
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
binding.progressCircular.visibility = View.INVISIBLE
|
binding.itemActions.progressCircular.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindCheckButtonState(played: Boolean) {
|
||||||
|
when (played) {
|
||||||
|
true -> binding.itemActions.checkButton.setTintColor(CoreR.color.red, requireActivity().theme)
|
||||||
|
false -> binding.itemActions.checkButton.setTintColorAttribute(
|
||||||
|
MaterialR.attr.colorOnSecondaryContainer,
|
||||||
|
requireActivity().theme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindFavoriteButtonState(favorite: Boolean) {
|
||||||
|
val favoriteDrawable = when (favorite) {
|
||||||
|
true -> CoreR.drawable.ic_heart_filled
|
||||||
|
false -> CoreR.drawable.ic_heart
|
||||||
|
}
|
||||||
|
binding.itemActions.favoriteButton.setImageResource(favoriteDrawable)
|
||||||
|
when (favorite) {
|
||||||
|
true -> binding.itemActions.favoriteButton.setTintColor(CoreR.color.red, requireActivity().theme)
|
||||||
|
false -> binding.itemActions.favoriteButton.setTintColorAttribute(
|
||||||
|
MaterialR.attr.colorOnSecondaryContainer,
|
||||||
|
requireActivity().theme
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
|
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
|
||||||
Timber.e(error.error.message)
|
Timber.e(error.error.message)
|
||||||
|
|
||||||
binding.playerItemsError.isVisible = true
|
binding.playerItemsError.isVisible = true
|
||||||
binding.playButton.setImageDrawable(
|
binding.itemActions.playButton.setImageDrawable(
|
||||||
ContextCompat.getDrawable(
|
ContextCompat.getDrawable(
|
||||||
requireActivity(),
|
requireActivity(),
|
||||||
CoreR.drawable.ic_play
|
CoreR.drawable.ic_play
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
binding.progressCircular.visibility = View.INVISIBLE
|
binding.itemActions.progressCircular.visibility = View.INVISIBLE
|
||||||
binding.playerItemsErrorDetails.setOnClickListener {
|
binding.playerItemsErrorDetails.setOnClickListener {
|
||||||
ErrorDialogFragment.newInstance(error.error).show(parentFragmentManager, ErrorDialogFragment.TAG)
|
ErrorDialogFragment.newInstance(error.error).show(parentFragmentManager, ErrorDialogFragment.TAG)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createErrorDialog(uiText: UiText) {
|
||||||
|
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
builder
|
||||||
|
.setTitle(CoreR.string.downloading_error)
|
||||||
|
.setMessage(uiText.asString(requireContext().resources))
|
||||||
|
.setPositiveButton(getString(CoreR.string.close)) { _, _ ->
|
||||||
|
}
|
||||||
|
builder.show()
|
||||||
|
binding.itemActions.progressDownload.isVisible = false
|
||||||
|
binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDownloadPreparingDialog() {
|
||||||
|
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
downloadPreparingDialog = builder
|
||||||
|
.setTitle(CoreR.string.preparing_download)
|
||||||
|
.setView(R.layout.preparing_download_dialog)
|
||||||
|
.setCancelable(false)
|
||||||
|
.create()
|
||||||
|
downloadPreparingDialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCancelDialog() {
|
||||||
|
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
val dialog = builder
|
||||||
|
.setTitle(CoreR.string.cancel_download)
|
||||||
|
.setMessage(CoreR.string.cancel_download_message)
|
||||||
|
.setPositiveButton(CoreR.string.stop_download) { _, _ ->
|
||||||
|
viewModel.cancelDownload()
|
||||||
|
}
|
||||||
|
.setNegativeButton(CoreR.string.cancel) { _, _ ->
|
||||||
|
}
|
||||||
|
.create()
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
private fun navigateToPlayerActivity(
|
private fun navigateToPlayerActivity(
|
||||||
playerItems: Array<PlayerItem>,
|
playerItems: Array<PlayerItem>,
|
||||||
) {
|
) {
|
||||||
|
@ -266,13 +385,19 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToSeries(id: UUID, name: String?) {
|
private fun navigateToSeries(id: UUID, name: String) {
|
||||||
findNavController().navigate(
|
findNavController().navigate(
|
||||||
EpisodeBottomSheetFragmentDirections.actionEpisodeBottomSheetFragmentToMediaInfoFragment(
|
EpisodeBottomSheetFragmentDirections.actionEpisodeBottomSheetFragmentToShowFragment(
|
||||||
itemId = id,
|
itemId = id,
|
||||||
itemName = name,
|
itemName = name
|
||||||
itemType = BaseItemKind.SERIES
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatDateTime(datetime: DateTime?): String {
|
||||||
|
if (datetime == null) return ""
|
||||||
|
val instant = datetime.toInstant(ZoneOffset.UTC)
|
||||||
|
val date = Date.from(instant)
|
||||||
|
return DateFormat.getDateInstance(DateFormat.SHORT).format(date)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,10 +17,13 @@ import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter
|
||||||
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
|
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
|
||||||
import dev.jdtech.jellyfin.databinding.FragmentFavoriteBinding
|
import dev.jdtech.jellyfin.databinding.FragmentFavoriteBinding
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidShow
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
import dev.jdtech.jellyfin.viewmodels.FavoriteViewModel
|
import dev.jdtech.jellyfin.viewmodels.FavoriteViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
@ -40,10 +43,10 @@ class FavoriteFragment : Fragment() {
|
||||||
|
|
||||||
binding.favoritesRecyclerView.adapter = FavoritesListAdapter(
|
binding.favoritesRecyclerView.adapter = FavoritesListAdapter(
|
||||||
ViewItemListAdapter.OnClickListener { item ->
|
ViewItemListAdapter.OnClickListener { item ->
|
||||||
navigateToMediaInfoFragment(item)
|
navigateToMediaItem(item)
|
||||||
},
|
},
|
||||||
HomeEpisodeListAdapter.OnClickListener { item ->
|
HomeEpisodeListAdapter.OnClickListener { item ->
|
||||||
navigateToEpisodeBottomSheetFragment(item)
|
navigateToMediaItem(item)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -96,21 +99,31 @@ class FavoriteFragment : Fragment() {
|
||||||
checkIfLoginRequired(uiState.error.message)
|
checkIfLoginRequired(uiState.error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
|
private fun navigateToMediaItem(item: FindroidItem) {
|
||||||
findNavController().navigate(
|
when (item) {
|
||||||
FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment(
|
is FindroidMovie -> {
|
||||||
item.id,
|
findNavController().navigate(
|
||||||
item.name,
|
FavoriteFragmentDirections.actionFavoriteFragmentToMovieFragment(
|
||||||
item.type
|
item.id,
|
||||||
)
|
item.name
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
|
}
|
||||||
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {
|
is FindroidShow -> {
|
||||||
findNavController().navigate(
|
findNavController().navigate(
|
||||||
FavoriteFragmentDirections.actionFavoriteFragmentToEpisodeBottomSheetFragment(
|
FavoriteFragmentDirections.actionFavoriteFragmentToShowFragment(
|
||||||
episode.id
|
item.id,
|
||||||
)
|
item.name
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is FindroidEpisode -> {
|
||||||
|
findNavController().navigate(
|
||||||
|
FavoriteFragmentDirections.actionFavoriteFragmentToEpisodeBottomSheetFragment(
|
||||||
|
item.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,8 +8,6 @@ import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.widget.Toast
|
|
||||||
import android.widget.Toast.LENGTH_LONG
|
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
import androidx.core.view.MenuHost
|
import androidx.core.view.MenuHost
|
||||||
import androidx.core.view.MenuProvider
|
import androidx.core.view.MenuProvider
|
||||||
|
@ -21,17 +19,22 @@ import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import dev.jdtech.jellyfin.AppPreferences
|
||||||
import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter
|
import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter
|
||||||
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
|
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
|
||||||
import dev.jdtech.jellyfin.adapters.ViewListAdapter
|
import dev.jdtech.jellyfin.adapters.ViewListAdapter
|
||||||
import dev.jdtech.jellyfin.core.R as CoreR
|
import dev.jdtech.jellyfin.core.R as CoreR
|
||||||
import dev.jdtech.jellyfin.databinding.FragmentHomeBinding
|
import dev.jdtech.jellyfin.databinding.FragmentHomeBinding
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidShow
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
|
import dev.jdtech.jellyfin.utils.restart
|
||||||
import dev.jdtech.jellyfin.viewmodels.HomeViewModel
|
import dev.jdtech.jellyfin.viewmodels.HomeViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
@ -44,6 +47,9 @@ class HomeFragment : Fragment() {
|
||||||
|
|
||||||
private lateinit var errorDialog: ErrorDialogFragment
|
private lateinit var errorDialog: ErrorDialogFragment
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var appPreferences: AppPreferences
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
|
@ -65,7 +71,6 @@ class HomeFragment : Fragment() {
|
||||||
object : MenuProvider {
|
object : MenuProvider {
|
||||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
menuInflater.inflate(CoreR.menu.home_menu, menu)
|
menuInflater.inflate(CoreR.menu.home_menu, menu)
|
||||||
|
|
||||||
val settings = menu.findItem(CoreR.id.action_settings)
|
val settings = menu.findItem(CoreR.id.action_settings)
|
||||||
val search = menu.findItem(CoreR.id.action_search)
|
val search = menu.findItem(CoreR.id.action_search)
|
||||||
val searchView = search.actionView as SearchView
|
val searchView = search.actionView as SearchView
|
||||||
|
@ -142,15 +147,14 @@ class HomeFragment : Fragment() {
|
||||||
binding.viewsRecyclerView.adapter = ViewListAdapter(
|
binding.viewsRecyclerView.adapter = ViewListAdapter(
|
||||||
onClickListener = ViewListAdapter.OnClickListener { navigateToLibraryFragment(it) },
|
onClickListener = ViewListAdapter.OnClickListener { navigateToLibraryFragment(it) },
|
||||||
onItemClickListener = ViewItemListAdapter.OnClickListener {
|
onItemClickListener = ViewItemListAdapter.OnClickListener {
|
||||||
navigateToMediaInfoFragment(it)
|
navigateToMediaItem(it)
|
||||||
},
|
},
|
||||||
onNextUpClickListener = HomeEpisodeListAdapter.OnClickListener { item ->
|
onNextUpClickListener = HomeEpisodeListAdapter.OnClickListener { item ->
|
||||||
when (item.type) {
|
navigateToMediaItem(item)
|
||||||
BaseItemKind.EPISODE -> navigateToEpisodeBottomSheetFragment(item)
|
},
|
||||||
BaseItemKind.MOVIE -> navigateToMediaInfoFragment(item)
|
onOnlineClickListener = ViewListAdapter.OnClickListenerOfflineCard {
|
||||||
else -> Toast.makeText(requireContext(), CoreR.string.unknown_error, LENGTH_LONG)
|
appPreferences.offlineMode = false
|
||||||
.show()
|
activity?.restart()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -212,34 +216,34 @@ class HomeFragment : Fragment() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
|
private fun navigateToMediaItem(item: FindroidItem) {
|
||||||
if (item.type == BaseItemKind.EPISODE) {
|
when (item) {
|
||||||
findNavController().navigate(
|
is FindroidMovie -> {
|
||||||
HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment(
|
findNavController().navigate(
|
||||||
item.seriesId!!,
|
HomeFragmentDirections.actionNavigationHomeToMovieFragment(
|
||||||
item.seriesName,
|
item.id,
|
||||||
BaseItemKind.SERIES
|
item.name
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
} else {
|
is FindroidShow -> {
|
||||||
findNavController().navigate(
|
findNavController().navigate(
|
||||||
HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment(
|
HomeFragmentDirections.actionNavigationHomeToShowFragment(
|
||||||
item.id,
|
item.id,
|
||||||
item.name,
|
item.name
|
||||||
item.type
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
is FindroidEpisode -> {
|
||||||
|
findNavController().navigate(
|
||||||
|
HomeFragmentDirections.actionNavigationHomeToEpisodeBottomSheetFragment(
|
||||||
|
item.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {
|
|
||||||
findNavController().navigate(
|
|
||||||
HomeFragmentDirections.actionNavigationHomeToEpisodeBottomSheetFragment(
|
|
||||||
episode.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun navigateToSettingsFragment() {
|
private fun navigateToSettingsFragment() {
|
||||||
findNavController().navigate(
|
findNavController().navigate(
|
||||||
HomeFragmentDirections.actionHomeFragmentToSettingsFragment()
|
HomeFragmentDirections.actionHomeFragmentToSettingsFragment()
|
||||||
|
|
|
@ -26,13 +26,16 @@ import dev.jdtech.jellyfin.databinding.FragmentLibraryBinding
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||||
import dev.jdtech.jellyfin.dialogs.SortDialogFragment
|
import dev.jdtech.jellyfin.dialogs.SortDialogFragment
|
||||||
import dev.jdtech.jellyfin.models.CollectionType
|
import dev.jdtech.jellyfin.models.CollectionType
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidCollection
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidShow
|
||||||
import dev.jdtech.jellyfin.models.SortBy
|
import dev.jdtech.jellyfin.models.SortBy
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
import dev.jdtech.jellyfin.viewmodels.LibraryViewModel
|
import dev.jdtech.jellyfin.viewmodels.LibraryViewModel
|
||||||
import java.lang.IllegalArgumentException
|
import java.lang.IllegalArgumentException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
@ -114,9 +117,9 @@ class LibraryFragment : Fragment() {
|
||||||
ViewItemPagingAdapter(
|
ViewItemPagingAdapter(
|
||||||
ViewItemPagingAdapter.OnClickListener { item ->
|
ViewItemPagingAdapter.OnClickListener { item ->
|
||||||
if (args.libraryType == CollectionType.BoxSets.type) {
|
if (args.libraryType == CollectionType.BoxSets.type) {
|
||||||
navigateToCollectionFragment(item)
|
navigateToItem(item)
|
||||||
} else {
|
} else {
|
||||||
navigateToMediaInfoFragment(item)
|
navigateToItem(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -197,22 +200,32 @@ class LibraryFragment : Fragment() {
|
||||||
checkIfLoginRequired(uiState.error.message)
|
checkIfLoginRequired(uiState.error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
|
private fun navigateToItem(item: FindroidItem) {
|
||||||
findNavController().navigate(
|
when (item) {
|
||||||
LibraryFragmentDirections.actionLibraryFragmentToMediaInfoFragment(
|
is FindroidMovie -> {
|
||||||
item.id,
|
findNavController().navigate(
|
||||||
item.name,
|
LibraryFragmentDirections.actionLibraryFragmentToMovieFragment(
|
||||||
item.type
|
item.id,
|
||||||
)
|
item.name
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
|
}
|
||||||
private fun navigateToCollectionFragment(collection: BaseItemDto) {
|
is FindroidShow -> {
|
||||||
findNavController().navigate(
|
findNavController().navigate(
|
||||||
LibraryFragmentDirections.actionLibraryFragmentToCollectionFragment(
|
LibraryFragmentDirections.actionLibraryFragmentToShowFragment(
|
||||||
collection.id,
|
item.id,
|
||||||
collection.name
|
item.name
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
is FindroidCollection -> {
|
||||||
|
findNavController().navigate(
|
||||||
|
LibraryFragmentDirections.actionLibraryFragmentToCollectionFragment(
|
||||||
|
item.id,
|
||||||
|
item.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,10 +23,10 @@ import dev.jdtech.jellyfin.adapters.CollectionListAdapter
|
||||||
import dev.jdtech.jellyfin.core.R as CoreR
|
import dev.jdtech.jellyfin.core.R as CoreR
|
||||||
import dev.jdtech.jellyfin.databinding.FragmentMediaBinding
|
import dev.jdtech.jellyfin.databinding.FragmentMediaBinding
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidCollection
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
import dev.jdtech.jellyfin.viewmodels.MediaViewModel
|
import dev.jdtech.jellyfin.viewmodels.MediaViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
@ -146,12 +146,12 @@ class MediaFragment : Fragment() {
|
||||||
checkIfLoginRequired(uiState.error.message)
|
checkIfLoginRequired(uiState.error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToLibraryFragment(library: BaseItemDto) {
|
private fun navigateToLibraryFragment(library: FindroidCollection) {
|
||||||
findNavController().navigate(
|
findNavController().navigate(
|
||||||
MediaFragmentDirections.actionNavigationMediaToLibraryFragment(
|
MediaFragmentDirections.actionNavigationMediaToLibraryFragment(
|
||||||
library.id,
|
library.id,
|
||||||
library.name,
|
library.name,
|
||||||
library.collectionType,
|
library.type.type,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,451 +0,0 @@
|
||||||
package dev.jdtech.jellyfin.fragments
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.res.ColorStateList
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.fragment.app.viewModels
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
|
||||||
import androidx.navigation.fragment.findNavController
|
|
||||||
import androidx.navigation.fragment.navArgs
|
|
||||||
import com.google.android.material.R as MaterialR
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import dev.jdtech.jellyfin.AppPreferences
|
|
||||||
import dev.jdtech.jellyfin.adapters.PersonListAdapter
|
|
||||||
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
|
|
||||||
import dev.jdtech.jellyfin.bindBaseItemImage
|
|
||||||
import dev.jdtech.jellyfin.bindItemBackdropImage
|
|
||||||
import dev.jdtech.jellyfin.core.R as CoreR
|
|
||||||
import dev.jdtech.jellyfin.databinding.FragmentMediaInfoBinding
|
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
|
||||||
import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
|
|
||||||
import dev.jdtech.jellyfin.models.AudioCodec
|
|
||||||
import dev.jdtech.jellyfin.models.DisplayProfile
|
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
|
||||||
import dev.jdtech.jellyfin.utils.setTintColor
|
|
||||||
import dev.jdtech.jellyfin.utils.setTintColorAttribute
|
|
||||||
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
|
|
||||||
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
|
||||||
import java.util.UUID
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class MediaInfoFragment : Fragment() {
|
|
||||||
|
|
||||||
private lateinit var binding: FragmentMediaInfoBinding
|
|
||||||
private val viewModel: MediaInfoViewModel by viewModels()
|
|
||||||
private val playerViewModel: PlayerViewModel by viewModels()
|
|
||||||
private val args: MediaInfoFragmentArgs by navArgs()
|
|
||||||
|
|
||||||
lateinit var errorDialog: ErrorDialogFragment
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var appPreferences: AppPreferences
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View {
|
|
||||||
binding = FragmentMediaInfoBinding.inflate(inflater, container, false)
|
|
||||||
|
|
||||||
return binding.root
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
||||||
viewModel.uiState.collect { uiState ->
|
|
||||||
Timber.d("$uiState")
|
|
||||||
when (uiState) {
|
|
||||||
is MediaInfoViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
|
||||||
is MediaInfoViewModel.UiState.Loading -> bindUiStateLoading()
|
|
||||||
is MediaInfoViewModel.UiState.Error -> bindUiStateError(uiState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
||||||
if (!args.isOffline) {
|
|
||||||
viewModel.loadData(args.itemId, args.itemType)
|
|
||||||
} else {
|
|
||||||
viewModel.loadData(args.playerItem!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.itemType != BaseItemKind.MOVIE) {
|
|
||||||
binding.downloadButton.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.errorLayout.errorRetryButton.setOnClickListener {
|
|
||||||
viewModel.loadData(args.itemId, args.itemType)
|
|
||||||
}
|
|
||||||
|
|
||||||
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
|
|
||||||
when (playerItems) {
|
|
||||||
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
|
|
||||||
is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.trailerButton.setOnClickListener {
|
|
||||||
if (viewModel.item?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
|
|
||||||
val intent = Intent(
|
|
||||||
Intent.ACTION_VIEW,
|
|
||||||
Uri.parse(viewModel.item?.remoteTrailers?.get(0)?.url)
|
|
||||||
)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.nextUp.setOnClickListener {
|
|
||||||
navigateToEpisodeBottomSheetFragment(viewModel.nextUp!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.seasonsRecyclerView.adapter =
|
|
||||||
ViewItemListAdapter(
|
|
||||||
ViewItemListAdapter.OnClickListener { season ->
|
|
||||||
navigateToSeasonFragment(season)
|
|
||||||
},
|
|
||||||
fixedWidth = true
|
|
||||||
)
|
|
||||||
binding.peopleRecyclerView.adapter = PersonListAdapter { person ->
|
|
||||||
navigateToPersonDetail(person.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.playButton.setOnClickListener {
|
|
||||||
binding.playButton.setImageResource(android.R.color.transparent)
|
|
||||||
binding.progressCircular.isVisible = true
|
|
||||||
if (viewModel.canRetry) {
|
|
||||||
binding.playButton.isEnabled = false
|
|
||||||
viewModel.download()
|
|
||||||
return@setOnClickListener
|
|
||||||
}
|
|
||||||
viewModel.item?.let { item ->
|
|
||||||
if (!args.isOffline) {
|
|
||||||
playerViewModel.loadPlayerItems(item) {
|
|
||||||
VideoVersionDialogFragment(item, playerViewModel).show(
|
|
||||||
parentFragmentManager,
|
|
||||||
"videoversiondialog"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
playerViewModel.loadOfflinePlayerItems(args.playerItem!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!args.isOffline) {
|
|
||||||
binding.errorLayout.errorRetryButton.setOnClickListener {
|
|
||||||
viewModel.loadData(args.itemId, args.itemType)
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
|
||||||
errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG)
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.checkButton.setOnClickListener {
|
|
||||||
when (viewModel.played) {
|
|
||||||
true -> {
|
|
||||||
viewModel.markAsUnplayed(args.itemId)
|
|
||||||
binding.checkButton.setTintColorAttribute(
|
|
||||||
MaterialR.attr.colorOnSecondaryContainer,
|
|
||||||
requireActivity().theme
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
false -> {
|
|
||||||
viewModel.markAsPlayed(args.itemId)
|
|
||||||
binding.checkButton.setTintColor(CoreR.color.red, requireActivity().theme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.favoriteButton.setOnClickListener {
|
|
||||||
when (viewModel.favorite) {
|
|
||||||
true -> {
|
|
||||||
viewModel.unmarkAsFavorite(args.itemId)
|
|
||||||
binding.favoriteButton.setImageResource(CoreR.drawable.ic_heart)
|
|
||||||
binding.favoriteButton.setTintColorAttribute(
|
|
||||||
MaterialR.attr.colorOnSecondaryContainer,
|
|
||||||
requireActivity().theme
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
false -> {
|
|
||||||
viewModel.markAsFavorite(args.itemId)
|
|
||||||
binding.favoriteButton.setImageResource(CoreR.drawable.ic_heart_filled)
|
|
||||||
binding.favoriteButton.setTintColor(CoreR.color.red, requireActivity().theme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.downloadButton.setOnClickListener {
|
|
||||||
binding.downloadButton.isEnabled = false
|
|
||||||
viewModel.download()
|
|
||||||
binding.downloadButton.imageTintList = ColorStateList.valueOf(
|
|
||||||
resources.getColor(
|
|
||||||
CoreR.color.red,
|
|
||||||
requireActivity().theme
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
binding.favoriteButton.isVisible = false
|
|
||||||
binding.checkButton.isVisible = false
|
|
||||||
binding.downloadButton.isVisible = false
|
|
||||||
binding.deleteButton.isVisible = true
|
|
||||||
|
|
||||||
binding.deleteButton.setOnClickListener {
|
|
||||||
viewModel.deleteItem()
|
|
||||||
findNavController().navigate(CoreR.id.downloadFragment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindUiStateNormal(uiState: MediaInfoViewModel.UiState.Normal) {
|
|
||||||
uiState.apply {
|
|
||||||
binding.originalTitle.isVisible = item.originalTitle != item.name
|
|
||||||
if (item.remoteTrailers.isNullOrEmpty()) {
|
|
||||||
binding.trailerButton.isVisible = false
|
|
||||||
}
|
|
||||||
binding.communityRating.isVisible = item.communityRating != null
|
|
||||||
binding.actors.isVisible = actors.isNotEmpty()
|
|
||||||
|
|
||||||
val clickable = canPlay && (available || canRetry)
|
|
||||||
binding.playButton.isEnabled = clickable
|
|
||||||
binding.playButton.alpha = if (!clickable) 0.5F else 1.0F
|
|
||||||
binding.playButton.setImageResource(if (!canRetry) CoreR.drawable.ic_play else CoreR.drawable.ic_rotate_ccw)
|
|
||||||
if (!(available || canRetry)) {
|
|
||||||
binding.playButton.setImageResource(android.R.color.transparent)
|
|
||||||
binding.progressCircular.isVisible = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check icon
|
|
||||||
when (played) {
|
|
||||||
true -> binding.checkButton.setTintColor(CoreR.color.red, requireActivity().theme)
|
|
||||||
false -> binding.checkButton.setTintColorAttribute(
|
|
||||||
MaterialR.attr.colorOnSecondaryContainer,
|
|
||||||
requireActivity().theme
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Favorite icon
|
|
||||||
val favoriteDrawable = when (favorite) {
|
|
||||||
true -> CoreR.drawable.ic_heart_filled
|
|
||||||
false -> CoreR.drawable.ic_heart
|
|
||||||
}
|
|
||||||
binding.favoriteButton.setImageResource(favoriteDrawable)
|
|
||||||
if (favorite) binding.favoriteButton.setTintColor(CoreR.color.red, requireActivity().theme)
|
|
||||||
|
|
||||||
when (canDownload) {
|
|
||||||
true -> {
|
|
||||||
binding.downloadButton.isVisible = true
|
|
||||||
binding.downloadButton.isEnabled = !downloaded
|
|
||||||
|
|
||||||
if (downloaded) binding.downloadButton.setTintColor(
|
|
||||||
CoreR.color.red,
|
|
||||||
requireActivity().theme
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
false -> {
|
|
||||||
binding.downloadButton.isVisible = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.name.text = item.name
|
|
||||||
binding.originalTitle.text = item.originalTitle
|
|
||||||
if (dateString.isEmpty()) {
|
|
||||||
binding.year.isVisible = false
|
|
||||||
} else {
|
|
||||||
binding.year.text = dateString
|
|
||||||
}
|
|
||||||
if (runTime.isEmpty()) {
|
|
||||||
binding.playtime.isVisible = false
|
|
||||||
} else {
|
|
||||||
binding.playtime.text = runTime
|
|
||||||
}
|
|
||||||
binding.officialRating.text = item.officialRating
|
|
||||||
binding.communityRating.text = item.communityRating.toString()
|
|
||||||
binding.genresLayout.isVisible = item.genres?.isNotEmpty() ?: false
|
|
||||||
binding.genres.text = genresString
|
|
||||||
binding.videoMeta.text = videoString
|
|
||||||
binding.audio.text = audioString
|
|
||||||
binding.subtitles.text = subtitleString
|
|
||||||
binding.subsChip.isVisible = subtitleString.isNotEmpty()
|
|
||||||
|
|
||||||
if (appPreferences.displayExtraInfo) {
|
|
||||||
binding.subtitlesLayout.isVisible = subtitleString.isNotEmpty()
|
|
||||||
binding.videoMetaLayout.isVisible = videoString.isNotEmpty()
|
|
||||||
binding.audioLayout.isVisible = audioString.isNotEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
videoMetadata?.let {
|
|
||||||
with(binding) {
|
|
||||||
videoMetaChips.isVisible = true
|
|
||||||
audioChannelChip.text = it.audioChannels.firstOrNull()?.raw
|
|
||||||
resChip.text = it.resolution.firstOrNull()?.raw
|
|
||||||
audioChannelChip.isVisible = it.audioChannels.isNotEmpty()
|
|
||||||
resChip.isVisible = it.resolution.isNotEmpty()
|
|
||||||
|
|
||||||
it.displayProfiles.firstOrNull()?.apply {
|
|
||||||
videoProfileChip.text = this.raw
|
|
||||||
videoProfileChip.isVisible = when (this) {
|
|
||||||
DisplayProfile.HDR,
|
|
||||||
DisplayProfile.HDR10,
|
|
||||||
DisplayProfile.HLG -> {
|
|
||||||
videoProfileChip.chipStartPadding = .0f
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
DisplayProfile.DOLBY_VISION -> {
|
|
||||||
videoProfileChip.isChipIconVisible = true
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
audioCodecChip.text = when (val codec = it.audioCodecs.firstOrNull()) {
|
|
||||||
AudioCodec.AC3, AudioCodec.EAC3, AudioCodec.TRUEHD -> {
|
|
||||||
audioCodecChip.isVisible = true
|
|
||||||
if (it.isAtmos.firstOrNull() == true) {
|
|
||||||
"${codec.raw} | Atmos"
|
|
||||||
} else codec.raw
|
|
||||||
}
|
|
||||||
|
|
||||||
AudioCodec.DTS -> {
|
|
||||||
audioCodecChip.apply {
|
|
||||||
isVisible = true
|
|
||||||
isChipIconVisible = false
|
|
||||||
chipStartPadding = .0f
|
|
||||||
}
|
|
||||||
codec.raw
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
audioCodecChip.isVisible = false
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.directorLayout.isVisible = director != null
|
|
||||||
binding.director.text = director?.name
|
|
||||||
binding.writersLayout.isVisible = writers.isNotEmpty()
|
|
||||||
binding.writers.text = writersString
|
|
||||||
binding.description.text = item.overview
|
|
||||||
binding.nextUpLayout.isVisible = nextUp != null
|
|
||||||
binding.nextUpName.text = getString(
|
|
||||||
CoreR.string.episode_name_extended,
|
|
||||||
nextUp?.parentIndexNumber,
|
|
||||||
nextUp?.indexNumber,
|
|
||||||
nextUp?.name
|
|
||||||
)
|
|
||||||
binding.seasonsLayout.isVisible = seasons.isNotEmpty()
|
|
||||||
val seasonsAdapter = binding.seasonsRecyclerView.adapter as ViewItemListAdapter
|
|
||||||
seasonsAdapter.submitList(seasons)
|
|
||||||
val actorsAdapter = binding.peopleRecyclerView.adapter as PersonListAdapter
|
|
||||||
actorsAdapter.submitList(actors)
|
|
||||||
bindItemBackdropImage(binding.itemBanner, item)
|
|
||||||
bindBaseItemImage(binding.nextUpImage, nextUp)
|
|
||||||
}
|
|
||||||
binding.loadingIndicator.isVisible = false
|
|
||||||
binding.mediaInfoScrollview.isVisible = true
|
|
||||||
binding.errorLayout.errorPanel.isVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindUiStateLoading() {
|
|
||||||
binding.loadingIndicator.isVisible = true
|
|
||||||
binding.errorLayout.errorPanel.isVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindUiStateError(uiState: MediaInfoViewModel.UiState.Error) {
|
|
||||||
errorDialog = ErrorDialogFragment.newInstance(uiState.error)
|
|
||||||
binding.loadingIndicator.isVisible = false
|
|
||||||
binding.mediaInfoScrollview.isVisible = false
|
|
||||||
binding.errorLayout.errorPanel.isVisible = true
|
|
||||||
checkIfLoginRequired(uiState.error.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
|
|
||||||
navigateToPlayerActivity(items.items.toTypedArray())
|
|
||||||
binding.playButton.setImageDrawable(
|
|
||||||
ContextCompat.getDrawable(
|
|
||||||
requireActivity(),
|
|
||||||
CoreR.drawable.ic_play
|
|
||||||
)
|
|
||||||
)
|
|
||||||
binding.progressCircular.visibility = View.INVISIBLE
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
|
|
||||||
Timber.e(error.error.message)
|
|
||||||
binding.playerItemsError.visibility = View.VISIBLE
|
|
||||||
binding.playButton.setImageDrawable(
|
|
||||||
ContextCompat.getDrawable(
|
|
||||||
requireActivity(),
|
|
||||||
CoreR.drawable.ic_play
|
|
||||||
)
|
|
||||||
)
|
|
||||||
binding.progressCircular.visibility = View.INVISIBLE
|
|
||||||
binding.playerItemsErrorDetails.setOnClickListener {
|
|
||||||
ErrorDialogFragment.newInstance(error.error)
|
|
||||||
.show(parentFragmentManager, ErrorDialogFragment.TAG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {
|
|
||||||
findNavController().navigate(
|
|
||||||
MediaInfoFragmentDirections.actionMediaInfoFragmentToEpisodeBottomSheetFragment(
|
|
||||||
episode.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun navigateToSeasonFragment(season: BaseItemDto) {
|
|
||||||
findNavController().navigate(
|
|
||||||
MediaInfoFragmentDirections.actionMediaInfoFragmentToSeasonFragment(
|
|
||||||
season.seriesId!!,
|
|
||||||
season.id,
|
|
||||||
season.seriesName,
|
|
||||||
season.name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun navigateToPlayerActivity(
|
|
||||||
playerItems: Array<PlayerItem>,
|
|
||||||
) {
|
|
||||||
findNavController().navigate(
|
|
||||||
MediaInfoFragmentDirections.actionMediaInfoFragmentToPlayerActivity(
|
|
||||||
playerItems
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun navigateToPersonDetail(personId: UUID) {
|
|
||||||
findNavController().navigate(
|
|
||||||
MediaInfoFragmentDirections.actionMediaInfoFragmentToPersonDetailFragment(personId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,511 @@
|
||||||
|
package dev.jdtech.jellyfin.fragments
|
||||||
|
|
||||||
|
import android.app.DownloadManager
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import androidx.navigation.fragment.navArgs
|
||||||
|
import com.google.android.material.R as MaterialR
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import dev.jdtech.jellyfin.AppPreferences
|
||||||
|
import dev.jdtech.jellyfin.R
|
||||||
|
import dev.jdtech.jellyfin.adapters.PersonListAdapter
|
||||||
|
import dev.jdtech.jellyfin.bindItemBackdropImage
|
||||||
|
import dev.jdtech.jellyfin.core.R as CoreR
|
||||||
|
import dev.jdtech.jellyfin.databinding.FragmentMovieBinding
|
||||||
|
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||||
|
import dev.jdtech.jellyfin.dialogs.getStorageSelectionDialog
|
||||||
|
import dev.jdtech.jellyfin.dialogs.getVideoVersionDialog
|
||||||
|
import dev.jdtech.jellyfin.models.AudioCodec
|
||||||
|
import dev.jdtech.jellyfin.models.DisplayProfile
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidSourceType
|
||||||
|
import dev.jdtech.jellyfin.models.PlayerItem
|
||||||
|
import dev.jdtech.jellyfin.models.UiText
|
||||||
|
import dev.jdtech.jellyfin.models.isDownloaded
|
||||||
|
import dev.jdtech.jellyfin.models.isDownloading
|
||||||
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
|
import dev.jdtech.jellyfin.utils.setTintColor
|
||||||
|
import dev.jdtech.jellyfin.utils.setTintColorAttribute
|
||||||
|
import dev.jdtech.jellyfin.viewmodels.MovieViewModel
|
||||||
|
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MovieFragment : Fragment() {
|
||||||
|
private lateinit var binding: FragmentMovieBinding
|
||||||
|
private val viewModel: MovieViewModel by viewModels()
|
||||||
|
private val playerViewModel: PlayerViewModel by viewModels()
|
||||||
|
private val args: MovieFragmentArgs by navArgs()
|
||||||
|
|
||||||
|
private lateinit var errorDialog: ErrorDialogFragment
|
||||||
|
private lateinit var downloadPreparingDialog: AlertDialog
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var appPreferences: AppPreferences
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
binding = FragmentMovieBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
launch {
|
||||||
|
viewModel.uiState.collect { uiState ->
|
||||||
|
Timber.d("$uiState")
|
||||||
|
when (uiState) {
|
||||||
|
is MovieViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
||||||
|
is MovieViewModel.UiState.Loading -> bindUiStateLoading()
|
||||||
|
is MovieViewModel.UiState.Error -> bindUiStateError(uiState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
launch {
|
||||||
|
viewModel.downloadStatus.collect { (status, progress) ->
|
||||||
|
when (status) {
|
||||||
|
10 -> {
|
||||||
|
downloadPreparingDialog.dismiss()
|
||||||
|
}
|
||||||
|
DownloadManager.STATUS_PENDING -> {
|
||||||
|
binding.itemActions.downloadButton.setImageResource(android.R.color.transparent)
|
||||||
|
binding.itemActions.progressDownload.isIndeterminate = true
|
||||||
|
binding.itemActions.progressDownload.isVisible = true
|
||||||
|
}
|
||||||
|
DownloadManager.STATUS_RUNNING -> {
|
||||||
|
binding.itemActions.downloadButton.setImageResource(android.R.color.transparent)
|
||||||
|
binding.itemActions.progressDownload.isVisible = true
|
||||||
|
if (progress < 5) {
|
||||||
|
binding.itemActions.progressDownload.isIndeterminate = true
|
||||||
|
} else {
|
||||||
|
binding.itemActions.progressDownload.isIndeterminate = false
|
||||||
|
binding.itemActions.progressDownload.setProgressCompat(progress, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadManager.STATUS_SUCCESSFUL -> {
|
||||||
|
binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_trash)
|
||||||
|
binding.itemActions.progressDownload.isVisible = false
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
binding.itemActions.progressDownload.isVisible = false
|
||||||
|
binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
launch {
|
||||||
|
viewModel.downloadError.collect { uiText ->
|
||||||
|
createErrorDialog(uiText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
launch {
|
||||||
|
viewModel.navigateBack.collect {
|
||||||
|
if (it) findNavController().navigateUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.errorLayout.errorRetryButton.setOnClickListener {
|
||||||
|
viewModel.loadData(args.itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
||||||
|
errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG)
|
||||||
|
}
|
||||||
|
|
||||||
|
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
|
||||||
|
when (playerItems) {
|
||||||
|
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
|
||||||
|
is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.itemActions.playButton.setOnClickListener {
|
||||||
|
binding.itemActions.playButton.isEnabled = false
|
||||||
|
binding.itemActions.playButton.setImageResource(android.R.color.transparent)
|
||||||
|
binding.itemActions.progressCircular.isVisible = true
|
||||||
|
if (viewModel.item.sources.size > 1) {
|
||||||
|
val dialog = getVideoVersionDialog(
|
||||||
|
requireContext(), viewModel.item,
|
||||||
|
onItemSelected = {
|
||||||
|
playerViewModel.loadPlayerItems(viewModel.item, it)
|
||||||
|
},
|
||||||
|
onCancel = {
|
||||||
|
playButtonNormal()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
dialog.show()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
playerViewModel.loadPlayerItems(viewModel.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.itemActions.trailerButton.setOnClickListener {
|
||||||
|
viewModel.item.trailer.let { trailerUri ->
|
||||||
|
val intent = Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
Uri.parse(trailerUri)
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Toast.makeText(requireContext(), e.localizedMessage, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.itemActions.checkButton.setOnClickListener {
|
||||||
|
val played = viewModel.togglePlayed()
|
||||||
|
bindCheckButtonState(played)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.itemActions.favoriteButton.setOnClickListener {
|
||||||
|
val favorite = viewModel.toggleFavorite()
|
||||||
|
bindFavoriteButtonState(favorite)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.itemActions.downloadButton.setOnClickListener {
|
||||||
|
if (viewModel.item.isDownloaded()) {
|
||||||
|
viewModel.deleteItem()
|
||||||
|
binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download)
|
||||||
|
} else if (viewModel.item.isDownloading()) {
|
||||||
|
createCancelDialog()
|
||||||
|
} else {
|
||||||
|
binding.itemActions.downloadButton.setImageResource(android.R.color.transparent)
|
||||||
|
binding.itemActions.progressDownload.isIndeterminate = true
|
||||||
|
binding.itemActions.progressDownload.isVisible = true
|
||||||
|
if (requireContext().getExternalFilesDirs(null).filterNotNull().size > 1) {
|
||||||
|
val storageDialog = getStorageSelectionDialog(
|
||||||
|
requireContext(),
|
||||||
|
onItemSelected = { storageIndex ->
|
||||||
|
if (viewModel.item.sources.size > 1) {
|
||||||
|
val dialog = getVideoVersionDialog(
|
||||||
|
requireContext(),
|
||||||
|
viewModel.item,
|
||||||
|
onItemSelected = { sourceIndex ->
|
||||||
|
createDownloadPreparingDialog()
|
||||||
|
viewModel.download(sourceIndex, storageIndex)
|
||||||
|
},
|
||||||
|
onCancel = {
|
||||||
|
binding.itemActions.progressDownload.isVisible = false
|
||||||
|
binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
dialog.show()
|
||||||
|
return@getStorageSelectionDialog
|
||||||
|
}
|
||||||
|
createDownloadPreparingDialog()
|
||||||
|
viewModel.download(storageIndex = storageIndex)
|
||||||
|
},
|
||||||
|
onCancel = {
|
||||||
|
binding.itemActions.progressDownload.isVisible = false
|
||||||
|
binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
storageDialog.show()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
if (viewModel.item.sources.size > 1) {
|
||||||
|
val dialog = getVideoVersionDialog(
|
||||||
|
requireContext(),
|
||||||
|
viewModel.item,
|
||||||
|
onItemSelected = { sourceIndex ->
|
||||||
|
createDownloadPreparingDialog()
|
||||||
|
viewModel.download(sourceIndex)
|
||||||
|
},
|
||||||
|
onCancel = {
|
||||||
|
binding.itemActions.progressDownload.isVisible = false
|
||||||
|
binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
dialog.show()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
createDownloadPreparingDialog()
|
||||||
|
viewModel.download()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.peopleRecyclerView.adapter = PersonListAdapter { person ->
|
||||||
|
navigateToPersonDetail(person.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
|
||||||
|
viewModel.loadData(args.itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindUiStateNormal(uiState: MovieViewModel.UiState.Normal) {
|
||||||
|
uiState.apply {
|
||||||
|
val canDownload =
|
||||||
|
item.canDownload && item.sources.any { it.type == FindroidSourceType.REMOTE }
|
||||||
|
val canDelete = item.sources.any { it.type == FindroidSourceType.LOCAL }
|
||||||
|
|
||||||
|
binding.originalTitle.isVisible = item.originalTitle != item.name
|
||||||
|
if (item.trailer != null) {
|
||||||
|
binding.itemActions.trailerButton.isVisible = true
|
||||||
|
}
|
||||||
|
binding.communityRating.isVisible = item.communityRating != null
|
||||||
|
binding.actors.isVisible = actors.isNotEmpty()
|
||||||
|
|
||||||
|
val canPlay = item.canPlay && item.sources.isNotEmpty()
|
||||||
|
binding.itemActions.playButton.isEnabled = canPlay
|
||||||
|
binding.itemActions.playButton.alpha = if (!canPlay) 0.5F else 1.0F
|
||||||
|
|
||||||
|
bindCheckButtonState(item.played)
|
||||||
|
|
||||||
|
bindFavoriteButtonState(item.favorite)
|
||||||
|
|
||||||
|
if (item.isDownloaded()) {
|
||||||
|
binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_trash)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (canDownload || canDelete) {
|
||||||
|
true -> binding.itemActions.downloadButton.isVisible = true
|
||||||
|
false -> binding.itemActions.downloadButton.isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.name.text = item.name
|
||||||
|
binding.originalTitle.text = item.originalTitle
|
||||||
|
if (dateString.isEmpty()) {
|
||||||
|
binding.year.isVisible = false
|
||||||
|
} else {
|
||||||
|
binding.year.text = dateString
|
||||||
|
}
|
||||||
|
if (runTime.isEmpty()) {
|
||||||
|
binding.playtime.isVisible = false
|
||||||
|
} else {
|
||||||
|
binding.playtime.text = runTime
|
||||||
|
}
|
||||||
|
binding.officialRating.text = item.officialRating
|
||||||
|
binding.communityRating.text = item.communityRating.toString()
|
||||||
|
binding.genresLayout.isVisible = item.genres.isNotEmpty()
|
||||||
|
binding.genres.text = genresString
|
||||||
|
binding.videoMeta.text = videoString
|
||||||
|
binding.audio.text = audioString
|
||||||
|
binding.subtitles.text = subtitleString
|
||||||
|
binding.subsChip.isVisible = subtitleString.isNotEmpty()
|
||||||
|
|
||||||
|
if (appPreferences.displayExtraInfo) {
|
||||||
|
binding.subtitlesLayout.isVisible = subtitleString.isNotEmpty()
|
||||||
|
binding.videoMetaLayout.isVisible = videoString.isNotEmpty()
|
||||||
|
binding.audioLayout.isVisible = audioString.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
videoMetadata.let {
|
||||||
|
with(binding) {
|
||||||
|
videoMetaChips.isVisible = true
|
||||||
|
audioChannelChip.text = it.audioChannels.firstOrNull()?.raw
|
||||||
|
resChip.text = it.resolution.firstOrNull()?.raw
|
||||||
|
audioChannelChip.isVisible = it.audioChannels.isNotEmpty()
|
||||||
|
resChip.isVisible = it.resolution.isNotEmpty()
|
||||||
|
|
||||||
|
it.displayProfiles.firstOrNull()?.apply {
|
||||||
|
videoProfileChip.text = this.raw
|
||||||
|
videoProfileChip.isVisible = when (this) {
|
||||||
|
DisplayProfile.HDR,
|
||||||
|
DisplayProfile.HDR10,
|
||||||
|
DisplayProfile.HLG -> {
|
||||||
|
videoProfileChip.chipStartPadding = .0f
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
DisplayProfile.DOLBY_VISION -> {
|
||||||
|
videoProfileChip.isChipIconVisible = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioCodecChip.text = when (val codec = it.audioCodecs.firstOrNull()) {
|
||||||
|
AudioCodec.AC3, AudioCodec.EAC3, AudioCodec.TRUEHD -> {
|
||||||
|
audioCodecChip.isVisible = true
|
||||||
|
if (it.isAtmos.firstOrNull() == true) {
|
||||||
|
"${codec.raw} | Atmos"
|
||||||
|
} else codec.raw
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioCodec.DTS -> {
|
||||||
|
audioCodecChip.apply {
|
||||||
|
isVisible = true
|
||||||
|
isChipIconVisible = false
|
||||||
|
chipStartPadding = .0f
|
||||||
|
}
|
||||||
|
codec.raw
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
audioCodecChip.isVisible = false
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.directorLayout.isVisible = director != null
|
||||||
|
binding.director.text = director?.name
|
||||||
|
binding.writersLayout.isVisible = writers.isNotEmpty()
|
||||||
|
binding.writers.text = writersString
|
||||||
|
binding.description.text = item.overview
|
||||||
|
val actorsAdapter = binding.peopleRecyclerView.adapter as PersonListAdapter
|
||||||
|
actorsAdapter.submitList(actors)
|
||||||
|
bindItemBackdropImage(binding.itemBanner, item)
|
||||||
|
}
|
||||||
|
binding.loadingIndicator.isVisible = false
|
||||||
|
binding.mediaInfoScrollview.isVisible = true
|
||||||
|
binding.errorLayout.errorPanel.isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindUiStateLoading() {
|
||||||
|
binding.loadingIndicator.isVisible = true
|
||||||
|
binding.errorLayout.errorPanel.isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindUiStateError(uiState: MovieViewModel.UiState.Error) {
|
||||||
|
errorDialog = ErrorDialogFragment.newInstance(uiState.error)
|
||||||
|
binding.loadingIndicator.isVisible = false
|
||||||
|
binding.mediaInfoScrollview.isVisible = false
|
||||||
|
binding.errorLayout.errorPanel.isVisible = true
|
||||||
|
checkIfLoginRequired(uiState.error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindCheckButtonState(played: Boolean) {
|
||||||
|
when (played) {
|
||||||
|
true -> binding.itemActions.checkButton.setTintColor(CoreR.color.red, requireActivity().theme)
|
||||||
|
false -> binding.itemActions.checkButton.setTintColorAttribute(
|
||||||
|
MaterialR.attr.colorOnSecondaryContainer,
|
||||||
|
requireActivity().theme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindFavoriteButtonState(favorite: Boolean) {
|
||||||
|
val favoriteDrawable = when (favorite) {
|
||||||
|
true -> CoreR.drawable.ic_heart_filled
|
||||||
|
false -> CoreR.drawable.ic_heart
|
||||||
|
}
|
||||||
|
binding.itemActions.favoriteButton.setImageResource(favoriteDrawable)
|
||||||
|
when (favorite) {
|
||||||
|
true -> binding.itemActions.favoriteButton.setTintColor(CoreR.color.red, requireActivity().theme)
|
||||||
|
false -> binding.itemActions.favoriteButton.setTintColorAttribute(
|
||||||
|
MaterialR.attr.colorOnSecondaryContainer,
|
||||||
|
requireActivity().theme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
|
||||||
|
navigateToPlayerActivity(items.items.toTypedArray())
|
||||||
|
binding.itemActions.playButton.setImageDrawable(
|
||||||
|
ContextCompat.getDrawable(
|
||||||
|
requireActivity(),
|
||||||
|
CoreR.drawable.ic_play
|
||||||
|
)
|
||||||
|
)
|
||||||
|
binding.itemActions.progressCircular.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
|
||||||
|
Timber.e(error.error.message)
|
||||||
|
binding.playerItemsError.visibility = View.VISIBLE
|
||||||
|
playButtonNormal()
|
||||||
|
binding.playerItemsErrorDetails.setOnClickListener {
|
||||||
|
ErrorDialogFragment.newInstance(error.error)
|
||||||
|
.show(parentFragmentManager, ErrorDialogFragment.TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playButtonNormal() {
|
||||||
|
binding.itemActions.playButton.isEnabled = true
|
||||||
|
binding.itemActions.playButton.setImageDrawable(
|
||||||
|
ContextCompat.getDrawable(
|
||||||
|
requireActivity(),
|
||||||
|
CoreR.drawable.ic_play
|
||||||
|
)
|
||||||
|
)
|
||||||
|
binding.itemActions.progressCircular.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createErrorDialog(uiText: UiText) {
|
||||||
|
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
builder
|
||||||
|
.setTitle(CoreR.string.downloading_error)
|
||||||
|
.setMessage(uiText.asString(requireContext().resources))
|
||||||
|
.setPositiveButton(getString(CoreR.string.close)) { _, _ ->
|
||||||
|
}
|
||||||
|
builder.show()
|
||||||
|
binding.itemActions.progressDownload.isVisible = false
|
||||||
|
binding.itemActions.downloadButton.setImageResource(CoreR.drawable.ic_download)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDownloadPreparingDialog() {
|
||||||
|
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
downloadPreparingDialog = builder
|
||||||
|
.setTitle(CoreR.string.preparing_download)
|
||||||
|
.setView(R.layout.preparing_download_dialog)
|
||||||
|
.setCancelable(false)
|
||||||
|
.create()
|
||||||
|
downloadPreparingDialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCancelDialog() {
|
||||||
|
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
val dialog = builder
|
||||||
|
.setTitle(CoreR.string.cancel_download)
|
||||||
|
.setMessage(CoreR.string.cancel_download_message)
|
||||||
|
.setPositiveButton(CoreR.string.stop_download) { _, _ ->
|
||||||
|
viewModel.cancelDownload()
|
||||||
|
}
|
||||||
|
.setNegativeButton(CoreR.string.cancel) { _, _ ->
|
||||||
|
}
|
||||||
|
.create()
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToPlayerActivity(
|
||||||
|
playerItems: Array<PlayerItem>,
|
||||||
|
) {
|
||||||
|
findNavController().navigate(
|
||||||
|
MovieFragmentDirections.actionMovieFragmentToPlayerActivity(
|
||||||
|
playerItems
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToPersonDetail(personId: UUID) {
|
||||||
|
findNavController().navigate(
|
||||||
|
MovieFragmentDirections.actionMovieFragmentToPersonDetailFragment(personId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,10 +20,12 @@ import dev.jdtech.jellyfin.bindItemImage
|
||||||
import dev.jdtech.jellyfin.core.R as CoreR
|
import dev.jdtech.jellyfin.core.R as CoreR
|
||||||
import dev.jdtech.jellyfin.databinding.FragmentPersonDetailBinding
|
import dev.jdtech.jellyfin.databinding.FragmentPersonDetailBinding
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidShow
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
import dev.jdtech.jellyfin.viewmodels.PersonDetailViewModel
|
import dev.jdtech.jellyfin.viewmodels.PersonDetailViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
@ -118,7 +120,7 @@ internal class PersonDetailFragment : Fragment() {
|
||||||
|
|
||||||
private fun adapter() = ViewItemListAdapter(
|
private fun adapter() = ViewItemListAdapter(
|
||||||
fixedWidth = true,
|
fixedWidth = true,
|
||||||
onClickListener = ViewItemListAdapter.OnClickListener { navigateToMediaInfoFragment(it) }
|
onClickListener = ViewItemListAdapter.OnClickListener { navigateToMediaItem(it) }
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun setupOverviewExpansion() = binding.overview.post {
|
private fun setupOverviewExpansion() = binding.overview.post {
|
||||||
|
@ -137,13 +139,24 @@ internal class PersonDetailFragment : Fragment() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
|
private fun navigateToMediaItem(item: FindroidItem) {
|
||||||
findNavController().navigate(
|
when (item) {
|
||||||
PersonDetailFragmentDirections.actionPersonDetailFragmentToMediaInfoFragment(
|
is FindroidMovie -> {
|
||||||
itemId = item.id,
|
findNavController().navigate(
|
||||||
itemName = item.name,
|
PersonDetailFragmentDirections.actionPersonDetailFragmentToMovieFragment(
|
||||||
itemType = item.type
|
itemId = item.id,
|
||||||
)
|
itemName = item.name
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is FindroidShow -> {
|
||||||
|
findNavController().navigate(
|
||||||
|
PersonDetailFragmentDirections.actionPersonDetailFragmentToShowFragment(
|
||||||
|
itemId = item.id,
|
||||||
|
itemName = item.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,10 +18,13 @@ import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter
|
||||||
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
|
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
|
||||||
import dev.jdtech.jellyfin.databinding.FragmentSearchResultBinding
|
import dev.jdtech.jellyfin.databinding.FragmentSearchResultBinding
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidShow
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
import dev.jdtech.jellyfin.viewmodels.SearchResultViewModel
|
import dev.jdtech.jellyfin.viewmodels.SearchResultViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
@ -42,10 +45,10 @@ class SearchResultFragment : Fragment() {
|
||||||
|
|
||||||
binding.searchResultsRecyclerView.adapter = FavoritesListAdapter(
|
binding.searchResultsRecyclerView.adapter = FavoritesListAdapter(
|
||||||
ViewItemListAdapter.OnClickListener { item ->
|
ViewItemListAdapter.OnClickListener { item ->
|
||||||
navigateToMediaInfoFragment(item)
|
navigateToMediaItem(item)
|
||||||
},
|
},
|
||||||
HomeEpisodeListAdapter.OnClickListener { item ->
|
HomeEpisodeListAdapter.OnClickListener { item ->
|
||||||
navigateToEpisodeBottomSheetFragment(item)
|
navigateToMediaItem(item)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -104,21 +107,31 @@ class SearchResultFragment : Fragment() {
|
||||||
checkIfLoginRequired(uiState.error.message)
|
checkIfLoginRequired(uiState.error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
|
private fun navigateToMediaItem(item: FindroidItem) {
|
||||||
findNavController().navigate(
|
when (item) {
|
||||||
FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment(
|
is FindroidMovie -> {
|
||||||
item.id,
|
findNavController().navigate(
|
||||||
item.name,
|
SearchResultFragmentDirections.actionSearchResultFragmentToMovieFragment(
|
||||||
item.type
|
item.id,
|
||||||
)
|
item.name
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
|
}
|
||||||
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {
|
is FindroidShow -> {
|
||||||
findNavController().navigate(
|
findNavController().navigate(
|
||||||
FavoriteFragmentDirections.actionFavoriteFragmentToEpisodeBottomSheetFragment(
|
SearchResultFragmentDirections.actionSearchResultFragmentToShowFragment(
|
||||||
episode.id
|
item.id,
|
||||||
)
|
item.name
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is FindroidEpisode -> {
|
||||||
|
findNavController().navigate(
|
||||||
|
SearchResultFragmentDirections.actionSearchResultFragmentToEpisodeBottomSheetFragment(
|
||||||
|
item.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,10 +16,12 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.adapters.EpisodeListAdapter
|
import dev.jdtech.jellyfin.adapters.EpisodeListAdapter
|
||||||
import dev.jdtech.jellyfin.databinding.FragmentSeasonBinding
|
import dev.jdtech.jellyfin.databinding.FragmentSeasonBinding
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
|
import dev.jdtech.jellyfin.models.PlayerItem
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
|
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||||
import dev.jdtech.jellyfin.viewmodels.SeasonViewModel
|
import dev.jdtech.jellyfin.viewmodels.SeasonViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
@ -27,6 +29,7 @@ class SeasonFragment : Fragment() {
|
||||||
|
|
||||||
private lateinit var binding: FragmentSeasonBinding
|
private lateinit var binding: FragmentSeasonBinding
|
||||||
private val viewModel: SeasonViewModel by viewModels()
|
private val viewModel: SeasonViewModel by viewModels()
|
||||||
|
private val playerViewModel: PlayerViewModel by viewModels()
|
||||||
private val args: SeasonFragmentArgs by navArgs()
|
private val args: SeasonFragmentArgs by navArgs()
|
||||||
|
|
||||||
private lateinit var errorDialog: ErrorDialogFragment
|
private lateinit var errorDialog: ErrorDialogFragment
|
||||||
|
@ -45,25 +48,36 @@ class SeasonFragment : Fragment() {
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
viewModel.uiState.collect { uiState ->
|
launch {
|
||||||
Timber.d("$uiState")
|
viewModel.uiState.collect { uiState ->
|
||||||
when (uiState) {
|
Timber.d("$uiState")
|
||||||
is SeasonViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
when (uiState) {
|
||||||
is SeasonViewModel.UiState.Loading -> bindUiStateLoading()
|
is SeasonViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
||||||
is SeasonViewModel.UiState.Error -> bindUiStateError(uiState)
|
is SeasonViewModel.UiState.Loading -> bindUiStateLoading()
|
||||||
|
is SeasonViewModel.UiState.Error -> bindUiStateError(uiState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
launch {
|
||||||
|
viewModel.navigateBack.collect {
|
||||||
|
if (it) findNavController().navigateUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
binding.errorLayout.errorRetryButton.setOnClickListener {
|
||||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
viewModel.loadEpisodes(args.seriesId, args.seasonId, args.offline)
|
||||||
viewModel.loadEpisodes(args.seriesId, args.seasonId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.errorLayout.errorRetryButton.setOnClickListener {
|
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
|
||||||
viewModel.loadEpisodes(args.seriesId, args.seasonId)
|
when (playerItems) {
|
||||||
|
is PlayerViewModel.PlayerItems -> {
|
||||||
|
navigateToPlayerActivity(playerItems.items.toTypedArray())
|
||||||
|
}
|
||||||
|
is PlayerViewModel.PlayerItemError -> {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
||||||
|
@ -75,10 +89,15 @@ class SeasonFragment : Fragment() {
|
||||||
EpisodeListAdapter.OnClickListener { episode ->
|
EpisodeListAdapter.OnClickListener { episode ->
|
||||||
navigateToEpisodeBottomSheetFragment(episode)
|
navigateToEpisodeBottomSheetFragment(episode)
|
||||||
},
|
},
|
||||||
args.seriesId, args.seriesName, args.seasonId, args.seasonName
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
|
||||||
|
viewModel.loadEpisodes(args.seriesId, args.seasonId, args.offline)
|
||||||
|
}
|
||||||
|
|
||||||
private fun bindUiStateNormal(uiState: SeasonViewModel.UiState.Normal) {
|
private fun bindUiStateNormal(uiState: SeasonViewModel.UiState.Normal) {
|
||||||
uiState.apply {
|
uiState.apply {
|
||||||
val adapter = binding.episodesRecyclerView.adapter as EpisodeListAdapter
|
val adapter = binding.episodesRecyclerView.adapter as EpisodeListAdapter
|
||||||
|
@ -102,11 +121,21 @@ class SeasonFragment : Fragment() {
|
||||||
checkIfLoginRequired(uiState.error.message)
|
checkIfLoginRequired(uiState.error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {
|
private fun navigateToEpisodeBottomSheetFragment(episode: FindroidEpisode) {
|
||||||
findNavController().navigate(
|
findNavController().navigate(
|
||||||
SeasonFragmentDirections.actionSeasonFragmentToEpisodeBottomSheetFragment(
|
SeasonFragmentDirections.actionSeasonFragmentToEpisodeBottomSheetFragment(
|
||||||
episode.id
|
episode.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun navigateToPlayerActivity(
|
||||||
|
playerItems: Array<PlayerItem>,
|
||||||
|
) {
|
||||||
|
findNavController().navigate(
|
||||||
|
SeasonFragmentDirections.actionSeasonFragmentToPlayerActivity(
|
||||||
|
playerItems
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import androidx.preference.PreferenceFragmentCompat
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.AppPreferences
|
import dev.jdtech.jellyfin.AppPreferences
|
||||||
import dev.jdtech.jellyfin.core.R as CoreR
|
import dev.jdtech.jellyfin.core.R as CoreR
|
||||||
|
import dev.jdtech.jellyfin.utils.restart
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
@ -36,6 +37,11 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findPreference<Preference>("pref_offline_mode")?.setOnPreferenceClickListener {
|
||||||
|
activity?.restart()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
findPreference<Preference>("privacyPolicy")?.setOnPreferenceClickListener {
|
findPreference<Preference>("privacyPolicy")?.setOnPreferenceClickListener {
|
||||||
val intent = Intent(
|
val intent = Intent(
|
||||||
Intent.ACTION_VIEW,
|
Intent.ACTION_VIEW,
|
||||||
|
|
|
@ -0,0 +1,348 @@
|
||||||
|
package dev.jdtech.jellyfin.fragments
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import androidx.navigation.fragment.navArgs
|
||||||
|
import com.google.android.material.R as MaterialR
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import dev.jdtech.jellyfin.AppPreferences
|
||||||
|
import dev.jdtech.jellyfin.adapters.PersonListAdapter
|
||||||
|
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
|
||||||
|
import dev.jdtech.jellyfin.bindCardItemImage
|
||||||
|
import dev.jdtech.jellyfin.bindItemBackdropImage
|
||||||
|
import dev.jdtech.jellyfin.core.R as CoreR
|
||||||
|
import dev.jdtech.jellyfin.databinding.FragmentShowBinding
|
||||||
|
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidSeason
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidSourceType
|
||||||
|
import dev.jdtech.jellyfin.models.PlayerItem
|
||||||
|
import dev.jdtech.jellyfin.models.isDownloaded
|
||||||
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
|
import dev.jdtech.jellyfin.utils.setTintColor
|
||||||
|
import dev.jdtech.jellyfin.utils.setTintColorAttribute
|
||||||
|
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||||
|
import dev.jdtech.jellyfin.viewmodels.ShowViewModel
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ShowFragment : Fragment() {
|
||||||
|
|
||||||
|
private lateinit var binding: FragmentShowBinding
|
||||||
|
private val viewModel: ShowViewModel by viewModels()
|
||||||
|
private val playerViewModel: PlayerViewModel by viewModels()
|
||||||
|
private val args: ShowFragmentArgs by navArgs()
|
||||||
|
|
||||||
|
private lateinit var errorDialog: ErrorDialogFragment
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var appPreferences: AppPreferences
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
binding = FragmentShowBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
launch {
|
||||||
|
viewModel.uiState.collect { uiState ->
|
||||||
|
Timber.d("$uiState")
|
||||||
|
when (uiState) {
|
||||||
|
is ShowViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
||||||
|
is ShowViewModel.UiState.Loading -> bindUiStateLoading()
|
||||||
|
is ShowViewModel.UiState.Error -> bindUiStateError(uiState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
launch {
|
||||||
|
viewModel.navigateBack.collect {
|
||||||
|
if (it) findNavController().navigateUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO make download button work for shows
|
||||||
|
binding.itemActions.downloadButton.visibility = View.GONE
|
||||||
|
|
||||||
|
binding.errorLayout.errorRetryButton.setOnClickListener {
|
||||||
|
viewModel.loadData(args.itemId, args.offline)
|
||||||
|
}
|
||||||
|
|
||||||
|
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
|
||||||
|
when (playerItems) {
|
||||||
|
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
|
||||||
|
is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.itemActions.trailerButton.setOnClickListener {
|
||||||
|
viewModel.item.trailer.let { trailerUri ->
|
||||||
|
val intent = Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
Uri.parse(trailerUri)
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Toast.makeText(requireContext(), e.localizedMessage, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.nextUp.setOnClickListener {
|
||||||
|
navigateToEpisodeBottomSheetFragment(viewModel.nextUp!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.seasonsRecyclerView.adapter =
|
||||||
|
ViewItemListAdapter(
|
||||||
|
ViewItemListAdapter.OnClickListener { season ->
|
||||||
|
if (season is FindroidSeason) navigateToSeasonFragment(season)
|
||||||
|
},
|
||||||
|
fixedWidth = true
|
||||||
|
)
|
||||||
|
binding.peopleRecyclerView.adapter = PersonListAdapter { person ->
|
||||||
|
navigateToPersonDetail(person.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.itemActions.playButton.setOnClickListener {
|
||||||
|
binding.itemActions.playButton.setImageResource(android.R.color.transparent)
|
||||||
|
binding.itemActions.progressCircular.isVisible = true
|
||||||
|
playerViewModel.loadPlayerItems(viewModel.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
||||||
|
errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.itemActions.checkButton.setOnClickListener {
|
||||||
|
val played = viewModel.togglePlayed()
|
||||||
|
bindCheckButtonState(played)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.itemActions.favoriteButton.setOnClickListener {
|
||||||
|
val favorite = viewModel.toggleFavorite()
|
||||||
|
bindFavoriteButtonState(favorite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
|
||||||
|
viewModel.loadData(args.itemId, args.offline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindUiStateNormal(uiState: ShowViewModel.UiState.Normal) {
|
||||||
|
uiState.apply {
|
||||||
|
val downloaded = item.isDownloaded()
|
||||||
|
val canDownload = item.canDownload && item.sources.any { it.type == FindroidSourceType.REMOTE }
|
||||||
|
|
||||||
|
binding.originalTitle.isVisible = item.originalTitle != item.name
|
||||||
|
if (item.trailer != null) {
|
||||||
|
binding.itemActions.trailerButton.isVisible = true
|
||||||
|
}
|
||||||
|
binding.communityRating.isVisible = item.communityRating != null
|
||||||
|
binding.actors.isVisible = actors.isNotEmpty()
|
||||||
|
|
||||||
|
val canPlay = item.canPlay /*&& item.sources.isNotEmpty()*/ // TODO currently the sources of a show is always empty, we need a way to check if sources are available
|
||||||
|
binding.itemActions.playButton.isEnabled = canPlay
|
||||||
|
binding.itemActions.playButton.alpha = if (!canPlay) 0.5F else 1.0F
|
||||||
|
|
||||||
|
bindCheckButtonState(item.played)
|
||||||
|
|
||||||
|
bindFavoriteButtonState(item.favorite)
|
||||||
|
|
||||||
|
when (canDownload) {
|
||||||
|
true -> {
|
||||||
|
binding.itemActions.downloadButton.isVisible = true
|
||||||
|
binding.itemActions.downloadButton.isEnabled = !downloaded
|
||||||
|
|
||||||
|
if (downloaded) binding.itemActions.downloadButton.setTintColor(
|
||||||
|
CoreR.color.red,
|
||||||
|
requireActivity().theme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
false -> {
|
||||||
|
binding.itemActions.downloadButton.isVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.name.text = item.name
|
||||||
|
binding.originalTitle.text = item.originalTitle
|
||||||
|
if (dateString.isEmpty()) {
|
||||||
|
binding.year.isVisible = false
|
||||||
|
} else {
|
||||||
|
binding.year.text = dateString
|
||||||
|
}
|
||||||
|
if (runTime.isEmpty()) {
|
||||||
|
binding.playtime.isVisible = false
|
||||||
|
} else {
|
||||||
|
binding.playtime.text = runTime
|
||||||
|
}
|
||||||
|
binding.officialRating.text = item.officialRating
|
||||||
|
binding.communityRating.text = item.communityRating.toString()
|
||||||
|
binding.genresLayout.isVisible = item.genres.isNotEmpty()
|
||||||
|
binding.genres.text = genresString
|
||||||
|
binding.videoMeta.text = videoString
|
||||||
|
binding.audio.text = audioString
|
||||||
|
binding.subtitles.text = subtitleString
|
||||||
|
|
||||||
|
if (appPreferences.displayExtraInfo) {
|
||||||
|
binding.subtitlesLayout.isVisible = subtitleString.isNotEmpty()
|
||||||
|
binding.videoMetaLayout.isVisible = videoString.isNotEmpty()
|
||||||
|
binding.audioLayout.isVisible = audioString.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.directorLayout.isVisible = director != null
|
||||||
|
binding.director.text = director?.name
|
||||||
|
binding.writersLayout.isVisible = writers.isNotEmpty()
|
||||||
|
binding.writers.text = writersString
|
||||||
|
binding.description.text = item.overview
|
||||||
|
binding.nextUpLayout.isVisible = nextUp != null
|
||||||
|
binding.nextUpName.text = getString(
|
||||||
|
CoreR.string.episode_name_extended,
|
||||||
|
nextUp?.parentIndexNumber,
|
||||||
|
nextUp?.indexNumber,
|
||||||
|
nextUp?.name
|
||||||
|
)
|
||||||
|
binding.seasonsLayout.isVisible = seasons.isNotEmpty()
|
||||||
|
val seasonsAdapter = binding.seasonsRecyclerView.adapter as ViewItemListAdapter
|
||||||
|
seasonsAdapter.submitList(seasons)
|
||||||
|
val actorsAdapter = binding.peopleRecyclerView.adapter as PersonListAdapter
|
||||||
|
actorsAdapter.submitList(actors)
|
||||||
|
bindItemBackdropImage(binding.itemBanner, item)
|
||||||
|
if (nextUp != null) bindCardItemImage(binding.nextUpImage, nextUp!!)
|
||||||
|
}
|
||||||
|
binding.loadingIndicator.isVisible = false
|
||||||
|
binding.mediaInfoScrollview.isVisible = true
|
||||||
|
binding.errorLayout.errorPanel.isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindUiStateLoading() {
|
||||||
|
binding.loadingIndicator.isVisible = true
|
||||||
|
binding.errorLayout.errorPanel.isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindUiStateError(uiState: ShowViewModel.UiState.Error) {
|
||||||
|
errorDialog = ErrorDialogFragment.newInstance(uiState.error)
|
||||||
|
binding.loadingIndicator.isVisible = false
|
||||||
|
binding.mediaInfoScrollview.isVisible = false
|
||||||
|
binding.errorLayout.errorPanel.isVisible = true
|
||||||
|
checkIfLoginRequired(uiState.error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindCheckButtonState(played: Boolean) {
|
||||||
|
when (played) {
|
||||||
|
true -> binding.itemActions.checkButton.setTintColor(CoreR.color.red, requireActivity().theme)
|
||||||
|
false -> binding.itemActions.checkButton.setTintColorAttribute(
|
||||||
|
MaterialR.attr.colorOnSecondaryContainer,
|
||||||
|
requireActivity().theme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindFavoriteButtonState(favorite: Boolean) {
|
||||||
|
val favoriteDrawable = when (favorite) {
|
||||||
|
true -> CoreR.drawable.ic_heart_filled
|
||||||
|
false -> CoreR.drawable.ic_heart
|
||||||
|
}
|
||||||
|
binding.itemActions.favoriteButton.setImageResource(favoriteDrawable)
|
||||||
|
when (favorite) {
|
||||||
|
true -> binding.itemActions.favoriteButton.setTintColor(CoreR.color.red, requireActivity().theme)
|
||||||
|
false -> binding.itemActions.favoriteButton.setTintColorAttribute(
|
||||||
|
MaterialR.attr.colorOnSecondaryContainer,
|
||||||
|
requireActivity().theme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
|
||||||
|
navigateToPlayerActivity(items.items.toTypedArray())
|
||||||
|
binding.itemActions.playButton.setImageDrawable(
|
||||||
|
ContextCompat.getDrawable(
|
||||||
|
requireActivity(),
|
||||||
|
CoreR.drawable.ic_play
|
||||||
|
)
|
||||||
|
)
|
||||||
|
binding.itemActions.progressCircular.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
|
||||||
|
Timber.e(error.error.message)
|
||||||
|
binding.playerItemsError.visibility = View.VISIBLE
|
||||||
|
binding.itemActions.playButton.setImageDrawable(
|
||||||
|
ContextCompat.getDrawable(
|
||||||
|
requireActivity(),
|
||||||
|
CoreR.drawable.ic_play
|
||||||
|
)
|
||||||
|
)
|
||||||
|
binding.itemActions.progressCircular.visibility = View.INVISIBLE
|
||||||
|
binding.playerItemsErrorDetails.setOnClickListener {
|
||||||
|
ErrorDialogFragment.newInstance(error.error)
|
||||||
|
.show(parentFragmentManager, ErrorDialogFragment.TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToEpisodeBottomSheetFragment(episode: FindroidItem) {
|
||||||
|
findNavController().navigate(
|
||||||
|
ShowFragmentDirections.actionShowFragmentToEpisodeBottomSheetFragment(
|
||||||
|
episode.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToSeasonFragment(season: FindroidSeason) {
|
||||||
|
findNavController().navigate(
|
||||||
|
ShowFragmentDirections.actionShowFragmentToSeasonFragment(
|
||||||
|
season.seriesId,
|
||||||
|
season.id,
|
||||||
|
season.seriesName,
|
||||||
|
season.name,
|
||||||
|
args.offline
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToPlayerActivity(
|
||||||
|
playerItems: Array<PlayerItem>,
|
||||||
|
) {
|
||||||
|
findNavController().navigate(
|
||||||
|
ShowFragmentDirections.actionShowFragmentToPlayerActivity(
|
||||||
|
playerItems
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToPersonDetail(personId: UUID) {
|
||||||
|
findNavController().navigate(
|
||||||
|
ShowFragmentDirections.actionShowFragmentToPersonDetailFragment(personId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,7 +22,7 @@
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
tools:context=".fragments.MediaInfoFragment">
|
tools:context=".fragments.ShowFragment">
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
@ -114,78 +114,6 @@
|
||||||
tools:text="7.3" />
|
tools:text="7.3" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<com.google.android.material.chip.ChipGroup
|
|
||||||
android:id="@+id/video_meta_chips"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="start"
|
|
||||||
android:layout_marginHorizontal="24dp"
|
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:singleLine="true"
|
|
||||||
tools:visibility="visible">
|
|
||||||
|
|
||||||
<com.google.android.material.chip.Chip
|
|
||||||
android:id="@+id/res_chip"
|
|
||||||
style="@style/MetaChip"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/temp"
|
|
||||||
android:visibility="gone"
|
|
||||||
tools:text="4K"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
<com.google.android.material.chip.Chip
|
|
||||||
android:id="@+id/video_profile_chip"
|
|
||||||
style="@style/MetaChip"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/temp"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:chipIcon="@drawable/ic_dolby"
|
|
||||||
app:chipIconSize="0dp"
|
|
||||||
app:chipIconTint="?attr/colorOnPrimarySurface"
|
|
||||||
app:chipIconVisible="false"
|
|
||||||
app:chipStartPadding="8dp"
|
|
||||||
tools:text="Vision"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
<com.google.android.material.chip.Chip
|
|
||||||
android:id="@+id/audio_codec_chip"
|
|
||||||
style="@style/MetaChip"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/temp"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:chipIcon="@drawable/ic_dolby"
|
|
||||||
app:chipIconSize="0dp"
|
|
||||||
app:chipIconTint="?attr/colorOnBackground"
|
|
||||||
app:chipIconVisible="true"
|
|
||||||
app:chipStartPadding="8dp"
|
|
||||||
tools:text="ATMOS"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
<com.google.android.material.chip.Chip
|
|
||||||
android:id="@+id/audio_channel_chip"
|
|
||||||
style="@style/MetaChip"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/temp"
|
|
||||||
android:visibility="gone"
|
|
||||||
tools:text="5.1"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
<com.google.android.material.chip.Chip
|
|
||||||
android:id="@+id/subs_chip"
|
|
||||||
style="@style/MetaChip"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/subtitle_chip_text"
|
|
||||||
android:visibility="gone"
|
|
||||||
tools:text="CC"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
</com.google.android.material.chip.ChipGroup>
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
@ -199,97 +127,13 @@
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<LinearLayout
|
<include
|
||||||
|
android:id="@+id/item_actions"
|
||||||
|
layout="@layout/item_actions"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="24dp"
|
android:layout_marginHorizontal="24dp"
|
||||||
android:layout_marginBottom="24dp">
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
<RelativeLayout
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp">
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/play_button"
|
|
||||||
android:layout_width="72dp"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:background="@drawable/button_setup_background"
|
|
||||||
android:contentDescription="@string/play_button_description"
|
|
||||||
android:foreground="@drawable/ripple_background"
|
|
||||||
android:paddingHorizontal="24dp"
|
|
||||||
android:paddingVertical="12dp"
|
|
||||||
android:src="@drawable/ic_play"
|
|
||||||
app:tint="?attr/colorOnPrimary" />
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
android:id="@+id/progress_circular"
|
|
||||||
android:layout_width="48dp"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:layout_centerHorizontal="true"
|
|
||||||
android:elevation="8dp"
|
|
||||||
android:indeterminateTint="?attr/colorOnPrimary"
|
|
||||||
android:padding="8dp"
|
|
||||||
android:visibility="invisible" />
|
|
||||||
</RelativeLayout>
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/trailer_button"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:background="@drawable/button_accent_background"
|
|
||||||
android:contentDescription="@string/trailer_button_description"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:src="@drawable/ic_film"
|
|
||||||
app:tint="?attr/colorOnSecondaryContainer" />
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/check_button"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:background="@drawable/button_accent_background"
|
|
||||||
android:contentDescription="@string/check_button_description"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:src="@drawable/ic_check"
|
|
||||||
app:tint="?attr/colorOnSecondaryContainer" />
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/favorite_button"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:background="@drawable/button_accent_background"
|
|
||||||
android:contentDescription="@string/download_button_description"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:src="@drawable/ic_heart"
|
|
||||||
app:tint="?attr/colorOnSecondaryContainer" />
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/download_button"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:background="@drawable/button_accent_background"
|
|
||||||
android:contentDescription="@string/download_button_description"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:src="@drawable/ic_download"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:tint="?attr/colorOnSecondaryContainer" />
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/delete_button"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:background="@drawable/button_accent_background"
|
|
||||||
android:contentDescription="@string/delete_button_description"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:src="@drawable/ic_trash"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:tint="?attr/colorOnSecondaryContainer" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/player_items_error"
|
android:id="@+id/player_items_error"
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
<variable
|
<variable
|
||||||
name="item"
|
name="item"
|
||||||
type="org.jellyfin.sdk.model.api.BaseItemDto" />
|
type="dev.jdtech.jellyfin.models.FindroidItem" />
|
||||||
</data>
|
</data>
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
@ -47,34 +47,57 @@
|
||||||
app:layout_constraintTop_toBottomOf="@id/item_image"
|
app:layout_constraintTop_toBottomOf="@id/item_image"
|
||||||
tools:text="Movie title" />
|
tools:text="Movie title" />
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/item_count"
|
android:layout_width="wrap_content"
|
||||||
android:layout_width="24dp"
|
android:layout_height="wrap_content"
|
||||||
android:layout_height="24dp"
|
android:orientation="horizontal"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_marginEnd="8dp"
|
||||||
android:background="@drawable/circle_background"
|
|
||||||
android:gravity="center"
|
|
||||||
android:text="@{item.userData.unplayedItemCount.toString()}"
|
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
|
||||||
android:textColor="?attr/colorOnPrimary"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
tools:text="9" />
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/played_icon"
|
|
||||||
android:layout_width="24dp"
|
|
||||||
android:layout_height="24dp"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:layout_marginEnd="8dp"
|
|
||||||
android:background="@drawable/circle_background"
|
|
||||||
android:contentDescription="@string/episode_watched_indicator"
|
|
||||||
android:padding="4dp"
|
|
||||||
android:src="@drawable/ic_check"
|
|
||||||
android:visibility="@{item.userData.played == true ? View.VISIBLE : View.GONE}"
|
|
||||||
app:layout_constraintEnd_toEndOf="@id/item_image"
|
app:layout_constraintEnd_toEndOf="@id/item_image"
|
||||||
app:layout_constraintTop_toTopOf="@id/item_image"
|
app:layout_constraintTop_toTopOf="@id/item_image">
|
||||||
app:tint="?attr/colorOnPrimary" />
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/downloaded_icon"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:background="@drawable/circle_background"
|
||||||
|
android:contentDescription="@string/downloaded_indicator"
|
||||||
|
android:padding="4dp"
|
||||||
|
android:src="@drawable/ic_download"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:tint="?attr/colorOnPrimary"
|
||||||
|
tools:visibility="visible"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/item_count"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:background="@drawable/circle_background"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@{item.unplayedItemCount.toString()}"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:textColor="?attr/colorOnPrimary"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:text="9" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/played_icon"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:background="@drawable/circle_background"
|
||||||
|
android:contentDescription="@string/episode_watched_indicator"
|
||||||
|
android:padding="4dp"
|
||||||
|
android:src="@drawable/ic_check"
|
||||||
|
android:visibility="@{item.played == true ? View.VISIBLE : View.GONE}"
|
||||||
|
app:layout_constraintEnd_toEndOf="@id/item_image"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/item_image"
|
||||||
|
app:tint="?attr/colorOnPrimary" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
</layout>
|
</layout>
|
47
app/phone/src/main/res/layout/card_offline.xml
Normal file
47
app/phone/src/main/res/layout/card_offline.xml
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="24dp"
|
||||||
|
android:layout_marginBottom="24dp"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="16dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/offline_icon"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
android:src="@drawable/ic_server_off"
|
||||||
|
android:contentDescription="@string/offline_mode_icon" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/offline_icon"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/offline_mode"/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/online_button"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/offline_mode_go_online"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"/>
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<variable
|
<variable
|
||||||
name="collection"
|
name="collection"
|
||||||
type="org.jellyfin.sdk.model.api.BaseItemDto" />
|
type="dev.jdtech.jellyfin.models.FindroidCollection" />
|
||||||
</data>
|
</data>
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
@ -26,7 +26,7 @@
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:adjustViewBounds="true"
|
android:adjustViewBounds="true"
|
||||||
android:scaleType="centerCrop"
|
android:scaleType="centerCrop"
|
||||||
app:baseItemImage="@{collection}"
|
app:cardItemImage="@{collection}"
|
||||||
app:layout_constraintDimensionRatio="H,16:9"
|
app:layout_constraintDimensionRatio="H,16:9"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
|
|
@ -58,6 +58,22 @@
|
||||||
tools:ignore="HardcodedText" />
|
tools:ignore="HardcodedText" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/downloaded_icon"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:background="@drawable/circle_background"
|
||||||
|
android:contentDescription="@string/downloaded_indicator"
|
||||||
|
android:padding="4dp"
|
||||||
|
android:src="@drawable/ic_download"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintEnd_toEndOf="@id/episode_image"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/episode_image"
|
||||||
|
app:tint="?attr/colorOnPrimary"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/progress_bar"
|
android:id="@+id/progress_bar"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
|
@ -145,108 +161,18 @@
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<include
|
||||||
android:id="@+id/buttons"
|
android:id="@+id/item_actions"
|
||||||
android:layout_width="0dp"
|
layout="@layout/item_actions"
|
||||||
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="24dp"
|
android:layout_marginHorizontal="24dp"
|
||||||
android:layout_marginTop="12dp"
|
android:layout_marginTop="12dp"
|
||||||
android:layout_marginBottom="24dp"
|
android:layout_marginBottom="24dp"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/episode_metadata">
|
app:layout_constraintTop_toBottomOf="@id/episode_metadata" />
|
||||||
|
|
||||||
<RelativeLayout
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp">
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/play_button"
|
|
||||||
android:layout_width="72dp"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:background="@drawable/button_setup_background"
|
|
||||||
android:contentDescription="@string/play_button_description"
|
|
||||||
android:foreground="@drawable/ripple_background"
|
|
||||||
android:paddingHorizontal="24dp"
|
|
||||||
android:paddingVertical="12dp"
|
|
||||||
android:src="@drawable/ic_play"
|
|
||||||
app:tint="?attr/colorOnPrimary" />
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
android:id="@+id/progress_circular"
|
|
||||||
android:layout_width="48dp"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:layout_centerHorizontal="true"
|
|
||||||
android:elevation="8dp"
|
|
||||||
android:indeterminateTint="?attr/colorOnPrimary"
|
|
||||||
android:padding="8dp"
|
|
||||||
android:visibility="invisible" />
|
|
||||||
</RelativeLayout>
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/check_button"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:background="@drawable/button_accent_background"
|
|
||||||
android:contentDescription="@string/check_button_description"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:src="@drawable/ic_check"
|
|
||||||
app:tint="?attr/colorOnSecondaryContainer" />
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/favorite_button"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:background="@drawable/button_accent_background"
|
|
||||||
android:contentDescription="@string/favorite_button_description"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:src="@drawable/ic_heart"
|
|
||||||
app:tint="?attr/colorOnSecondaryContainer" />
|
|
||||||
|
|
||||||
<RelativeLayout
|
|
||||||
android:id="@+id/download_button_wrapper"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:visibility="gone">
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/download_button"
|
|
||||||
android:layout_width="48dp"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:background="@drawable/button_accent_background"
|
|
||||||
android:contentDescription="@string/download_button_description"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:src="@drawable/ic_download"
|
|
||||||
app:tint="?attr/colorOnSecondaryContainer" />
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
android:id="@+id/progress_download"
|
|
||||||
android:layout_width="48dp"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:layout_centerHorizontal="true"
|
|
||||||
android:elevation="8dp"
|
|
||||||
android:indeterminateTint="@android:color/white"
|
|
||||||
android:padding="8dp"
|
|
||||||
android:visibility="invisible" />
|
|
||||||
</RelativeLayout>
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/delete_button"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:background="@drawable/button_accent_background"
|
|
||||||
android:contentDescription="@string/delete_button_description"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:src="@drawable/ic_trash"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:tint="?attr/colorOnSecondaryContainer"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/player_items_error"
|
android:id="@+id/player_items_error"
|
||||||
|
@ -255,11 +181,11 @@
|
||||||
android:layout_marginHorizontal="24dp"
|
android:layout_marginHorizontal="24dp"
|
||||||
android:layout_marginTop="12dp"
|
android:layout_marginTop="12dp"
|
||||||
android:layout_marginBottom="12dp"
|
android:layout_marginBottom="12dp"
|
||||||
android:orientation="horizontal"
|
android:orientation="vertical"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/buttons"
|
app:layout_constraintTop_toBottomOf="@id/item_actions"
|
||||||
tools:visibility="visible">
|
tools:visibility="visible">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|
|
@ -7,11 +7,9 @@
|
||||||
|
|
||||||
<import type="android.view.View" />
|
<import type="android.view.View" />
|
||||||
|
|
||||||
<import type="org.jellyfin.sdk.model.api.LocationType" />
|
|
||||||
|
|
||||||
<variable
|
<variable
|
||||||
name="episode"
|
name="episode"
|
||||||
type="org.jellyfin.sdk.model.api.BaseItemDto" />
|
type="dev.jdtech.jellyfin.models.FindroidEpisode" />
|
||||||
</data>
|
</data>
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
@ -28,47 +26,73 @@
|
||||||
android:layout_width="100dp"
|
android:layout_width="100dp"
|
||||||
android:layout_height="100dp"
|
android:layout_height="100dp"
|
||||||
android:scaleType="centerCrop"
|
android:scaleType="centerCrop"
|
||||||
app:baseItemImage="@{episode}"
|
app:cardItemImage="@{episode}"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Findroid.Image" />
|
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Findroid.Image" />
|
||||||
|
|
||||||
<ImageView
|
<LinearLayout
|
||||||
android:id="@+id/played_icon"
|
android:layout_width="wrap_content"
|
||||||
android:layout_width="24dp"
|
android:layout_height="wrap_content"
|
||||||
android:layout_height="24dp"
|
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_marginEnd="8dp"
|
||||||
android:background="@drawable/circle_background"
|
android:orientation="horizontal"
|
||||||
android:contentDescription="@string/episode_watched_indicator"
|
|
||||||
android:padding="4dp"
|
|
||||||
android:src="@drawable/ic_check"
|
|
||||||
android:visibility="@{episode.userData.played == true ? View.VISIBLE : View.GONE}"
|
|
||||||
app:layout_constraintEnd_toEndOf="@id/episode_image"
|
app:layout_constraintEnd_toEndOf="@id/episode_image"
|
||||||
app:layout_constraintTop_toTopOf="@id/episode_image"
|
|
||||||
app:tint="?attr/colorOnPrimary" />
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:id="@+id/missing_icon"
|
|
||||||
android:layout_width="24dp"
|
|
||||||
android:layout_height="24dp"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:layout_marginEnd="8dp"
|
|
||||||
android:background="@drawable/circle_background"
|
|
||||||
android:backgroundTint="?attr/colorError"
|
|
||||||
android:visibility="@{episode.locationType == LocationType.VIRTUAL ? View.VISIBLE : View.GONE}"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/played_icon"
|
|
||||||
app:layout_constraintTop_toTopOf="@id/episode_image">
|
app:layout_constraintTop_toTopOf="@id/episode_image">
|
||||||
|
|
||||||
<TextView
|
<ImageView
|
||||||
android:layout_width="match_parent"
|
android:id="@+id/downloaded_icon"
|
||||||
android:layout_height="match_parent"
|
android:layout_width="24dp"
|
||||||
android:gravity="center"
|
android:layout_height="24dp"
|
||||||
android:text="M"
|
android:layout_marginStart="8dp"
|
||||||
android:textColor="@android:color/white"
|
android:background="@drawable/circle_background"
|
||||||
tools:ignore="HardcodedText" />
|
android:contentDescription="@string/downloaded_indicator"
|
||||||
</FrameLayout>
|
android:padding="4dp"
|
||||||
|
android:src="@drawable/ic_download"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintEnd_toEndOf="@id/episode_image"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/episode_image"
|
||||||
|
app:tint="?attr/colorOnPrimary"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/missing_icon"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:background="@drawable/circle_background"
|
||||||
|
android:backgroundTint="?attr/colorError"
|
||||||
|
android:visibility="@{episode.missing ? View.VISIBLE : View.GONE}"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/played_icon"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/episode_image"
|
||||||
|
tools:visibility="visible">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="M"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
tools:ignore="HardcodedText" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/played_icon"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:background="@drawable/circle_background"
|
||||||
|
android:contentDescription="@string/episode_watched_indicator"
|
||||||
|
android:padding="4dp"
|
||||||
|
android:src="@drawable/ic_check"
|
||||||
|
android:visibility="@{episode.played ? View.VISIBLE : View.GONE}"
|
||||||
|
app:layout_constraintEnd_toEndOf="@id/episode_image"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/episode_image"
|
||||||
|
app:tint="?attr/colorOnPrimary" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/progress_bar"
|
android:id="@+id/progress_bar"
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
android:id="@+id/error_panel"
|
android:id="@+id/error_panel"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
@ -16,7 +17,6 @@
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center_horizontal"
|
|
||||||
android:layout_marginBottom="4dp"
|
android:layout_marginBottom="4dp"
|
||||||
android:importantForAccessibility="no"
|
android:importantForAccessibility="no"
|
||||||
android:src="@drawable/ic_alert_circle" />
|
android:src="@drawable/ic_alert_circle" />
|
||||||
|
@ -24,19 +24,17 @@
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center_horizontal"
|
|
||||||
android:text="@string/error_loading_data"
|
android:text="@string/error_loading_data"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
style="?android:attr/buttonBarStyle"
|
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/error_details_button"
|
android:id="@+id/error_details_button"
|
||||||
style="?android:attr/buttonBarButtonStyle"
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_marginEnd="8dp"
|
||||||
|
@ -44,7 +42,7 @@
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/error_retry_button"
|
android:id="@+id/error_retry_button"
|
||||||
style="?android:attr/buttonBarButtonStyle"
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/retry" />
|
android:text="@string/retry" />
|
||||||
|
|
|
@ -1,48 +0,0 @@
|
||||||
<?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"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent">
|
|
||||||
|
|
||||||
<include
|
|
||||||
android:id="@+id/error_layout"
|
|
||||||
layout="@layout/error_panel" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/no_downloads_text"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/no_downloads"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/downloads_recycler_view"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:clipToPadding="false"
|
|
||||||
android:paddingTop="16dp"
|
|
||||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintHorizontal_bias="0.0"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintVertical_bias="0.0"
|
|
||||||
tools:itemCount="4"
|
|
||||||
tools:listitem="@layout/download_section" />
|
|
||||||
|
|
||||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
|
||||||
android:id="@+id/loading_indicator"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:indeterminate="true"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
|
@ -1,36 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
tools:context=".fragments.SeasonFragment">
|
|
||||||
|
|
||||||
<data>
|
|
||||||
|
|
||||||
<variable
|
|
||||||
name="viewModel"
|
|
||||||
type="dev.jdtech.jellyfin.viewmodels.DownloadSeriesViewModel" />
|
|
||||||
|
|
||||||
</data>
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent">
|
|
||||||
|
|
||||||
<include
|
|
||||||
android:id="@+id/error_layout"
|
|
||||||
layout="@layout/error_panel" />
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/episodes_recycler_view"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:clipToPadding="false"
|
|
||||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
tools:itemCount="4"
|
|
||||||
tools:listitem="@layout/episode_item" />
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
</layout>
|
|
54
app/phone/src/main/res/layout/fragment_downloads.xml
Normal file
54
app/phone/src/main/res/layout/fragment_downloads.xml
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" >
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<include
|
||||||
|
android:id="@+id/error_layout"
|
||||||
|
layout="@layout/error_panel" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/no_downloads_text"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/no_downloads"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/downloads_recycler_view"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:paddingTop="16dp"
|
||||||
|
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintHorizontal_bias="0.0"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintVertical_bias="0.0"
|
||||||
|
tools:itemCount="4"
|
||||||
|
tools:listitem="@layout/favorite_section" />
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||||
|
android:id="@+id/loading_indicator"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:indeterminate="true"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
460
app/phone/src/main/res/layout/fragment_movie.xml
Normal file
460
app/phone/src/main/res/layout/fragment_movie.xml
Normal file
|
@ -0,0 +1,460 @@
|
||||||
|
<?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"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<include
|
||||||
|
android:id="@+id/error_layout"
|
||||||
|
layout="@layout/error_panel" />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:id="@+id/media_info_scrollview"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="200dp"
|
||||||
|
android:layout_marginBottom="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/item_banner"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="200dp"
|
||||||
|
android:scaleType="centerCrop"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:ignore="ContentDescription" />
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:background="@drawable/header_gradient"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/name"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="24dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.HeadlineMedium"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/original_title"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
tools:text="Alita: Battle Angel" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/original_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="24dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="24dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/year"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
tools:text="2019" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/playtime"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
tools:text="122 min" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/official_rating"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
tools:text="PG-13" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/community_rating"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:drawablePadding="4dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
app:drawableStartCompat="@drawable/ic_star"
|
||||||
|
app:drawableTint="@color/yellow"
|
||||||
|
tools:text="7.3" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<HorizontalScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:scrollbars="none">
|
||||||
|
|
||||||
|
<com.google.android.material.chip.ChipGroup
|
||||||
|
android:id="@+id/video_meta_chips"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="start"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:singleLine="true">
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/res_chip"
|
||||||
|
style="@style/MetaChip"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/temp"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:text="4K"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/video_profile_chip"
|
||||||
|
style="@style/MetaChip"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/temp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:chipIcon="@drawable/ic_dolby"
|
||||||
|
app:chipIconSize="0dp"
|
||||||
|
app:chipIconTint="?attr/colorOnPrimarySurface"
|
||||||
|
app:chipIconVisible="false"
|
||||||
|
app:chipStartPadding="8dp"
|
||||||
|
tools:text="Vision"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/audio_codec_chip"
|
||||||
|
style="@style/MetaChip"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/temp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:chipIcon="@drawable/ic_dolby"
|
||||||
|
app:chipIconSize="0dp"
|
||||||
|
app:chipIconTint="?attr/colorOnBackground"
|
||||||
|
app:chipIconVisible="true"
|
||||||
|
app:chipStartPadding="8dp"
|
||||||
|
tools:text="ATMOS"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/audio_channel_chip"
|
||||||
|
style="@style/MetaChip"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/temp"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:text="5.1"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/subs_chip"
|
||||||
|
style="@style/MetaChip"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/subtitle_chip_text"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:text="CC"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
</com.google.android.material.chip.ChipGroup>
|
||||||
|
|
||||||
|
</HorizontalScrollView>
|
||||||
|
|
||||||
|
<include
|
||||||
|
android:id="@+id/item_actions"
|
||||||
|
layout="@layout/item_actions"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="24dp"
|
||||||
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/player_items_error"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="24dp"
|
||||||
|
android:layout_marginTop="-12dp"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/player_items_error_text"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:text="@string/error_preparing_player_items"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:textColor="?attr/colorError" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/player_items_error_details"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/view_details_underlined"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:textColor="?attr/colorError" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/info"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="24dp"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:id="@+id/video_meta_layout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/video_meta_title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/video"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/video_meta"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="64dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:text="4K HEVC HDR" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:id="@+id/audio_layout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/audio_title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/audio"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/audio"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="64dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="3"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:text="AC-3 Eng 5.1, AC-3 iTA 5.1" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:id="@+id/subtitles_layout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="15dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/subtitles_title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/subtitle"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/subtitles"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="64dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="3"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:text="English - SUBRIP, SDH - English - SUBRIP" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/description"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="15dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
tools:text="An angel falls. A warrior rises. When Alita awakens with no memory of who she is in a future world she does not recognize, she is taken in by Ido, a compassionate doctor who realizes that somewhere in this abandoned cyborg shell is the heart and soul of a young woman with an extraordinary past." />
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:id="@+id/genres_layout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/genres_title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/genres"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/genres"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="64dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:text="Action, Science Fiction, Adventure" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:id="@+id/director_layout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/director_title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/director"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/director"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="64dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:text="Robert Rodriguez" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:id="@+id/writers_layout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/writers_title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/writers"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/writers"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="64dp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:text="James Cameron, Laeta Kalogridis, Yukito Kishiro" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/actors"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="24dp"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
android:text="@string/cast_amp_crew"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||||
|
android:textSize="18sp" />
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/people_recycler_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||||
|
tools:itemCount="3"
|
||||||
|
tools:listitem="@layout/person_item" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||||
|
android:id="@+id/loading_indicator"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:indeterminate="true"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -21,8 +21,7 @@
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical">
|
||||||
tools:context=".fragments.MediaInfoFragment">
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
@ -72,7 +71,8 @@
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="24dp">
|
android:layout_marginHorizontal="24dp"
|
||||||
|
android:layout_marginBottom="12dp">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/year"
|
android:id="@+id/year"
|
||||||
|
@ -114,170 +114,13 @@
|
||||||
tools:text="7.3" />
|
tools:text="7.3" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<com.google.android.material.chip.ChipGroup
|
<include
|
||||||
android:id="@+id/video_meta_chips"
|
android:id="@+id/item_actions"
|
||||||
android:layout_width="wrap_content"
|
layout="@layout/item_actions"
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="start"
|
|
||||||
android:layout_marginHorizontal="24dp"
|
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:singleLine="true"
|
|
||||||
tools:visibility="visible">
|
|
||||||
|
|
||||||
<com.google.android.material.chip.Chip
|
|
||||||
android:id="@+id/res_chip"
|
|
||||||
style="@style/MetaChip"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/temp"
|
|
||||||
android:visibility="gone"
|
|
||||||
tools:text="4K"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
<com.google.android.material.chip.Chip
|
|
||||||
android:id="@+id/video_profile_chip"
|
|
||||||
style="@style/MetaChip"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/temp"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:chipIcon="@drawable/ic_dolby"
|
|
||||||
app:chipIconSize="0dp"
|
|
||||||
app:chipIconTint="?attr/colorOnPrimarySurface"
|
|
||||||
app:chipIconVisible="false"
|
|
||||||
app:chipStartPadding="8dp"
|
|
||||||
tools:text="Vision"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
<com.google.android.material.chip.Chip
|
|
||||||
android:id="@+id/audio_codec_chip"
|
|
||||||
style="@style/MetaChip"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/temp"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:chipIcon="@drawable/ic_dolby"
|
|
||||||
app:chipIconSize="0dp"
|
|
||||||
app:chipIconTint="?attr/colorOnBackground"
|
|
||||||
app:chipIconVisible="true"
|
|
||||||
app:chipStartPadding="8dp"
|
|
||||||
tools:text="ATMOS"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
<com.google.android.material.chip.Chip
|
|
||||||
android:id="@+id/audio_channel_chip"
|
|
||||||
style="@style/MetaChip"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/temp"
|
|
||||||
android:visibility="gone"
|
|
||||||
tools:text="5.1"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
<com.google.android.material.chip.Chip
|
|
||||||
android:id="@+id/subs_chip"
|
|
||||||
style="@style/MetaChip"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/subtitle_chip_text"
|
|
||||||
android:visibility="gone"
|
|
||||||
tools:text="CC"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
</com.google.android.material.chip.ChipGroup>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="24dp"
|
android:layout_marginHorizontal="24dp"
|
||||||
android:layout_marginVertical="15dp">
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
<RelativeLayout
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp">
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/play_button"
|
|
||||||
android:layout_width="72dp"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:background="@drawable/button_setup_background"
|
|
||||||
android:contentDescription="@string/play_button_description"
|
|
||||||
android:foreground="@drawable/ripple_background"
|
|
||||||
android:paddingHorizontal="24dp"
|
|
||||||
android:paddingVertical="12dp"
|
|
||||||
android:src="@drawable/ic_play"
|
|
||||||
app:tint="?attr/colorOnPrimary" />
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
android:id="@+id/progress_circular"
|
|
||||||
android:layout_width="48dp"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:layout_centerHorizontal="true"
|
|
||||||
android:elevation="8dp"
|
|
||||||
android:indeterminateTint="?attr/colorOnPrimary"
|
|
||||||
android:padding="8dp"
|
|
||||||
android:visibility="invisible" />
|
|
||||||
</RelativeLayout>
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/trailer_button"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:background="@drawable/button_accent_background"
|
|
||||||
android:contentDescription="@string/trailer_button_description"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:src="@drawable/ic_film"
|
|
||||||
app:tint="?attr/colorOnSecondaryContainer" />
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/check_button"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:background="@drawable/button_accent_background"
|
|
||||||
android:contentDescription="@string/check_button_description"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:src="@drawable/ic_check"
|
|
||||||
app:tint="?attr/colorOnSecondaryContainer" />
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/favorite_button"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:background="@drawable/button_accent_background"
|
|
||||||
android:contentDescription="@string/download_button_description"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:src="@drawable/ic_heart"
|
|
||||||
app:tint="?attr/colorOnSecondaryContainer" />
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/download_button"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:background="@drawable/button_accent_background"
|
|
||||||
android:contentDescription="@string/download_button_description"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:src="@drawable/ic_download"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:tint="?attr/colorOnSecondaryContainer" />
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/delete_button"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:background="@drawable/button_accent_background"
|
|
||||||
android:contentDescription="@string/delete_button_description"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:src="@drawable/ic_trash"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:tint="?attr/colorOnSecondaryContainer" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/player_items_error"
|
android:id="@+id/player_items_error"
|
||||||
|
@ -286,7 +129,7 @@
|
||||||
android:layout_marginHorizontal="24dp"
|
android:layout_marginHorizontal="24dp"
|
||||||
android:layout_marginTop="-12dp"
|
android:layout_marginTop="-12dp"
|
||||||
android:layout_marginBottom="12dp"
|
android:layout_marginBottom="12dp"
|
||||||
android:orientation="horizontal"
|
android:orientation="vertical"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
tools:visibility="visible">
|
tools:visibility="visible">
|
||||||
|
|
|
@ -6,8 +6,8 @@
|
||||||
<data>
|
<data>
|
||||||
|
|
||||||
<variable
|
<variable
|
||||||
name="episode"
|
name="item"
|
||||||
type="org.jellyfin.sdk.model.api.BaseItemDto" />
|
type="dev.jdtech.jellyfin.models.FindroidItem" />
|
||||||
</data>
|
</data>
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
@ -23,7 +23,7 @@
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:scaleType="centerCrop"
|
android:scaleType="centerCrop"
|
||||||
app:baseItemImage="@{episode}"
|
app:cardItemImage="@{item}"
|
||||||
app:layout_constraintDimensionRatio="H,16:9"
|
app:layout_constraintDimensionRatio="H,16:9"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
@ -49,13 +49,28 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:text="@{String.format(@string/episode_name_extended, episode.parentIndexNumber, episode.indexNumber, episode.name)}"
|
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/primary_name"
|
app:layout_constraintTop_toBottomOf="@id/primary_name"
|
||||||
tools:text="The Girl Flautist" />
|
tools:text="The Girl Flautist" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/downloaded_icon"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:background="@drawable/circle_background"
|
||||||
|
android:contentDescription="@string/downloaded_indicator"
|
||||||
|
android:padding="4dp"
|
||||||
|
android:src="@drawable/ic_download"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintEnd_toEndOf="@id/episode_image"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/episode_image"
|
||||||
|
app:tint="?attr/colorOnPrimary"
|
||||||
|
tools:visibility="visible"/>
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/progress_bar"
|
android:id="@+id/progress_bar"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
|
|
104
app/phone/src/main/res/layout/item_actions.xml
Normal file
104
app/phone/src/main/res/layout/item_actions.xml
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="24dp"
|
||||||
|
android:layout_marginBottom="24dp">
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="12dp">
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/play_button"
|
||||||
|
android:layout_width="72dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:background="@drawable/button_setup_background"
|
||||||
|
android:contentDescription="@string/play_button_description"
|
||||||
|
android:foreground="@drawable/ripple_background"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingVertical="12dp"
|
||||||
|
android:src="@drawable/ic_play"
|
||||||
|
app:tint="?attr/colorOnPrimary" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progress_circular"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_centerHorizontal="true"
|
||||||
|
android:elevation="8dp"
|
||||||
|
android:indeterminateTint="?attr/colorOnPrimary"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:visibility="invisible" />
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/trailer_button"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:background="@drawable/button_accent_background"
|
||||||
|
android:contentDescription="@string/trailer_button_description"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:src="@drawable/ic_film"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible"
|
||||||
|
app:tint="?attr/colorOnSecondaryContainer" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/check_button"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:background="@drawable/button_accent_background"
|
||||||
|
android:contentDescription="@string/check_button_description"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:src="@drawable/ic_check"
|
||||||
|
app:tint="?attr/colorOnSecondaryContainer" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/favorite_button"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:background="@drawable/button_accent_background"
|
||||||
|
android:contentDescription="@string/favorite_button_description"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:src="@drawable/ic_heart"
|
||||||
|
app:tint="?attr/colorOnSecondaryContainer" />
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="12dp">
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/download_button"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:background="@drawable/button_accent_background"
|
||||||
|
android:contentDescription="@string/download_button_description"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:src="@drawable/ic_download"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:tint="?attr/colorOnSecondaryContainer"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||||
|
android:id="@+id/progress_download"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_centerHorizontal="true"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:elevation="8dp"
|
||||||
|
android:progress="0"
|
||||||
|
android:progressTint="?attr/colorOnSecondary"
|
||||||
|
android:visibility="invisible"
|
||||||
|
app:indicatorSize="32dp"
|
||||||
|
app:trackCornerRadius="2dp" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
</LinearLayout>
|
|
@ -22,7 +22,6 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="24dp"
|
android:layout_marginStart="24dp"
|
||||||
android:layout_marginBottom="12dp"
|
android:layout_marginBottom="12dp"
|
||||||
android:text="@{section.name}"
|
|
||||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||||
android:textSize="18sp"
|
android:textSize="18sp"
|
||||||
tools:text="Next Up" />
|
tools:text="Next Up" />
|
||||||
|
|
13
app/phone/src/main/res/layout/preparing_download_dialog.xml
Normal file
13
app/phone/src/main/res/layout/preparing_download_dialog.xml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<?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="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="24dp">
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:indeterminate="true" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
|
@ -19,7 +19,7 @@
|
||||||
android:id="@+id/header"
|
android:id="@+id/header"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="200dp"
|
android:layout_height="200dp"
|
||||||
android:layout_marginBottom="24dp"
|
android:layout_marginBottom="16dp"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent">
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
|
@ -18,8 +18,15 @@
|
||||||
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
||||||
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
|
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_navigation_home_to_mediaInfoFragment"
|
android:id="@+id/action_navigation_home_to_showFragment"
|
||||||
app:destination="@id/mediaInfoFragment"
|
app:destination="@id/showFragment"
|
||||||
|
app:enterAnim="@anim/nav_default_enter_anim"
|
||||||
|
app:exitAnim="@anim/nav_default_exit_anim"
|
||||||
|
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
||||||
|
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_navigation_home_to_movieFragment"
|
||||||
|
app:destination="@id/movieFragment"
|
||||||
app:enterAnim="@anim/nav_default_enter_anim"
|
app:enterAnim="@anim/nav_default_enter_anim"
|
||||||
app:exitAnim="@anim/nav_default_exit_anim"
|
app:exitAnim="@anim/nav_default_exit_anim"
|
||||||
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
||||||
|
@ -80,8 +87,7 @@
|
||||||
</fragment>
|
</fragment>
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/settingsFragment"
|
android:id="@+id/settingsFragment"
|
||||||
android:name="dev.jdtech.jellyfin.fragments.SettingsFragment">
|
android:name="dev.jdtech.jellyfin.fragments.SettingsFragment" />
|
||||||
</fragment>
|
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/libraryFragment"
|
android:id="@+id/libraryFragment"
|
||||||
android:name="dev.jdtech.jellyfin.fragments.LibraryFragment"
|
android:name="dev.jdtech.jellyfin.fragments.LibraryFragment"
|
||||||
|
@ -96,8 +102,15 @@
|
||||||
app:argType="string"
|
app:argType="string"
|
||||||
app:nullable="true" />
|
app:nullable="true" />
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_libraryFragment_to_mediaInfoFragment"
|
android:id="@+id/action_libraryFragment_to_showFragment"
|
||||||
app:destination="@id/mediaInfoFragment"
|
app:destination="@id/showFragment"
|
||||||
|
app:enterAnim="@anim/nav_default_enter_anim"
|
||||||
|
app:exitAnim="@anim/nav_default_exit_anim"
|
||||||
|
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
||||||
|
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_libraryFragment_to_movieFragment"
|
||||||
|
app:destination="@id/movieFragment"
|
||||||
app:enterAnim="@anim/nav_default_enter_anim"
|
app:enterAnim="@anim/nav_default_enter_anim"
|
||||||
app:exitAnim="@anim/nav_default_exit_anim"
|
app:exitAnim="@anim/nav_default_exit_anim"
|
||||||
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
||||||
|
@ -116,10 +129,39 @@
|
||||||
app:nullable="true" />
|
app:nullable="true" />
|
||||||
</fragment>
|
</fragment>
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/mediaInfoFragment"
|
android:id="@+id/showFragment"
|
||||||
android:name="dev.jdtech.jellyfin.fragments.MediaInfoFragment"
|
android:name="dev.jdtech.jellyfin.fragments.ShowFragment"
|
||||||
android:label="{itemName}"
|
android:label="{itemName}"
|
||||||
tools:layout="@layout/fragment_media_info">
|
tools:layout="@layout/fragment_show">
|
||||||
|
<argument
|
||||||
|
android:name="itemId"
|
||||||
|
app:argType="java.util.UUID" />
|
||||||
|
<argument
|
||||||
|
android:name="itemName"
|
||||||
|
app:argType="string" />
|
||||||
|
<argument
|
||||||
|
android:name="offline"
|
||||||
|
app:argType="boolean"
|
||||||
|
android:defaultValue="false" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_showFragment_to_seasonFragment"
|
||||||
|
app:destination="@id/seasonFragment" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_showFragment_to_episodeBottomSheetFragment"
|
||||||
|
app:destination="@id/episodeBottomSheetFragment" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_showFragment_to_playerActivity"
|
||||||
|
app:destination="@id/playerActivity" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_showFragment_to_personDetailFragment"
|
||||||
|
app:destination="@id/personDetailFragment" />
|
||||||
|
</fragment>
|
||||||
|
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/movieFragment"
|
||||||
|
android:name="dev.jdtech.jellyfin.fragments.MovieFragment"
|
||||||
|
android:label="{itemName}"
|
||||||
|
tools:layout="@layout/fragment_movie">
|
||||||
<argument
|
<argument
|
||||||
android:name="itemId"
|
android:name="itemId"
|
||||||
app:argType="java.util.UUID" />
|
app:argType="java.util.UUID" />
|
||||||
|
@ -128,31 +170,12 @@
|
||||||
android:defaultValue="Media Info"
|
android:defaultValue="Media Info"
|
||||||
app:argType="string"
|
app:argType="string"
|
||||||
app:nullable="true" />
|
app:nullable="true" />
|
||||||
<argument
|
|
||||||
android:name="itemType"
|
|
||||||
app:argType="org.jellyfin.sdk.model.api.BaseItemKind"
|
|
||||||
android:defaultValue="MOVIE" />
|
|
||||||
<argument
|
|
||||||
android:name="playerItem"
|
|
||||||
android:defaultValue="@null"
|
|
||||||
app:argType="dev.jdtech.jellyfin.models.PlayerItem"
|
|
||||||
app:nullable="true" />
|
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_mediaInfoFragment_to_seasonFragment"
|
android:id="@+id/action_movieFragment_to_playerActivity"
|
||||||
app:destination="@id/seasonFragment" />
|
|
||||||
<action
|
|
||||||
android:id="@+id/action_mediaInfoFragment_to_episodeBottomSheetFragment"
|
|
||||||
app:destination="@id/episodeBottomSheetFragment" />
|
|
||||||
<action
|
|
||||||
android:id="@+id/action_mediaInfoFragment_to_playerActivity"
|
|
||||||
app:destination="@id/playerActivity" />
|
app:destination="@id/playerActivity" />
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_mediaInfoFragment_to_personDetailFragment"
|
android:id="@+id/action_movieFragment_to_personDetailFragment"
|
||||||
app:destination="@id/personDetailFragment" />
|
app:destination="@id/personDetailFragment" />
|
||||||
<argument
|
|
||||||
android:name="isOffline"
|
|
||||||
app:argType="boolean"
|
|
||||||
android:defaultValue="false" />
|
|
||||||
</fragment>
|
</fragment>
|
||||||
|
|
||||||
<fragment
|
<fragment
|
||||||
|
@ -176,26 +199,16 @@
|
||||||
android:defaultValue="Season"
|
android:defaultValue="Season"
|
||||||
app:argType="string"
|
app:argType="string"
|
||||||
app:nullable="true" />
|
app:nullable="true" />
|
||||||
|
<argument
|
||||||
|
android:name="offline"
|
||||||
|
app:argType="boolean"
|
||||||
|
android:defaultValue="false" />
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_seasonFragment_to_episodeBottomSheetFragment"
|
android:id="@+id/action_seasonFragment_to_episodeBottomSheetFragment"
|
||||||
app:destination="@id/episodeBottomSheetFragment" />
|
app:destination="@id/episodeBottomSheetFragment" />
|
||||||
</fragment>
|
|
||||||
<fragment
|
|
||||||
android:id="@+id/downloadSeriesFragment"
|
|
||||||
android:name="dev.jdtech.jellyfin.fragments.DownloadSeriesFragment"
|
|
||||||
android:label="{seriesName}"
|
|
||||||
tools:layout="@layout/fragment_season">
|
|
||||||
<argument
|
|
||||||
android:name="seriesMetadata"
|
|
||||||
app:argType="dev.jdtech.jellyfin.models.DownloadSeriesMetadata"/>
|
|
||||||
<argument
|
|
||||||
android:name="seriesName"
|
|
||||||
android:defaultValue="Series"
|
|
||||||
app:argType="string"
|
|
||||||
app:nullable="true" />
|
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_downloadSeriesFragment_to_episodeBottomSheetFragment"
|
android:id="@+id/action_seasonFragment_to_playerActivity"
|
||||||
app:destination="@id/episodeBottomSheetFragment" />
|
app:destination="@id/playerActivity" />
|
||||||
</fragment>
|
</fragment>
|
||||||
<dialog
|
<dialog
|
||||||
android:id="@+id/episodeBottomSheetFragment"
|
android:id="@+id/episodeBottomSheetFragment"
|
||||||
|
@ -205,21 +218,12 @@
|
||||||
<argument
|
<argument
|
||||||
android:name="episodeId"
|
android:name="episodeId"
|
||||||
app:argType="java.util.UUID" />
|
app:argType="java.util.UUID" />
|
||||||
<argument
|
|
||||||
android:name="playerItem"
|
|
||||||
android:defaultValue="@null"
|
|
||||||
app:argType="dev.jdtech.jellyfin.models.PlayerItem"
|
|
||||||
app:nullable="true" />
|
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_episodeBottomSheetFragment_to_playerActivity"
|
android:id="@+id/action_episodeBottomSheetFragment_to_playerActivity"
|
||||||
app:destination="@id/playerActivity" />
|
app:destination="@id/playerActivity" />
|
||||||
<argument
|
|
||||||
android:name="isOffline"
|
|
||||||
app:argType="boolean"
|
|
||||||
android:defaultValue="false" />
|
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_episodeBottomSheetFragment_to_mediaInfoFragment"
|
android:id="@+id/action_episodeBottomSheetFragment_to_showFragment"
|
||||||
app:destination="@id/mediaInfoFragment" />
|
app:destination="@id/showFragment" />
|
||||||
</dialog>
|
</dialog>
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/favoriteFragment"
|
android:id="@+id/favoriteFragment"
|
||||||
|
@ -230,8 +234,11 @@
|
||||||
android:id="@+id/action_favoriteFragment_to_episodeBottomSheetFragment"
|
android:id="@+id/action_favoriteFragment_to_episodeBottomSheetFragment"
|
||||||
app:destination="@id/episodeBottomSheetFragment" />
|
app:destination="@id/episodeBottomSheetFragment" />
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_favoriteFragment_to_mediaInfoFragment"
|
android:id="@+id/action_favoriteFragment_to_showFragment"
|
||||||
app:destination="@id/mediaInfoFragment" />
|
app:destination="@id/showFragment" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_favoriteFragment_to_movieFragment"
|
||||||
|
app:destination="@id/movieFragment" />
|
||||||
</fragment>
|
</fragment>
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/collectionFragment"
|
android:id="@+id/collectionFragment"
|
||||||
|
@ -250,23 +257,11 @@
|
||||||
android:id="@+id/action_collectionFragment_to_episodeBottomSheetFragment"
|
android:id="@+id/action_collectionFragment_to_episodeBottomSheetFragment"
|
||||||
app:destination="@id/episodeBottomSheetFragment" />
|
app:destination="@id/episodeBottomSheetFragment" />
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_collectionFragment_to_mediaInfoFragment"
|
android:id="@+id/action_collectionFragment_to_showFragment"
|
||||||
app:destination="@id/mediaInfoFragment" />
|
app:destination="@id/showFragment" />
|
||||||
</fragment>
|
|
||||||
<fragment
|
|
||||||
android:id="@+id/downloadFragment"
|
|
||||||
android:name="dev.jdtech.jellyfin.fragments.DownloadFragment"
|
|
||||||
android:label="@string/title_download"
|
|
||||||
tools:layout="@layout/fragment_download">
|
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_downloadFragment_to_episodeBottomSheetFragment"
|
android:id="@+id/action_collectionFragment_to_movieFragment"
|
||||||
app:destination="@id/episodeBottomSheetFragment" />
|
app:destination="@id/movieFragment" />
|
||||||
<action
|
|
||||||
android:id="@+id/action_downloadFragment_to_mediaInfoFragment"
|
|
||||||
app:destination="@id/mediaInfoFragment" />
|
|
||||||
<action
|
|
||||||
android:id="@+id/action_downloadFragment_to_downloadSeriesFragment"
|
|
||||||
app:destination="@id/downloadSeriesFragment" />
|
|
||||||
</fragment>
|
</fragment>
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/searchResultFragment"
|
android:id="@+id/searchResultFragment"
|
||||||
|
@ -274,11 +269,14 @@
|
||||||
android:label="{query}"
|
android:label="{query}"
|
||||||
tools:layout="@layout/fragment_search_result">
|
tools:layout="@layout/fragment_search_result">
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_favoriteFragment_to_episodeBottomSheetFragment"
|
android:id="@+id/action_searchResultFragment_to_episodeBottomSheetFragment"
|
||||||
app:destination="@id/episodeBottomSheetFragment" />
|
app:destination="@id/episodeBottomSheetFragment" />
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_favoriteFragment_to_mediaInfoFragment"
|
android:id="@+id/action_searchResultFragment_to_showFragment"
|
||||||
app:destination="@id/mediaInfoFragment" />
|
app:destination="@id/showFragment" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_searchResultFragment_to_movieFragment"
|
||||||
|
app:destination="@id/movieFragment" />
|
||||||
<argument
|
<argument
|
||||||
android:name="query"
|
android:name="query"
|
||||||
app:argType="string" />
|
app:argType="string" />
|
||||||
|
@ -318,8 +316,8 @@
|
||||||
app:popUpToInclusive="true" />
|
app:popUpToInclusive="true" />
|
||||||
<argument
|
<argument
|
||||||
android:name="reLogin"
|
android:name="reLogin"
|
||||||
app:argType="boolean"
|
android:defaultValue="false"
|
||||||
android:defaultValue="false" />
|
app:argType="boolean" />
|
||||||
</fragment>
|
</fragment>
|
||||||
|
|
||||||
<fragment
|
<fragment
|
||||||
|
@ -333,8 +331,11 @@
|
||||||
app:argType="java.util.UUID" />
|
app:argType="java.util.UUID" />
|
||||||
|
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_personDetailFragment_to_mediaInfoFragment"
|
android:id="@+id/action_personDetailFragment_to_showFragment"
|
||||||
app:destination="@id/mediaInfoFragment" />
|
app:destination="@id/showFragment" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_personDetailFragment_to_movieFragment"
|
||||||
|
app:destination="@id/movieFragment" />
|
||||||
</fragment>
|
</fragment>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
|
@ -385,4 +386,25 @@
|
||||||
app:argType="string" />
|
app:argType="string" />
|
||||||
</fragment>
|
</fragment>
|
||||||
|
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/downloadsFragment"
|
||||||
|
android:name="dev.jdtech.jellyfin.fragments.DownloadsFragment"
|
||||||
|
android:label="@string/title_download"
|
||||||
|
tools:layout="@layout/fragment_favorite">
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_downloadsFragment_to_movieFragment"
|
||||||
|
app:destination="@id/movieFragment"
|
||||||
|
app:enterAnim="@anim/nav_default_enter_anim"
|
||||||
|
app:exitAnim="@anim/nav_default_exit_anim"
|
||||||
|
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
||||||
|
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_downloadsFragment_to_showFragment"
|
||||||
|
app:destination="@id/showFragment"
|
||||||
|
app:enterAnim="@anim/nav_default_enter_anim"
|
||||||
|
app:exitAnim="@anim/nav_default_exit_anim"
|
||||||
|
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
||||||
|
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
|
||||||
|
</fragment>
|
||||||
|
|
||||||
</navigation>
|
</navigation>
|
|
@ -45,6 +45,8 @@ dependencies {
|
||||||
implementation(libs.androidx.activity)
|
implementation(libs.androidx.activity)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.androidx.core)
|
implementation(libs.androidx.core)
|
||||||
|
implementation(libs.androidx.hilt.work)
|
||||||
|
kapt(libs.androidx.hilt.compiler)
|
||||||
implementation(libs.androidx.lifecycle.runtime)
|
implementation(libs.androidx.lifecycle.runtime)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel)
|
implementation(libs.androidx.lifecycle.viewmodel)
|
||||||
implementation(libs.androidx.navigation.fragment)
|
implementation(libs.androidx.navigation.fragment)
|
||||||
|
@ -53,6 +55,7 @@ dependencies {
|
||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.androidx.room.runtime)
|
||||||
kapt(libs.androidx.room.compiler)
|
kapt(libs.androidx.room.compiler)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
|
implementation(libs.androidx.work)
|
||||||
implementation(libs.glide)
|
implementation(libs.glide)
|
||||||
kapt(libs.glide.compiler)
|
kapt(libs.glide.compiler)
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
package dev.jdtech.jellyfin.database
|
|
||||||
|
|
||||||
import androidx.room.Database
|
|
||||||
import androidx.room.RoomDatabase
|
|
||||||
import androidx.room.TypeConverters
|
|
||||||
import dev.jdtech.jellyfin.models.Server
|
|
||||||
import dev.jdtech.jellyfin.models.ServerAddress
|
|
||||||
import dev.jdtech.jellyfin.models.User
|
|
||||||
|
|
||||||
@Database(entities = [Server::class, ServerAddress::class, User::class], version = 2, exportSchema = false)
|
|
||||||
@TypeConverters(Converters::class)
|
|
||||||
abstract class ServerDatabase : RoomDatabase() {
|
|
||||||
abstract val serverDatabaseDao: ServerDatabaseDao
|
|
||||||
}
|
|
|
@ -1,79 +0,0 @@
|
||||||
package dev.jdtech.jellyfin.database
|
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.room.Dao
|
|
||||||
import androidx.room.Insert
|
|
||||||
import androidx.room.OnConflictStrategy
|
|
||||||
import androidx.room.Query
|
|
||||||
import androidx.room.Transaction
|
|
||||||
import androidx.room.Update
|
|
||||||
import dev.jdtech.jellyfin.models.Server
|
|
||||||
import dev.jdtech.jellyfin.models.ServerAddress
|
|
||||||
import dev.jdtech.jellyfin.models.ServerWithAddresses
|
|
||||||
import dev.jdtech.jellyfin.models.ServerWithAddressesAndUsers
|
|
||||||
import dev.jdtech.jellyfin.models.ServerWithUsers
|
|
||||||
import dev.jdtech.jellyfin.models.User
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface ServerDatabaseDao {
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
fun insertServer(server: Server)
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
fun insertServerAddress(address: ServerAddress)
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
fun insertUser(user: User)
|
|
||||||
|
|
||||||
@Update
|
|
||||||
fun update(server: Server)
|
|
||||||
|
|
||||||
@Query("select * from servers where id = :id")
|
|
||||||
fun get(id: String): Server?
|
|
||||||
|
|
||||||
@Query("select * from users where id = :id")
|
|
||||||
fun getUser(id: UUID): User?
|
|
||||||
|
|
||||||
@Transaction
|
|
||||||
@Query("select * from servers where id = :id")
|
|
||||||
fun getServerWithAddresses(id: String): ServerWithAddresses
|
|
||||||
|
|
||||||
@Transaction
|
|
||||||
@Query("select * from servers where id = :id")
|
|
||||||
fun getServerWithUsers(id: String): ServerWithUsers
|
|
||||||
|
|
||||||
@Transaction
|
|
||||||
@Query("select * from servers where id = :id")
|
|
||||||
fun getServerWithAddressesAndUsers(id: String): ServerWithAddressesAndUsers?
|
|
||||||
|
|
||||||
@Query("delete from servers")
|
|
||||||
fun clear()
|
|
||||||
|
|
||||||
@Query("select * from servers")
|
|
||||||
fun getAllServers(): LiveData<List<Server>>
|
|
||||||
|
|
||||||
@Query("select * from servers")
|
|
||||||
fun getAllServersSync(): List<Server>
|
|
||||||
|
|
||||||
@Query("select count(*) from servers")
|
|
||||||
fun getServersCount(): Int
|
|
||||||
|
|
||||||
@Query("delete from servers where id = :id")
|
|
||||||
fun delete(id: String)
|
|
||||||
|
|
||||||
@Query("delete from users where id = :id")
|
|
||||||
fun deleteUser(id: UUID)
|
|
||||||
|
|
||||||
@Query("delete from serverAddresses where id = :id")
|
|
||||||
fun deleteServerAddress(id: UUID)
|
|
||||||
|
|
||||||
@Query("update servers set currentUserId = :userId where id = :serverId")
|
|
||||||
fun updateServerCurrentUser(serverId: String, userId: UUID)
|
|
||||||
|
|
||||||
@Query("select * from users where id = (select currentUserId from servers where id = :serverId)")
|
|
||||||
fun getServerCurrentUser(serverId: String): User?
|
|
||||||
|
|
||||||
@Query("select * from serverAddresses where id = (select currentServerAddressId from servers where id = :serverId)")
|
|
||||||
fun getServerCurrentAddress(serverId: String): ServerAddress?
|
|
||||||
}
|
|
|
@ -28,17 +28,16 @@ object ApiModule {
|
||||||
socketTimeout = appPreferences.socketTimeout
|
socketTimeout = appPreferences.socketTimeout
|
||||||
)
|
)
|
||||||
|
|
||||||
val serverId = appPreferences.currentServer
|
val serverId = appPreferences.currentServer ?: return jellyfinApi
|
||||||
if (serverId != null) {
|
|
||||||
val serverWithAddressesAndUsers = serverDatabase.getServerWithAddressesAndUsers(serverId) ?: return jellyfinApi
|
val serverWithAddressesAndUsers = serverDatabase.getServerWithAddressesAndUsers(serverId) ?: return jellyfinApi
|
||||||
val server = serverWithAddressesAndUsers.server
|
val server = serverWithAddressesAndUsers.server
|
||||||
val serverAddress = serverWithAddressesAndUsers.addresses.firstOrNull { it.id == server.currentServerAddressId } ?: return jellyfinApi
|
val serverAddress = serverWithAddressesAndUsers.addresses.firstOrNull { it.id == server.currentServerAddressId } ?: return jellyfinApi
|
||||||
val user = serverWithAddressesAndUsers.users.firstOrNull { it.id == server.currentUserId }
|
val user = serverWithAddressesAndUsers.users.firstOrNull { it.id == server.currentUserId }
|
||||||
jellyfinApi.apply {
|
jellyfinApi.apply {
|
||||||
api.baseUrl = serverAddress.address
|
api.baseUrl = serverAddress.address
|
||||||
api.accessToken = user?.accessToken
|
api.accessToken = user?.accessToken
|
||||||
userId = user?.id
|
userId = user?.id
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return jellyfinApi
|
return jellyfinApi
|
||||||
|
|
|
@ -7,8 +7,6 @@ import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import dev.jdtech.jellyfin.database.DownloadDatabase
|
|
||||||
import dev.jdtech.jellyfin.database.DownloadDatabaseDao
|
|
||||||
import dev.jdtech.jellyfin.database.ServerDatabase
|
import dev.jdtech.jellyfin.database.ServerDatabase
|
||||||
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
@ -29,18 +27,4 @@ object DatabaseModule {
|
||||||
.build()
|
.build()
|
||||||
.serverDatabaseDao
|
.serverDatabaseDao
|
||||||
}
|
}
|
||||||
|
|
||||||
@Singleton
|
|
||||||
@Provides
|
|
||||||
fun provideDownloadDatabaseDao(@ApplicationContext app: Context): DownloadDatabaseDao {
|
|
||||||
return Room.databaseBuilder(
|
|
||||||
app.applicationContext,
|
|
||||||
DownloadDatabase::class.java,
|
|
||||||
"downloads"
|
|
||||||
)
|
|
||||||
.fallbackToDestructiveMigration()
|
|
||||||
.allowMainThreadQueries()
|
|
||||||
.build()
|
|
||||||
.downloadDatabaseDao
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
package dev.jdtech.jellyfin.di
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dev.jdtech.jellyfin.AppPreferences
|
||||||
|
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||||
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
|
import dev.jdtech.jellyfin.utils.Downloader
|
||||||
|
import dev.jdtech.jellyfin.utils.DownloaderImpl
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object DownloaderModule {
|
||||||
|
@Singleton
|
||||||
|
@Provides
|
||||||
|
fun provideDownloader(
|
||||||
|
application: Application,
|
||||||
|
serverDatabase: ServerDatabaseDao,
|
||||||
|
jellyfinRepository: JellyfinRepository,
|
||||||
|
appPreferences: AppPreferences,
|
||||||
|
): Downloader {
|
||||||
|
return DownloaderImpl(application, serverDatabase, jellyfinRepository, appPreferences)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,22 +1,56 @@
|
||||||
package dev.jdtech.jellyfin.di
|
package dev.jdtech.jellyfin.di
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dev.jdtech.jellyfin.AppPreferences
|
||||||
import dev.jdtech.jellyfin.api.JellyfinApi
|
import dev.jdtech.jellyfin.api.JellyfinApi
|
||||||
|
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepositoryImpl
|
import dev.jdtech.jellyfin.repository.JellyfinRepositoryImpl
|
||||||
|
import dev.jdtech.jellyfin.repository.JellyfinRepositoryOfflineImpl
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object RepositoryModule {
|
object RepositoryModule {
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
|
@Provides
|
||||||
|
fun provideJellyfinRepositoryImpl(
|
||||||
|
application: Application,
|
||||||
|
jellyfinApi: JellyfinApi,
|
||||||
|
serverDatabase: ServerDatabaseDao,
|
||||||
|
appPreferences: AppPreferences,
|
||||||
|
): JellyfinRepositoryImpl {
|
||||||
|
println("Creating new jellyfinRepositoryImpl")
|
||||||
|
return JellyfinRepositoryImpl(application, jellyfinApi, serverDatabase, appPreferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
@Provides
|
||||||
|
fun provideJellyfinRepositoryOfflineImpl(
|
||||||
|
application: Application,
|
||||||
|
jellyfinApi: JellyfinApi,
|
||||||
|
serverDatabase: ServerDatabaseDao,
|
||||||
|
appPreferences: AppPreferences,
|
||||||
|
): JellyfinRepositoryOfflineImpl {
|
||||||
|
println("Creating new jellyfinRepositoryOfflineImpl")
|
||||||
|
return JellyfinRepositoryOfflineImpl(application, jellyfinApi, serverDatabase, appPreferences)
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun provideJellyfinRepository(
|
fun provideJellyfinRepository(
|
||||||
jellyfinApi: JellyfinApi
|
jellyfinRepositoryImpl: JellyfinRepositoryImpl,
|
||||||
|
jellyfinRepositoryOfflineImpl: JellyfinRepositoryOfflineImpl,
|
||||||
|
appPreferences: AppPreferences,
|
||||||
): JellyfinRepository {
|
): JellyfinRepository {
|
||||||
return JellyfinRepositoryImpl(jellyfinApi)
|
println("Creating new JellyfinRepository")
|
||||||
|
return when (appPreferences.offlineMode) {
|
||||||
|
true -> jellyfinRepositoryOfflineImpl
|
||||||
|
false -> jellyfinRepositoryImpl
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,20 @@
|
||||||
package dev.jdtech.jellyfin.models
|
package dev.jdtech.jellyfin.models
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
|
|
||||||
sealed class EpisodeItem {
|
sealed class EpisodeItem {
|
||||||
abstract val id: UUID
|
abstract val id: UUID
|
||||||
|
|
||||||
object Header : EpisodeItem() {
|
data class Header(
|
||||||
override val id: UUID = UUID.randomUUID()
|
val seriesId: UUID,
|
||||||
|
val seasonId: UUID,
|
||||||
|
val seriesName: String,
|
||||||
|
val seasonName: String,
|
||||||
|
) : EpisodeItem() {
|
||||||
|
override val id: UUID = UUID.fromString("99abd692-1136-4291-b0b1-11e2bf532cb9")
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Episode(val episode: BaseItemDto) : EpisodeItem() {
|
data class Episode(val episode: FindroidEpisode) : EpisodeItem() {
|
||||||
override val id = episode.id
|
override val id = episode.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
package dev.jdtech.jellyfin.models
|
package dev.jdtech.jellyfin.models
|
||||||
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
|
|
||||||
data class FavoriteSection(
|
data class FavoriteSection(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val name: UiText,
|
val name: UiText,
|
||||||
var items: List<BaseItemDto>
|
var items: List<FindroidItem>
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,6 +3,10 @@ package dev.jdtech.jellyfin.models
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
sealed class HomeItem {
|
sealed class HomeItem {
|
||||||
|
object OfflineCard : HomeItem() {
|
||||||
|
override val id: UUID = UUID.fromString("dbfef8a9-7ff0-4c36-9e36-81dfd65fdd46")
|
||||||
|
}
|
||||||
|
|
||||||
data class Libraries(val section: HomeSection) : HomeItem() {
|
data class Libraries(val section: HomeSection) : HomeItem() {
|
||||||
override val id = section.id
|
override val id = section.id
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
package dev.jdtech.jellyfin.models
|
package dev.jdtech.jellyfin.models
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
|
|
||||||
data class HomeSection(
|
data class HomeSection(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val name: String,
|
val name: UiText,
|
||||||
var items: List<BaseItemDto>
|
var items: List<FindroidItem>
|
||||||
)
|
)
|
||||||
|
|
|
@ -13,7 +13,7 @@ sealed class UiText {
|
||||||
fun asString(resources: Resources): String {
|
fun asString(resources: Resources): String {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is DynamicString -> return value
|
is DynamicString -> return value
|
||||||
is StringResource -> resources.getString(resId, args)
|
is StringResource -> resources.getString(resId, *args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
package dev.jdtech.jellyfin.models
|
package dev.jdtech.jellyfin.models
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
|
|
||||||
data class View(
|
data class View(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val name: String?,
|
val name: String?,
|
||||||
var items: List<BaseItemDto>? = null,
|
var items: List<FindroidItem>? = null,
|
||||||
val type: String?
|
val type: String?
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
package dev.jdtech.jellyfin.utils
|
package dev.jdtech.jellyfin.utils
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
@ -46,3 +48,9 @@ inline fun <reified T : Serializable> Bundle.serializable(key: String): T? = whe
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getSerializable(key, T::class.java)
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getSerializable(key, T::class.java)
|
||||||
else -> @Suppress("DEPRECATION") getSerializable(key) as? T
|
else -> @Suppress("DEPRECATION") getSerializable(key) as? T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Activity.restart() {
|
||||||
|
val intent = Intent(this, this::class.java)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,80 @@
|
||||||
|
package dev.jdtech.jellyfin.utils
|
||||||
|
|
||||||
|
import android.app.DownloadManager
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidEpisode
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidSeason
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidShow
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidSource
|
||||||
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class DownloadReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var database: ServerDatabaseDao
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var downloader: Downloader
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var repository: JellyfinRepository
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action == "android.intent.action.DOWNLOAD_COMPLETE") {
|
||||||
|
val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
|
||||||
|
if (id != -1L) {
|
||||||
|
val source = database.getSourceByDownloadId(id)
|
||||||
|
if (source != null) {
|
||||||
|
val path = source.path.replace(".download", "")
|
||||||
|
val successfulRename = File(source.path).renameTo(File(path))
|
||||||
|
if (successfulRename) {
|
||||||
|
database.setSourcePath(source.id, path)
|
||||||
|
} else {
|
||||||
|
val items = mutableListOf<FindroidItem>()
|
||||||
|
items.addAll(
|
||||||
|
database.getMovies().map { it.toFindroidMovie(database, repository.getUserId()) }
|
||||||
|
)
|
||||||
|
items.addAll(
|
||||||
|
database.getShows().map { it.toFindroidShow(database, repository.getUserId()) }
|
||||||
|
)
|
||||||
|
items.addAll(
|
||||||
|
database.getSeasons().map { it.toFindroidSeason(database, repository.getUserId()) }
|
||||||
|
)
|
||||||
|
items.addAll(
|
||||||
|
database.getEpisodes().map { it.toFindroidEpisode(database, repository.getUserId()) }
|
||||||
|
)
|
||||||
|
|
||||||
|
items.firstOrNull { it.id == source.itemId }?.let {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
downloader.deleteItem(it, source.toFindroidSource(database))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val mediaStream = database.getMediaStreamByDownloadId(id)
|
||||||
|
if (mediaStream != null) {
|
||||||
|
val path = mediaStream.path.replace(".download", "")
|
||||||
|
val successfulRename = File(mediaStream.path).renameTo(File(path))
|
||||||
|
if (successfulRename) {
|
||||||
|
database.setMediaStreamPath(mediaStream.id, path)
|
||||||
|
} else {
|
||||||
|
database.deleteMediaStream(mediaStream.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
19
core/src/main/java/dev/jdtech/jellyfin/utils/Downloader.kt
Normal file
19
core/src/main/java/dev/jdtech/jellyfin/utils/Downloader.kt
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
package dev.jdtech.jellyfin.utils
|
||||||
|
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidSource
|
||||||
|
import dev.jdtech.jellyfin.models.UiText
|
||||||
|
|
||||||
|
interface Downloader {
|
||||||
|
suspend fun downloadItem(
|
||||||
|
item: FindroidItem,
|
||||||
|
sourceId: String,
|
||||||
|
storageIndex: Int = 0,
|
||||||
|
): Pair<Long, UiText?>
|
||||||
|
|
||||||
|
suspend fun cancelDownload(item: FindroidItem, source: FindroidSource)
|
||||||
|
|
||||||
|
suspend fun deleteItem(item: FindroidItem, source: FindroidSource)
|
||||||
|
|
||||||
|
suspend fun getProgress(downloadId: Long?): Pair<Int, Int>
|
||||||
|
}
|
242
core/src/main/java/dev/jdtech/jellyfin/utils/DownloaderImpl.kt
Normal file
242
core/src/main/java/dev/jdtech/jellyfin/utils/DownloaderImpl.kt
Normal file
|
@ -0,0 +1,242 @@
|
||||||
|
package dev.jdtech.jellyfin.utils
|
||||||
|
|
||||||
|
import android.app.DownloadManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Environment
|
||||||
|
import android.os.StatFs
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import dev.jdtech.jellyfin.AppPreferences
|
||||||
|
import dev.jdtech.jellyfin.core.R as CoreR
|
||||||
|
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidSource
|
||||||
|
import dev.jdtech.jellyfin.models.TrickPlayManifest
|
||||||
|
import dev.jdtech.jellyfin.models.UiText
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidEpisodeDto
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidMediaStreamDto
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidMovieDto
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidSeasonDto
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidShowDto
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidSourceDto
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidUserDataDto
|
||||||
|
import dev.jdtech.jellyfin.models.toIntroDto
|
||||||
|
import dev.jdtech.jellyfin.models.toTrickPlayManifestDto
|
||||||
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.Exception
|
||||||
|
|
||||||
|
class DownloaderImpl(
|
||||||
|
private val context: Context,
|
||||||
|
private val database: ServerDatabaseDao,
|
||||||
|
private val jellyfinRepository: JellyfinRepository,
|
||||||
|
private val appPreferences: AppPreferences,
|
||||||
|
) : Downloader {
|
||||||
|
private val downloadManager = context.getSystemService(DownloadManager::class.java)
|
||||||
|
|
||||||
|
override suspend fun downloadItem(
|
||||||
|
item: FindroidItem,
|
||||||
|
sourceId: String,
|
||||||
|
storageIndex: Int,
|
||||||
|
): Pair<Long, UiText?> {
|
||||||
|
try {
|
||||||
|
val source = jellyfinRepository.getMediaSources(item.id).first { it.id == sourceId }
|
||||||
|
val intro = jellyfinRepository.getIntroTimestamps(item.id)
|
||||||
|
val trickPlayManifest = jellyfinRepository.getTrickPlayManifest(item.id)
|
||||||
|
val trickPlayData = if (trickPlayManifest != null) {
|
||||||
|
jellyfinRepository.getTrickPlayData(
|
||||||
|
item.id,
|
||||||
|
trickPlayManifest.widthResolutions.max()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val storageLocation = context.getExternalFilesDirs(null)[storageIndex]
|
||||||
|
if (storageLocation == null || Environment.getExternalStorageState(storageLocation) != Environment.MEDIA_MOUNTED) {
|
||||||
|
return Pair(-1, UiText.StringResource(CoreR.string.storage_unavailable))
|
||||||
|
}
|
||||||
|
val path =
|
||||||
|
Uri.fromFile(File(storageLocation, "downloads/${item.id}.${source.id}.download"))
|
||||||
|
val stats = StatFs(storageLocation.path)
|
||||||
|
if (stats.availableBytes < source.size) {
|
||||||
|
return Pair(
|
||||||
|
-1,
|
||||||
|
UiText.StringResource(
|
||||||
|
CoreR.string.not_enough_storage,
|
||||||
|
source.size.div(1000000),
|
||||||
|
stats.availableBytes.div(1000000)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (item) {
|
||||||
|
is FindroidMovie -> {
|
||||||
|
database.insertMovie(item.toFindroidMovieDto(appPreferences.currentServer!!))
|
||||||
|
database.insertSource(source.toFindroidSourceDto(item.id, path.path.orEmpty()))
|
||||||
|
database.insertUserData(item.toFindroidUserDataDto(jellyfinRepository.getUserId()))
|
||||||
|
downloadExternalMediaStreams(item, source, storageIndex)
|
||||||
|
if (intro != null) {
|
||||||
|
database.insertIntro(intro.toIntroDto(item.id))
|
||||||
|
}
|
||||||
|
if (trickPlayManifest != null && trickPlayData != null) {
|
||||||
|
downloadTrickPlay(item, trickPlayManifest, trickPlayData)
|
||||||
|
}
|
||||||
|
val request = DownloadManager.Request(source.path.toUri())
|
||||||
|
.setTitle(item.name)
|
||||||
|
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
|
||||||
|
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
|
||||||
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||||
|
.setDestinationUri(path)
|
||||||
|
val downloadId = downloadManager.enqueue(request)
|
||||||
|
database.setSourceDownloadId(source.id, downloadId)
|
||||||
|
return Pair(downloadId, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
is FindroidEpisode -> {
|
||||||
|
database.insertShow(
|
||||||
|
jellyfinRepository.getShow(item.seriesId)
|
||||||
|
.toFindroidShowDto(appPreferences.currentServer!!)
|
||||||
|
)
|
||||||
|
database.insertSeason(
|
||||||
|
jellyfinRepository.getSeason(item.seasonId).toFindroidSeasonDto()
|
||||||
|
)
|
||||||
|
database.insertEpisode(item.toFindroidEpisodeDto(appPreferences.currentServer!!))
|
||||||
|
database.insertSource(source.toFindroidSourceDto(item.id, path.path.orEmpty()))
|
||||||
|
database.insertUserData(item.toFindroidUserDataDto(jellyfinRepository.getUserId()))
|
||||||
|
downloadExternalMediaStreams(item, source, storageIndex)
|
||||||
|
if (intro != null) {
|
||||||
|
database.insertIntro(intro.toIntroDto(item.id))
|
||||||
|
}
|
||||||
|
if (trickPlayManifest != null && trickPlayData != null) {
|
||||||
|
downloadTrickPlay(item, trickPlayManifest, trickPlayData)
|
||||||
|
}
|
||||||
|
val request = DownloadManager.Request(source.path.toUri())
|
||||||
|
.setTitle(item.name)
|
||||||
|
.setAllowedOverMetered(appPreferences.downloadOverMobileData)
|
||||||
|
.setAllowedOverRoaming(appPreferences.downloadWhenRoaming)
|
||||||
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||||
|
.setDestinationUri(path)
|
||||||
|
val downloadId = downloadManager.enqueue(request)
|
||||||
|
database.setSourceDownloadId(source.id, downloadId)
|
||||||
|
return Pair(downloadId, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Pair(-1, null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
try {
|
||||||
|
val source = jellyfinRepository.getMediaSources(item.id).first { it.id == sourceId }
|
||||||
|
deleteItem(item, source)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
|
||||||
|
return Pair(-1, if (e.message != null) UiText.DynamicString(e.message!!) else UiText.StringResource(CoreR.string.unknown_error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun cancelDownload(item: FindroidItem, source: FindroidSource) {
|
||||||
|
if (source.downloadId != null) {
|
||||||
|
downloadManager.remove(source.downloadId!!)
|
||||||
|
}
|
||||||
|
deleteItem(item, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteItem(item: FindroidItem, source: FindroidSource) {
|
||||||
|
when (item) {
|
||||||
|
is FindroidMovie -> {
|
||||||
|
database.deleteMovie(item.id)
|
||||||
|
}
|
||||||
|
is FindroidEpisode -> {
|
||||||
|
database.deleteEpisode(item.id)
|
||||||
|
val remainingEpisodes = database.getEpisodesBySeasonId(item.seasonId)
|
||||||
|
if (remainingEpisodes.isEmpty()) {
|
||||||
|
database.deleteSeason(item.seasonId)
|
||||||
|
val remainingSeasons = database.getSeasonsByShowId(item.seriesId)
|
||||||
|
if (remainingSeasons.isEmpty()) {
|
||||||
|
database.deleteShow(item.seriesId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
database.deleteSource(source.id)
|
||||||
|
File(source.path).delete()
|
||||||
|
|
||||||
|
val mediaStreams = database.getMediaStreamsBySourceId(source.id)
|
||||||
|
for (mediaStream in mediaStreams) {
|
||||||
|
File(mediaStream.path).delete()
|
||||||
|
}
|
||||||
|
database.deleteMediaStreamsBySourceId(source.id)
|
||||||
|
|
||||||
|
database.deleteUserData(item.id)
|
||||||
|
|
||||||
|
database.deleteIntro(item.id)
|
||||||
|
|
||||||
|
database.deleteTrickPlayManifest(item.id)
|
||||||
|
File(context.filesDir, "trickplay/${item.id}.bif").delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getProgress(downloadId: Long?): Pair<Int, Int> {
|
||||||
|
var downloadStatus = -1
|
||||||
|
var progress = -1
|
||||||
|
if (downloadId == null) {
|
||||||
|
return Pair(downloadStatus, progress)
|
||||||
|
}
|
||||||
|
val query = DownloadManager.Query()
|
||||||
|
.setFilterById(downloadId)
|
||||||
|
val cursor = downloadManager.query(query)
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
downloadStatus = cursor.getInt(
|
||||||
|
cursor.getColumnIndexOrThrow(
|
||||||
|
DownloadManager.COLUMN_STATUS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
when (downloadStatus) {
|
||||||
|
DownloadManager.STATUS_RUNNING -> {
|
||||||
|
val totalBytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
|
||||||
|
if (totalBytes > 0) {
|
||||||
|
val downloadedBytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
|
||||||
|
progress = downloadedBytes.times(100).div(totalBytes).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadManager.STATUS_SUCCESSFUL -> {
|
||||||
|
progress = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
downloadStatus = DownloadManager.STATUS_FAILED
|
||||||
|
}
|
||||||
|
return Pair(downloadStatus, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadExternalMediaStreams(
|
||||||
|
item: FindroidItem,
|
||||||
|
source: FindroidSource,
|
||||||
|
storageIndex: Int = 0,
|
||||||
|
) {
|
||||||
|
val storageLocation = context.getExternalFilesDirs(null)[storageIndex]
|
||||||
|
for (mediaStream in source.mediaStreams.filter { it.isExternal }) {
|
||||||
|
val id = UUID.randomUUID()
|
||||||
|
val streamPath = Uri.fromFile(File(storageLocation, "downloads/${item.id}.${source.id}.$id.download"))
|
||||||
|
database.insertMediaStream(mediaStream.toFindroidMediaStreamDto(id, source.id, streamPath.path.orEmpty()))
|
||||||
|
val request = DownloadManager.Request(Uri.parse(mediaStream.path))
|
||||||
|
.setTitle(mediaStream.title)
|
||||||
|
.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI)
|
||||||
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
|
||||||
|
.setDestinationUri(streamPath)
|
||||||
|
val downloadId = downloadManager.enqueue(request)
|
||||||
|
database.setMediaStreamDownloadId(id, downloadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadTrickPlay(
|
||||||
|
item: FindroidItem,
|
||||||
|
trickPlayManifest: TrickPlayManifest,
|
||||||
|
byteArray: ByteArray
|
||||||
|
) {
|
||||||
|
database.insertTrickPlayManifest(trickPlayManifest.toTrickPlayManifestDto(item.id))
|
||||||
|
File(context.filesDir, "trickplay").mkdirs()
|
||||||
|
val file = File(context.filesDir, "trickplay/${item.id}.bif")
|
||||||
|
file.writeBytes(byteArray)
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.jdtech.jellyfin.Constants
|
import dev.jdtech.jellyfin.Constants
|
||||||
import dev.jdtech.jellyfin.core.R
|
import dev.jdtech.jellyfin.core.R
|
||||||
import dev.jdtech.jellyfin.models.FavoriteSection
|
import dev.jdtech.jellyfin.models.FavoriteSection
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidShow
|
||||||
import dev.jdtech.jellyfin.models.UiText
|
import dev.jdtech.jellyfin.models.UiText
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
@ -15,7 +18,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CollectionViewModel
|
class CollectionViewModel
|
||||||
|
@ -50,7 +52,7 @@ constructor(
|
||||||
FavoriteSection(
|
FavoriteSection(
|
||||||
Constants.FAVORITE_TYPE_MOVIES,
|
Constants.FAVORITE_TYPE_MOVIES,
|
||||||
UiText.StringResource(R.string.movies_label),
|
UiText.StringResource(R.string.movies_label),
|
||||||
items.filter { it.type == BaseItemKind.MOVIE }
|
items.filterIsInstance<FindroidMovie>()
|
||||||
).let {
|
).let {
|
||||||
if (it.items.isNotEmpty()) favoriteSections.add(
|
if (it.items.isNotEmpty()) favoriteSections.add(
|
||||||
it
|
it
|
||||||
|
@ -59,7 +61,7 @@ constructor(
|
||||||
FavoriteSection(
|
FavoriteSection(
|
||||||
Constants.FAVORITE_TYPE_SHOWS,
|
Constants.FAVORITE_TYPE_SHOWS,
|
||||||
UiText.StringResource(R.string.shows_label),
|
UiText.StringResource(R.string.shows_label),
|
||||||
items.filter { it.type == BaseItemKind.SERIES }
|
items.filterIsInstance<FindroidShow>()
|
||||||
).let {
|
).let {
|
||||||
if (it.items.isNotEmpty()) favoriteSections.add(
|
if (it.items.isNotEmpty()) favoriteSections.add(
|
||||||
it
|
it
|
||||||
|
@ -68,7 +70,7 @@ constructor(
|
||||||
FavoriteSection(
|
FavoriteSection(
|
||||||
Constants.FAVORITE_TYPE_EPISODES,
|
Constants.FAVORITE_TYPE_EPISODES,
|
||||||
UiText.StringResource(R.string.episodes_label),
|
UiText.StringResource(R.string.episodes_label),
|
||||||
items.filter { it.type == BaseItemKind.EPISODE }
|
items.filterIsInstance<FindroidEpisode>()
|
||||||
).let {
|
).let {
|
||||||
if (it.items.isNotEmpty()) favoriteSections.add(
|
if (it.items.isNotEmpty()) favoriteSections.add(
|
||||||
it
|
it
|
||||||
|
|
|
@ -1,46 +0,0 @@
|
||||||
package dev.jdtech.jellyfin.viewmodels
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import dev.jdtech.jellyfin.models.DownloadEpisodeItem
|
|
||||||
import dev.jdtech.jellyfin.models.DownloadSeriesMetadata
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class DownloadSeriesViewModel
|
|
||||||
@Inject
|
|
||||||
constructor() : ViewModel() {
|
|
||||||
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
|
||||||
val uiState = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
sealed class UiState {
|
|
||||||
data class Normal(val downloadEpisodes: List<DownloadEpisodeItem>) : UiState()
|
|
||||||
object Loading : UiState()
|
|
||||||
data class Error(val error: Exception) : UiState()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadEpisodes(seriesMetadata: DownloadSeriesMetadata) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_uiState.emit(UiState.Loading)
|
|
||||||
try {
|
|
||||||
_uiState.emit(UiState.Normal(getEpisodes((seriesMetadata))))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.emit(UiState.Error(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getEpisodes(seriesMetadata: DownloadSeriesMetadata): List<DownloadEpisodeItem> {
|
|
||||||
val episodes = seriesMetadata.episodes
|
|
||||||
return listOf(DownloadEpisodeItem.Header) + episodes.sortedWith(
|
|
||||||
compareBy(
|
|
||||||
{ it.item!!.parentIndexNumber },
|
|
||||||
{ it.item!!.indexNumber }
|
|
||||||
)
|
|
||||||
).map { DownloadEpisodeItem.Episode(it) }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,89 +0,0 @@
|
||||||
package dev.jdtech.jellyfin.viewmodels
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import dev.jdtech.jellyfin.database.DownloadDatabaseDao
|
|
||||||
import dev.jdtech.jellyfin.models.DownloadSection
|
|
||||||
import dev.jdtech.jellyfin.models.DownloadSeriesMetadata
|
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
|
||||||
import dev.jdtech.jellyfin.utils.checkDownloadStatus
|
|
||||||
import dev.jdtech.jellyfin.utils.loadDownloadedEpisodes
|
|
||||||
import java.util.UUID
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class DownloadViewModel
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val application: Application,
|
|
||||||
private val downloadDatabase: DownloadDatabaseDao,
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
|
||||||
val uiState = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
sealed class UiState {
|
|
||||||
data class Normal(val downloadSections: List<DownloadSection>) : UiState()
|
|
||||||
object Loading : UiState()
|
|
||||||
data class Error(val error: Exception) : UiState()
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadData() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_uiState.emit(UiState.Loading)
|
|
||||||
try {
|
|
||||||
checkDownloadStatus(downloadDatabase, application)
|
|
||||||
val items = loadDownloadedEpisodes(downloadDatabase)
|
|
||||||
|
|
||||||
val showsMap = mutableMapOf<UUID, MutableList<PlayerItem>>()
|
|
||||||
items.filter { it.item?.type == BaseItemKind.EPISODE }.forEach {
|
|
||||||
showsMap.computeIfAbsent(it.item!!.seriesId!!) { mutableListOf() } += it
|
|
||||||
}
|
|
||||||
val shows = showsMap.map {
|
|
||||||
DownloadSeriesMetadata(
|
|
||||||
it.key,
|
|
||||||
it.value[0].item!!.seriesName,
|
|
||||||
it.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadSections = mutableListOf<DownloadSection>()
|
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
DownloadSection(
|
|
||||||
UUID.randomUUID(),
|
|
||||||
"Movies",
|
|
||||||
items.filter { it.item?.type == BaseItemKind.MOVIE }
|
|
||||||
).let {
|
|
||||||
if (it.items!!.isNotEmpty()) downloadSections.add(
|
|
||||||
it
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DownloadSection(
|
|
||||||
UUID.randomUUID(),
|
|
||||||
"Shows",
|
|
||||||
null,
|
|
||||||
shows
|
|
||||||
).let {
|
|
||||||
if (it.series!!.isNotEmpty()) downloadSections.add(
|
|
||||||
it
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_uiState.emit(UiState.Normal(downloadSections))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.emit(UiState.Error(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dev.jdtech.jellyfin.AppPreferences
|
||||||
|
import dev.jdtech.jellyfin.Constants
|
||||||
|
import dev.jdtech.jellyfin.core.R
|
||||||
|
import dev.jdtech.jellyfin.models.FavoriteSection
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidShow
|
||||||
|
import dev.jdtech.jellyfin.models.UiText
|
||||||
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class DownloadsViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val appPreferences: AppPreferences,
|
||||||
|
private val repository: JellyfinRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
private val _connectionError = MutableSharedFlow<Exception>()
|
||||||
|
val connectionError = _connectionError.asSharedFlow()
|
||||||
|
|
||||||
|
sealed class UiState {
|
||||||
|
data class Normal(val sections: List<FavoriteSection>) : UiState()
|
||||||
|
object Loading : UiState()
|
||||||
|
data class Error(val error: Exception) : UiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
testServerConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun testServerConnection() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
if (appPreferences.offlineMode) return@launch
|
||||||
|
repository.getPublicSystemInfo()
|
||||||
|
// Give the UI a chance to load
|
||||||
|
delay(100)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_connectionError.emit(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.emit(UiState.Loading)
|
||||||
|
|
||||||
|
val sections = mutableListOf<FavoriteSection>()
|
||||||
|
|
||||||
|
val items = repository.getDownloads()
|
||||||
|
|
||||||
|
FavoriteSection(
|
||||||
|
Constants.FAVORITE_TYPE_MOVIES,
|
||||||
|
UiText.StringResource(R.string.movies_label),
|
||||||
|
items.filterIsInstance<FindroidMovie>()
|
||||||
|
).let {
|
||||||
|
if (it.items.isNotEmpty()) sections.add(
|
||||||
|
it
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FavoriteSection(
|
||||||
|
Constants.FAVORITE_TYPE_SHOWS,
|
||||||
|
UiText.StringResource(R.string.shows_label),
|
||||||
|
items.filterIsInstance<FindroidShow>()
|
||||||
|
).let {
|
||||||
|
if (it.items.isNotEmpty()) sections.add(
|
||||||
|
it
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_uiState.emit(UiState.Normal(sections))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,193 +1,190 @@
|
||||||
package dev.jdtech.jellyfin.viewmodels
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.DownloadManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.jdtech.jellyfin.database.DownloadDatabaseDao
|
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidSourceType
|
||||||
|
import dev.jdtech.jellyfin.models.UiText
|
||||||
|
import dev.jdtech.jellyfin.models.isDownloading
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
import dev.jdtech.jellyfin.utils.canRetryDownload
|
import dev.jdtech.jellyfin.utils.Downloader
|
||||||
import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode
|
import java.io.File
|
||||||
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
|
|
||||||
import dev.jdtech.jellyfin.utils.isItemAvailable
|
|
||||||
import dev.jdtech.jellyfin.utils.isItemDownloaded
|
|
||||||
import dev.jdtech.jellyfin.utils.requestDownload
|
|
||||||
import java.text.DateFormat
|
|
||||||
import java.time.ZoneOffset
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.random.Random
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
|
||||||
import org.jellyfin.sdk.model.DateTime
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import org.jellyfin.sdk.model.api.PlayAccess
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class EpisodeBottomSheetViewModel
|
class EpisodeBottomSheetViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val application: Application,
|
private val repository: JellyfinRepository,
|
||||||
private val jellyfinRepository: JellyfinRepository,
|
private val database: ServerDatabaseDao,
|
||||||
private val downloadDatabase: DownloadDatabaseDao
|
private val downloader: Downloader,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||||
val uiState = _uiState.asStateFlow()
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _downloadStatus = MutableStateFlow(Pair(0, 0))
|
||||||
|
val downloadStatus = _downloadStatus.asStateFlow()
|
||||||
|
|
||||||
|
private val _downloadError = MutableSharedFlow<UiText>()
|
||||||
|
val downloadError = _downloadError.asSharedFlow()
|
||||||
|
|
||||||
|
private val _navigateBack = MutableSharedFlow<Boolean>()
|
||||||
|
val navigateBack = _navigateBack.asSharedFlow()
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
sealed class UiState {
|
sealed class UiState {
|
||||||
data class Normal(
|
data class Normal(
|
||||||
val episode: BaseItemDto,
|
val episode: FindroidEpisode,
|
||||||
val runTime: String,
|
|
||||||
val dateString: String,
|
|
||||||
val played: Boolean,
|
|
||||||
val favorite: Boolean,
|
|
||||||
val canPlay: Boolean,
|
|
||||||
val canDownload: Boolean,
|
|
||||||
val downloaded: Boolean,
|
|
||||||
val available: Boolean,
|
|
||||||
val canRetry: Boolean
|
|
||||||
) : UiState()
|
) : UiState()
|
||||||
|
|
||||||
object Loading : UiState()
|
object Loading : UiState()
|
||||||
data class Error(val error: Exception) : UiState()
|
data class Error(val error: Exception) : UiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
var item: BaseItemDto? = null
|
lateinit var item: FindroidEpisode
|
||||||
private var runTime: String = ""
|
private var played: Boolean = false
|
||||||
private var dateString: String = ""
|
private var favorite: Boolean = false
|
||||||
var played: Boolean = false
|
|
||||||
var favorite: Boolean = false
|
|
||||||
private var canPlay = true
|
|
||||||
private var canDownload = false
|
|
||||||
private var downloaded: Boolean = false
|
|
||||||
private var available: Boolean = true
|
|
||||||
var canRetry: Boolean = false
|
|
||||||
var playerItems: MutableList<PlayerItem> = mutableListOf()
|
|
||||||
|
|
||||||
fun loadEpisode(episodeId: UUID) {
|
fun loadEpisode(episodeId: UUID) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.emit(UiState.Loading)
|
_uiState.emit(UiState.Loading)
|
||||||
try {
|
try {
|
||||||
val tempItem = jellyfinRepository.getItem(episodeId)
|
item = repository.getEpisode(episodeId)
|
||||||
item = tempItem
|
played = item.played
|
||||||
runTime = "${tempItem.runTimeTicks?.div(600000000)} min"
|
favorite = item.favorite
|
||||||
dateString = getDateString(tempItem.premiereDate)
|
if (item.isDownloading()) {
|
||||||
played = tempItem.userData?.played == true
|
pollDownloadProgress()
|
||||||
favorite = tempItem.userData?.isFavorite == true
|
}
|
||||||
canPlay = tempItem.playAccess != PlayAccess.NONE
|
|
||||||
canDownload = tempItem.canDownload == true
|
|
||||||
downloaded = isItemDownloaded(downloadDatabase, episodeId)
|
|
||||||
_uiState.emit(
|
_uiState.emit(
|
||||||
UiState.Normal(
|
UiState.Normal(
|
||||||
tempItem,
|
item,
|
||||||
runTime,
|
|
||||||
dateString,
|
|
||||||
played,
|
|
||||||
favorite,
|
|
||||||
canPlay,
|
|
||||||
canDownload,
|
|
||||||
downloaded,
|
|
||||||
available,
|
|
||||||
canRetry,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
} catch (_: NullPointerException) {
|
||||||
|
// Navigate back because item does not exist (probably because it's been deleted)
|
||||||
|
_navigateBack.emit(true)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.emit(UiState.Error(e))
|
_uiState.emit(UiState.Error(e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadEpisode(playerItem: PlayerItem) {
|
fun togglePlayed(): Boolean {
|
||||||
viewModelScope.launch {
|
when (played) {
|
||||||
_uiState.emit(UiState.Loading)
|
false -> {
|
||||||
playerItems.add(playerItem)
|
played = true
|
||||||
item = downloadMetadataToBaseItemDto(playerItem.item!!)
|
viewModelScope.launch {
|
||||||
available = isItemAvailable(playerItem.itemId)
|
try {
|
||||||
canRetry = canRetryDownload(playerItem.itemId, downloadDatabase, application)
|
repository.markAsPlayed(item.id)
|
||||||
_uiState.emit(
|
} catch (_: Exception) {}
|
||||||
UiState.Normal(
|
}
|
||||||
item!!,
|
}
|
||||||
runTime,
|
true -> {
|
||||||
dateString,
|
played = false
|
||||||
played,
|
viewModelScope.launch {
|
||||||
favorite,
|
try {
|
||||||
canPlay,
|
repository.markAsUnplayed(item.id)
|
||||||
canDownload,
|
} catch (_: Exception) {}
|
||||||
downloaded,
|
}
|
||||||
available,
|
|
||||||
canRetry,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun markAsPlayed(itemId: UUID) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
jellyfinRepository.markAsPlayed(itemId)
|
|
||||||
} catch (e: ApiClientException) {
|
|
||||||
Timber.d(e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
played = true
|
return played
|
||||||
}
|
}
|
||||||
|
|
||||||
fun markAsUnplayed(itemId: UUID) {
|
fun toggleFavorite(): Boolean {
|
||||||
viewModelScope.launch {
|
when (favorite) {
|
||||||
try {
|
false -> {
|
||||||
jellyfinRepository.markAsUnplayed(itemId)
|
favorite = true
|
||||||
} catch (e: ApiClientException) {
|
viewModelScope.launch {
|
||||||
Timber.d(e)
|
try {
|
||||||
|
repository.markAsFavorite(item.id)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true -> {
|
||||||
|
favorite = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.unmarkAsFavorite(item.id)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
played = false
|
return favorite
|
||||||
}
|
}
|
||||||
|
|
||||||
fun markAsFavorite(itemId: UUID) {
|
fun download(sourceIndex: Int = 0, storageIndex: Int = 0) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
val result = downloader.downloadItem(item, item.sources[sourceIndex].id, storageIndex)
|
||||||
jellyfinRepository.markAsFavorite(itemId)
|
// Send one time signal to fragment that the download has been initiated
|
||||||
} catch (e: ApiClientException) {
|
_downloadStatus.emit(Pair(10, Random.nextInt()))
|
||||||
Timber.d(e)
|
|
||||||
|
if (result.second != null) {
|
||||||
|
_downloadError.emit(result.second!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadEpisode(item.id)
|
||||||
}
|
}
|
||||||
favorite = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unmarkAsFavorite(itemId: UUID) {
|
fun cancelDownload() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
downloader.cancelDownload(item, item.sources.first { it.type == FindroidSourceType.LOCAL })
|
||||||
jellyfinRepository.unmarkAsFavorite(itemId)
|
loadEpisode(item.id)
|
||||||
} catch (e: ApiClientException) {
|
|
||||||
Timber.d(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
favorite = false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun download() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
requestDownload(
|
|
||||||
jellyfinRepository,
|
|
||||||
downloadDatabase,
|
|
||||||
application,
|
|
||||||
item!!.id
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteEpisode() {
|
fun deleteEpisode() {
|
||||||
deleteDownloadedEpisode(downloadDatabase, playerItems[0].itemId)
|
viewModelScope.launch {
|
||||||
|
downloader.deleteItem(item, item.sources.first { it.type == FindroidSourceType.LOCAL })
|
||||||
|
loadEpisode(item.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDateString(datetime: DateTime?): String {
|
private fun pollDownloadProgress() {
|
||||||
if (datetime == null) return ""
|
handler.removeCallbacksAndMessages(null)
|
||||||
val instant = datetime.toInstant(ZoneOffset.UTC)
|
val downloadProgressRunnable = object : Runnable {
|
||||||
val date = Date.from(instant)
|
override fun run() {
|
||||||
return DateFormat.getDateInstance(DateFormat.SHORT).format(date)
|
viewModelScope.launch {
|
||||||
|
val source = item.sources.firstOrNull { it.type == FindroidSourceType.LOCAL }
|
||||||
|
val (downloadStatus, progress) = downloader.getProgress(source?.downloadId)
|
||||||
|
_downloadStatus.emit(Pair(downloadStatus, progress))
|
||||||
|
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
|
||||||
|
if (source == null) return@launch
|
||||||
|
val path = source.path.replace(".download", "")
|
||||||
|
File(source.path).renameTo(File(path))
|
||||||
|
database.setSourcePath(source.id, path)
|
||||||
|
loadEpisode(item.id)
|
||||||
|
}
|
||||||
|
if (downloadStatus == DownloadManager.STATUS_FAILED) {
|
||||||
|
if (source == null) return@launch
|
||||||
|
downloader.deleteItem(item, source)
|
||||||
|
loadEpisode(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.isDownloading()) {
|
||||||
|
handler.postDelayed(this, 2000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handler.post(downloadProgressRunnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.jdtech.jellyfin.Constants
|
import dev.jdtech.jellyfin.Constants
|
||||||
import dev.jdtech.jellyfin.core.R
|
import dev.jdtech.jellyfin.core.R
|
||||||
import dev.jdtech.jellyfin.models.FavoriteSection
|
import dev.jdtech.jellyfin.models.FavoriteSection
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidShow
|
||||||
import dev.jdtech.jellyfin.models.UiText
|
import dev.jdtech.jellyfin.models.UiText
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
@ -14,7 +17,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class FavoriteViewModel
|
class FavoriteViewModel
|
||||||
|
@ -41,18 +43,13 @@ constructor(
|
||||||
try {
|
try {
|
||||||
val items = jellyfinRepository.getFavoriteItems()
|
val items = jellyfinRepository.getFavoriteItems()
|
||||||
|
|
||||||
if (items.isEmpty()) {
|
|
||||||
_uiState.emit(UiState.Normal(emptyList()))
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val favoriteSections = mutableListOf<FavoriteSection>()
|
val favoriteSections = mutableListOf<FavoriteSection>()
|
||||||
|
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
FavoriteSection(
|
FavoriteSection(
|
||||||
Constants.FAVORITE_TYPE_MOVIES,
|
Constants.FAVORITE_TYPE_MOVIES,
|
||||||
UiText.StringResource(R.string.movies_label),
|
UiText.StringResource(R.string.movies_label),
|
||||||
items.filter { it.type == BaseItemKind.MOVIE }
|
items.filterIsInstance<FindroidMovie>()
|
||||||
).let {
|
).let {
|
||||||
if (it.items.isNotEmpty()) favoriteSections.add(
|
if (it.items.isNotEmpty()) favoriteSections.add(
|
||||||
it
|
it
|
||||||
|
@ -61,7 +58,7 @@ constructor(
|
||||||
FavoriteSection(
|
FavoriteSection(
|
||||||
Constants.FAVORITE_TYPE_SHOWS,
|
Constants.FAVORITE_TYPE_SHOWS,
|
||||||
UiText.StringResource(R.string.shows_label),
|
UiText.StringResource(R.string.shows_label),
|
||||||
items.filter { it.type == BaseItemKind.SERIES }
|
items.filterIsInstance<FindroidShow>()
|
||||||
).let {
|
).let {
|
||||||
if (it.items.isNotEmpty()) favoriteSections.add(
|
if (it.items.isNotEmpty()) favoriteSections.add(
|
||||||
it
|
it
|
||||||
|
@ -70,7 +67,7 @@ constructor(
|
||||||
FavoriteSection(
|
FavoriteSection(
|
||||||
Constants.FAVORITE_TYPE_EPISODES,
|
Constants.FAVORITE_TYPE_EPISODES,
|
||||||
UiText.StringResource(R.string.episodes_label),
|
UiText.StringResource(R.string.episodes_label),
|
||||||
items.filter { it.type == BaseItemKind.EPISODE }
|
items.filterIsInstance<FindroidEpisode>()
|
||||||
).let {
|
).let {
|
||||||
if (it.items.isNotEmpty()) favoriteSections.add(
|
if (it.items.isNotEmpty()) favoriteSections.add(
|
||||||
it
|
it
|
||||||
|
|
|
@ -1,30 +1,26 @@
|
||||||
package dev.jdtech.jellyfin.viewmodels
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dev.jdtech.jellyfin.AppPreferences
|
||||||
import dev.jdtech.jellyfin.core.R
|
import dev.jdtech.jellyfin.core.R
|
||||||
import dev.jdtech.jellyfin.database.DownloadDatabaseDao
|
|
||||||
import dev.jdtech.jellyfin.models.CollectionType
|
import dev.jdtech.jellyfin.models.CollectionType
|
||||||
import dev.jdtech.jellyfin.models.HomeItem
|
import dev.jdtech.jellyfin.models.HomeItem
|
||||||
import dev.jdtech.jellyfin.models.HomeSection
|
import dev.jdtech.jellyfin.models.HomeSection
|
||||||
|
import dev.jdtech.jellyfin.models.UiText
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
import dev.jdtech.jellyfin.utils.syncPlaybackProgress
|
|
||||||
import dev.jdtech.jellyfin.utils.toView
|
import dev.jdtech.jellyfin.utils.toView
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class HomeViewModel @Inject internal constructor(
|
class HomeViewModel @Inject internal constructor(
|
||||||
private val application: Application,
|
|
||||||
private val repository: JellyfinRepository,
|
private val repository: JellyfinRepository,
|
||||||
private val downloadDatabase: DownloadDatabaseDao,
|
private val appPreferences: AppPreferences,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||||
val uiState = _uiState.asStateFlow()
|
val uiState = _uiState.asStateFlow()
|
||||||
|
@ -35,6 +31,14 @@ class HomeViewModel @Inject internal constructor(
|
||||||
data class Error(val error: Exception) : UiState()
|
data class Error(val error: Exception) : UiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val uuidLibraries = UUID(4104409383667715086, -6276889634004763134) // 38f5ca96-9e4b-4c0e-a8e4-02225ed07e02
|
||||||
|
private val uuidContinueWatching = UUID(4937169328197226115, -4704919157662094443) // 44845958-8326-4e83-beb4-c4f42e9eeb95
|
||||||
|
private val uuidNextUp = UUID(1783371395749072194, -6164625418200444295) // 18bfced5-f237-4d42-aa72-d9d7fed19279
|
||||||
|
|
||||||
|
private val uiTextLibraries = UiText.StringResource(R.string.libraries)
|
||||||
|
private val uiTextContinueWatching = UiText.StringResource(R.string.continue_watching)
|
||||||
|
private val uiTextNextUp = UiText.StringResource(R.string.next_up)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
@ -50,15 +54,14 @@ class HomeViewModel @Inject internal constructor(
|
||||||
try {
|
try {
|
||||||
val items = mutableListOf<HomeItem>()
|
val items = mutableListOf<HomeItem>()
|
||||||
|
|
||||||
|
if (appPreferences.offlineMode) items.add(HomeItem.OfflineCard)
|
||||||
|
|
||||||
if (includeLibraries) {
|
if (includeLibraries) {
|
||||||
items.add(loadLibraries())
|
items.add(loadLibraries())
|
||||||
}
|
}
|
||||||
|
|
||||||
val updated = items + loadDynamicItems() + loadViews()
|
val updated = items + loadDynamicItems() + loadViews()
|
||||||
|
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
syncPlaybackProgress(downloadDatabase, repository)
|
|
||||||
}
|
|
||||||
_uiState.emit(UiState.Normal(updated))
|
_uiState.emit(UiState.Normal(updated))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.emit(UiState.Error(e))
|
_uiState.emit(UiState.Error(e))
|
||||||
|
@ -67,13 +70,13 @@ class HomeViewModel @Inject internal constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadLibraries(): HomeItem {
|
private suspend fun loadLibraries(): HomeItem {
|
||||||
val items = repository.getItems()
|
val items = repository.getLibraries()
|
||||||
val collections =
|
val collections =
|
||||||
items.filter { collection -> CollectionType.unsupportedCollections.none { it.type == collection.collectionType } }
|
items.filter { collection -> CollectionType.unsupportedCollections.none { it == collection.type } }
|
||||||
return HomeItem.Libraries(
|
return HomeItem.Libraries(
|
||||||
HomeSection(
|
HomeSection(
|
||||||
UUID.fromString("38f5ca96-9e4b-4c0e-a8e4-02225ed07e02"),
|
uuidLibraries,
|
||||||
application.resources.getString(R.string.libraries),
|
uiTextLibraries,
|
||||||
collections
|
collections
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -87,8 +90,8 @@ class HomeViewModel @Inject internal constructor(
|
||||||
if (resumeItems.isNotEmpty()) {
|
if (resumeItems.isNotEmpty()) {
|
||||||
items.add(
|
items.add(
|
||||||
HomeSection(
|
HomeSection(
|
||||||
UUID.fromString("44845958-8326-4e83-beb4-c4f42e9eeb95"),
|
uuidContinueWatching,
|
||||||
application.resources.getString(R.string.continue_watching),
|
uiTextContinueWatching,
|
||||||
resumeItems
|
resumeItems
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -97,8 +100,8 @@ class HomeViewModel @Inject internal constructor(
|
||||||
if (nextUpItems.isNotEmpty()) {
|
if (nextUpItems.isNotEmpty()) {
|
||||||
items.add(
|
items.add(
|
||||||
HomeSection(
|
HomeSection(
|
||||||
UUID.fromString("18bfced5-f237-4d42-aa72-d9d7fed19279"),
|
uuidNextUp,
|
||||||
application.resources.getString(R.string.next_up),
|
uiTextNextUp,
|
||||||
nextUpItems
|
nextUpItems
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.cachedIn
|
import androidx.paging.cachedIn
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
import dev.jdtech.jellyfin.models.SortBy
|
import dev.jdtech.jellyfin.models.SortBy
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
@ -13,7 +14,6 @@ import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
@ -30,7 +30,7 @@ constructor(
|
||||||
var itemsloaded = false
|
var itemsloaded = false
|
||||||
|
|
||||||
sealed class UiState {
|
sealed class UiState {
|
||||||
data class Normal(val items: Flow<PagingData<BaseItemDto>>) : UiState()
|
data class Normal(val items: Flow<PagingData<FindroidItem>>) : UiState()
|
||||||
object Loading : UiState()
|
object Loading : UiState()
|
||||||
data class Error(val error: Exception) : UiState()
|
data class Error(val error: Exception) : UiState()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,428 +0,0 @@
|
||||||
package dev.jdtech.jellyfin.viewmodels
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import dev.jdtech.jellyfin.database.DownloadDatabaseDao
|
|
||||||
import dev.jdtech.jellyfin.models.AudioChannel
|
|
||||||
import dev.jdtech.jellyfin.models.AudioCodec
|
|
||||||
import dev.jdtech.jellyfin.models.DisplayProfile
|
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
|
||||||
import dev.jdtech.jellyfin.models.Resolution
|
|
||||||
import dev.jdtech.jellyfin.models.VideoMetadata
|
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
|
||||||
import dev.jdtech.jellyfin.utils.canRetryDownload
|
|
||||||
import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode
|
|
||||||
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
|
|
||||||
import dev.jdtech.jellyfin.utils.isItemAvailable
|
|
||||||
import dev.jdtech.jellyfin.utils.isItemDownloaded
|
|
||||||
import dev.jdtech.jellyfin.utils.requestDownload
|
|
||||||
import java.util.UUID
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemPerson
|
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
|
||||||
import org.jellyfin.sdk.model.api.PlayAccess
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class MediaInfoViewModel
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val application: Application,
|
|
||||||
private val jellyfinRepository: JellyfinRepository,
|
|
||||||
private val downloadDatabase: DownloadDatabaseDao,
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
|
||||||
val uiState = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
sealed class UiState {
|
|
||||||
data class Normal(
|
|
||||||
val item: BaseItemDto,
|
|
||||||
val actors: List<BaseItemPerson>,
|
|
||||||
val director: BaseItemPerson?,
|
|
||||||
val writers: List<BaseItemPerson>,
|
|
||||||
val videoMetadata: VideoMetadata?,
|
|
||||||
val writersString: String,
|
|
||||||
val genresString: String,
|
|
||||||
val videoString: String,
|
|
||||||
val audioString: String,
|
|
||||||
val subtitleString: String,
|
|
||||||
val runTime: String,
|
|
||||||
val dateString: String,
|
|
||||||
val nextUp: BaseItemDto?,
|
|
||||||
val seasons: List<BaseItemDto>,
|
|
||||||
val played: Boolean,
|
|
||||||
val favorite: Boolean,
|
|
||||||
val canPlay: Boolean,
|
|
||||||
val canDownload: Boolean,
|
|
||||||
val downloaded: Boolean,
|
|
||||||
var canRetry: Boolean = false,
|
|
||||||
val available: Boolean,
|
|
||||||
) : UiState()
|
|
||||||
|
|
||||||
object Loading : UiState()
|
|
||||||
data class Error(val error: Exception) : UiState()
|
|
||||||
}
|
|
||||||
|
|
||||||
var item: BaseItemDto? = null
|
|
||||||
private var actors: List<BaseItemPerson> = emptyList()
|
|
||||||
private var director: BaseItemPerson? = null
|
|
||||||
private var writers: List<BaseItemPerson> = emptyList()
|
|
||||||
private var videoMetadata: VideoMetadata? = null
|
|
||||||
private var writersString: String = ""
|
|
||||||
private var genresString: String = ""
|
|
||||||
private var videoString: String = ""
|
|
||||||
private var audioString: String = ""
|
|
||||||
private var subtitleString: String = ""
|
|
||||||
private var runTime: String = ""
|
|
||||||
private var dateString: String = ""
|
|
||||||
var nextUp: BaseItemDto? = null
|
|
||||||
var seasons: List<BaseItemDto> = emptyList()
|
|
||||||
var played: Boolean = false
|
|
||||||
var favorite: Boolean = false
|
|
||||||
private var canPlay: Boolean = true
|
|
||||||
private var canDownload: Boolean = false
|
|
||||||
private var downloaded: Boolean = false
|
|
||||||
var canRetry: Boolean = false
|
|
||||||
private var available: Boolean = true
|
|
||||||
|
|
||||||
lateinit var playerItem: PlayerItem
|
|
||||||
|
|
||||||
fun loadData(itemId: UUID, itemType: BaseItemKind) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_uiState.emit(UiState.Loading)
|
|
||||||
try {
|
|
||||||
val tempItem = jellyfinRepository.getItem(itemId)
|
|
||||||
item = tempItem
|
|
||||||
actors = getActors(tempItem)
|
|
||||||
director = getDirector(tempItem)
|
|
||||||
writers = getWriters(tempItem)
|
|
||||||
writersString = writers.joinToString(separator = ", ") { it.name.toString() }
|
|
||||||
videoMetadata =
|
|
||||||
if (tempItem.type == BaseItemKind.MOVIE) parseVideoMetadata(tempItem) else null
|
|
||||||
genresString = tempItem.genres?.joinToString(separator = ", ") ?: ""
|
|
||||||
videoString = getMediaString(tempItem, MediaStreamType.VIDEO)
|
|
||||||
audioString = getMediaString(tempItem, MediaStreamType.AUDIO)
|
|
||||||
subtitleString = getMediaString(tempItem, MediaStreamType.SUBTITLE)
|
|
||||||
runTime = "${tempItem.runTimeTicks?.div(600000000)} min"
|
|
||||||
dateString = getDateString(tempItem)
|
|
||||||
played = tempItem.userData?.played ?: false
|
|
||||||
favorite = tempItem.userData?.isFavorite ?: false
|
|
||||||
canPlay = tempItem.playAccess != PlayAccess.NONE
|
|
||||||
canDownload = tempItem.canDownload == true
|
|
||||||
downloaded = isItemDownloaded(downloadDatabase, itemId)
|
|
||||||
if (itemType == BaseItemKind.SERIES) {
|
|
||||||
nextUp = getNextUp(itemId)
|
|
||||||
seasons = jellyfinRepository.getSeasons(itemId)
|
|
||||||
}
|
|
||||||
_uiState.emit(
|
|
||||||
UiState.Normal(
|
|
||||||
tempItem,
|
|
||||||
actors,
|
|
||||||
director,
|
|
||||||
writers,
|
|
||||||
videoMetadata,
|
|
||||||
writersString,
|
|
||||||
genresString,
|
|
||||||
videoString,
|
|
||||||
audioString,
|
|
||||||
subtitleString,
|
|
||||||
runTime,
|
|
||||||
dateString,
|
|
||||||
nextUp,
|
|
||||||
seasons,
|
|
||||||
played,
|
|
||||||
favorite,
|
|
||||||
canPlay,
|
|
||||||
canDownload,
|
|
||||||
downloaded,
|
|
||||||
canRetry,
|
|
||||||
available
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.emit(UiState.Error(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadData(pItem: PlayerItem) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
playerItem = pItem
|
|
||||||
val tempItem = downloadMetadataToBaseItemDto(playerItem.item!!)
|
|
||||||
item = tempItem
|
|
||||||
actors = getActors(tempItem)
|
|
||||||
director = getDirector(tempItem)
|
|
||||||
writers = getWriters(tempItem)
|
|
||||||
writersString = writers.joinToString(separator = ", ") { it.name.toString() }
|
|
||||||
videoMetadata =
|
|
||||||
if (tempItem.type == BaseItemKind.MOVIE) parseVideoMetadata(tempItem) else null
|
|
||||||
genresString = tempItem.genres?.joinToString(separator = ", ") ?: ""
|
|
||||||
videoString = getMediaString(tempItem, MediaStreamType.VIDEO)
|
|
||||||
audioString = getMediaString(tempItem, MediaStreamType.AUDIO)
|
|
||||||
subtitleString = getMediaString(tempItem, MediaStreamType.SUBTITLE)
|
|
||||||
runTime = ""
|
|
||||||
dateString = ""
|
|
||||||
played = tempItem.userData?.played ?: false
|
|
||||||
favorite = tempItem.userData?.isFavorite ?: false
|
|
||||||
available = isItemAvailable(tempItem.id)
|
|
||||||
canRetry = canRetryDownload(tempItem.id, downloadDatabase, application)
|
|
||||||
_uiState.emit(
|
|
||||||
UiState.Normal(
|
|
||||||
tempItem,
|
|
||||||
actors,
|
|
||||||
director,
|
|
||||||
writers,
|
|
||||||
videoMetadata,
|
|
||||||
writersString,
|
|
||||||
genresString,
|
|
||||||
videoString,
|
|
||||||
audioString,
|
|
||||||
subtitleString,
|
|
||||||
runTime,
|
|
||||||
dateString,
|
|
||||||
nextUp,
|
|
||||||
seasons,
|
|
||||||
played,
|
|
||||||
favorite,
|
|
||||||
canPlay,
|
|
||||||
canDownload,
|
|
||||||
downloaded,
|
|
||||||
canRetry,
|
|
||||||
available
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getActors(item: BaseItemDto): List<BaseItemPerson> {
|
|
||||||
val actors: List<BaseItemPerson>
|
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
actors = item.people?.filter { it.type == "Actor" } ?: emptyList()
|
|
||||||
}
|
|
||||||
return actors
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getDirector(item: BaseItemDto): BaseItemPerson? {
|
|
||||||
val director: BaseItemPerson?
|
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
director = item.people?.firstOrNull { it.type == "Director" }
|
|
||||||
}
|
|
||||||
return director
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getWriters(item: BaseItemDto): List<BaseItemPerson> {
|
|
||||||
val writers: List<BaseItemPerson>
|
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
writers = item.people?.filter { it.type == "Writer" } ?: emptyList()
|
|
||||||
}
|
|
||||||
return writers
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getMediaString(item: BaseItemDto, type: MediaStreamType): String {
|
|
||||||
val streams: List<MediaStream>
|
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
streams = item.mediaStreams?.filter { it.type == type } ?: emptyList()
|
|
||||||
}
|
|
||||||
return streams.map { it.displayTitle }.joinToString(separator = ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun parseVideoMetadata(item: BaseItemDto): VideoMetadata {
|
|
||||||
val resolution = mutableListOf<Resolution>()
|
|
||||||
val audioChannels = mutableListOf<AudioChannel>()
|
|
||||||
val displayProfile = mutableListOf<DisplayProfile>()
|
|
||||||
val audioCodecs = mutableListOf<AudioCodec>()
|
|
||||||
val isAtmosAudio = mutableListOf<Boolean>()
|
|
||||||
|
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
item.mediaStreams?.filter { stream ->
|
|
||||||
when (stream.type) {
|
|
||||||
MediaStreamType.AUDIO -> {
|
|
||||||
/**
|
|
||||||
* Match audio profile from [MediaStream.channelLayout]
|
|
||||||
*/
|
|
||||||
audioChannels.add(
|
|
||||||
when (stream.channelLayout) {
|
|
||||||
AudioChannel.CH_2_1.raw -> AudioChannel.CH_2_1
|
|
||||||
AudioChannel.CH_5_1.raw -> AudioChannel.CH_5_1
|
|
||||||
AudioChannel.CH_7_1.raw -> AudioChannel.CH_7_1
|
|
||||||
else -> AudioChannel.CH_2_0
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Match [MediaStream.displayTitle] for Dolby Atmos
|
|
||||||
*/
|
|
||||||
stream.displayTitle?.apply {
|
|
||||||
isAtmosAudio.add(contains("ATMOS", true))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Match audio codec from [MediaStream.codec]
|
|
||||||
*/
|
|
||||||
audioCodecs.add(
|
|
||||||
when (stream.codec?.lowercase()) {
|
|
||||||
AudioCodec.FLAC.toString() -> AudioCodec.FLAC
|
|
||||||
AudioCodec.AAC.toString() -> AudioCodec.AAC
|
|
||||||
AudioCodec.AC3.toString() -> AudioCodec.AC3
|
|
||||||
AudioCodec.EAC3.toString() -> AudioCodec.EAC3
|
|
||||||
AudioCodec.VORBIS.toString() -> AudioCodec.VORBIS
|
|
||||||
AudioCodec.OPUS.toString() -> AudioCodec.OPUS
|
|
||||||
AudioCodec.TRUEHD.toString() -> AudioCodec.TRUEHD
|
|
||||||
AudioCodec.DTS.toString() -> AudioCodec.DTS
|
|
||||||
else -> AudioCodec.MP3
|
|
||||||
}
|
|
||||||
)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
MediaStreamType.VIDEO -> {
|
|
||||||
with(stream) {
|
|
||||||
/**
|
|
||||||
* Match dynamic range from [MediaStream.videoRangeType]
|
|
||||||
*/
|
|
||||||
displayProfile.add(
|
|
||||||
/**
|
|
||||||
* Since [MediaStream.videoRangeType] is [DisplayProfile.HDR10]
|
|
||||||
* Check if [MediaStream.videoDoViTitle] is not null and return
|
|
||||||
* [DisplayProfile.DOLBY_VISION] accordingly
|
|
||||||
*/
|
|
||||||
if (stream.videoDoViTitle != null) {
|
|
||||||
DisplayProfile.DOLBY_VISION
|
|
||||||
} else when (videoRangeType) {
|
|
||||||
DisplayProfile.HDR.raw -> DisplayProfile.HDR
|
|
||||||
DisplayProfile.HDR10.raw -> DisplayProfile.HDR10
|
|
||||||
DisplayProfile.HLG.raw -> DisplayProfile.HLG
|
|
||||||
else -> DisplayProfile.SDR
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Force stream [MediaStream.height] and [MediaStream.width] as not null
|
|
||||||
* since we are inside [MediaStreamType.VIDEO] block
|
|
||||||
*/
|
|
||||||
resolution.add(
|
|
||||||
when {
|
|
||||||
height!! <= 1080 && width!! <= 1920 -> {
|
|
||||||
Resolution.HD
|
|
||||||
}
|
|
||||||
|
|
||||||
height!! <= 2160 && width!! <= 3840 -> {
|
|
||||||
Resolution.UHD
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> Resolution.SD
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return VideoMetadata(
|
|
||||||
resolution,
|
|
||||||
displayProfile.toSet().toList(),
|
|
||||||
audioChannels.toSet().toList(),
|
|
||||||
audioCodecs.toSet().toList(),
|
|
||||||
isAtmosAudio
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getNextUp(seriesId: UUID): BaseItemDto? {
|
|
||||||
val nextUpItems = jellyfinRepository.getNextUp(seriesId)
|
|
||||||
return if (nextUpItems.isNotEmpty()) {
|
|
||||||
nextUpItems[0]
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun markAsPlayed(itemId: UUID) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
jellyfinRepository.markAsPlayed(itemId)
|
|
||||||
} catch (e: ApiClientException) {
|
|
||||||
Timber.d(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
played = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun markAsUnplayed(itemId: UUID) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
jellyfinRepository.markAsUnplayed(itemId)
|
|
||||||
} catch (e: ApiClientException) {
|
|
||||||
Timber.d(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
played = false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun markAsFavorite(itemId: UUID) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
jellyfinRepository.markAsFavorite(itemId)
|
|
||||||
} catch (e: ApiClientException) {
|
|
||||||
Timber.d(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
favorite = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun unmarkAsFavorite(itemId: UUID) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
jellyfinRepository.unmarkAsFavorite(itemId)
|
|
||||||
} catch (e: ApiClientException) {
|
|
||||||
Timber.d(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
favorite = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getDateString(item: BaseItemDto): String {
|
|
||||||
val dateRange: MutableList<String> = mutableListOf()
|
|
||||||
item.productionYear?.let { dateRange.add(it.toString()) }
|
|
||||||
when (item.status) {
|
|
||||||
"Continuing" -> {
|
|
||||||
dateRange.add("Present")
|
|
||||||
}
|
|
||||||
|
|
||||||
"Ended" -> {
|
|
||||||
item.endDate?.let { dateRange.add(it.year.toString()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (dateRange.count() > 1 && dateRange[0] == dateRange[1]) return dateRange[0]
|
|
||||||
return dateRange.joinToString(separator = " - ")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun download() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
requestDownload(
|
|
||||||
jellyfinRepository,
|
|
||||||
downloadDatabase,
|
|
||||||
application,
|
|
||||||
item!!.id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteItem() {
|
|
||||||
deleteDownloadedEpisode(downloadDatabase, playerItem.itemId)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,12 +4,12 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.jdtech.jellyfin.models.CollectionType
|
import dev.jdtech.jellyfin.models.CollectionType
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidCollection
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class MediaViewModel
|
class MediaViewModel
|
||||||
|
@ -22,7 +22,7 @@ constructor(
|
||||||
val uiState = _uiState.asStateFlow()
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
sealed class UiState {
|
sealed class UiState {
|
||||||
data class Normal(val collections: List<BaseItemDto>) : UiState()
|
data class Normal(val collections: List<FindroidCollection>) : UiState()
|
||||||
object Loading : UiState()
|
object Loading : UiState()
|
||||||
data class Error(val error: Exception) : UiState()
|
data class Error(val error: Exception) : UiState()
|
||||||
}
|
}
|
||||||
|
@ -35,9 +35,9 @@ constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.emit(UiState.Loading)
|
_uiState.emit(UiState.Loading)
|
||||||
try {
|
try {
|
||||||
val items = jellyfinRepository.getItems()
|
val items = jellyfinRepository.getLibraries()
|
||||||
val collections =
|
val collections =
|
||||||
items.filter { collection -> CollectionType.unsupportedCollections.none { it.type == collection.collectionType } }
|
items.filter { collection -> CollectionType.unsupportedCollections.none { it == collection.type } }
|
||||||
_uiState.emit(UiState.Normal(collections))
|
_uiState.emit(UiState.Normal(collections))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.emit(
|
_uiState.emit(
|
||||||
|
|
|
@ -0,0 +1,385 @@
|
||||||
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
|
import android.app.DownloadManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||||
|
import dev.jdtech.jellyfin.models.AudioChannel
|
||||||
|
import dev.jdtech.jellyfin.models.AudioCodec
|
||||||
|
import dev.jdtech.jellyfin.models.DisplayProfile
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMediaStream
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidSourceType
|
||||||
|
import dev.jdtech.jellyfin.models.Resolution
|
||||||
|
import dev.jdtech.jellyfin.models.UiText
|
||||||
|
import dev.jdtech.jellyfin.models.VideoMetadata
|
||||||
|
import dev.jdtech.jellyfin.models.isDownloading
|
||||||
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
|
import dev.jdtech.jellyfin.utils.Downloader
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.random.Random
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Runnable
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemPerson
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class MovieViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val repository: JellyfinRepository,
|
||||||
|
private val database: ServerDatabaseDao,
|
||||||
|
private val downloader: Downloader
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _downloadStatus = MutableStateFlow(Pair(0, 0))
|
||||||
|
val downloadStatus = _downloadStatus.asStateFlow()
|
||||||
|
|
||||||
|
private val _downloadError = MutableSharedFlow<UiText>()
|
||||||
|
val downloadError = _downloadError.asSharedFlow()
|
||||||
|
|
||||||
|
private val _navigateBack = MutableSharedFlow<Boolean>()
|
||||||
|
val navigateBack = _navigateBack.asSharedFlow()
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
sealed class UiState {
|
||||||
|
data class Normal(
|
||||||
|
val item: FindroidMovie,
|
||||||
|
val actors: List<BaseItemPerson>,
|
||||||
|
val director: BaseItemPerson?,
|
||||||
|
val writers: List<BaseItemPerson>,
|
||||||
|
val videoMetadata: VideoMetadata,
|
||||||
|
val writersString: String,
|
||||||
|
val genresString: String,
|
||||||
|
val videoString: String,
|
||||||
|
val audioString: String,
|
||||||
|
val subtitleString: String,
|
||||||
|
val runTime: String,
|
||||||
|
val dateString: String,
|
||||||
|
) : UiState()
|
||||||
|
|
||||||
|
object Loading : UiState()
|
||||||
|
data class Error(val error: Exception) : UiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
lateinit var item: FindroidMovie
|
||||||
|
private var played: Boolean = false
|
||||||
|
private var favorite: Boolean = false
|
||||||
|
private var writers: List<BaseItemPerson> = emptyList()
|
||||||
|
private var writersString: String = ""
|
||||||
|
private var runTime: String = ""
|
||||||
|
|
||||||
|
fun loadData(itemId: UUID) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.emit(UiState.Loading)
|
||||||
|
try {
|
||||||
|
item = repository.getMovie(itemId)
|
||||||
|
played = item.played
|
||||||
|
favorite = item.favorite
|
||||||
|
writers = getWriters(item)
|
||||||
|
writersString = writers.joinToString(separator = ", ") { it.name.toString() }
|
||||||
|
runTime = "${item.runtimeTicks.div(600000000)} min"
|
||||||
|
if (item.isDownloading()) {
|
||||||
|
pollDownloadProgress()
|
||||||
|
}
|
||||||
|
_uiState.emit(
|
||||||
|
UiState.Normal(
|
||||||
|
item,
|
||||||
|
getActors(item),
|
||||||
|
getDirector(item),
|
||||||
|
writers,
|
||||||
|
parseVideoMetadata(item),
|
||||||
|
writersString,
|
||||||
|
item.genres.joinToString(separator = ", "),
|
||||||
|
getMediaString(item, MediaStreamType.VIDEO),
|
||||||
|
getMediaString(item, MediaStreamType.AUDIO),
|
||||||
|
getMediaString(item, MediaStreamType.SUBTITLE),
|
||||||
|
runTime,
|
||||||
|
getDateString(item),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (_: NullPointerException) {
|
||||||
|
// Navigate back because item does not exist (probably because it's been deleted)
|
||||||
|
_navigateBack.emit(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.emit(UiState.Error(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getActors(item: FindroidMovie): List<BaseItemPerson> {
|
||||||
|
val actors: List<BaseItemPerson>
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
actors = item.people.filter { it.type == "Actor" }
|
||||||
|
}
|
||||||
|
return actors
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getDirector(item: FindroidMovie): BaseItemPerson? {
|
||||||
|
val director: BaseItemPerson?
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
director = item.people.firstOrNull { it.type == "Director" }
|
||||||
|
}
|
||||||
|
return director
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getWriters(item: FindroidMovie): List<BaseItemPerson> {
|
||||||
|
val writers: List<BaseItemPerson>
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
writers = item.people.filter { it.type == "Writer" }
|
||||||
|
}
|
||||||
|
return writers
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getMediaString(item: FindroidMovie, type: MediaStreamType): String {
|
||||||
|
val streams: List<FindroidMediaStream>
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
streams =
|
||||||
|
item.sources.getOrNull(0)?.mediaStreams?.filter { it.type == type } ?: emptyList()
|
||||||
|
}
|
||||||
|
return streams.map { it.displayTitle }.joinToString(separator = ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun parseVideoMetadata(item: FindroidMovie): VideoMetadata {
|
||||||
|
val resolution = mutableListOf<Resolution>()
|
||||||
|
val audioChannels = mutableListOf<AudioChannel>()
|
||||||
|
val displayProfile = mutableListOf<DisplayProfile>()
|
||||||
|
val audioCodecs = mutableListOf<AudioCodec>()
|
||||||
|
val isAtmosAudio = mutableListOf<Boolean>()
|
||||||
|
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
item.sources.getOrNull(0)?.mediaStreams?.filter { stream ->
|
||||||
|
when (stream.type) {
|
||||||
|
MediaStreamType.AUDIO -> {
|
||||||
|
/**
|
||||||
|
* Match audio profile from [MediaStream.channelLayout]
|
||||||
|
*/
|
||||||
|
audioChannels.add(
|
||||||
|
when (stream.channelLayout) {
|
||||||
|
AudioChannel.CH_2_1.raw -> AudioChannel.CH_2_1
|
||||||
|
AudioChannel.CH_5_1.raw -> AudioChannel.CH_5_1
|
||||||
|
AudioChannel.CH_7_1.raw -> AudioChannel.CH_7_1
|
||||||
|
else -> AudioChannel.CH_2_0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match [MediaStream.displayTitle] for Dolby Atmos
|
||||||
|
*/
|
||||||
|
stream.displayTitle?.apply {
|
||||||
|
isAtmosAudio.add(contains("ATMOS", true))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match audio codec from [MediaStream.codec]
|
||||||
|
*/
|
||||||
|
audioCodecs.add(
|
||||||
|
when (stream.codec.lowercase()) {
|
||||||
|
AudioCodec.FLAC.toString() -> AudioCodec.FLAC
|
||||||
|
AudioCodec.AAC.toString() -> AudioCodec.AAC
|
||||||
|
AudioCodec.AC3.toString() -> AudioCodec.AC3
|
||||||
|
AudioCodec.EAC3.toString() -> AudioCodec.EAC3
|
||||||
|
AudioCodec.VORBIS.toString() -> AudioCodec.VORBIS
|
||||||
|
AudioCodec.OPUS.toString() -> AudioCodec.OPUS
|
||||||
|
AudioCodec.TRUEHD.toString() -> AudioCodec.TRUEHD
|
||||||
|
AudioCodec.DTS.toString() -> AudioCodec.DTS
|
||||||
|
else -> AudioCodec.MP3
|
||||||
|
}
|
||||||
|
)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaStreamType.VIDEO -> {
|
||||||
|
with(stream) {
|
||||||
|
/**
|
||||||
|
* Match dynamic range from [MediaStream.videoRangeType]
|
||||||
|
*/
|
||||||
|
displayProfile.add(
|
||||||
|
/**
|
||||||
|
* Since [MediaStream.videoRangeType] is [DisplayProfile.HDR10]
|
||||||
|
* Check if [MediaStream.videoDoViTitle] is not null and return
|
||||||
|
* [DisplayProfile.DOLBY_VISION] accordingly
|
||||||
|
*/
|
||||||
|
if (stream.videoDoViTitle != null) {
|
||||||
|
DisplayProfile.DOLBY_VISION
|
||||||
|
} else when (videoRangeType) {
|
||||||
|
DisplayProfile.HDR.raw -> DisplayProfile.HDR
|
||||||
|
DisplayProfile.HDR10.raw -> DisplayProfile.HDR10
|
||||||
|
DisplayProfile.HLG.raw -> DisplayProfile.HLG
|
||||||
|
else -> DisplayProfile.SDR
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force stream [MediaStream.height] and [MediaStream.width] as not null
|
||||||
|
* since we are inside [MediaStreamType.VIDEO] block
|
||||||
|
*/
|
||||||
|
resolution.add(
|
||||||
|
when {
|
||||||
|
height!! <= 1080 && width!! <= 1920 -> {
|
||||||
|
Resolution.HD
|
||||||
|
}
|
||||||
|
|
||||||
|
height!! <= 2160 && width!! <= 3840 -> {
|
||||||
|
Resolution.UHD
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Resolution.SD
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return VideoMetadata(
|
||||||
|
resolution,
|
||||||
|
displayProfile.toSet().toList(),
|
||||||
|
audioChannels.toSet().toList(),
|
||||||
|
audioCodecs.toSet().toList(),
|
||||||
|
isAtmosAudio
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePlayed(): Boolean {
|
||||||
|
when (played) {
|
||||||
|
false -> {
|
||||||
|
played = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.markAsPlayed(item.id)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true -> {
|
||||||
|
played = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.markAsUnplayed(item.id)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return played
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleFavorite(): Boolean {
|
||||||
|
when (favorite) {
|
||||||
|
false -> {
|
||||||
|
favorite = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.markAsFavorite(item.id)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true -> {
|
||||||
|
favorite = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.unmarkAsFavorite(item.id)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return favorite
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDateString(item: FindroidMovie): String {
|
||||||
|
val dateRange: MutableList<String> = mutableListOf()
|
||||||
|
item.productionYear?.let { dateRange.add(it.toString()) }
|
||||||
|
when (item.status) {
|
||||||
|
"Continuing" -> {
|
||||||
|
dateRange.add("Present")
|
||||||
|
}
|
||||||
|
|
||||||
|
"Ended" -> {
|
||||||
|
item.endDate?.let { dateRange.add(it.year.toString()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dateRange.count() > 1 && dateRange[0] == dateRange[1]) return dateRange[0]
|
||||||
|
return dateRange.joinToString(separator = " - ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun download(sourceIndex: Int = 0, storageIndex: Int = 0) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = downloader.downloadItem(item, item.sources[sourceIndex].id, storageIndex)
|
||||||
|
|
||||||
|
// Send one time signal to fragment that the download has been initiated
|
||||||
|
_downloadStatus.emit(Pair(10, Random.nextInt()))
|
||||||
|
|
||||||
|
if (result.second != null) {
|
||||||
|
_downloadError.emit(result.second!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelDownload() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
downloader.cancelDownload(item, item.sources.first { it.type == FindroidSourceType.LOCAL })
|
||||||
|
loadData(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteItem() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
downloader.deleteItem(item, item.sources.first { it.type == FindroidSourceType.LOCAL })
|
||||||
|
loadData(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pollDownloadProgress() {
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
val downloadProgressRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val source = item.sources.firstOrNull { it.type == FindroidSourceType.LOCAL }
|
||||||
|
val (downloadStatus, progress) = downloader.getProgress(source?.downloadId)
|
||||||
|
_downloadStatus.emit(Pair(downloadStatus, progress))
|
||||||
|
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
|
||||||
|
if (source == null) return@launch
|
||||||
|
val path = source.path.replace(".download", "")
|
||||||
|
File(source.path).renameTo(File(path))
|
||||||
|
database.setSourcePath(source.id, path)
|
||||||
|
loadData(item.id)
|
||||||
|
}
|
||||||
|
if (downloadStatus == DownloadManager.STATUS_FAILED) {
|
||||||
|
if (source == null) return@launch
|
||||||
|
downloader.deleteItem(item, source)
|
||||||
|
loadData(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.isDownloading()) {
|
||||||
|
handler.postDelayed(this, 2000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handler.post(downloadProgressRunnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,8 @@ package dev.jdtech.jellyfin.viewmodels
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidShow
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
@ -44,8 +46,8 @@ class PersonDetailViewModel @Inject internal constructor(
|
||||||
recursive = true
|
recursive = true
|
||||||
)
|
)
|
||||||
|
|
||||||
val movies = items.filter { it.type == BaseItemKind.MOVIE }
|
val movies = items.filterIsInstance<FindroidMovie>()
|
||||||
val shows = items.filter { it.type == BaseItemKind.SERIES }
|
val shows = items.filterIsInstance<FindroidShow>()
|
||||||
|
|
||||||
val starredIn = StarredIn(movies, shows)
|
val starredIn = StarredIn(movies, shows)
|
||||||
|
|
||||||
|
@ -63,7 +65,7 @@ class PersonDetailViewModel @Inject internal constructor(
|
||||||
)
|
)
|
||||||
|
|
||||||
data class StarredIn(
|
data class StarredIn(
|
||||||
val movies: List<BaseItemDto>,
|
val movies: List<FindroidMovie>,
|
||||||
val shows: List<BaseItemDto>
|
val shows: List<FindroidShow>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.jdtech.jellyfin.Constants
|
import dev.jdtech.jellyfin.Constants
|
||||||
import dev.jdtech.jellyfin.core.R
|
import dev.jdtech.jellyfin.core.R
|
||||||
import dev.jdtech.jellyfin.models.FavoriteSection
|
import dev.jdtech.jellyfin.models.FavoriteSection
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidShow
|
||||||
import dev.jdtech.jellyfin.models.UiText
|
import dev.jdtech.jellyfin.models.UiText
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
@ -14,7 +17,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SearchResultViewModel
|
class SearchResultViewModel
|
||||||
|
@ -48,7 +50,7 @@ constructor(
|
||||||
FavoriteSection(
|
FavoriteSection(
|
||||||
Constants.FAVORITE_TYPE_MOVIES,
|
Constants.FAVORITE_TYPE_MOVIES,
|
||||||
UiText.StringResource(R.string.movies_label),
|
UiText.StringResource(R.string.movies_label),
|
||||||
items.filter { it.type == BaseItemKind.MOVIE }
|
items.filterIsInstance<FindroidMovie>()
|
||||||
).let {
|
).let {
|
||||||
if (it.items.isNotEmpty()) sections.add(
|
if (it.items.isNotEmpty()) sections.add(
|
||||||
it
|
it
|
||||||
|
@ -57,7 +59,7 @@ constructor(
|
||||||
FavoriteSection(
|
FavoriteSection(
|
||||||
Constants.FAVORITE_TYPE_SHOWS,
|
Constants.FAVORITE_TYPE_SHOWS,
|
||||||
UiText.StringResource(R.string.shows_label),
|
UiText.StringResource(R.string.shows_label),
|
||||||
items.filter { it.type == BaseItemKind.SERIES }
|
items.filterIsInstance<FindroidShow>()
|
||||||
).let {
|
).let {
|
||||||
if (it.items.isNotEmpty()) sections.add(
|
if (it.items.isNotEmpty()) sections.add(
|
||||||
it
|
it
|
||||||
|
@ -66,7 +68,7 @@ constructor(
|
||||||
FavoriteSection(
|
FavoriteSection(
|
||||||
Constants.FAVORITE_TYPE_EPISODES,
|
Constants.FAVORITE_TYPE_EPISODES,
|
||||||
UiText.StringResource(R.string.episodes_label),
|
UiText.StringResource(R.string.episodes_label),
|
||||||
items.filter { it.type == BaseItemKind.EPISODE }
|
items.filterIsInstance<FindroidEpisode>()
|
||||||
).let {
|
).let {
|
||||||
if (it.items.isNotEmpty()) sections.add(
|
if (it.items.isNotEmpty()) sections.add(
|
||||||
it
|
it
|
||||||
|
|
|
@ -4,10 +4,13 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.jdtech.jellyfin.models.EpisodeItem
|
import dev.jdtech.jellyfin.models.EpisodeItem
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidSeason
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.ItemFields
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
|
@ -21,27 +24,42 @@ constructor(
|
||||||
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||||
val uiState = _uiState.asStateFlow()
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _navigateBack = MutableSharedFlow<Boolean>()
|
||||||
|
val navigateBack = _navigateBack.asSharedFlow()
|
||||||
|
|
||||||
sealed class UiState {
|
sealed class UiState {
|
||||||
data class Normal(val episodes: List<EpisodeItem>) : UiState()
|
data class Normal(val episodes: List<EpisodeItem>) : UiState()
|
||||||
object Loading : UiState()
|
object Loading : UiState()
|
||||||
data class Error(val error: Exception) : UiState()
|
data class Error(val error: Exception) : UiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadEpisodes(seriesId: UUID, seasonId: UUID) {
|
lateinit var season: FindroidSeason
|
||||||
|
|
||||||
|
fun loadEpisodes(seriesId: UUID, seasonId: UUID, offline: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.emit(UiState.Loading)
|
_uiState.emit(UiState.Loading)
|
||||||
try {
|
try {
|
||||||
val episodes = getEpisodes(seriesId, seasonId)
|
season = getSeason(seasonId)
|
||||||
|
val episodes = getEpisodes(seriesId, seasonId, offline)
|
||||||
_uiState.emit(UiState.Normal(episodes))
|
_uiState.emit(UiState.Normal(episodes))
|
||||||
|
} catch (_: NullPointerException) {
|
||||||
|
// Navigate back because item does not exist (probably because it's been deleted)
|
||||||
|
_navigateBack.emit(true)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.emit(UiState.Error(e))
|
_uiState.emit(UiState.Error(e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getEpisodes(seriesId: UUID, seasonId: UUID): List<EpisodeItem> {
|
private suspend fun getSeason(seasonId: UUID): FindroidSeason {
|
||||||
|
return jellyfinRepository.getSeason(seasonId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getEpisodes(seriesId: UUID, seasonId: UUID, offline: Boolean): List<EpisodeItem> {
|
||||||
|
val header = EpisodeItem.Header(seriesId = season.seriesId, seasonId = season.id, seriesName = season.seriesName, seasonName = season.name)
|
||||||
val episodes =
|
val episodes =
|
||||||
jellyfinRepository.getEpisodes(seriesId, seasonId, fields = listOf(ItemFields.OVERVIEW))
|
jellyfinRepository.getEpisodes(seriesId, seasonId, fields = listOf(ItemFields.OVERVIEW), offline = offline)
|
||||||
return listOf(EpisodeItem.Header) + episodes.map { EpisodeItem.Episode(it) }
|
|
||||||
|
return listOf(header) + episodes.map { EpisodeItem.Episode(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,213 @@
|
||||||
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMediaStream
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidSeason
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidShow
|
||||||
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemPerson
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ShowViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val jellyfinRepository: JellyfinRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _navigateBack = MutableSharedFlow<Boolean>()
|
||||||
|
val navigateBack = _navigateBack.asSharedFlow()
|
||||||
|
|
||||||
|
sealed class UiState {
|
||||||
|
data class Normal(
|
||||||
|
val item: FindroidShow,
|
||||||
|
val actors: List<BaseItemPerson>,
|
||||||
|
val director: BaseItemPerson?,
|
||||||
|
val writers: List<BaseItemPerson>,
|
||||||
|
val writersString: String,
|
||||||
|
val genresString: String,
|
||||||
|
val videoString: String,
|
||||||
|
val audioString: String,
|
||||||
|
val subtitleString: String,
|
||||||
|
val runTime: String,
|
||||||
|
val dateString: String,
|
||||||
|
val nextUp: FindroidEpisode?,
|
||||||
|
val seasons: List<FindroidSeason>,
|
||||||
|
) : UiState()
|
||||||
|
|
||||||
|
object Loading : UiState()
|
||||||
|
data class Error(val error: Exception) : UiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
lateinit var item: FindroidShow
|
||||||
|
private var played: Boolean = false
|
||||||
|
private var favorite: Boolean = false
|
||||||
|
private var actors: List<BaseItemPerson> = emptyList()
|
||||||
|
private var director: BaseItemPerson? = null
|
||||||
|
private var writers: List<BaseItemPerson> = emptyList()
|
||||||
|
private var writersString: String = ""
|
||||||
|
private var genresString: String = ""
|
||||||
|
private var videoString: String = ""
|
||||||
|
private var audioString: String = ""
|
||||||
|
private var subtitleString: String = ""
|
||||||
|
private var runTime: String = ""
|
||||||
|
private var dateString: String = ""
|
||||||
|
var nextUp: FindroidEpisode? = null
|
||||||
|
var seasons: List<FindroidSeason> = emptyList()
|
||||||
|
|
||||||
|
fun loadData(itemId: UUID, offline: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.emit(UiState.Loading)
|
||||||
|
try {
|
||||||
|
item = jellyfinRepository.getShow(itemId)
|
||||||
|
played = item.played
|
||||||
|
favorite = item.favorite
|
||||||
|
actors = getActors(item)
|
||||||
|
director = getDirector(item)
|
||||||
|
writers = getWriters(item)
|
||||||
|
writersString = writers.joinToString(separator = ", ") { it.name.toString() }
|
||||||
|
genresString = item.genres.joinToString(separator = ", ")
|
||||||
|
videoString = getMediaString(item, MediaStreamType.VIDEO)
|
||||||
|
audioString = getMediaString(item, MediaStreamType.AUDIO)
|
||||||
|
subtitleString = getMediaString(item, MediaStreamType.SUBTITLE)
|
||||||
|
runTime = "${item.runtimeTicks.div(600000000)} min"
|
||||||
|
dateString = getDateString(item)
|
||||||
|
nextUp = getNextUp(itemId)
|
||||||
|
seasons = jellyfinRepository.getSeasons(itemId, offline)
|
||||||
|
_uiState.emit(
|
||||||
|
UiState.Normal(
|
||||||
|
item,
|
||||||
|
actors,
|
||||||
|
director,
|
||||||
|
writers,
|
||||||
|
writersString,
|
||||||
|
genresString,
|
||||||
|
videoString,
|
||||||
|
audioString,
|
||||||
|
subtitleString,
|
||||||
|
runTime,
|
||||||
|
dateString,
|
||||||
|
nextUp,
|
||||||
|
seasons,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (_: NullPointerException) {
|
||||||
|
// Navigate back because item does not exist (probably because it's been deleted)
|
||||||
|
_navigateBack.emit(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.emit(UiState.Error(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getActors(item: FindroidShow): List<BaseItemPerson> {
|
||||||
|
val actors: List<BaseItemPerson>
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
actors = item.people.filter { it.type == "Actor" }
|
||||||
|
}
|
||||||
|
return actors
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getDirector(item: FindroidShow): BaseItemPerson? {
|
||||||
|
val director: BaseItemPerson?
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
director = item.people.firstOrNull { it.type == "Director" }
|
||||||
|
}
|
||||||
|
return director
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getWriters(item: FindroidShow): List<BaseItemPerson> {
|
||||||
|
val writers: List<BaseItemPerson>
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
writers = item.people.filter { it.type == "Writer" }
|
||||||
|
}
|
||||||
|
return writers
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getMediaString(item: FindroidShow, type: MediaStreamType): String {
|
||||||
|
val streams: List<FindroidMediaStream>
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
streams = item.sources.getOrNull(0)?.mediaStreams?.filter { it.type == type } ?: emptyList()
|
||||||
|
}
|
||||||
|
return streams.map { it.displayTitle }.joinToString(separator = ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getNextUp(seriesId: UUID): FindroidEpisode? {
|
||||||
|
val nextUpItems = jellyfinRepository.getNextUp(seriesId)
|
||||||
|
return nextUpItems.getOrNull(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePlayed(): Boolean {
|
||||||
|
when (played) {
|
||||||
|
false -> {
|
||||||
|
played = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
jellyfinRepository.markAsPlayed(item.id)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true -> {
|
||||||
|
played = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
jellyfinRepository.markAsUnplayed(item.id)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return played
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleFavorite(): Boolean {
|
||||||
|
when (favorite) {
|
||||||
|
false -> {
|
||||||
|
favorite = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
jellyfinRepository.markAsFavorite(item.id)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true -> {
|
||||||
|
favorite = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
jellyfinRepository.unmarkAsFavorite(item.id)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return favorite
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDateString(item: FindroidShow): String {
|
||||||
|
val dateRange: MutableList<String> = mutableListOf()
|
||||||
|
item.productionYear?.let { dateRange.add(it.toString()) }
|
||||||
|
when (item.status) {
|
||||||
|
"Continuing" -> {
|
||||||
|
dateRange.add("Present")
|
||||||
|
}
|
||||||
|
|
||||||
|
"Ended" -> {
|
||||||
|
item.endDate?.let { dateRange.add(it.year.toString()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dateRange.count() > 1 && dateRange[0] == dateRange[1]) return dateRange[0]
|
||||||
|
return dateRange.joinToString(separator = " - ")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,106 @@
|
||||||
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||||
|
import dev.jdtech.jellyfin.models.Server
|
||||||
|
import dev.jdtech.jellyfin.models.StorageItem
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidEpisode
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidMovie
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidSeason
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidShow
|
||||||
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class StorageViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val database: ServerDatabaseDao,
|
||||||
|
private val repository: JellyfinRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _serversState = MutableStateFlow<List<Server>>(emptyList())
|
||||||
|
val serversState = _serversState.asStateFlow()
|
||||||
|
|
||||||
|
private val _itemsState = MutableStateFlow<List<StorageItem>>(emptyList())
|
||||||
|
val itemsState = _itemsState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadServers()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadServers() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val servers = database.getAllServersSync()
|
||||||
|
_serversState.emit(servers)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadItems(serverId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val items = mutableListOf<StorageItem>()
|
||||||
|
|
||||||
|
database.getMoviesByServerId(serverId)
|
||||||
|
.map { it.toFindroidMovie(database, repository.getUserId()) }
|
||||||
|
.map { movie ->
|
||||||
|
items.add(
|
||||||
|
StorageItem(
|
||||||
|
item = movie,
|
||||||
|
introTimestamps = database.getIntro(movie.id) != null,
|
||||||
|
trickPlayData = database.getTrickPlayManifest(movie.id) != null,
|
||||||
|
size = File(movie.sources.first().path).length().div(1000000)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
database.getShowsByServerId(serverId)
|
||||||
|
.map { it.toFindroidShow(database, repository.getUserId()) }
|
||||||
|
.map { show ->
|
||||||
|
items.add(
|
||||||
|
StorageItem(
|
||||||
|
item = show,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
database.getSeasonsByShowId(show.id)
|
||||||
|
.map { it.toFindroidSeason(database, repository.getUserId()) }
|
||||||
|
.map { season ->
|
||||||
|
items.add(
|
||||||
|
StorageItem(
|
||||||
|
item = season,
|
||||||
|
indent = 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
database.getEpisodesBySeasonId(season.id)
|
||||||
|
.map { it.toFindroidEpisode(database, repository.getUserId()) }
|
||||||
|
.map { episode ->
|
||||||
|
items.add(
|
||||||
|
StorageItem(
|
||||||
|
item = episode,
|
||||||
|
introTimestamps = database.getIntro(episode.id) != null,
|
||||||
|
trickPlayData = database.getTrickPlayManifest(episode.id) != null,
|
||||||
|
size = File(episode.sources.first().path).length()
|
||||||
|
.div(1000000),
|
||||||
|
indent = 2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_itemsState.emit(items)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
88
core/src/main/java/dev/jdtech/jellyfin/work/SyncWorker.kt
Normal file
88
core/src/main/java/dev/jdtech/jellyfin/work/SyncWorker.kt
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
package dev.jdtech.jellyfin.work
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.hilt.work.HiltWorker
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import dev.jdtech.jellyfin.AppPreferences
|
||||||
|
import dev.jdtech.jellyfin.api.JellyfinApi
|
||||||
|
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidItem
|
||||||
|
import dev.jdtech.jellyfin.models.User
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidEpisode
|
||||||
|
import dev.jdtech.jellyfin.models.toFindroidMovie
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@HiltWorker
|
||||||
|
class SyncWorker @AssistedInject constructor(
|
||||||
|
@Assisted private val context: Context,
|
||||||
|
@Assisted private val workerParams: WorkerParameters,
|
||||||
|
val database: ServerDatabaseDao,
|
||||||
|
val appPreferences: AppPreferences,
|
||||||
|
) : CoroutineWorker(context, workerParams) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val jellyfinApi = JellyfinApi(
|
||||||
|
androidContext = context.applicationContext,
|
||||||
|
requestTimeout = appPreferences.requestTimeout,
|
||||||
|
connectTimeout = appPreferences.connectTimeout,
|
||||||
|
socketTimeout = appPreferences.socketTimeout
|
||||||
|
)
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
val servers = database.getAllServersSync()
|
||||||
|
|
||||||
|
for (server in servers) {
|
||||||
|
val serverWithAddressesAndUsers = database.getServerWithAddressesAndUsers(server.id) ?: continue
|
||||||
|
val serverAddress = serverWithAddressesAndUsers.addresses.firstOrNull { it.id == server.currentServerAddressId } ?: continue
|
||||||
|
for (user in serverWithAddressesAndUsers.users) {
|
||||||
|
jellyfinApi.apply {
|
||||||
|
api.baseUrl = serverAddress.address
|
||||||
|
api.accessToken = user.accessToken
|
||||||
|
userId = user.id
|
||||||
|
}
|
||||||
|
val movies = database.getMoviesByServerId(server.id).map { it.toFindroidMovie(database, user.id) }
|
||||||
|
val episodes = database.getEpisodesByServerId(server.id).map { it.toFindroidEpisode(database, user.id) }
|
||||||
|
|
||||||
|
syncUserData(jellyfinApi, user, movies)
|
||||||
|
syncUserData(jellyfinApi, user, episodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun syncUserData(
|
||||||
|
jellyfinApi: JellyfinApi,
|
||||||
|
user: User,
|
||||||
|
items: List<FindroidItem>
|
||||||
|
) {
|
||||||
|
for (item in items) {
|
||||||
|
val userData = database.getUserDataToBeSynced(user.id, item.id) ?: continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
when (userData.played) {
|
||||||
|
true -> jellyfinApi.playStateApi.markPlayedItem(user.id, item.id)
|
||||||
|
false -> jellyfinApi.playStateApi.markUnplayedItem(user.id, item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (userData.favorite) {
|
||||||
|
true -> jellyfinApi.userLibraryApi.markFavoriteItem(user.id, item.id)
|
||||||
|
false -> jellyfinApi.userLibraryApi.unmarkFavoriteItem(user.id, item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
jellyfinApi.playStateApi.onPlaybackProgress(
|
||||||
|
userId = user.id,
|
||||||
|
itemId = item.id,
|
||||||
|
positionTicks = userData.playbackPositionTicks,
|
||||||
|
)
|
||||||
|
|
||||||
|
database.setUserDataToBeSynced(user.id, item.id, false)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
28
core/src/main/res/drawable/ic_database.xml
Normal file
28
core/src/main/res/drawable/ic_database.xml
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:pathData="M3,5a9,3 0,1 0,18 0a9,3 0,1 0,-18 0z"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M21,12c0,1.66 -4,3 -9,3s-9,-1.34 -9,-3"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M3,5v14c0,1.66 4,3 9,3s9,-1.34 9,-3V5"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"/>
|
||||||
|
</vector>
|
49
core/src/main/res/drawable/ic_server_off.xml
Normal file
49
core/src/main/res/drawable/ic_server_off.xml
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:pathData="M7,2h13a2,2 0,0 1,2 2v4a2,2 0,0 1,-2 2h-5"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M10,10 L2.5,2.5C2,2 2,2.5 2,5v3a2,2 0,0 0,2 2h6z"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M22,17v-1a2,2 0,0 0,-2 -2h-1"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M4,14a2,2 0,0 0,-2 2v4a2,2 0,0 0,2 2h16.5l1,-0.5 0.5,0.5 -8,-8H4z"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M6,18h0.01"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"/>
|
||||||
|
<path
|
||||||
|
android:pathData="m2,2 l20,20"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"/>
|
||||||
|
</vector>
|
|
@ -17,7 +17,7 @@
|
||||||
android:title="@string/title_favorite" />
|
android:title="@string/title_favorite" />
|
||||||
|
|
||||||
<item
|
<item
|
||||||
android:id="@+id/downloadFragment"
|
android:id="@+id/downloadsFragment"
|
||||||
android:icon="@drawable/ic_download"
|
android:icon="@drawable/ic_download"
|
||||||
android:title="@string/title_download" />
|
android:title="@string/title_download" />
|
||||||
|
|
||||||
|
|
14
core/src/main/res/menu/bottom_nav_menu_offline.xml
Normal file
14
core/src/main/res/menu/bottom_nav_menu_offline.xml
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/homeFragment"
|
||||||
|
android:icon="@drawable/ic_home"
|
||||||
|
android:title="@string/title_home" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/downloadsFragment"
|
||||||
|
android:icon="@drawable/ic_download"
|
||||||
|
android:title="@string/title_download" />
|
||||||
|
|
||||||
|
</menu>
|
|
@ -29,6 +29,7 @@
|
||||||
<string name="title_favorite">Favorites</string>
|
<string name="title_favorite">Favorites</string>
|
||||||
<string name="title_settings">Settings</string>
|
<string name="title_settings">Settings</string>
|
||||||
<string name="title_download">Downloads</string>
|
<string name="title_download">Downloads</string>
|
||||||
|
<string name="title_storage">Storage</string>
|
||||||
<string name="view_all">View all</string>
|
<string name="view_all">View all</string>
|
||||||
<string name="error_loading_data">Error loading data</string>
|
<string name="error_loading_data">Error loading data</string>
|
||||||
<string name="retry">Retry</string>
|
<string name="retry">Retry</string>
|
||||||
|
@ -42,6 +43,7 @@
|
||||||
<string name="check_button_description">Mark as watched or unwatched</string>
|
<string name="check_button_description">Mark as watched or unwatched</string>
|
||||||
<string name="favorite_button_description">Favorite</string>
|
<string name="favorite_button_description">Favorite</string>
|
||||||
<string name="episode_watched_indicator">Episode watched indicator</string>
|
<string name="episode_watched_indicator">Episode watched indicator</string>
|
||||||
|
<string name="downloaded_indicator">Downloaded indicator</string>
|
||||||
<string name="episode_name">%1$d. %2$s</string>
|
<string name="episode_name">%1$d. %2$s</string>
|
||||||
<string name="episode_name_extended">S%1$d:E%2$d - %3$s</string>
|
<string name="episode_name_extended">S%1$d:E%2$d - %3$s</string>
|
||||||
<string name="next_up">Next Up</string>
|
<string name="next_up">Next Up</string>
|
||||||
|
@ -165,4 +167,20 @@
|
||||||
<string name="dolby_logo_desc">Dolby Logo</string>
|
<string name="dolby_logo_desc">Dolby Logo</string>
|
||||||
<string name="extra_info">Display Extra Info</string>
|
<string name="extra_info">Display Extra Info</string>
|
||||||
<string name="extra_info_summary">Displays detailed information about Audio, Video and Subtitles</string>
|
<string name="extra_info_summary">Displays detailed information about Audio, Video and Subtitles</string>
|
||||||
|
<string name="offline_mode">Offline Mode</string>
|
||||||
|
<string name="offline_mode_icon">Offline Mode icon</string>
|
||||||
|
<string name="offline_mode_go_online">Go online</string>
|
||||||
|
<string name="mega_byte_suffix">%d MB</string>
|
||||||
|
<string name="downloading_error">Error while downloading</string>
|
||||||
|
<string name="not_enough_storage">This item requires %d MB of free storage but only %d MB is available</string>
|
||||||
|
<string name="no_server_connection">No connection to the Jellyfin server, to watch offline enable Offline Mode</string>
|
||||||
|
<string name="select_storage_location">Select storage location</string>
|
||||||
|
<string name="storage_unavailable">Storage location is unavailable</string>
|
||||||
|
<string name="internal">Internal</string>
|
||||||
|
<string name="external">External</string>
|
||||||
|
<string name="storage_name">%s (%d MB free)</string>
|
||||||
|
<string name="preparing_download">Preparing download</string>
|
||||||
|
<string name="cancel_download">Cancel download</string>
|
||||||
|
<string name="cancel_download_message">Are you sure you want to cancel the download?</string>
|
||||||
|
<string name="stop_download">Stop download</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
|
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
<Preference
|
<Preference
|
||||||
app:fragment="dev.jdtech.jellyfin.fragments.SettingsLanguageFragment"
|
app:fragment="dev.jdtech.jellyfin.fragments.SettingsLanguageFragment"
|
||||||
|
@ -50,6 +51,11 @@
|
||||||
app:fragment="dev.jdtech.jellyfin.fragments.SettingsCacheFragment"
|
app:fragment="dev.jdtech.jellyfin.fragments.SettingsCacheFragment"
|
||||||
app:title="@string/settings_category_cache" />
|
app:title="@string/settings_category_cache" />
|
||||||
|
|
||||||
|
<SwitchPreferenceCompat
|
||||||
|
android:defaultValue="false"
|
||||||
|
app:key="pref_offline_mode"
|
||||||
|
app:title="@string/offline_mode" />
|
||||||
|
|
||||||
<PreferenceCategory app:title="@string/about">
|
<PreferenceCategory app:title="@string/about">
|
||||||
|
|
||||||
<Preference
|
<Preference
|
||||||
|
|
|
@ -3,10 +3,10 @@
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
<SwitchPreferenceCompat
|
<SwitchPreferenceCompat
|
||||||
android:defaultValue="false"
|
android:defaultValue="false"
|
||||||
app:key="download_mobile_data"
|
app:key="pref_downloads_mobile_data"
|
||||||
app:title="@string/download_mobile_data" />
|
app:title="@string/download_mobile_data" />
|
||||||
<SwitchPreferenceCompat
|
<SwitchPreferenceCompat
|
||||||
android:defaultValue="false"
|
android:defaultValue="false"
|
||||||
app:key="download_roaming"
|
app:key="pref_downloads_roaming"
|
||||||
app:title="@string/download_roaming" />
|
app:title="@string/download_roaming" />
|
||||||
</PreferenceScreen>
|
</PreferenceScreen>
|
|
@ -2,6 +2,7 @@ plugins {
|
||||||
alias(libs.plugins.android.library)
|
alias(libs.plugins.android.library)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.kotlin.kapt)
|
||||||
alias(libs.plugins.ktlint)
|
alias(libs.plugins.ktlint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,6 +20,12 @@ android {
|
||||||
buildConfigField("String", "VERSION_NAME", "\"$appVersionName\"")
|
buildConfigField("String", "VERSION_NAME", "\"$appVersionName\"")
|
||||||
|
|
||||||
consumerProguardFile("proguard-rules.pro")
|
consumerProguardFile("proguard-rules.pro")
|
||||||
|
|
||||||
|
kapt {
|
||||||
|
arguments {
|
||||||
|
arg("room.schemaLocation", "$projectDir/schemas")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
@ -45,6 +52,9 @@ ktlint {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":preferences"))
|
implementation(project(":preferences"))
|
||||||
implementation(libs.androidx.paging)
|
implementation(libs.androidx.paging)
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
kapt(libs.androidx.room.compiler)
|
||||||
|
implementation(libs.androidx.room.ktx)
|
||||||
implementation(libs.jellyfin.core)
|
implementation(libs.jellyfin.core)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.timber)
|
implementation(libs.timber)
|
||||||
|
|
166
data/schemas/dev.jdtech.jellyfin.database.ServerDatabase/2.json
Normal file
166
data/schemas/dev.jdtech.jellyfin.database.ServerDatabase/2.json
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 2,
|
||||||
|
"identityHash": "43e0a559dd72e1a4ce8250489f90dc01",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "servers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `currentServerAddressId` TEXT, `currentUserId` TEXT, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "currentServerAddressId",
|
||||||
|
"columnName": "currentServerAddressId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "currentUserId",
|
||||||
|
"columnName": "currentUserId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "serverAddresses",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `address` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "address",
|
||||||
|
"columnName": "address",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_serverAddresses_serverId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_serverAddresses_serverId` ON `${TABLE_NAME}` (`serverId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "servers",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "users",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `serverId` TEXT NOT NULL, `accessToken` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "accessToken",
|
||||||
|
"columnName": "accessToken",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_users_serverId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "servers",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, '43e0a559dd72e1a4ce8250489f90dc01')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
819
data/schemas/dev.jdtech.jellyfin.database.ServerDatabase/3.json
Normal file
819
data/schemas/dev.jdtech.jellyfin.database.ServerDatabase/3.json
Normal file
|
@ -0,0 +1,819 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 3,
|
||||||
|
"identityHash": "3cb9aaa3295b9e461cb94dfc708258ed",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "servers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `currentServerAddressId` TEXT, `currentUserId` TEXT, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "currentServerAddressId",
|
||||||
|
"columnName": "currentServerAddressId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "currentUserId",
|
||||||
|
"columnName": "currentUserId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "serverAddresses",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `address` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "address",
|
||||||
|
"columnName": "address",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_serverAddresses_serverId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_serverAddresses_serverId` ON `${TABLE_NAME}` (`serverId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "servers",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "users",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `serverId` TEXT NOT NULL, `accessToken` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "accessToken",
|
||||||
|
"columnName": "accessToken",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_users_serverId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "servers",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "movies",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT, `name` TEXT NOT NULL, `originalTitle` TEXT, `overview` TEXT NOT NULL, `runtimeTicks` INTEGER NOT NULL, `premiereDate` INTEGER, `communityRating` REAL, `officialRating` TEXT, `status` TEXT NOT NULL, `productionYear` INTEGER, `endDate` INTEGER, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "originalTitle",
|
||||||
|
"columnName": "originalTitle",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "overview",
|
||||||
|
"columnName": "overview",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "runtimeTicks",
|
||||||
|
"columnName": "runtimeTicks",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "premiereDate",
|
||||||
|
"columnName": "premiereDate",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "communityRating",
|
||||||
|
"columnName": "communityRating",
|
||||||
|
"affinity": "REAL",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "officialRating",
|
||||||
|
"columnName": "officialRating",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"columnName": "status",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "productionYear",
|
||||||
|
"columnName": "productionYear",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "endDate",
|
||||||
|
"columnName": "endDate",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "shows",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT, `name` TEXT NOT NULL, `originalTitle` TEXT, `overview` TEXT NOT NULL, `runtimeTicks` INTEGER NOT NULL, `communityRating` REAL, `officialRating` TEXT, `status` TEXT NOT NULL, `productionYear` INTEGER, `endDate` INTEGER, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "originalTitle",
|
||||||
|
"columnName": "originalTitle",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "overview",
|
||||||
|
"columnName": "overview",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "runtimeTicks",
|
||||||
|
"columnName": "runtimeTicks",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "communityRating",
|
||||||
|
"columnName": "communityRating",
|
||||||
|
"affinity": "REAL",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "officialRating",
|
||||||
|
"columnName": "officialRating",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"columnName": "status",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "productionYear",
|
||||||
|
"columnName": "productionYear",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "endDate",
|
||||||
|
"columnName": "endDate",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "seasons",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `seriesId` TEXT NOT NULL, `name` TEXT NOT NULL, `seriesName` TEXT NOT NULL, `overview` TEXT NOT NULL, `indexNumber` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`seriesId`) REFERENCES `shows`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "seriesId",
|
||||||
|
"columnName": "seriesId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "seriesName",
|
||||||
|
"columnName": "seriesName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "overview",
|
||||||
|
"columnName": "overview",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "indexNumber",
|
||||||
|
"columnName": "indexNumber",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_seasons_seriesId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"seriesId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_seasons_seriesId` ON `${TABLE_NAME}` (`seriesId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "shows",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"seriesId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "episodes",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT, `seasonId` TEXT NOT NULL, `seriesId` TEXT NOT NULL, `name` TEXT NOT NULL, `seriesName` TEXT NOT NULL, `overview` TEXT NOT NULL, `indexNumber` INTEGER NOT NULL, `indexNumberEnd` INTEGER, `parentIndexNumber` INTEGER NOT NULL, `runtimeTicks` INTEGER NOT NULL, `premiereDate` INTEGER, `communityRating` REAL, PRIMARY KEY(`id`), FOREIGN KEY(`seasonId`) REFERENCES `seasons`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`seriesId`) REFERENCES `shows`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "seasonId",
|
||||||
|
"columnName": "seasonId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "seriesId",
|
||||||
|
"columnName": "seriesId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "seriesName",
|
||||||
|
"columnName": "seriesName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "overview",
|
||||||
|
"columnName": "overview",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "indexNumber",
|
||||||
|
"columnName": "indexNumber",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "indexNumberEnd",
|
||||||
|
"columnName": "indexNumberEnd",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "parentIndexNumber",
|
||||||
|
"columnName": "parentIndexNumber",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "runtimeTicks",
|
||||||
|
"columnName": "runtimeTicks",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "premiereDate",
|
||||||
|
"columnName": "premiereDate",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "communityRating",
|
||||||
|
"columnName": "communityRating",
|
||||||
|
"affinity": "REAL",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_episodes_seasonId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"seasonId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_seasonId` ON `${TABLE_NAME}` (`seasonId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_episodes_seriesId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"seriesId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_seriesId` ON `${TABLE_NAME}` (`seriesId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "seasons",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"seasonId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "shows",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"seriesId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "sources",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itemId` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `path` TEXT NOT NULL, `downloadId` INTEGER, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "path",
|
||||||
|
"columnName": "path",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "downloadId",
|
||||||
|
"columnName": "downloadId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "mediastreams",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sourceId` TEXT NOT NULL, `title` TEXT NOT NULL, `displayTitle` TEXT, `language` TEXT NOT NULL, `type` TEXT NOT NULL, `codec` TEXT NOT NULL, `isExternal` INTEGER NOT NULL, `path` TEXT NOT NULL, `channelLayout` TEXT, `videoRangeType` TEXT, `height` INTEGER, `width` INTEGER, `videoDoViTitle` TEXT, `downloadId` INTEGER, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sourceId",
|
||||||
|
"columnName": "sourceId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "displayTitle",
|
||||||
|
"columnName": "displayTitle",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "language",
|
||||||
|
"columnName": "language",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "codec",
|
||||||
|
"columnName": "codec",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isExternal",
|
||||||
|
"columnName": "isExternal",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "path",
|
||||||
|
"columnName": "path",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "channelLayout",
|
||||||
|
"columnName": "channelLayout",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoRangeType",
|
||||||
|
"columnName": "videoRangeType",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "height",
|
||||||
|
"columnName": "height",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "width",
|
||||||
|
"columnName": "width",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoDoViTitle",
|
||||||
|
"columnName": "videoDoViTitle",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "downloadId",
|
||||||
|
"columnName": "downloadId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "trickPlayManifests",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`itemId` TEXT NOT NULL, `version` TEXT NOT NULL, `resolution` INTEGER NOT NULL, PRIMARY KEY(`itemId`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "version",
|
||||||
|
"columnName": "version",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "resolution",
|
||||||
|
"columnName": "resolution",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "intros",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`itemId` TEXT NOT NULL, `start` REAL NOT NULL, `end` REAL NOT NULL, `showAt` REAL NOT NULL, `hideAt` REAL NOT NULL, PRIMARY KEY(`itemId`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "start",
|
||||||
|
"columnName": "start",
|
||||||
|
"affinity": "REAL",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "end",
|
||||||
|
"columnName": "end",
|
||||||
|
"affinity": "REAL",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "showAt",
|
||||||
|
"columnName": "showAt",
|
||||||
|
"affinity": "REAL",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "hideAt",
|
||||||
|
"columnName": "hideAt",
|
||||||
|
"affinity": "REAL",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "userdata",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `itemId` TEXT NOT NULL, `played` INTEGER NOT NULL, `favorite` INTEGER NOT NULL, `playbackPositionTicks` INTEGER NOT NULL, `toBeSynced` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "played",
|
||||||
|
"columnName": "played",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "favorite",
|
||||||
|
"columnName": "favorite",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "playbackPositionTicks",
|
||||||
|
"columnName": "playbackPositionTicks",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "toBeSynced",
|
||||||
|
"columnName": "toBeSynced",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"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, '3cb9aaa3295b9e461cb94dfc708258ed')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
package dev.jdtech.jellyfin.database
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.UUID
|
||||||
|
import org.jellyfin.sdk.model.DateTime
|
||||||
|
|
||||||
|
class Converters {
|
||||||
|
@TypeConverter
|
||||||
|
fun fromStringToUUID(value: String?): UUID? {
|
||||||
|
return value?.let { UUID.fromString(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromUUIDToString(value: UUID?): String? {
|
||||||
|
return value?.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromDateTimeToLong(value: DateTime?): Long? {
|
||||||
|
return value?.toEpochSecond(ZoneOffset.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromLongToDatetime(value: Long?): DateTime? {
|
||||||
|
return value?.let { DateTime.ofEpochSecond(it, 0, ZoneOffset.UTC) }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
package dev.jdtech.jellyfin.database
|
||||||
|
|
||||||
|
import androidx.room.AutoMigration
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidEpisodeDto
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMediaStreamDto
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovieDto
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidSeasonDto
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidShowDto
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidSourceDto
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidUserDataDto
|
||||||
|
import dev.jdtech.jellyfin.models.IntroDto
|
||||||
|
import dev.jdtech.jellyfin.models.Server
|
||||||
|
import dev.jdtech.jellyfin.models.ServerAddress
|
||||||
|
import dev.jdtech.jellyfin.models.TrickPlayManifestDto
|
||||||
|
import dev.jdtech.jellyfin.models.User
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [Server::class, ServerAddress::class, User::class, FindroidMovieDto::class, FindroidShowDto::class, FindroidSeasonDto::class, FindroidEpisodeDto::class, FindroidSourceDto::class, FindroidMediaStreamDto::class, TrickPlayManifestDto::class, IntroDto::class, FindroidUserDataDto::class],
|
||||||
|
version = 3,
|
||||||
|
autoMigrations = [
|
||||||
|
AutoMigration(from = 2, to = 3)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@TypeConverters(Converters::class)
|
||||||
|
abstract class ServerDatabase : RoomDatabase() {
|
||||||
|
abstract val serverDatabaseDao: ServerDatabaseDao
|
||||||
|
}
|
|
@ -0,0 +1,263 @@
|
||||||
|
package dev.jdtech.jellyfin.database
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import androidx.room.Update
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidEpisodeDto
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMediaStreamDto
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidMovieDto
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidSeasonDto
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidShowDto
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidSourceDto
|
||||||
|
import dev.jdtech.jellyfin.models.FindroidUserDataDto
|
||||||
|
import dev.jdtech.jellyfin.models.IntroDto
|
||||||
|
import dev.jdtech.jellyfin.models.Server
|
||||||
|
import dev.jdtech.jellyfin.models.ServerAddress
|
||||||
|
import dev.jdtech.jellyfin.models.ServerWithAddresses
|
||||||
|
import dev.jdtech.jellyfin.models.ServerWithAddressesAndUsers
|
||||||
|
import dev.jdtech.jellyfin.models.ServerWithUsers
|
||||||
|
import dev.jdtech.jellyfin.models.TrickPlayManifestDto
|
||||||
|
import dev.jdtech.jellyfin.models.User
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class ServerDatabaseDao {
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
abstract fun insertServer(server: Server)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
abstract fun insertServerAddress(address: ServerAddress)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
abstract fun insertUser(user: User)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
abstract fun update(server: Server)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM servers WHERE id = :id")
|
||||||
|
abstract fun get(id: String): Server?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM users WHERE id = :id")
|
||||||
|
abstract fun getUser(id: UUID): User?
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM servers WHERE id = :id")
|
||||||
|
abstract fun getServerWithAddresses(id: String): ServerWithAddresses
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM servers WHERE id = :id")
|
||||||
|
abstract fun getServerWithUsers(id: String): ServerWithUsers
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM servers WHERE id = :id")
|
||||||
|
abstract fun getServerWithAddressesAndUsers(id: String): ServerWithAddressesAndUsers?
|
||||||
|
|
||||||
|
@Query("DELETE FROM servers")
|
||||||
|
abstract fun clear()
|
||||||
|
|
||||||
|
@Query("SELECT * FROM servers")
|
||||||
|
abstract fun getAllServers(): LiveData<List<Server>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM servers")
|
||||||
|
abstract fun getAllServersSync(): List<Server>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM servers")
|
||||||
|
abstract fun getServersCount(): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM servers WHERE id = :id")
|
||||||
|
abstract fun delete(id: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM users WHERE id = :id")
|
||||||
|
abstract fun deleteUser(id: UUID)
|
||||||
|
|
||||||
|
@Query("DELETE FROM serverAddresses WHERE id = :id")
|
||||||
|
abstract fun deleteServerAddress(id: UUID)
|
||||||
|
|
||||||
|
@Query("UPDATE servers SET currentUserId = :userId WHERE id = :serverId")
|
||||||
|
abstract fun updateServerCurrentUser(serverId: String, userId: UUID)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM users WHERE id = (SELECT currentUserId FROM servers WHERE id = :serverId)")
|
||||||
|
abstract fun getServerCurrentUser(serverId: String): User?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM serverAddresses WHERE id = (SELECT currentServerAddressId FROM servers WHERE id = :serverId)")
|
||||||
|
abstract fun getServerCurrentAddress(serverId: String): ServerAddress?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
abstract fun insertMovie(movie: FindroidMovieDto)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
abstract fun insertSource(source: FindroidSourceDto)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM movies WHERE id = :id")
|
||||||
|
abstract fun getMovie(id: UUID): FindroidMovieDto
|
||||||
|
|
||||||
|
@Query("SELECT * FROM movies JOIN sources ON movies.id = sources.itemId ORDER BY movies.name ASC")
|
||||||
|
abstract fun getMoviesAndSources(): Map<FindroidMovieDto, List<FindroidSourceDto>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources WHERE itemId = :itemId")
|
||||||
|
abstract fun getSources(itemId: UUID): List<FindroidSourceDto>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources WHERE downloadId = :downloadId")
|
||||||
|
abstract fun getSourceByDownloadId(downloadId: Long): FindroidSourceDto?
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET downloadId = :downloadId WHERE id = :id")
|
||||||
|
abstract fun setSourceDownloadId(id: String, downloadId: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET path = :path WHERE id = :id")
|
||||||
|
abstract fun setSourcePath(id: String, path: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM sources WHERE id = :id")
|
||||||
|
abstract fun deleteSource(id: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM movies WHERE id = :id")
|
||||||
|
abstract fun deleteMovie(id: UUID)
|
||||||
|
|
||||||
|
@Query("UPDATE userdata SET playbackPositionTicks = :playbackPositionTicks WHERE itemId = :itemId AND userid = :userId")
|
||||||
|
abstract fun setPlaybackPositionTicks(itemId: UUID, userId: UUID, playbackPositionTicks: Long)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
abstract fun insertMediaStream(mediaStream: FindroidMediaStreamDto)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM mediastreams WHERE sourceId = :sourceId")
|
||||||
|
abstract fun getMediaStreamsBySourceId(sourceId: String): List<FindroidMediaStreamDto>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM mediastreams WHERE downloadId = :downloadId")
|
||||||
|
abstract fun getMediaStreamByDownloadId(downloadId: Long): FindroidMediaStreamDto?
|
||||||
|
|
||||||
|
@Query("UPDATE mediastreams SET downloadId = :downloadId WHERE id = :id")
|
||||||
|
abstract fun setMediaStreamDownloadId(id: UUID, downloadId: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE mediastreams SET path = :path WHERE id = :id")
|
||||||
|
abstract fun setMediaStreamPath(id: UUID, path: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM mediastreams WHERE id = :id")
|
||||||
|
abstract fun deleteMediaStream(id: UUID)
|
||||||
|
|
||||||
|
@Query("DELETE FROM mediastreams WHERE sourceId = :sourceId")
|
||||||
|
abstract fun deleteMediaStreamsBySourceId(sourceId: String)
|
||||||
|
|
||||||
|
@Query("UPDATE userdata SET played = :played WHERE userId = :userId AND itemId = :itemId")
|
||||||
|
abstract fun setPlayed(userId: UUID, itemId: UUID, played: Boolean)
|
||||||
|
|
||||||
|
@Query("UPDATE userdata SET favorite = :favorite WHERE userId = :userId AND itemId = :itemId")
|
||||||
|
abstract fun setFavorite(userId: UUID, itemId: UUID, favorite: Boolean)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
abstract fun insertTrickPlayManifest(trickPlayManifestDto: TrickPlayManifestDto)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM trickPlayManifests WHERE itemId = :itemId")
|
||||||
|
abstract fun getTrickPlayManifest(itemId: UUID): TrickPlayManifestDto?
|
||||||
|
|
||||||
|
@Query("DELETE FROM trickPlayManifests WHERE itemId = :itemId")
|
||||||
|
abstract fun deleteTrickPlayManifest(itemId: UUID)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM movies ORDER BY name ASC")
|
||||||
|
abstract fun getMovies(): List<FindroidMovieDto>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM movies WHERE serverId = :serverId ORDER BY name ASC")
|
||||||
|
abstract fun getMoviesByServerId(serverId: String): List<FindroidMovieDto>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
abstract fun insertShow(show: FindroidShowDto)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM shows WHERE id = :id")
|
||||||
|
abstract fun getShow(id: UUID): FindroidShowDto
|
||||||
|
|
||||||
|
@Query("SELECT * FROM shows ORDER BY name ASC")
|
||||||
|
abstract fun getShows(): List<FindroidShowDto>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM shows WHERE serverId = :serverId ORDER BY name ASC")
|
||||||
|
abstract fun getShowsByServerId(serverId: String): List<FindroidShowDto>
|
||||||
|
|
||||||
|
@Query("DELETE FROM shows WHERE id = :id")
|
||||||
|
abstract fun deleteShow(id: UUID)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
abstract fun insertSeason(show: FindroidSeasonDto)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM seasons WHERE id = :id")
|
||||||
|
abstract fun getSeason(id: UUID): FindroidSeasonDto
|
||||||
|
|
||||||
|
@Query("SELECT * FROM seasons WHERE seriesId = :seriesId ORDER BY indexNumber ASC")
|
||||||
|
abstract fun getSeasonsByShowId(seriesId: UUID): List<FindroidSeasonDto>
|
||||||
|
|
||||||
|
@Query("DELETE FROM seasons WHERE id = :id")
|
||||||
|
abstract fun deleteSeason(id: UUID)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
abstract fun insertEpisode(episode: FindroidEpisodeDto)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM episodes WHERE id = :id")
|
||||||
|
abstract fun getEpisode(id: UUID): FindroidEpisodeDto
|
||||||
|
|
||||||
|
@Query("SELECT * FROM episodes WHERE seriesId = :seriesId ORDER BY parentIndexNumber ASC, indexNumber ASC")
|
||||||
|
abstract fun getEpisodesByShowId(seriesId: UUID): List<FindroidEpisodeDto>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM episodes WHERE seasonId = :seasonId ORDER BY indexNumber ASC")
|
||||||
|
abstract fun getEpisodesBySeasonId(seasonId: UUID): List<FindroidEpisodeDto>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM episodes WHERE serverId = :serverId ORDER BY seriesName ASC, parentIndexNumber ASC, indexNumber ASC")
|
||||||
|
abstract fun getEpisodesByServerId(serverId: String): List<FindroidEpisodeDto>
|
||||||
|
|
||||||
|
@Query("SELECT episodes.id, episodes.serverId, episodes.seasonId, episodes.seriesId, episodes.name, episodes.seriesName, episodes.overview, episodes.indexNumber, episodes.indexNumberEnd, episodes.parentIndexNumber, episodes.runtimeTicks, episodes.premiereDate, episodes.communityRating FROM episodes INNER JOIN userdata ON episodes.id = userdata.itemId WHERE serverId = :serverId AND playbackPositionTicks > 0 ORDER BY episodes.parentIndexNumber ASC, episodes.indexNumber ASC")
|
||||||
|
abstract fun getEpisodeResumeItems(serverId: String): List<FindroidEpisodeDto>
|
||||||
|
|
||||||
|
@Query("DELETE FROM episodes WHERE id = :id")
|
||||||
|
abstract fun deleteEpisode(id: UUID)
|
||||||
|
|
||||||
|
@Query("DELETE FROM episodes WHERE seasonId = :seasonId")
|
||||||
|
abstract fun deleteEpisodesBySeasonId(seasonId: UUID)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
abstract fun insertIntro(intro: IntroDto)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM intros WHERE itemId = :itemId")
|
||||||
|
abstract fun getIntro(itemId: UUID): IntroDto?
|
||||||
|
|
||||||
|
@Query("DELETE FROM intros WHERE itemId = :itemId")
|
||||||
|
abstract fun deleteIntro(itemId: UUID)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM seasons")
|
||||||
|
abstract fun getSeasons(): List<FindroidSeasonDto>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM episodes")
|
||||||
|
abstract fun getEpisodes(): List<FindroidEpisodeDto>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM userdata WHERE itemId = :itemId AND userId = :userId")
|
||||||
|
abstract fun getUserData(itemId: UUID, userId: UUID): FindroidUserDataDto?
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open fun getUserDataOrCreateNew(itemId: UUID, userId: UUID): FindroidUserDataDto {
|
||||||
|
var userData = getUserData(itemId, userId)
|
||||||
|
|
||||||
|
// Create user data when there is none
|
||||||
|
if (userData == null) {
|
||||||
|
userData = FindroidUserDataDto(
|
||||||
|
userId = userId,
|
||||||
|
itemId = itemId,
|
||||||
|
played = false,
|
||||||
|
favorite = false,
|
||||||
|
playbackPositionTicks = 0L
|
||||||
|
)
|
||||||
|
insertUserData(userData)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userData
|
||||||
|
}
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
abstract fun insertUserData(userData: FindroidUserDataDto)
|
||||||
|
|
||||||
|
@Query("DELETE FROM userdata WHERE itemId = :itemId")
|
||||||
|
abstract fun deleteUserData(itemId: UUID)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM userdata WHERE userId = :userId AND itemId = :itemId AND toBeSynced = TRUE")
|
||||||
|
abstract fun getUserDataToBeSynced(userId: UUID, itemId: UUID): FindroidUserDataDto?
|
||||||
|
|
||||||
|
@Query("UPDATE userdata SET toBeSynced = :toBeSynced WHERE itemId = :itemId AND userId = :userId")
|
||||||
|
abstract fun setUserDataToBeSynced(userId: UUID, itemId: UUID, toBeSynced: Boolean)
|
||||||
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
package dev.jdtech.jellyfin.models
|
package dev.jdtech.jellyfin.models
|
||||||
|
|
||||||
enum class CollectionType(val type: String) {
|
enum class CollectionType(val type: String) {
|
||||||
|
Movies("movies"),
|
||||||
|
TvShows("tvshows"),
|
||||||
HomeVideos("homevideos"),
|
HomeVideos("homevideos"),
|
||||||
Music("music"),
|
Music("music"),
|
||||||
Playlists("playlists"),
|
Playlists("playlists"),
|
|
@ -0,0 +1,26 @@
|
||||||
|
package dev.jdtech.jellyfin.models
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
|
||||||
|
data class FindroidBoxSet(
|
||||||
|
override val id: UUID,
|
||||||
|
override val name: String,
|
||||||
|
override val originalTitle: String? = null,
|
||||||
|
override val overview: String = "",
|
||||||
|
override val played: Boolean = false,
|
||||||
|
override val favorite: Boolean = false,
|
||||||
|
override val canPlay: Boolean = false,
|
||||||
|
override val canDownload: Boolean = false,
|
||||||
|
override val sources: List<FindroidSource> = emptyList(),
|
||||||
|
override val runtimeTicks: Long = 0L,
|
||||||
|
override val playbackPositionTicks: Long = 0L,
|
||||||
|
override val unplayedItemCount: Int? = null,
|
||||||
|
) : FindroidItem
|
||||||
|
|
||||||
|
fun BaseItemDto.toFindroidBoxSet(): FindroidBoxSet {
|
||||||
|
return FindroidBoxSet(
|
||||||
|
id = id,
|
||||||
|
name = name.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
package dev.jdtech.jellyfin.models
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
|
||||||
|
data class FindroidCollection(
|
||||||
|
override val id: UUID,
|
||||||
|
override val name: String,
|
||||||
|
override val originalTitle: String? = null,
|
||||||
|
override val overview: String = "",
|
||||||
|
override val played: Boolean = false,
|
||||||
|
override val favorite: Boolean = false,
|
||||||
|
override val canPlay: Boolean = false,
|
||||||
|
override val canDownload: Boolean = false,
|
||||||
|
override val sources: List<FindroidSource> = emptyList(),
|
||||||
|
override val runtimeTicks: Long = 0L,
|
||||||
|
override val playbackPositionTicks: Long = 0L,
|
||||||
|
override val unplayedItemCount: Int? = null,
|
||||||
|
val type: CollectionType
|
||||||
|
) : FindroidItem
|
||||||
|
|
||||||
|
fun BaseItemDto.toFindroidCollection(): FindroidCollection? {
|
||||||
|
val type = CollectionType.values().firstOrNull { it.type == collectionType }
|
||||||
|
return if (type != null) {
|
||||||
|
FindroidCollection(
|
||||||
|
id = id,
|
||||||
|
name = name.orEmpty(),
|
||||||
|
type = type,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue