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:
Jarne Demeulemeester 2023-05-07 16:05:40 +02:00 committed by GitHub
parent c36705c206
commit 00c84fa9d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
143 changed files with 7395 additions and 3608 deletions

View file

@ -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)

View file

@ -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>

View file

@ -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()

View file

@ -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")

View file

@ -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
}
}
} }

View file

@ -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)
} }
} }

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
} }
} }

View file

@ -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)
} }
} }

View file

@ -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)
} }
} }

View file

@ -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)
} }
} }

View file

@ -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()
}
} }

View file

@ -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
}

View file

@ -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
)
)
}
}
} }
} }

View file

@ -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
)
)
}
}

View file

@ -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
)
)
}
}

View file

@ -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
)
)
}
}
}
}

View file

@ -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)
}
} }

View file

@ -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
)
)
}
}
} }
} }

View file

@ -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()

View file

@ -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
)
)
}
}
} }
} }

View file

@ -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,
) )
) )
} }

View file

@ -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)
)
}
}

View file

@ -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)
)
}
}

View file

@ -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
)
)
}
}
} }
} }

View file

@ -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
)
)
}
}
} }
} }

View file

@ -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
)
)
}
} }

View file

@ -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,

View file

@ -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)
)
}
}

View file

@ -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"

View file

@ -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>

View 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>

View file

@ -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"

View file

@ -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

View file

@ -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"

View file

@ -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" />

View file

@ -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>

View file

@ -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>

View 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>

View 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>

View file

@ -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">

View file

@ -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"

View 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>

View file

@ -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" />

View 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>

View file

@ -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">

View file

@ -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>

View file

@ -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)

View file

@ -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
}

View file

@ -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?
}

View file

@ -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

View file

@ -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
}
} }

View file

@ -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)
}
}

View file

@ -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
}
} }
} }

View file

@ -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
} }
} }

View file

@ -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>
) )

View file

@ -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
} }

View file

@ -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>
) )

View file

@ -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)
} }
} }
} }

View file

@ -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?
) )

View file

@ -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)
}

View file

@ -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)
}
}
}
}
}
}
}

View 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>
}

View 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)
}
}

View file

@ -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

View file

@ -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) }
}
}

View file

@ -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))
}
}
}
}

View file

@ -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))
}
}
}

View file

@ -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)
} }
} }

View file

@ -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

View file

@ -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
) )
) )

View file

@ -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()
} }

View file

@ -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)
}
}

View file

@ -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(

View file

@ -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)
}
}

View file

@ -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>
) )
} }

View file

@ -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

View file

@ -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) }
} }
} }

View file

@ -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 = " - ")
}
}

View file

@ -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)
}
}
}
}

View 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) {}
}
}
}

View 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>

View 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>

View file

@ -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" />

View 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>

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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)

View 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')"
]
}
}

View 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')"
]
}
}

View file

@ -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) }
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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"),

View file

@ -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(),
)
}

View file

@ -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