Merge remote-tracking branch 'origin/develop'

This commit is contained in:
Jarne Demeulemeester 2021-12-19 15:37:17 +01:00
commit df15e2c520
No known key found for this signature in database
GPG key ID: B61B7B150DB6A6D2
193 changed files with 7309 additions and 2251 deletions

0
.idea/gradle.properties Normal file
View file

View file

@ -54,15 +54,24 @@ android {
}
dependencies {
implementation("androidx.core:core-ktx:1.6.0")
implementation("androidx.core:core-splashscreen:1.0.0-alpha01")
implementation("androidx.appcompat:appcompat:1.3.1")
implementation("androidx.leanback:leanback:1.2.0-alpha02")
implementation("androidx.core:core-ktx:1.7.0")
implementation("androidx.core:core-splashscreen:1.0.0-alpha02")
implementation("androidx.appcompat:appcompat:1.4.0")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
// Material
implementation("com.google.android.material:material:1.4.0")
// ConstraintLayout
implementation("androidx.constraintlayout:constraintlayout:2.1.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.2")
// Lifecycle
val lifecycleVersion = "2.4.0"
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion")
// Navigation
val navigationVersion = "2.3.5"
@ -84,8 +93,8 @@ dependencies {
implementation("androidx.preference:preference-ktx:$preferenceVersion")
// Jellyfin
val jellyfinVersion = "1.0.3"
implementation("org.jellyfin.sdk:jellyfin-platform-android:$jellyfinVersion")
val jellyfinVersion = "1.1.1"
implementation("org.jellyfin.sdk:jellyfin-core:$jellyfinVersion")
// Glide
val glideVersion = "4.12.0"
@ -93,12 +102,12 @@ dependencies {
kapt("com.github.bumptech.glide:compiler:$glideVersion")
// Hilt
val hiltVersion = "2.38.1"
val hiltVersion = "2.40.2"
implementation("com.google.dagger:hilt-android:$hiltVersion")
kapt("com.google.dagger:hilt-compiler:$hiltVersion")
// ExoPlayer
val exoplayerVersion = "2.15.0"
val exoplayerVersion = "2.15.1"
implementation("com.google.android.exoplayer:exoplayer-core:$exoplayerVersion")
implementation("com.google.android.exoplayer:exoplayer-ui:$exoplayerVersion")
implementation(files("libs/extension-ffmpeg-release.aar"))
@ -110,7 +119,7 @@ dependencies {
val timberVersion = "5.0.1"
implementation("com.jakewharton.timber:timber:$timberVersion")
val aboutLibrariesVersion = "8.9.1"
val aboutLibrariesVersion = "8.9.4"
implementation("com.mikepenz:aboutlibraries-core:$aboutLibrariesVersion")
implementation("com.mikepenz:aboutlibraries:$aboutLibrariesVersion")

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View file

@ -0,0 +1,27 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.056557618"
android:scaleY="0.056557618"
android:translateX="25.0425"
android:translateY="25.0425">
<path
android:pathData="m586.76,542.3 l25.39,-43.97c3.53,-6.1 -5.62,-11.39 -9.15,-5.29l-25.71,44.52c-19.66,-8.97 -41.74,-13.97 -65.29,-13.97 -23.56,0 -45.63,5 -65.29,13.97l-25.7,-44.52c-3.52,-6.1 -12.67,-0.82 -9.15,5.28l25.39,43.98c-43.59,23.71 -73.41,67.84 -77.77,119.98L664.53,662.28C660.16,610.14 630.35,566.01 586.76,542.3m-74.71,-405.8c-99.41,0 -419.26,580 -370.53,677.95 48.74,97.96 692.8,96.83 741.05,0 48.25,-96.83 -271.12,-677.95 -370.53,-677.95zM754.92,729.57c-31.63,63.42 -453.64,64.23 -485.59,0C237.38,665.34 447.01,285.29 512.04,285.29c65.04,0 274.51,380.69 242.88,444.28z"
android:strokeWidth="0.23938">
<aapt:attr name="android:fillColor">
<gradient
android:startY="479.77658"
android:startX="363.41766"
android:endY="702.5666"
android:endX="749.3077"
android:type="linear">
<item android:offset="0" android:color="#FFE01F39"/>
<item android:offset="1" android:color="#FF00A4DC"/>
</gradient>
</aapt:attr>
</path>
</group>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Findroid Debug</string>
</resources>

View file

@ -4,28 +4,55 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application
android:name=".BaseApplication"
android:allowBackup="true"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:banner="@mipmap/ic_banner"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Jellyfin"
android:theme="@style/Theme.Findroid"
android:usesCleartextTraffic="true">
<activity
android:name=".PlayerActivity"
android:screenOrientation="userLandscape" />
<activity
android:name=".tv.TvPlayerActivity"
android:screenOrientation="userLandscape" />
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.JellyfinSplashScreen"
android:windowSoftInputMode="adjustPan">
android:theme="@style/Theme.FindroidSplashScreen"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".MainActivityTv"
android:exported="true"
android:theme="@style/Theme.Jellyfin.Tv"
android:windowSoftInputMode="adjustPan">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>

View file

@ -0,0 +1,69 @@
package dev.jdtech.jellyfin
import android.os.Build
import android.view.View
import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.updatePadding
import com.google.android.exoplayer2.trackselection.MappingTrackSelector
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
abstract class BasePlayerActivity: AppCompatActivity() {
abstract val viewModel: PlayerActivityViewModel
override fun onPause() {
super.onPause()
viewModel.playWhenReady = viewModel.player.playWhenReady == true
viewModel.player.playWhenReady = false
}
override fun onResume() {
super.onResume()
viewModel.player.playWhenReady = viewModel.playWhenReady
hideSystemUI()
}
@Suppress("DEPRECATION")
protected fun hideSystemUI() {
// These methods are deprecated but we still use them because the new WindowInsetsControllerCompat has a bug which makes the action bar reappear
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
protected fun isRendererType(
mappedTrackInfo: MappingTrackSelector.MappedTrackInfo,
rendererIndex: Int,
type: Int
): Boolean {
val trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex)
if (trackGroupArray.length == 0) {
return false
}
val trackType = mappedTrackInfo.getRendererType(rendererIndex)
return type == trackType
}
protected fun configureInsets(playerControls: View) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
playerControls
.setOnApplyWindowInsetsListener { _, windowInsets ->
val cutout = windowInsets.displayCutout
playerControls.updatePadding(
left = cutout?.safeInsetLeft ?: 0,
top = cutout?.safeInsetTop ?: 0,
right = cutout?.safeInsetRight ?: 0,
bottom = cutout?.safeInsetBottom ?: 0,
)
return@setOnApplyWindowInsetsListener windowInsets
}
}
}
}

View file

@ -1,18 +1,25 @@
package dev.jdtech.jellyfin
import android.view.View
import android.widget.ImageView
import androidx.databinding.BindingAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import dev.jdtech.jellyfin.adapters.*
import dev.jdtech.jellyfin.adapters.DownloadsListAdapter
import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter
import dev.jdtech.jellyfin.adapters.HomeItem
import dev.jdtech.jellyfin.adapters.PersonListAdapter
import dev.jdtech.jellyfin.adapters.ServerGridAdapter
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.adapters.ViewListAdapter
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.Server
import dev.jdtech.jellyfin.models.FavoriteSection
import dev.jdtech.jellyfin.models.DownloadSection
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType
import java.util.*
import java.util.UUID
@BindingAdapter("servers")
fun bindServers(recyclerView: RecyclerView, data: List<Server>?) {
@ -20,12 +27,6 @@ fun bindServers(recyclerView: RecyclerView, data: List<Server>?) {
adapter.submitList(data)
}
@BindingAdapter("views")
fun bindViews(recyclerView: RecyclerView, data: List<HomeItem>?) {
val adapter = recyclerView.adapter as ViewListAdapter
adapter.submitList(data)
}
@BindingAdapter("items")
fun bindItems(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
val adapter = recyclerView.adapter as ViewItemListAdapter
@ -34,50 +35,26 @@ fun bindItems(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
@BindingAdapter("itemImage")
fun bindItemImage(imageView: ImageView, item: BaseItemDto) {
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
val itemId =
if (item.type == "Episode" || item.type == "Season" && item.imageTags.isNullOrEmpty()) item.seriesId else item.id
Glide
.with(imageView.context)
.load(jellyfinApi.api.baseUrl.plus("/items/${itemId}/Images/${ImageType.PRIMARY}"))
.transition(DrawableTransitionOptions.withCrossFade())
.placeholder(R.color.neutral_800)
.into(imageView)
imageView.contentDescription = "${item.name} poster"
imageView
.loadImage("/items/$itemId/Images/${ImageType.PRIMARY}")
.posterDescription(item.name)
}
@BindingAdapter("itemBackdropImage")
fun bindItemBackdropImage(imageView: ImageView, item: BaseItemDto?) {
if (item == null) return
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
Glide
.with(imageView.context)
.load(jellyfinApi.api.baseUrl.plus("/items/${item.id}/Images/${ImageType.BACKDROP}"))
.transition(DrawableTransitionOptions.withCrossFade())
.into(imageView)
imageView.contentDescription = "${item.name} backdrop"
imageView
.loadImage("/items/${item.id}/Images/${ImageType.BACKDROP}")
.backdropDescription(item.name)
}
@BindingAdapter("itemBackdropById")
fun bindItemBackdropById(imageView: ImageView, itemId: UUID) {
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
Glide
.with(imageView.context)
.load(jellyfinApi.api.baseUrl.plus("/items/${itemId}/Images/${ImageType.BACKDROP}"))
.transition(DrawableTransitionOptions.withCrossFade())
.into(imageView)
}
@BindingAdapter("collections")
fun bindCollections(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
val adapter = recyclerView.adapter as CollectionListAdapter
adapter.submitList(data)
imageView.loadImage("/items/$itemId/Images/${ImageType.BACKDROP}")
}
@BindingAdapter("people")
@ -88,22 +65,9 @@ fun bindPeople(recyclerView: RecyclerView, data: List<BaseItemPerson>?) {
@BindingAdapter("personImage")
fun bindPersonImage(imageView: ImageView, person: BaseItemPerson) {
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
Glide
.with(imageView.context)
.load(jellyfinApi.api.baseUrl.plus("/items/${person.id}/Images/${ImageType.PRIMARY}"))
.transition(DrawableTransitionOptions.withCrossFade())
.placeholder(R.color.neutral_800)
.into(imageView)
imageView.contentDescription = "${person.name} poster"
}
@BindingAdapter("episodes")
fun bindEpisodes(recyclerView: RecyclerView, data: List<EpisodeItem>?) {
val adapter = recyclerView.adapter as EpisodeListAdapter
adapter.submitList(data)
imageView
.loadImage("/items/${person.id}/Images/${ImageType.PRIMARY}")
.posterDescription(person.name)
}
@BindingAdapter("homeEpisodes")
@ -116,12 +80,10 @@ fun bindHomeEpisodes(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
fun bindBaseItemImage(imageView: ImageView, episode: BaseItemDto?) {
if (episode == null) return
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
var imageItemId = episode.id
var imageType = ImageType.PRIMARY
if (!episode.imageTags.isNullOrEmpty()) {
if (!episode.imageTags.isNullOrEmpty()) { //TODO: Downloadmetadata currently does not store imagetags, so it always uses the backdrop
when (episode.type) {
"Movie" -> {
if (!episode.backdropImageTags.isNullOrEmpty()) {
@ -141,30 +103,33 @@ fun bindBaseItemImage(imageView: ImageView, episode: BaseItemDto?) {
}
}
Glide
.with(imageView.context)
.load(jellyfinApi.api.baseUrl.plus("/items/${imageItemId}/Images/$imageType"))
.transition(DrawableTransitionOptions.withCrossFade())
.placeholder(R.color.neutral_800)
.into(imageView)
imageView.contentDescription = "${episode.name} poster"
imageView
.loadImage("/items/${imageItemId}/Images/$imageType")
.posterDescription(episode.name)
}
@BindingAdapter("seasonPoster")
fun bindSeasonPoster(imageView: ImageView, seasonId: UUID) {
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
imageView.loadImage("/items/${seasonId}/Images/${ImageType.PRIMARY}")
}
Glide
.with(imageView.context)
.load(jellyfinApi.api.baseUrl.plus("/items/${seasonId}/Images/${ImageType.PRIMARY}"))
private fun ImageView.loadImage(url: String, errorPlaceHolderId: Int? = null): View {
val api = JellyfinApi.getInstance(context.applicationContext)
return Glide
.with(context)
.load("${api.api.baseUrl}$url")
.transition(DrawableTransitionOptions.withCrossFade())
.placeholder(R.color.neutral_800)
.into(imageView)
.also { if (errorPlaceHolderId != null) error(errorPlaceHolderId) }
.into(this)
.view
}
@BindingAdapter("favoriteSections")
fun bindFavoriteSections(recyclerView: RecyclerView, data: List<FavoriteSection>?) {
val adapter = recyclerView.adapter as FavoritesListAdapter
adapter.submitList(data)
private fun View.posterDescription(name: String?) {
contentDescription = String.format(context.resources.getString(R.string.image_description_poster), name)
}
private fun View.backdropDescription(name: String?) {
contentDescription = String.format(context.resources.getString(R.string.image_description_backdrop), name)
}

View file

@ -3,29 +3,30 @@ package dev.jdtech.jellyfin
import android.os.Bundle
import android.view.View
import androidx.activity.viewModels
import com.google.android.material.bottomnavigation.BottomNavigationView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.bottomnavigation.BottomNavigationView
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.ActivityMainBinding
import dev.jdtech.jellyfin.fragments.InitializingFragmentDirections
import dev.jdtech.jellyfin.databinding.ActivityMainAppBinding
import dev.jdtech.jellyfin.fragments.HomeFragmentDirections
import dev.jdtech.jellyfin.utils.loadDownloadLocation
import dev.jdtech.jellyfin.viewmodels.MainViewModel
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var binding: ActivityMainAppBinding
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
binding = ActivityMainAppBinding.inflate(layoutInflater)
setContentView(binding.root)
@ -42,7 +43,7 @@ class MainActivity : AppCompatActivity() {
// menu should be considered as top level destinations.
val appBarConfiguration = AppBarConfiguration(
setOf(
R.id.homeFragment, R.id.mediaFragment, R.id.favoriteFragment
R.id.homeFragment, R.id.mediaFragment, R.id.favoriteFragment, R.id.downloadFragment
)
)
@ -57,20 +58,14 @@ class MainActivity : AppCompatActivity() {
if (destination.id == R.id.about_libraries_dest) binding.mainToolbar.title = getString(R.string.app_info)
}
loadDownloadLocation(applicationContext)
viewModel.navigateToAddServer.observe(this, {
if (it) {
navController.navigate(InitializingFragmentDirections.actionInitializingFragmentToAddServerFragment3())
navController.navigate(HomeFragmentDirections.actionHomeFragmentToAddServerFragment())
viewModel.doneNavigateToAddServer()
}
})
viewModel.doneLoading.observe(this, {
if (it) {
if (navController.currentDestination!!.id == R.id.initializingFragment) {
navController.navigate(InitializingFragmentDirections.actionInitializingFragmentToNavigationHome())
}
}
})
}
override fun onSupportNavigateUp(): Boolean {

View file

@ -0,0 +1,37 @@
package dev.jdtech.jellyfin
import android.os.Bundle
import androidx.activity.viewModels
import androidx.fragment.app.FragmentActivity
import androidx.navigation.fragment.NavHostFragment
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.ActivityMainTvBinding
import dev.jdtech.jellyfin.tv.ui.HomeFragmentDirections
import dev.jdtech.jellyfin.utils.loadDownloadLocation
import dev.jdtech.jellyfin.viewmodels.MainViewModel
@AndroidEntryPoint
internal class MainActivityTv : FragmentActivity() {
private lateinit var binding: ActivityMainTvBinding
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainTvBinding.inflate(layoutInflater)
setContentView(binding.root)
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.tv_nav_host) as NavHostFragment
val navController = navHostFragment.navController
loadDownloadLocation(applicationContext)
viewModel.navigateToAddServer.observe(this, {
if (it) {
navController.navigate(HomeFragmentDirections.actionHomeFragmentToAddServerFragment())
viewModel.doneNavigateToAddServer()
}
})
}
}

View file

@ -1,36 +1,39 @@
package dev.jdtech.jellyfin
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import android.content.Context
import android.media.AudioManager
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import android.widget.ImageButton
import android.widget.TextView
import androidx.activity.viewModels
import androidx.core.view.updatePadding
import androidx.navigation.navArgs
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.trackselection.MappingTrackSelector
import com.google.android.exoplayer2.ui.TrackSelectionDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.ActivityPlayerBinding
import dev.jdtech.jellyfin.dialogs.SpeedSelectionDialogFragment
import dev.jdtech.jellyfin.dialogs.TrackSelectionDialogFragment
import dev.jdtech.jellyfin.mpv.MPVPlayer
import dev.jdtech.jellyfin.mpv.TrackType
import dev.jdtech.jellyfin.utils.PlayerGestureHelper
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
import timber.log.Timber
@AndroidEntryPoint
class PlayerActivity : AppCompatActivity() {
private lateinit var binding: ActivityPlayerBinding
private val viewModel: PlayerActivityViewModel by viewModels()
class PlayerActivity : BasePlayerActivity() {
lateinit var binding: ActivityPlayerBinding
private lateinit var playerGestureHelper: PlayerGestureHelper
override val viewModel: PlayerActivityViewModel by viewModels()
private val args: PlayerActivityArgs by navArgs()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.d("Creating player activity")
binding = ActivityPlayerBinding.inflate(layoutInflater)
setContentView(binding.root)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@ -39,19 +42,9 @@ class PlayerActivity : AppCompatActivity() {
val playerControls = binding.playerView.findViewById<View>(R.id.player_controls)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
binding.playerView.findViewById<View>(R.id.player_controls)
.setOnApplyWindowInsetsListener { _, windowInsets ->
val cutout = windowInsets.displayCutout
playerControls.updatePadding(
left = cutout?.safeInsetLeft ?: 0,
top = cutout?.safeInsetTop ?: 0,
right = cutout?.safeInsetRight ?: 0,
bottom = cutout?.safeInsetBottom ?: 0,
)
return@setOnApplyWindowInsetsListener windowInsets
}
}
configureInsets(playerControls)
playerGestureHelper = PlayerGestureHelper(this, binding.playerView, getSystemService(Context.AUDIO_SERVICE) as AudioManager)
binding.playerView.findViewById<View>(R.id.back_button).setOnClickListener {
onBackPressed()
@ -65,6 +58,7 @@ class PlayerActivity : AppCompatActivity() {
val audioButton = binding.playerView.findViewById<ImageButton>(R.id.btn_audio_track)
val subtitleButton = binding.playerView.findViewById<ImageButton>(R.id.btn_subtitle)
val speedButton = binding.playerView.findViewById<ImageButton>(R.id.btn_speed)
audioButton.isEnabled = false
audioButton.imageAlpha = 75
@ -72,6 +66,9 @@ class PlayerActivity : AppCompatActivity() {
subtitleButton.isEnabled = false
subtitleButton.imageAlpha = 75
speedButton.isEnabled = false
speedButton.imageAlpha = 75
audioButton.setOnClickListener {
when (viewModel.player) {
is MPVPlayer -> {
@ -134,12 +131,21 @@ class PlayerActivity : AppCompatActivity() {
}
}
speedButton.setOnClickListener {
SpeedSelectionDialogFragment(viewModel).show(
supportFragmentManager,
"speedselectiondialog"
)
}
viewModel.fileLoaded.observe(this, {
if (it) {
audioButton.isEnabled = true
audioButton.imageAlpha = 255
subtitleButton.isEnabled = true
subtitleButton.imageAlpha = 255
speedButton.isEnabled = true
speedButton.imageAlpha = 255
}
})
@ -152,44 +158,5 @@ class PlayerActivity : AppCompatActivity() {
viewModel.initializePlayer(args.items)
hideSystemUI()
}
override fun onPause() {
super.onPause()
viewModel.playWhenReady = viewModel.player.playWhenReady == true
viewModel.player.playWhenReady = false
}
override fun onResume() {
super.onResume()
viewModel.player.playWhenReady = viewModel.playWhenReady
hideSystemUI()
}
@Suppress("DEPRECATION")
private fun hideSystemUI() {
// These methods are deprecated but we still use them because the new WindowInsetsControllerCompat has a bug which makes the action bar reappear
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
private fun isRendererType(
mappedTrackInfo: MappingTrackSelector.MappedTrackInfo,
rendererIndex: Int,
type: Int
): Boolean {
val trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex)
if (trackGroupArray.length == 0) {
return false
}
val trackType = mappedTrackInfo.getRendererType(rendererIndex)
return type == trackType
}
}

View file

@ -0,0 +1,68 @@
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.HomeEpisodeItemBinding
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
import timber.log.Timber
class DownloadEpisodeListAdapter(private val onClickListener: OnClickListener) : ListAdapter<PlayerItem, DownloadEpisodeListAdapter.EpisodeViewHolder>(DiffCallback) {
class EpisodeViewHolder(private var binding: HomeEpisodeItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(episode: PlayerItem) {
val metadata = episode.metadata!!
binding.episode = downloadMetadataToBaseItemDto(episode.metadata)
if (metadata.playedPercentage != null) {
binding.progressBar.layoutParams.width = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
(metadata.playedPercentage.times(2.24)).toFloat(), binding.progressBar.context.resources.displayMetrics).toInt()
binding.progressBar.visibility = View.VISIBLE
}
if (metadata.type == "Movie") {
binding.primaryName.text = metadata.name
Timber.d(metadata.name)
binding.secondaryName.visibility = View.GONE
} else if (metadata.type == "Episode") {
binding.primaryName.text = metadata.seriesName
}
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): EpisodeViewHolder {
return EpisodeViewHolder(
HomeEpisodeItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: EpisodeViewHolder, position: Int) {
val item = getItem(position)
holder.itemView.setOnClickListener {
onClickListener.onClick(item)
}
holder.bind(item)
}
class OnClickListener(val clickListener: (item: PlayerItem) -> Unit) {
fun onClick(item: PlayerItem) = clickListener(item)
}
}

View file

@ -0,0 +1,67 @@
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.R
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.metadata!!
binding.item = downloadMetadataToBaseItemDto(metadata)
binding.itemName.text = if (metadata.type == "Episode") metadata.seriesName else item.name
binding.itemCount.visibility = View.GONE
if (fixedWidth) {
binding.itemLayout.layoutParams.width =
parent.resources.getDimension(R.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

@ -0,0 +1,63 @@
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 onEpisodeClickListener: DownloadEpisodeListAdapter.OnClickListener
) : ListAdapter<DownloadSection, DownloadsListAdapter.SectionViewHolder>(DiffCallback) {
class SectionViewHolder(private var binding: DownloadSectionBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(
section: DownloadSection,
onClickListener: DownloadViewItemListAdapter.OnClickListener,
onEpisodeClickListener: DownloadEpisodeListAdapter.OnClickListener
) {
binding.section = section
if (section.name == "Movies" || section.name == "Shows") {
binding.itemsRecyclerView.adapter =
DownloadViewItemListAdapter(onClickListener, fixedWidth = true)
(binding.itemsRecyclerView.adapter as DownloadViewItemListAdapter).submitList(section.items)
} else if (section.name == "Episodes") {
binding.itemsRecyclerView.adapter =
DownloadEpisodeListAdapter(onEpisodeClickListener)
(binding.itemsRecyclerView.adapter as DownloadEpisodeListAdapter).submitList(section.items)
}
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, onEpisodeClickListener)
}
}

View file

@ -8,7 +8,8 @@ import androidx.recyclerview.widget.RecyclerView
import dev.jdtech.jellyfin.databinding.PersonItemBinding
import org.jellyfin.sdk.model.api.BaseItemPerson
class PersonListAdapter :ListAdapter<BaseItemPerson, PersonListAdapter.PersonViewHolder>(DiffCallback) {
class PersonListAdapter(private val clickListener: (item: BaseItemPerson) -> Unit) :ListAdapter<BaseItemPerson, PersonListAdapter.PersonViewHolder>(DiffCallback) {
class PersonViewHolder(private var binding: PersonItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(person: BaseItemPerson) {
@ -40,5 +41,6 @@ class PersonListAdapter :ListAdapter<BaseItemPerson, PersonListAdapter.PersonVie
override fun onBindViewHolder(holder: PersonViewHolder, position: Int) {
val item = getItem(position)
holder.bind(item)
holder.itemView.setOnClickListener { clickListener(item) }
}
}

View file

@ -10,7 +10,7 @@ import dev.jdtech.jellyfin.databinding.NextUpSectionBinding
import dev.jdtech.jellyfin.databinding.ViewItemBinding
import dev.jdtech.jellyfin.models.HomeSection
import dev.jdtech.jellyfin.models.View
import java.util.*
import java.util.UUID
private const val ITEM_VIEW_TYPE_NEXT_UP = 0
private const val ITEM_VIEW_TYPE_VIEW = 1
@ -20,6 +20,7 @@ class ViewListAdapter(
private val onItemClickListener: ViewItemListAdapter.OnClickListener,
private val onNextUpClickListener: HomeEpisodeListAdapter.OnClickListener
) : ListAdapter<HomeItem, RecyclerView.ViewHolder>(DiffCallback) {
class ViewViewHolder(private var binding: ViewItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(
@ -50,7 +51,8 @@ class ViewListAdapter(
companion object DiffCallback : DiffUtil.ItemCallback<HomeItem>() {
override fun areItemsTheSame(oldItem: HomeItem, newItem: HomeItem): Boolean {
return oldItem.id == newItem.id
return oldItem.ids.size == newItem.ids.size
&& oldItem.ids.mapIndexed { i, old -> old == newItem.ids[i] }.all { it }
}
override fun areContentsTheSame(oldItem: HomeItem, newItem: HomeItem): Boolean {
@ -105,12 +107,12 @@ class ViewListAdapter(
sealed class HomeItem {
data class Section(val homeSection: HomeSection) : HomeItem() {
override val id = homeSection.id
override val ids = homeSection.items.map { it.id }
}
data class ViewItem(val view: View) : HomeItem() {
override val id = view.id
override val ids = view.items?.map { it.id }.orEmpty()
}
abstract val id: UUID
abstract val ids: List<UUID>
}

View file

@ -2,75 +2,62 @@ package dev.jdtech.jellyfin.api
import android.content.Context
import dev.jdtech.jellyfin.BuildConfig
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.android
import org.jellyfin.sdk.api.operations.*
import org.jellyfin.sdk.api.client.extensions.devicesApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.sessionApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import java.util.*
import java.util.UUID
/**
* Jellyfin API class using org.jellyfin.sdk:jellyfin-platform-android
*
* @param context The context
* @param androidContext The context
* @param baseUrl The url of the server
* @constructor Creates a new [JellyfinApi] instance
*/
class JellyfinApi(context: Context, baseUrl: String) {
val jellyfin = Jellyfin {
class JellyfinApi(androidContext: Context) {
val jellyfin = createJellyfin {
clientInfo =
ClientInfo(name = context.applicationInfo.loadLabel(context.packageManager).toString(), version = BuildConfig.VERSION_NAME)
android(context)
ClientInfo(name = androidContext.applicationInfo.loadLabel(androidContext.packageManager).toString(), version = BuildConfig.VERSION_NAME)
context = androidContext
}
val api = jellyfin.createApi(baseUrl = baseUrl)
val api = jellyfin.createApi()
var userId: UUID? = null
val systemApi = SystemApi(api)
val userApi = UserApi(api)
val viewsApi = UserViewsApi(api)
val itemsApi = ItemsApi(api)
val userLibraryApi = UserLibraryApi(api)
val showsApi = TvShowsApi(api)
val sessionApi = SessionApi(api)
val videosApi = VideosApi(api)
val mediaInfoApi = MediaInfoApi(api)
val playStateApi = PlayStateApi(api)
val devicesApi = api.devicesApi
val systemApi = api.systemApi
val userApi = api.userApi
val viewsApi = api.userViewsApi
val itemsApi = api.itemsApi
val userLibraryApi = api.userLibraryApi
val showsApi = api.tvShowsApi
val sessionApi = api.sessionApi
val videosApi = api.videosApi
val mediaInfoApi = api.mediaInfoApi
val playStateApi = api.playStateApi
companion object {
@Volatile
private var INSTANCE: JellyfinApi? = null
/**
* Creates or gets a new instance of [JellyfinApi]
*
* If there already is an instance, it will return that instance and ignore the [baseUrl] parameter
*
* @param context The context
* @param baseUrl The url of the server
*/
fun getInstance(context: Context, baseUrl: String): JellyfinApi {
fun getInstance(context: Context): JellyfinApi {
synchronized(this) {
var instance = INSTANCE
if (instance == null) {
instance = JellyfinApi(context.applicationContext, baseUrl)
instance = JellyfinApi(context.applicationContext)
INSTANCE = instance
}
return instance
}
}
/**
* Create a new [JellyfinApi] instance
*
* @param context The context
* @param baseUrl The url of the server
*/
fun newInstance(context: Context, baseUrl: String): JellyfinApi {
synchronized(this) {
val instance = JellyfinApi(context.applicationContext, baseUrl)
INSTANCE = instance
return instance
}
}
}
}

View file

@ -12,7 +12,7 @@ interface ServerDatabaseDao {
fun update(server: Server)
@Query("select * from servers where id = :id")
fun get(id: String): Server
fun get(id: String): Server?
@Query("delete from servers")
fun clear()

View file

@ -1,12 +1,15 @@
package dev.jdtech.jellyfin.di
import android.content.Context
import android.content.SharedPreferences
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import java.util.UUID
import javax.inject.Singleton
@Module
@ -14,7 +17,23 @@ import javax.inject.Singleton
object ApiModule {
@Singleton
@Provides
fun provideJellyfinApi(@ApplicationContext application: Context): JellyfinApi {
return JellyfinApi.getInstance(application, "")
fun provideJellyfinApi(
@ApplicationContext application: Context,
sharedPreferences: SharedPreferences,
serverDatabase: ServerDatabaseDao
): JellyfinApi {
val jellyfinApi = JellyfinApi.getInstance(application)
val serverId = sharedPreferences.getString("selectedServer", null)
if (serverId != null) {
val server = serverDatabase.get(serverId) ?: return jellyfinApi
jellyfinApi.apply {
api.baseUrl = server.address
api.accessToken = server.accessToken
userId = UUID.fromString(server.userId)
}
}
return jellyfinApi
}
}

View file

@ -23,6 +23,7 @@ object DatabaseModule {
"servers"
)
.fallbackToDestructiveMigration()
.allowMainThreadQueries()
.build()
.serverDatabaseDao
}

View file

@ -0,0 +1,51 @@
package dev.jdtech.jellyfin.di
import android.content.Context
import androidx.preference.PreferenceManager
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.engine.DiskCacheStrategy.NONE
import com.bumptech.glide.load.engine.DiskCacheStrategy.RESOURCE
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
import com.bumptech.glide.module.AppGlideModule
import com.bumptech.glide.request.RequestOptions
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import timber.log.Timber
private const val cacheDefaultSize = 250
@GlideModule
class GlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder) {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
val use = preferences.getBoolean("use_image_cache", false)
if (use) {
val sizeMb = preferences.getString("image_cache_size", "$cacheDefaultSize")?.toInt()!!
val sizeB = 1024L * 1024 * sizeMb
Timber.d("Setting image cache to use $sizeMb MB of disk space")
builder.setDiskCache(InternalCacheDiskCacheFactory(context, sizeB))
builder.caching(enabled = true)
} else {
builder.caching(enabled = false)
Timber.d("Image cache disabled. Clearing all persisted data.")
MainScope().launch {
GlideApp.getPhotoCacheDir(context)?.also {
if (it.exists()) it.deleteRecursively()
}
}
}
}
private fun GlideBuilder.caching(enabled: Boolean) {
setDefaultRequestOptions(
RequestOptions().diskCacheStrategy(
if (enabled) RESOURCE else NONE
)
)
}
}

View file

@ -1,9 +1,9 @@
package dev.jdtech.jellyfin.dialogs
import android.app.AlertDialog
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.database.Server
import dev.jdtech.jellyfin.viewmodels.ServerSelectViewModel
@ -12,7 +12,7 @@ import java.lang.IllegalStateException
class DeleteServerDialogFragment(private val viewModel: ServerSelectViewModel, val server: Server) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return activity?.let {
val builder = AlertDialog.Builder(it)
val builder = MaterialAlertDialogBuilder(it)
builder.setTitle(getString(R.string.remove_server))
.setMessage(getString(R.string.remove_server_dialog_text, server.name))
.setPositiveButton(getString(R.string.remove)) { _, _ ->

View file

@ -14,9 +14,9 @@ class ErrorDialogFragment(private val errorMessage: String) : DialogFragment() {
val builder = MaterialAlertDialogBuilder(it, R.style.ErrorDialogStyle)
builder
.setMessage(errorMessage)
.setPositiveButton("close") { _, _ ->
.setPositiveButton(getString(R.string.close)) { _, _ ->
}
.setNeutralButton("share") { _, _ ->
.setNeutralButton(getString(R.string.share)) { _, _ ->
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, errorMessage)

View file

@ -0,0 +1,87 @@
package dev.jdtech.jellyfin.dialogs
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.utils.SortBy
import dev.jdtech.jellyfin.viewmodels.LibraryViewModel
import org.jellyfin.sdk.model.api.SortOrder
import java.lang.IllegalStateException
import java.util.*
class SortDialogFragment(
private val parentId: UUID,
private val libraryType: String?,
private val viewModel: LibraryViewModel,
private val sortType: String
) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return activity?.let {
val sp = PreferenceManager.getDefaultSharedPreferences(it.applicationContext)
val builder = MaterialAlertDialogBuilder(it)
// Current sort by
val currentSortByString = sp.getString("sortBy", SortBy.defaultValue.name)!!
val currentSortBy = SortBy.fromString(currentSortByString)
// Current sort order
val currentSortOrderString = sp.getString("sortOrder", SortOrder.ASCENDING.name)!!
val currentSortOrder = try {
SortOrder.valueOf(currentSortOrderString)
} catch (e: java.lang.IllegalArgumentException) {
SortOrder.ASCENDING
}
when (sortType) {
"sortBy" -> {
val sortByOptions = resources.getStringArray(R.array.sort_by_options)
val sortByValues = SortBy.values()
builder
.setTitle(getString(R.string.sort_by))
.setSingleChoiceItems(
sortByOptions, currentSortBy.ordinal
) { dialog, which ->
sp.edit().putString("sortBy", sortByValues[which].name).apply()
viewModel.loadItems(
parentId,
libraryType,
sortBy = sortByValues[which],
sortOrder = currentSortOrder
)
dialog.dismiss()
}
}
"sortOrder" -> {
val sortByOptions = resources.getStringArray(R.array.sort_order_options)
val sortOrderValues = SortOrder.values()
builder
.setTitle(getString(R.string.sort_order))
.setSingleChoiceItems(
sortByOptions, currentSortOrder.ordinal
) { dialog, which ->
sp.edit().putString("sortOrder", sortOrderValues[which].name).apply()
val sortOrder = try {
sortOrderValues[which]
} catch (e: IllegalArgumentException) {
SortOrder.ASCENDING
}
viewModel.loadItems(
parentId,
libraryType,
sortBy = currentSortBy,
sortOrder = sortOrder
)
dialog.dismiss()
}
}
}
builder.create()
} ?: throw IllegalStateException("Activity cannot be null")
}
}

View file

@ -0,0 +1,35 @@
package dev.jdtech.jellyfin.dialogs
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
import java.lang.IllegalStateException
class SpeedSelectionDialogFragment(
private val viewModel: PlayerActivityViewModel
) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val speedTexts = listOf("0.5x", "0.75x", "1x", "1.25x", "1.5x", "1.75x", "2x")
val speedNumbers = listOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f)
return activity?.let { activity ->
val builder = MaterialAlertDialogBuilder(activity)
builder.setTitle(getString(R.string.select_playback_speed))
.setSingleChoiceItems(
speedTexts.toTypedArray(),
speedNumbers.indexOf(viewModel.playbackSpeed)
) { dialog, which ->
viewModel.selectSpeed(
speedNumbers[which]
)
dialog.dismiss()
}
builder.create()
} ?: throw IllegalStateException("Activity cannot be null")
}
}

View file

@ -1,9 +1,10 @@
package dev.jdtech.jellyfin.dialogs
import android.app.AlertDialog
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.mpv.TrackType
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
import java.lang.IllegalStateException
@ -24,15 +25,16 @@ class TrackSelectionDialogFragment(
}
}
return activity?.let { activity ->
val builder = AlertDialog.Builder(activity)
builder.setTitle("Select audio track")
val builder = MaterialAlertDialogBuilder(activity)
builder.setTitle(getString(R.string.select_audio_track))
.setSingleChoiceItems(
trackNames.toTypedArray(),
viewModel.currentAudioTracks.indexOfFirst { it.selected }) { _, which ->
viewModel.currentAudioTracks.indexOfFirst { it.selected }) { dialog, which ->
viewModel.switchToTrack(
TrackType.AUDIO,
viewModel.currentAudioTracks[which]
)
dialog.dismiss()
}
builder.create()
} ?: throw IllegalStateException("Activity cannot be null")
@ -46,27 +48,22 @@ class TrackSelectionDialogFragment(
}
}
return activity?.let { activity ->
val builder = AlertDialog.Builder(activity)
builder.setTitle("Select subtitle track")
val builder = MaterialAlertDialogBuilder(activity)
builder.setTitle(getString(R.string.select_subtile_track))
.setSingleChoiceItems(
trackNames.toTypedArray(),
viewModel.currentSubtitleTracks.indexOfFirst { it.selected }) { _, which ->
viewModel.currentSubtitleTracks.indexOfFirst { it.selected }) { dialog, which ->
viewModel.switchToTrack(
TrackType.SUBTITLE,
viewModel.currentSubtitleTracks[which]
)
dialog.dismiss()
}
builder.create()
} ?: throw IllegalStateException("Activity cannot be null")
}
else -> {
trackNames = listOf()
return activity?.let {
val builder = AlertDialog.Builder(it)
builder.setTitle("Select ? track")
.setMessage("Unknown track type")
builder.create()
} ?: throw IllegalStateException("Activity cannot be null")
throw IllegalStateException("TrackType must be AUDIO or SUBTITLE")
}
}
}

View file

@ -1,24 +1,27 @@
package dev.jdtech.jellyfin.dialogs
import android.app.AlertDialog
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.lang.IllegalStateException
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import org.jellyfin.sdk.model.api.BaseItemDto
class VideoVersionDialogFragment(
private val viewModel: MediaInfoViewModel
private val item: BaseItemDto,
private val viewModel: PlayerViewModel
) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val items = viewModel.item.value?.mediaSources?.map { it.name }
return activity?.let {
val builder = AlertDialog.Builder(it)
builder.setTitle("Select a version")
.setItems(items?.toTypedArray()) { _, which ->
viewModel.preparePlayerItems(which)
}
builder.create()
val items = item.mediaSources?.map { it.name }?.toTypedArray()
return activity?.let { activity ->
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.select_a_version)
.setItems(items) { _, which ->
viewModel.loadPlayerItems(item, which)
}.create()
} ?: throw IllegalStateException("Activity cannot be null")
}
}

View file

@ -1,15 +1,22 @@
package dev.jdtech.jellyfin.fragments
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
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.databinding.FragmentAddServerBinding
import dev.jdtech.jellyfin.viewmodels.AddServerViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint
class AddServerFragment : Fragment() {
@ -23,35 +30,62 @@ class AddServerFragment : Fragment() {
): View {
binding = FragmentAddServerBinding.inflate(inflater)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.buttonConnect.setOnClickListener {
val serverAddress = binding.editTextServerAddress.text.toString()
if (serverAddress.isNotBlank()) {
viewModel.checkServer(serverAddress)
binding.progressCircular.visibility = View.VISIBLE
} else {
binding.editTextServerAddressLayout.error = "Empty server address"
binding.editTextServerAddress.setOnEditorActionListener { _, actionId, _ ->
return@setOnEditorActionListener when (actionId) {
EditorInfo.IME_ACTION_GO -> {
connectToServer()
true
}
else -> false
}
}
viewModel.navigateToLogin.observe(viewLifecycleOwner, {
if (it) {
navigateToLoginFragment()
}
binding.progressCircular.visibility = View.GONE
})
binding.buttonConnect.setOnClickListener {
connectToServer()
}
viewModel.error.observe(viewLifecycleOwner, {
binding.editTextServerAddressLayout.error = it
})
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is AddServerViewModel.UiState.Normal -> bindUiStateNormal()
is AddServerViewModel.UiState.Error -> bindUiStateError(uiState)
is AddServerViewModel.UiState.Loading -> bindUiStateLoading()
}
}
viewModel.onNavigateToLogin(viewLifecycleOwner.lifecycleScope) {
Timber.d("Navigate to login: $it")
if (it) {
navigateToLoginFragment()
}
}
}
}
return binding.root
}
private fun bindUiStateNormal() {
binding.progressCircular.isVisible = false
}
private fun bindUiStateError(uiState: AddServerViewModel.UiState.Error) {
binding.progressCircular.isVisible = false
binding.editTextServerAddressLayout.error = uiState.message
}
private fun bindUiStateLoading() {
binding.progressCircular.isVisible = true
binding.editTextServerAddressLayout.error = null
}
private fun connectToServer() {
val serverAddress = binding.editTextServerAddress.text.toString()
viewModel.checkServer(serverAddress)
}
private fun navigateToLoginFragment() {
findNavController().navigate(AddServerFragmentDirections.actionAddServerFragment3ToLoginFragment2())
viewModel.onNavigateToLoginDone()
}
}

View file

@ -0,0 +1,118 @@
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.R
import dev.jdtech.jellyfin.adapters.*
import dev.jdtech.jellyfin.databinding.FragmentDownloadBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.DownloadViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.*
@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)
}, DownloadEpisodeListAdapter.OnClickListener { item ->
navigateToEpisodeBottomSheetFragment(item)
})
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { 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, "errordialog")
}
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) {
val error = uiState.message ?: resources.getString(R.string.unknown_error)
errorDialog = ErrorDialogFragment(error)
binding.loadingIndicator.isVisible = false
binding.downloadsRecyclerView.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
}
private fun navigateToMediaInfoFragment(item: PlayerItem) {
findNavController().navigate(
DownloadFragmentDirections.actionDownloadFragmentToMediaInfoFragment(
UUID.randomUUID(),
item.name,
item.metadata?.type ?: "Unknown",
item,
isOffline = true
)
)
}
private fun navigateToEpisodeBottomSheetFragment(episode: PlayerItem) {
findNavController().navigate(
DownloadFragmentDirections.actionDownloadFragmentToEpisodeBottomSheetFragment(
UUID.randomUUID(),
episode,
isOffline = true
)
)
}
}

View file

@ -6,16 +6,26 @@ 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.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.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.bindBaseItemImage
import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.LocationType
import timber.log.Timber
import java.util.*
@AndroidEntryPoint
class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
@ -23,6 +33,7 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
private lateinit var binding: EpisodeBottomSheetBinding
private val viewModel: EpisodeBottomSheetViewModel by viewModels()
private val playerViewModel: PlayerViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
@ -31,102 +42,173 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
): View {
binding = EpisodeBottomSheetBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.playButton.setOnClickListener {
binding.playButton.setImageResource(android.R.color.transparent)
binding.progressCircular.visibility = View.VISIBLE
viewModel.preparePlayerItems()
}
binding.checkButton.setOnClickListener {
when (viewModel.played.value) {
true -> viewModel.markAsUnplayed(args.episodeId)
false -> viewModel.markAsPlayed(args.episodeId)
binding.progressCircular.isVisible = true
viewModel.item?.let {
if (!args.isOffline) {
playerViewModel.loadPlayerItems(it)
} else {
playerViewModel.loadOfflinePlayerItems(viewModel.playerItems[0])
}
}
}
binding.favoriteButton.setOnClickListener {
when (viewModel.favorite.value) {
true -> viewModel.unmarkAsFavorite(args.episodeId)
false -> viewModel.markAsFavorite(args.episodeId)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is EpisodeBottomSheetViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is EpisodeBottomSheetViewModel.UiState.Loading -> bindUiStateLoading()
is EpisodeBottomSheetViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
}
}
viewModel.item.observe(viewLifecycleOwner, { episode ->
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
when (playerItems) {
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
}
}
if(!args.isOffline) {
val episodeId: UUID = args.episodeId
binding.checkButton.setOnClickListener {
when (viewModel.played) {
true -> {
viewModel.markAsUnplayed(episodeId)
binding.checkButton.setImageResource(R.drawable.ic_check)
}
false -> {
viewModel.markAsPlayed(episodeId)
binding.checkButton.setImageResource(R.drawable.ic_check_filled)
}
}
}
binding.favoriteButton.setOnClickListener {
when (viewModel.favorite) {
true -> {
viewModel.unmarkAsFavorite(episodeId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart)
}
false -> {
viewModel.markAsFavorite(episodeId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled)
}
}
}
binding.downloadButton.setOnClickListener {
binding.downloadButton.isEnabled = false
viewModel.loadDownloadRequestItem(episodeId)
binding.downloadButton.setImageResource(android.R.color.transparent)
binding.progressDownload.isVisible = true
}
binding.deleteButton.isVisible = false
viewModel.loadEpisode(episodeId)
} else {
val playerItem = args.playerItem!!
viewModel.loadEpisode(playerItem)
binding.deleteButton.setOnClickListener {
viewModel.deleteEpisode()
dismiss()
findNavController().navigate(R.id.downloadFragment)
}
binding.checkButton.isVisible = false
binding.favoriteButton.isVisible = false
binding.downloadButtonWrapper.isVisible = false
}
return binding.root
}
private fun bindUiStateNormal(uiState: EpisodeBottomSheetViewModel.UiState.Normal) {
uiState.apply {
if (episode.userData?.playedPercentage != null) {
binding.progressBar.layoutParams.width = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
(episode.userData?.playedPercentage?.times(1.26))!!.toFloat(),
context?.resources?.displayMetrics
).toInt()
binding.progressBar.visibility = View.VISIBLE
binding.progressBar.isVisible = true
}
binding.communityRating.visibility = when (episode.communityRating != null) {
false -> View.GONE
true -> View.VISIBLE
}
})
viewModel.played.observe(viewLifecycleOwner, {
val drawable = when (it) {
// Check icon
val checkDrawable = when (played) {
true -> R.drawable.ic_check_filled
false -> R.drawable.ic_check
}
binding.checkButton.setImageResource(checkDrawable)
binding.checkButton.setImageResource(drawable)
})
viewModel.favorite.observe(viewLifecycleOwner, {
val drawable = when (it) {
// Favorite icon
val favoriteDrawable = when (favorite) {
true -> R.drawable.ic_heart_filled
false -> R.drawable.ic_heart
}
binding.favoriteButton.setImageResource(favoriteDrawable)
binding.favoriteButton.setImageResource(drawable)
})
viewModel.navigateToPlayer.observe(viewLifecycleOwner, {
if (it) {
navigateToPlayerActivity(
viewModel.playerItems.toTypedArray(),
)
viewModel.doneNavigateToPlayer()
binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE
// Download icon
val downloadDrawable = when (downloaded) {
true -> R.drawable.ic_download_filled
false -> R.drawable.ic_download
}
})
binding.downloadButton.setImageResource(downloadDrawable)
viewModel.playerItemsError.observe(viewLifecycleOwner, { errorMessage ->
if (errorMessage != null) {
binding.playerItemsError.visibility = View.VISIBLE
binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE
} else {
binding.playerItemsError.visibility = View.GONE
}
})
binding.playerItemsErrorDetails.setOnClickListener {
ErrorDialogFragment(
viewModel.playerItemsError.value ?: getString(R.string.unknown_error)
).show(parentFragmentManager, "errordialog")
binding.episodeName.text = String.format(getString(R.string.episode_name_extended), episode.parentIndexNumber, episode.indexNumber, episode.name)
binding.overview.text = episode.overview
binding.year.text = dateString
binding.playtime.text = runTime
binding.communityRating.isVisible = episode.communityRating != null
binding.communityRating.text = episode.communityRating.toString()
binding.missingIcon.isVisible = episode.locationType == LocationType.VIRTUAL
bindBaseItemImage(binding.episodeImage, episode)
}
binding.loadingIndicator.isVisible = false
}
viewModel.loadEpisode(args.episodeId)
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
}
return binding.root
private fun bindUiStateError(uiState: EpisodeBottomSheetViewModel.UiState.Error) {
binding.loadingIndicator.isVisible = false
binding.overview.text = uiState.message
}
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
navigateToPlayerActivity(items.items.toTypedArray())
binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE
}
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
Timber.e(error.message)
binding.playerItemsError.isVisible = true
binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE
binding.playerItemsErrorDetails.setOnClickListener {
ErrorDialogFragment(error.message).show(parentFragmentManager, "errordialog")
}
}
private fun navigateToPlayerActivity(

View file

@ -5,7 +5,11 @@ import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
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.R
@ -16,7 +20,9 @@ import dev.jdtech.jellyfin.databinding.FragmentFavoriteBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.FavoriteViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint
class FavoriteFragment : Fragment() {
@ -24,14 +30,14 @@ class FavoriteFragment : Fragment() {
private lateinit var binding: FragmentFavoriteBinding
private val viewModel: FavoriteViewModel by viewModels()
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentFavoriteBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.favoritesRecyclerView.adapter = FavoritesListAdapter(
ViewItemListAdapter.OnClickListener { item ->
navigateToMediaInfoFragment(item)
@ -39,40 +45,56 @@ class FavoriteFragment : Fragment() {
navigateToEpisodeBottomSheetFragment(item)
})
viewModel.finishedLoading.observe(viewLifecycleOwner, { isFinished ->
binding.loadingIndicator.visibility = if (isFinished) View.GONE else View.VISIBLE
})
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.favoritesRecyclerView.visibility = View.GONE
} else {
binding.errorLayout.errorPanel.visibility = View.GONE
binding.favoritesRecyclerView.visibility = View.VISIBLE
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is FavoriteViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is FavoriteViewModel.UiState.Loading -> bindUiStateLoading()
is FavoriteViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
}
})
}
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData()
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog")
errorDialog.show(parentFragmentManager, "errordialog")
}
viewModel.favoriteSections.observe(viewLifecycleOwner, { sections ->
if (sections.isEmpty()) {
binding.noFavoritesText.visibility = View.VISIBLE
} else {
binding.noFavoritesText.visibility = View.GONE
}
})
return binding.root
}
private fun bindUiStateNormal(uiState: FavoriteViewModel.UiState.Normal) {
uiState.apply {
binding.noFavoritesText.isVisible = favoriteSections.isEmpty()
val adapter = binding.favoritesRecyclerView.adapter as FavoritesListAdapter
adapter.submitList(favoriteSections)
}
binding.loadingIndicator.isVisible = false
binding.favoritesRecyclerView.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateError(uiState: FavoriteViewModel.UiState.Error) {
val error = uiState.message ?: resources.getString(R.string.unknown_error)
errorDialog = ErrorDialogFragment(error)
binding.loadingIndicator.isVisible = false
binding.favoritesRecyclerView.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
}
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
findNavController().navigate(
FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment(

View file

@ -1,9 +1,20 @@
package dev.jdtech.jellyfin.fragments
import android.os.Bundle
import android.view.*
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
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.R
@ -12,9 +23,16 @@ import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.adapters.ViewListAdapter
import dev.jdtech.jellyfin.databinding.FragmentHomeBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.models.ContentType
import dev.jdtech.jellyfin.models.ContentType.EPISODE
import dev.jdtech.jellyfin.models.ContentType.MOVIE
import dev.jdtech.jellyfin.models.ContentType.TVSHOW
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.utils.contentType
import dev.jdtech.jellyfin.viewmodels.HomeViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint
class HomeFragment : Fragment() {
@ -22,6 +40,8 @@ class HomeFragment : Fragment() {
private lateinit var binding: FragmentHomeBinding
private val viewModel: HomeViewModel by viewModels()
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
@ -50,51 +70,85 @@ class HomeFragment : Fragment() {
): View {
binding = FragmentHomeBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.viewsRecyclerView.adapter = ViewListAdapter(ViewListAdapter.OnClickListener {
navigateToLibraryFragment(it)
}, ViewItemListAdapter.OnClickListener {
navigateToMediaInfoFragment(it)
}, HomeEpisodeListAdapter.OnClickListener { item ->
when (item.type) {
"Episode" -> {
navigateToEpisodeBottomSheetFragment(item)
setupView()
bindState()
return binding.root
}
override fun onResume() {
super.onResume()
viewModel.refreshData()
}
private fun setupView() {
binding.refreshLayout.setOnRefreshListener {
viewModel.refreshData()
// binding.refreshLayout.isRefreshing = false
}
binding.viewsRecyclerView.adapter = ViewListAdapter(
onClickListener = ViewListAdapter.OnClickListener { navigateToLibraryFragment(it) },
onItemClickListener = ViewItemListAdapter.OnClickListener {
navigateToMediaInfoFragment(it)
},
onNextUpClickListener = HomeEpisodeListAdapter.OnClickListener { item ->
when (item.contentType()) {
EPISODE -> navigateToEpisodeBottomSheetFragment(item)
MOVIE -> navigateToMediaInfoFragment(item)
else -> Toast.makeText(requireContext(), R.string.unknown_error, LENGTH_LONG)
.show()
}
"Movie" -> {
navigateToMediaInfoFragment(item)
}
}
})
viewModel.finishedLoading.observe(viewLifecycleOwner, {
binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE
})
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.viewsRecyclerView.visibility = View.GONE
} else {
binding.errorLayout.errorPanel.visibility = View.GONE
binding.viewsRecyclerView.visibility = View.VISIBLE
}
})
})
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData()
viewModel.refreshData()
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(
parentFragmentManager,
"errordialog"
)
errorDialog.show(parentFragmentManager, "errordialog")
}
}
return binding.root
private fun bindState() {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is HomeViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is HomeViewModel.UiState.Loading -> bindUiStateLoading()
is HomeViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
}
}
}
private fun bindUiStateNormal(uiState: HomeViewModel.UiState.Normal) {
uiState.apply {
val adapter = binding.viewsRecyclerView.adapter as ViewListAdapter
adapter.submitList(uiState.homeItems)
}
binding.loadingIndicator.isVisible = false
binding.refreshLayout.isRefreshing = false
binding.viewsRecyclerView.isVisible = true
}
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateError(uiState: HomeViewModel.UiState.Error) {
val error = uiState.message ?: getString(R.string.unknown_error)
errorDialog = ErrorDialogFragment(error)
binding.loadingIndicator.isVisible = false
binding.refreshLayout.isRefreshing = false
binding.viewsRecyclerView.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
}
private fun navigateToLibraryFragment(view: dev.jdtech.jellyfin.models.View) {
@ -108,12 +162,12 @@ class HomeFragment : Fragment() {
}
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
if (item.type == "Episode") {
if (item.contentType() == EPISODE) {
findNavController().navigate(
HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment(
item.seriesId!!,
item.seriesName,
"Series"
TVSHOW.type
)
)
} else {
@ -121,7 +175,7 @@ class HomeFragment : Fragment() {
HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment(
item.id,
item.name,
item.type ?: "Unknown"
item.type ?: ContentType.UNKNOWN.type
)
)
}

View file

@ -1,20 +0,0 @@
package dev.jdtech.jellyfin.fragments
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import dev.jdtech.jellyfin.R
class InitializingFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_initializing, container, false)
}
}

View file

@ -1,11 +1,14 @@
package dev.jdtech.jellyfin.fragments
import android.content.SharedPreferences
import android.os.Bundle
import android.view.*
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
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
@ -14,60 +17,127 @@ import dev.jdtech.jellyfin.viewmodels.LibraryViewModel
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.databinding.FragmentLibraryBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.dialogs.SortDialogFragment
import dev.jdtech.jellyfin.utils.SortBy
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.SortOrder
import java.lang.IllegalArgumentException
import javax.inject.Inject
@AndroidEntryPoint
class LibraryFragment : Fragment() {
private lateinit var binding: FragmentLibraryBinding
private val viewModel: LibraryViewModel by viewModels()
private val args: LibraryFragmentArgs by navArgs()
private lateinit var errorDialog: ErrorDialogFragment
@Inject
lateinit var sp: SharedPreferences
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.library_menu, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_sort_by -> {
SortDialogFragment(args.libraryId, args.libraryType, viewModel, "sortBy").show(
parentFragmentManager,
"sortdialog"
)
true
}
R.id.action_sort_order -> {
SortDialogFragment(args.libraryId, args.libraryType, viewModel, "sortOrder").show(
parentFragmentManager,
"sortdialog"
)
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentLibraryBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.viewModel = viewModel
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.itemsRecyclerView.visibility = View.GONE
} else {
binding.errorLayout.errorPanel.visibility = View.GONE
binding.itemsRecyclerView.visibility = View.VISIBLE
}
})
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadItems(args.libraryId, args.libraryType)
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog")
errorDialog.show(
parentFragmentManager,
"errordialog"
)
}
viewModel.finishedLoading.observe(viewLifecycleOwner, {
binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE
})
binding.itemsRecyclerView.adapter =
ViewItemListAdapter(ViewItemListAdapter.OnClickListener { item ->
navigateToMediaInfoFragment(item)
})
viewModel.loadItems(args.libraryId, args.libraryType)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
when (uiState) {
is LibraryViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is LibraryViewModel.UiState.Loading -> bindUiStateLoading()
is LibraryViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
// Sorting options
val sortBy = SortBy.fromString(sp.getString("sortBy", SortBy.defaultValue.name)!!)
val sortOrder = try {
SortOrder.valueOf(sp.getString("sortOrder", SortOrder.ASCENDING.name)!!)
} catch (e: IllegalArgumentException) {
SortOrder.ASCENDING
}
viewModel.loadItems(args.libraryId, args.libraryType, sortBy = sortBy, sortOrder = sortOrder)
}
}
}
private fun bindUiStateNormal(uiState: LibraryViewModel.UiState.Normal) {
val adapter = binding.itemsRecyclerView.adapter as ViewItemListAdapter
adapter.submitList(uiState.items)
binding.loadingIndicator.isVisible = false
binding.itemsRecyclerView.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateError(uiState: LibraryViewModel.UiState.Error) {
val error = uiState.message ?: getString(R.string.unknown_error)
errorDialog = ErrorDialogFragment(error)
binding.loadingIndicator.isVisible = false
binding.itemsRecyclerView.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
}
private fun navigateToMediaInfoFragment(item: BaseItemDto) {

View file

@ -4,50 +4,90 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
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.databinding.FragmentLoginBinding
import dev.jdtech.jellyfin.viewmodels.LoginViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint
class LoginFragment : Fragment() {
private lateinit var binding: FragmentLoginBinding
private val viewModel: LoginViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentLoginBinding.inflate(inflater)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding = FragmentLoginBinding.inflate(inflater)
binding.buttonLogin.setOnClickListener {
val username = binding.editTextUsername.text.toString()
val password = binding.editTextPassword.text.toString()
binding.progressCircular.visibility = View.VISIBLE
viewModel.login(username, password)
binding.editTextPassword.setOnEditorActionListener { _, actionId, _ ->
return@setOnEditorActionListener when (actionId) {
EditorInfo.IME_ACTION_GO -> {
login()
true
}
else -> false
}
}
viewModel.error.observe(viewLifecycleOwner, {
binding.progressCircular.visibility = View.GONE
binding.editTextUsernameLayout.error = it
})
binding.buttonLogin.setOnClickListener {
login()
}
viewModel.navigateToMain.observe(viewLifecycleOwner, {
if (it) {
navigateToMainActivity()
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when(uiState) {
is LoginViewModel.UiState.Normal -> bindUiStateNormal()
is LoginViewModel.UiState.Error -> bindUiStateError(uiState)
is LoginViewModel.UiState.Loading -> bindUiStateLoading()
}
}
viewModel.onNavigateToMain(viewLifecycleOwner.lifecycleScope) {
Timber.d("Navigate to MainActivity: $it")
if (it) {
navigateToMainActivity()
}
}
}
})
}
return binding.root
}
private fun bindUiStateNormal() {
binding.progressCircular.isVisible = false
}
private fun bindUiStateError(uiState: LoginViewModel.UiState.Error) {
binding.progressCircular.isVisible = false
binding.editTextUsernameLayout.error = uiState.message
}
private fun bindUiStateLoading() {
binding.progressCircular.isVisible = true
binding.editTextUsernameLayout.error = null
}
private fun login() {
val username = binding.editTextUsername.text.toString()
val password = binding.editTextPassword.text.toString()
binding.progressCircular.visibility = View.VISIBLE
viewModel.login(username, password)
}
private fun navigateToMainActivity() {
findNavController().navigate(LoginFragmentDirections.actionLoginFragment2ToNavigationHome())
viewModel.doneNavigatingToMain()
}
}

View file

@ -3,8 +3,12 @@ package dev.jdtech.jellyfin.fragments
import android.os.Bundle
import android.view.*
import androidx.appcompat.widget.SearchView
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.R
@ -13,7 +17,9 @@ import dev.jdtech.jellyfin.databinding.FragmentMediaBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.MediaViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint
class MediaFragment : Fragment() {
@ -21,6 +27,10 @@ class MediaFragment : Fragment() {
private lateinit var binding: FragmentMediaBinding
private val viewModel: MediaViewModel by viewModels()
private var originalSoftInputMode: Int? = null
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
@ -31,9 +41,9 @@ class MediaFragment : Fragment() {
val search = menu.findItem(R.id.action_search)
val searchView = search.actionView as SearchView
searchView.queryHint = "Search movies, shows, episodes..."
searchView.queryHint = getString(R.string.search_hint)
searchView.setOnQueryTextListener(object: SearchView.OnQueryTextListener {
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(p0: String?): Boolean {
if (p0 != null) {
navigateToSearchResultFragment(p0)
@ -54,39 +64,71 @@ class MediaFragment : Fragment() {
): View {
binding = FragmentMediaBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.viewsRecyclerView.adapter =
CollectionListAdapter(CollectionListAdapter.OnClickListener { library ->
navigateToLibraryFragment(library)
})
viewModel.finishedLoading.observe(viewLifecycleOwner, {
binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE
})
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.viewsRecyclerView.visibility = View.GONE
} else {
binding.errorLayout.errorPanel.visibility = View.GONE
binding.viewsRecyclerView.visibility = View.VISIBLE
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is MediaViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is MediaViewModel.UiState.Loading -> bindUiStateLoading()
is MediaViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
}
})
}
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData()
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog")
errorDialog.show(parentFragmentManager, "errordialog")
}
return binding.root
}
override fun onStart() {
super.onStart()
requireActivity().window.let {
originalSoftInputMode = it.attributes?.softInputMode
it.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN)
}
}
override fun onStop() {
super.onStop()
originalSoftInputMode?.let { activity?.window?.setSoftInputMode(it) }
}
private fun bindUiStateNormal(uiState: MediaViewModel.UiState.Normal) {
binding.loadingIndicator.isVisible = false
binding.viewsRecyclerView.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
val adapter = binding.viewsRecyclerView.adapter as CollectionListAdapter
adapter.submitList(uiState.collections)
}
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateError(uiState: MediaViewModel.UiState.Error) {
val error = uiState.message ?: resources.getString(R.string.unknown_error)
errorDialog = ErrorDialogFragment(error)
binding.loadingIndicator.isVisible = false
binding.viewsRecyclerView.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
}
private fun navigateToLibraryFragment(library: BaseItemDto) {
findNavController().navigate(
MediaFragmentDirections.actionNavigationMediaToLibraryFragment(

View file

@ -6,199 +6,276 @@ 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 dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
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.databinding.FragmentMediaInfoBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber
import java.util.UUID
@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
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentMediaInfoBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.viewModel = viewModel
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.mediaInfoScrollview.visibility = View.GONE
} else {
binding.errorLayout.errorPanel.visibility = View.GONE
binding.mediaInfoScrollview.visibility = View.VISIBLE
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is MediaInfoViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is MediaInfoViewModel.UiState.Loading -> bindUiStateLoading()
is MediaInfoViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
if (!args.isOffline) {
viewModel.loadData(args.itemId, args.itemType)
} else {
viewModel.loadData(args.playerItem!!)
}
}
})
}
if(args.itemType != "Movie") {
binding.downloadButton.visibility = View.GONE
}
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData(args.itemId, args.itemType)
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(
parentFragmentManager,
"errordialog"
)
}
viewModel.item.observe(viewLifecycleOwner, { item ->
if (item.originalTitle != item.name) {
binding.originalTitle.visibility = View.VISIBLE
} else {
binding.originalTitle.visibility = View.GONE
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
when (playerItems) {
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
}
if (item.remoteTrailers.isNullOrEmpty()) {
binding.trailerButton.visibility = View.GONE
}
binding.communityRating.visibility = when (item.communityRating != null) {
true -> View.VISIBLE
false -> View.GONE
}
})
viewModel.actors.observe(viewLifecycleOwner, { actors ->
when (actors.isNullOrEmpty()) {
false -> binding.actors.visibility = View.VISIBLE
true -> binding.actors.visibility = View.GONE
}
})
viewModel.navigateToPlayer.observe(viewLifecycleOwner, { playerItems ->
if (playerItems != null) {
navigateToPlayerActivity(
playerItems
)
viewModel.doneNavigatingToPlayer()
binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE
}
})
viewModel.played.observe(viewLifecycleOwner, {
val drawable = when (it) {
true -> R.drawable.ic_check_filled
false -> R.drawable.ic_check
}
binding.checkButton.setImageResource(drawable)
})
viewModel.favorite.observe(viewLifecycleOwner, {
val drawable = when (it) {
true -> R.drawable.ic_heart_filled
false -> R.drawable.ic_heart
}
binding.favoriteButton.setImageResource(drawable)
})
viewModel.playerItemsError.observe(viewLifecycleOwner, { errorMessage ->
if (errorMessage != null) {
binding.playerItemsError.visibility = View.VISIBLE
binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE
} else {
binding.playerItemsError.visibility = View.GONE
}
})
binding.playerItemsErrorDetails.setOnClickListener {
ErrorDialogFragment(
viewModel.playerItemsError.value ?: getString(R.string.unknown_error)
).show(parentFragmentManager, "errordialog")
}
binding.trailerButton.setOnClickListener {
if (viewModel.item.value?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
if (viewModel.item?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(viewModel.item.value?.remoteTrailers?.get(0)?.url)
Uri.parse(viewModel.item?.remoteTrailers?.get(0)?.url)
)
startActivity(intent)
}
binding.nextUp.setOnClickListener {
navigateToEpisodeBottomSheetFragment(viewModel.nextUp.value!!)
navigateToEpisodeBottomSheetFragment(viewModel.nextUp!!)
}
binding.seasonsRecyclerView.adapter =
ViewItemListAdapter(ViewItemListAdapter.OnClickListener { season ->
navigateToSeasonFragment(season)
}, fixedWidth = true)
binding.peopleRecyclerView.adapter = PersonListAdapter()
binding.peopleRecyclerView.adapter = PersonListAdapter { person ->
val uuid = person.id?.toUUID()
if (uuid != null) {
navigateToPersonDetail(uuid)
} else {
Toast.makeText(requireContext(), R.string.error_getting_person_id, Toast.LENGTH_SHORT).show()
}
}
binding.playButton.setOnClickListener {
binding.playButton.setImageResource(android.R.color.transparent)
binding.progressCircular.visibility = View.VISIBLE
if (args.itemType == "Movie") {
if (viewModel.item.value?.mediaSources != null) {
if (viewModel.item.value?.mediaSources?.size!! > 1) {
VideoVersionDialogFragment(viewModel).show(
binding.progressCircular.isVisible = true
viewModel.item?.let { item ->
if (!args.isOffline) {
playerViewModel.loadPlayerItems(item) {
VideoVersionDialogFragment(item, playerViewModel).show(
parentFragmentManager,
"videoversiondialog"
)
} else {
viewModel.preparePlayerItems()
}
} else {
playerViewModel.loadOfflinePlayerItems(args.playerItem!!)
}
}
}
if (!args.isOffline) {
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData(args.itemId, args.itemType)
}
binding.checkButton.setOnClickListener {
when (viewModel.played) {
true -> {
viewModel.markAsUnplayed(args.itemId)
binding.checkButton.setImageResource(R.drawable.ic_check)
}
false -> {
viewModel.markAsPlayed(args.itemId)
binding.checkButton.setImageResource(R.drawable.ic_check_filled)
}
}
} else if (args.itemType == "Series") {
viewModel.preparePlayerItems()
}
binding.favoriteButton.setOnClickListener {
when (viewModel.favorite) {
true -> {
viewModel.unmarkAsFavorite(args.itemId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart)
}
false -> {
viewModel.markAsFavorite(args.itemId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled)
}
}
}
binding.downloadButton.setOnClickListener {
viewModel.loadDownloadRequestItem(args.itemId)
}
binding.deleteButton.isVisible = false
} else {
binding.favoriteButton.isVisible = false
binding.checkButton.isVisible = false
binding.downloadButton.isVisible = false
binding.deleteButton.setOnClickListener {
viewModel.deleteItem()
findNavController().navigate(R.id.downloadFragment)
}
}
}
binding.checkButton.setOnClickListener {
when (viewModel.played.value) {
true -> viewModel.markAsUnplayed(args.itemId)
false -> viewModel.markAsPlayed(args.itemId)
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()
binding.favoriteButton.setOnClickListener {
when (viewModel.favorite.value) {
true -> viewModel.unmarkAsFavorite(args.itemId)
false -> viewModel.markAsFavorite(args.itemId)
// Check icon
val checkDrawable = when (played) {
true -> R.drawable.ic_check_filled
false -> R.drawable.ic_check
}
}
binding.checkButton.setImageResource(checkDrawable)
viewModel.loadData(args.itemId, args.itemType)
// Favorite icon
val favoriteDrawable = when (favorite) {
true -> R.drawable.ic_heart_filled
false -> R.drawable.ic_heart
}
binding.favoriteButton.setImageResource(favoriteDrawable)
// Download icon
val downloadDrawable = when (downloaded) {
true -> R.drawable.ic_download_filled
false -> R.drawable.ic_download
}
binding.downloadButton.setImageResource(downloadDrawable)
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.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 = String.format(getString(R.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)
}
}
private fun bindUiStateLoading() {}
private fun bindUiStateError(uiState: MediaInfoViewModel.UiState.Error) {
val error = uiState.message ?: getString(R.string.unknown_error)
binding.mediaInfoScrollview.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
}
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
navigateToPlayerActivity(items.items.toTypedArray())
binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE
}
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
Timber.e(error.message)
binding.playerItemsError.visibility = View.VISIBLE
binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE
binding.playerItemsErrorDetails.setOnClickListener {
ErrorDialogFragment(error.message).show(parentFragmentManager, "errordialog")
}
}
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {
@ -225,8 +302,14 @@ class MediaInfoFragment : Fragment() {
) {
findNavController().navigate(
MediaInfoFragmentDirections.actionMediaInfoFragmentToPlayerActivity(
playerItems,
playerItems
)
)
}
private fun navigateToPersonDetail(personId: UUID) {
findNavController().navigate(
MediaInfoFragmentDirections.actionMediaInfoFragmentToPersonDetailFragment(personId)
)
}
}

View file

@ -0,0 +1,145 @@
package dev.jdtech.jellyfin.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
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.R
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.bindItemImage
import dev.jdtech.jellyfin.databinding.FragmentPersonDetailBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.PersonDetailViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint
internal class PersonDetailFragment : Fragment() {
private lateinit var binding: FragmentPersonDetailBinding
private val viewModel: PersonDetailViewModel by viewModels()
private val args: PersonDetailFragmentArgs by navArgs()
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentPersonDetailBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.moviesList.adapter = adapter()
binding.showList.adapter = adapter()
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is PersonDetailViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is PersonDetailViewModel.UiState.Loading -> bindUiStateLoading()
is PersonDetailViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
viewModel.loadData(args.personId)
}
}
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData(args.personId)
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
errorDialog.show(parentFragmentManager, "errordialog")
}
}
private fun bindUiStateNormal(uiState: PersonDetailViewModel.UiState.Normal) {
uiState.apply {
binding.name.text = data.name
binding.overview.text = data.overview
setupOverviewExpansion()
bindItemImage(binding.personImage, data.dto)
if (starredIn.movies.isNotEmpty()) {
binding.movieLabel.isVisible = true
val moviesAdapter = binding.moviesList.adapter as ViewItemListAdapter
moviesAdapter.submitList(starredIn.movies)
}
if (starredIn.shows.isNotEmpty()) {
binding.showLabel.isVisible = true
val showsAdapter = binding.showList.adapter as ViewItemListAdapter
showsAdapter.submitList(starredIn.shows)
}
}
binding.loadingIndicator.isVisible = false
binding.fragmentContent.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateError(uiState: PersonDetailViewModel.UiState.Error) {
val error = uiState.message ?: resources.getString(R.string.unknown_error)
errorDialog = ErrorDialogFragment(error)
binding.loadingIndicator.isVisible = false
binding.fragmentContent.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
}
private fun adapter() = ViewItemListAdapter(
fixedWidth = true,
onClickListener = ViewItemListAdapter.OnClickListener { navigateToMediaInfoFragment(it) }
)
private fun setupOverviewExpansion() = binding.overview.post {
binding.readAll.setOnClickListener {
with(binding.overview) {
if (layoutParams.height == ConstraintLayout.LayoutParams.WRAP_CONTENT) {
updateLayoutParams { height = ConstraintLayout.LayoutParams.MATCH_CONSTRAINT }
binding.readAll.text = getString(R.string.view_all)
binding.overviewGradient.isVisible = true
} else {
updateLayoutParams { height = ConstraintLayout.LayoutParams.WRAP_CONTENT }
binding.readAll.text = getString(R.string.hide)
binding.overviewGradient.isVisible = false
}
}
}
}
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
findNavController().navigate(
PersonDetailFragmentDirections.actionPersonDetailFragmentToMediaInfoFragment(
itemId = item.id,
itemName = item.name,
itemType = item.type ?: "Unknown"
)
)
}
}

View file

@ -5,7 +5,11 @@ import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
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
@ -17,24 +21,25 @@ import dev.jdtech.jellyfin.databinding.FragmentSearchResultBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.SearchResultViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint
class SearchResultFragment : Fragment() {
private lateinit var binding: FragmentSearchResultBinding
private val viewModel: SearchResultViewModel by viewModels()
private val args: SearchResultFragmentArgs by navArgs()
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentSearchResultBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.searchResultsRecyclerView.adapter = FavoritesListAdapter(
ViewItemListAdapter.OnClickListener { item ->
navigateToMediaInfoFragment(item)
@ -42,42 +47,58 @@ class SearchResultFragment : Fragment() {
navigateToEpisodeBottomSheetFragment(item)
})
viewModel.finishedLoading.observe(viewLifecycleOwner, { isFinished ->
binding.loadingIndicator.visibility = if (isFinished) View.GONE else View.VISIBLE
})
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.searchResultsRecyclerView.visibility = View.GONE
} else {
binding.errorLayout.errorPanel.visibility = View.GONE
binding.searchResultsRecyclerView.visibility = View.VISIBLE
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is SearchResultViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is SearchResultViewModel.UiState.Loading -> bindUiStateLoading()
is SearchResultViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
viewModel.loadData(args.query)
}
})
}
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData(args.query)
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog")
errorDialog.show(parentFragmentManager, "errordialog")
}
viewModel.sections.observe(viewLifecycleOwner, { sections ->
if (sections.isEmpty()) {
binding.noSearchResultsText.visibility = View.VISIBLE
} else {
binding.noSearchResultsText.visibility = View.GONE
}
})
viewModel.loadData(args.query)
return binding.root
}
private fun bindUiStateNormal(uiState: SearchResultViewModel.UiState.Normal) {
uiState.apply {
binding.noSearchResultsText.isVisible = sections.isEmpty()
val adapter = binding.searchResultsRecyclerView.adapter as FavoritesListAdapter
adapter.submitList(uiState.sections)
}
binding.loadingIndicator.isVisible = false
binding.searchResultsRecyclerView.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateError(uiState: SearchResultViewModel.UiState.Error) {
val error = uiState.message ?: getString(R.string.unknown_error)
errorDialog = ErrorDialogFragment(error)
binding.loadingIndicator.isVisible = false
binding.searchResultsRecyclerView.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
}
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
findNavController().navigate(
FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment(

View file

@ -5,7 +5,11 @@ import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
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
@ -15,58 +19,81 @@ import dev.jdtech.jellyfin.databinding.FragmentSeasonBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.SeasonViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint
class SeasonFragment : Fragment() {
private lateinit var binding: FragmentSeasonBinding
private val viewModel: SeasonViewModel by viewModels()
private val args: SeasonFragmentArgs by navArgs()
private lateinit var errorDialog: ErrorDialogFragment
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentSeasonBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.viewModel = viewModel
viewModel.error.observe(viewLifecycleOwner, { error ->
if (error != null) {
checkIfLoginRequired(error)
binding.errorLayout.errorPanel.visibility = View.VISIBLE
binding.episodesRecyclerView.visibility = View.GONE
} else {
binding.errorLayout.errorPanel.visibility = View.GONE
binding.episodesRecyclerView.visibility = View.VISIBLE
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is SeasonViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is SeasonViewModel.UiState.Loading -> bindUiStateLoading()
is SeasonViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
viewModel.loadEpisodes(args.seriesId, args.seasonId)
}
})
}
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadEpisodes(args.seriesId, args.seasonId)
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog")
errorDialog.show(parentFragmentManager, "errordialog")
}
viewModel.finishedLoading.observe(viewLifecycleOwner, {
binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE
})
binding.episodesRecyclerView.adapter =
EpisodeListAdapter(EpisodeListAdapter.OnClickListener { episode ->
navigateToEpisodeBottomSheetFragment(episode)
}, args.seriesId, args.seriesName, args.seasonId, args.seasonName)
viewModel.loadEpisodes(args.seriesId, args.seasonId)
}
private fun bindUiStateNormal(uiState: SeasonViewModel.UiState.Normal) {
uiState.apply {
val adapter = binding.episodesRecyclerView.adapter as EpisodeListAdapter
adapter.submitList(uiState.episodes)
}
binding.loadingIndicator.isVisible = false
binding.episodesRecyclerView.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateLoading() {
binding.loadingIndicator.isVisible = true
binding.errorLayout.errorPanel.isVisible = false
}
private fun bindUiStateError(uiState: SeasonViewModel.UiState.Error) {
val error = uiState.message ?: getString(R.string.unknown_error)
errorDialog = ErrorDialogFragment(error)
binding.loadingIndicator.isVisible = false
binding.episodesRecyclerView.isVisible = false
binding.errorLayout.errorPanel.isVisible = true
checkIfLoginRequired(error)
}
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {

View file

@ -6,12 +6,16 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
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.databinding.FragmentServerSelectBinding
import dev.jdtech.jellyfin.dialogs.DeleteServerDialogFragment
import dev.jdtech.jellyfin.adapters.ServerGridAdapter
import dev.jdtech.jellyfin.viewmodels.ServerSelectViewModel
import kotlinx.coroutines.launch
@AndroidEntryPoint
class ServerSelectFragment : Fragment() {
@ -44,11 +48,15 @@ class ServerSelectFragment : Fragment() {
navigateToAddServerFragment()
}
viewModel.navigateToMain.observe(viewLifecycleOwner, {
if (it) {
navigateToMainActivity()
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onNavigateToMain(viewLifecycleOwner.lifecycleScope) {
if (it) {
navigateToMainActivity()
}
}
}
})
}
return binding.root
}
@ -61,6 +69,5 @@ class ServerSelectFragment : Fragment() {
private fun navigateToMainActivity() {
findNavController().navigate(ServerSelectFragmentDirections.actionServerSelectFragmentToHomeFragment())
viewModel.doneNavigatingToMain()
}
}

View file

@ -3,14 +3,26 @@ package dev.jdtech.jellyfin.fragments
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.appcompat.app.AppCompatDelegate.*
import android.text.InputType
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES
import androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.viewmodels.SettingsViewModel
@AndroidEntryPoint
class SettingsFragment: PreferenceFragmentCompat() {
private val viewModel: SettingsViewModel by viewModels()
class SettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.fragment_settings, rootKey)
@ -41,5 +53,14 @@ class SettingsFragment : PreferenceFragmentCompat() {
findNavController().navigate(SettingsFragmentDirections.actionSettingsFragmentToAboutLibraries())
true
}
findPreference<EditTextPreference>("image_cache_size")?.setOnBindEditTextListener { editText ->
editText.inputType = InputType.TYPE_CLASS_NUMBER
}
findPreference<EditTextPreference>("deviceName")?.setOnPreferenceChangeListener { _, name ->
viewModel.updateDeviceName(name.toString())
true
}
}
}

View file

@ -0,0 +1,21 @@
package dev.jdtech.jellyfin.models
import dev.jdtech.jellyfin.models.CollectionType.Books
import dev.jdtech.jellyfin.models.CollectionType.HomeVideos
import dev.jdtech.jellyfin.models.CollectionType.LiveTv
import dev.jdtech.jellyfin.models.CollectionType.Music
import dev.jdtech.jellyfin.models.CollectionType.Playlists
import dev.jdtech.jellyfin.models.CollectionType.BoxSets
enum class CollectionType (val type: String) {
HomeVideos("homevideos"),
Music("music"),
Playlists("playlists"),
Books("books"),
LiveTv("livetv"),
BoxSets("boxsets")
}
fun unsupportedCollections() = listOf(
HomeVideos, Music, Playlists, Books, LiveTv, BoxSets
)

View file

@ -0,0 +1,8 @@
package dev.jdtech.jellyfin.models
enum class ContentType(val type: String) {
MOVIE("Movie"),
TVSHOW("Series"),
EPISODE("Episode"),
UNKNOWN("")
}

View file

@ -0,0 +1,20 @@
package dev.jdtech.jellyfin.models
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.util.*
@Parcelize
data class DownloadMetadata(
val id: UUID,
val type: String?,
val seriesName: String? = null,
val name: String? = null,
val parentIndexNumber: Int? = null,
val indexNumber: Int? = null,
val playbackPosition: Long? = null,
val playedPercentage: Double? = null,
val seriesId: UUID? = null,
val played: Boolean? = null,
val overview: String? = null
) : Parcelable

View file

@ -0,0 +1,12 @@
package dev.jdtech.jellyfin.models
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.util.*
@Parcelize
data class DownloadRequestItem(
val uri: String,
val itemId: UUID,
val metadata: DownloadMetadata
) : Parcelable

View file

@ -0,0 +1,9 @@
package dev.jdtech.jellyfin.models
import java.util.*
data class DownloadSection(
val id: UUID,
val name: String,
var items: List<PlayerItem>
)

View file

@ -1,10 +1,8 @@
package dev.jdtech.jellyfin.models
import org.jellyfin.sdk.model.api.BaseItemDto
import java.util.*
data class HomeSection(
val id: UUID,
val name: String?,
var items: List<BaseItemDto>? = null
val name: String,
var items: List<BaseItemDto>
)

View file

@ -2,12 +2,14 @@ package dev.jdtech.jellyfin.models
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.util.*
import java.util.UUID
@Parcelize
data class PlayerItem(
val name: String?,
val itemId: UUID,
val mediaSourceId: String,
val playbackPosition: Long
val playbackPosition: Long,
val mediaSourceUri: String = "",
val metadata: DownloadMetadata? = null
) : Parcelable

View file

@ -1,7 +1,7 @@
package dev.jdtech.jellyfin.models
import org.jellyfin.sdk.model.api.BaseItemDto
import java.util.*
import java.util.UUID
data class View(
val id: UUID,

View file

@ -32,8 +32,10 @@ import kotlinx.parcelize.Parcelize
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.lang.IllegalArgumentException
import java.util.concurrent.CopyOnWriteArraySet
@Suppress("SpellCheckingInspection")
@ -1199,22 +1201,22 @@ class MPVPlayer(
* @param volume The volume to set.
*/
override fun setDeviceVolume(volume: Int) {
TODO("Not yet implemented")
throw IllegalArgumentException("You should use global volume controls. Check out AUDIO_SERVICE.")
}
/** Increases the volume of the device. */
override fun increaseDeviceVolume() {
TODO("Not yet implemented")
throw IllegalArgumentException("You should use global volume controls. Check out AUDIO_SERVICE.")
}
/** Decreases the volume of the device. */
override fun decreaseDeviceVolume() {
TODO("Not yet implemented")
throw IllegalArgumentException("You should use global volume controls. Check out AUDIO_SERVICE.")
}
/** Sets the mute state of the device. */
override fun setDeviceMuted(muted: Boolean) {
TODO("Not yet implemented")
throw IllegalArgumentException("You should use global volume controls. Check out AUDIO_SERVICE.")
}
private class CurrentTrackSelection(

View file

@ -1,8 +1,12 @@
package dev.jdtech.jellyfin.repository
import dev.jdtech.jellyfin.models.ContentType
import dev.jdtech.jellyfin.utils.SortBy
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.SortOrder
import java.util.*
interface JellyfinRepository {
@ -13,7 +17,15 @@ interface JellyfinRepository {
suspend fun getItems(
parentId: UUID? = null,
includeTypes: List<String>? = null,
recursive: Boolean = false
recursive: Boolean = false,
sortBy: SortBy = SortBy.defaultValue,
sortOrder: SortOrder = SortOrder.ASCENDING
): List<BaseItemDto>
suspend fun getPersonItems(
personIds: List<UUID>,
includeTypes: List<ContentType>? = null,
recursive: Boolean = true
): List<BaseItemDto>
suspend fun getFavoriteItems(): List<BaseItemDto>

View file

@ -1,6 +1,8 @@
package dev.jdtech.jellyfin.repository
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.models.ContentType
import dev.jdtech.jellyfin.utils.SortBy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.*
@ -12,7 +14,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
val views: List<BaseItemDto>
withContext(Dispatchers.IO) {
views =
jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items ?: listOf()
jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items ?: emptyList()
}
return views
}
@ -28,16 +30,38 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
override suspend fun getItems(
parentId: UUID?,
includeTypes: List<String>?,
recursive: Boolean,
sortBy: SortBy,
sortOrder: SortOrder
): List<BaseItemDto> {
val items: List<BaseItemDto>
Timber.d("$sortBy $sortOrder")
withContext(Dispatchers.IO) {
items = jellyfinApi.itemsApi.getItems(
jellyfinApi.userId!!,
parentId = parentId,
includeItemTypes = includeTypes,
recursive = recursive,
sortBy = listOf(sortBy.SortString),
sortOrder = listOf(sortOrder)
).content.items ?: emptyList()
}
return items
}
override suspend fun getPersonItems(
personIds: List<UUID>,
includeTypes: List<ContentType>?,
recursive: Boolean
): List<BaseItemDto> {
val items: List<BaseItemDto>
withContext(Dispatchers.IO) {
items = jellyfinApi.itemsApi.getItems(
jellyfinApi.userId!!,
parentId = parentId,
includeItemTypes = includeTypes,
personIds = personIds,
includeItemTypes = includeTypes?.map { it.type },
recursive = recursive
).content.items ?: listOf()
).content.items ?: emptyList()
}
return items
}
@ -50,7 +74,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
filters = listOf(ItemFilter.IS_FAVORITE),
includeItemTypes = listOf("Movie", "Series", "Episode"),
recursive = true
).content.items ?: listOf()
).content.items ?: emptyList()
}
return items
}
@ -63,7 +87,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
searchTerm = searchQuery,
includeItemTypes = listOf("Movie", "Series", "Episode"),
recursive = true
).content.items ?: listOf()
).content.items ?: emptyList()
}
return items
}
@ -75,7 +99,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
jellyfinApi.itemsApi.getResumeItems(
jellyfinApi.userId!!,
includeItemTypes = listOf("Movie", "Episode"),
).content.items ?: listOf()
).content.items ?: emptyList()
}
return items
}
@ -95,7 +119,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
val seasons: List<BaseItemDto>
withContext(Dispatchers.IO) {
seasons = jellyfinApi.showsApi.getSeasons(seriesId, jellyfinApi.userId!!).content.items
?: listOf()
?: emptyList()
}
return seasons
}
@ -106,7 +130,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
nextUpItems = jellyfinApi.showsApi.getNextUp(
jellyfinApi.userId!!,
seriesId = seriesId?.toString(),
).content.items ?: listOf()
).content.items ?: emptyList()
}
return nextUpItems
}
@ -125,7 +149,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
seasonId = seasonId,
fields = fields,
startItemId = startItemId
).content.items ?: listOf()
).content.items ?: emptyList()
}
return episodes
}
@ -139,15 +163,15 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
name = "Direct play all",
maxStaticBitrate = 1_000_000_000,
maxStreamingBitrate = 1_000_000_000,
codecProfiles = listOf(),
containerProfiles = listOf(),
codecProfiles = emptyList(),
containerProfiles = emptyList(),
directPlayProfiles = listOf(
DirectPlayProfile(
type = DlnaProfileType.VIDEO
), DirectPlayProfile(type = DlnaProfileType.AUDIO)
),
transcodingProfiles = listOf(),
responseProfiles = listOf(),
transcodingProfiles = emptyList(),
responseProfiles = emptyList(),
enableAlbumArtInDidl = false,
enableMsMediaReceiverRegistrar = false,
enableSingleAlbumArtLimit = false,
@ -165,7 +189,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
maxStreamingBitrate = 1_000_000_000,
)
)
mediaSourceInfoList = mediaInfo.mediaSources ?: listOf()
mediaSourceInfoList = mediaInfo.mediaSources ?: emptyList()
return mediaSourceInfoList
}
@ -272,7 +296,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
withContext(Dispatchers.IO) {
intros =
jellyfinApi.userLibraryApi.getIntros(jellyfinApi.userId!!, itemId).content.items
?: listOf()
?: emptyList()
}
return intros
}

View file

@ -0,0 +1,155 @@
package dev.jdtech.jellyfin.tv
import android.os.Bundle
import android.view.Gravity
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.widget.ImageButton
import android.widget.PopupWindow
import android.widget.TextView
import androidx.activity.viewModels
import androidx.navigation.navArgs
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.BasePlayerActivity
import dev.jdtech.jellyfin.PlayerActivityArgs
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.databinding.ActivityPlayerTvBinding
import dev.jdtech.jellyfin.mpv.MPVPlayer
import dev.jdtech.jellyfin.mpv.TrackType.AUDIO
import dev.jdtech.jellyfin.mpv.TrackType.SUBTITLE
import dev.jdtech.jellyfin.tv.ui.TrackSelectorAdapter
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
import timber.log.Timber
@AndroidEntryPoint
internal class TvPlayerActivity : BasePlayerActivity() {
private lateinit var binding: ActivityPlayerTvBinding
override val viewModel: PlayerActivityViewModel by viewModels()
private val args: PlayerActivityArgs by navArgs()
private var displayedPopup: PopupWindow? = null
override fun onCreate(savedInstanceState: Bundle?) {
Timber.d("Player activity created.")
super.onCreate(savedInstanceState)
binding = ActivityPlayerTvBinding.inflate(layoutInflater)
setContentView(binding.root)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
binding.playerView.player = viewModel.player
val playerControls = binding.playerView.findViewById<View>(R.id.tv_player_controls)
configureInsets(playerControls)
bind()
viewModel.initializePlayer(args.items)
hideSystemUI()
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return if (!binding.playerView.isControllerVisible) {
binding.playerView.showController()
true
} else {
false
}
}
private fun bind() = with(binding.playerView) {
val videoNameTextView = findViewById<TextView>(R.id.video_name)
viewModel.currentItemTitle.observe(this@TvPlayerActivity, { title ->
videoNameTextView.text = title
})
findViewById<ImageButton>(R.id.exo_play_pause).apply {
setOnClickListener {
when {
viewModel.player.isPlaying -> {
viewModel.player.pause()
setImageDrawable(resources.getDrawable(R.drawable.ic_play))
}
viewModel.player.isLoading -> Unit
else -> {
viewModel.player.play()
setImageDrawable(resources.getDrawable(R.drawable.ic_pause))
}
}
}
}
findViewById<View>(R.id.back_button).setOnClickListener {
onBackPressed()
}
bindAudioControl()
bindSubtitleControl()
}
private fun bindAudioControl() {
val audioBtn = binding.playerView.findViewById<ImageButton>(R.id.btn_audio_track)
audioBtn.setOnFocusChangeListener { v, hasFocus ->
displayedPopup = if (hasFocus) {
val items = viewModel.currentSubtitleTracks.toUiTrack()
audioBtn.showPopupWindowAbove(items, AUDIO)
} else {
displayedPopup?.dismiss()
null
}
}
}
private fun bindSubtitleControl() {
val subtitleBtn = binding.playerView.findViewById<ImageButton>(R.id.btn_subtitle)
subtitleBtn.setOnFocusChangeListener { v, hasFocus ->
v.isFocusable = true
displayedPopup = if (hasFocus) {
val items = viewModel.currentSubtitleTracks.toUiTrack()
subtitleBtn.showPopupWindowAbove(items, SUBTITLE)
} else {
displayedPopup?.dismiss()
null
}
}
}
private fun List<MPVPlayer.Companion.Track>.toUiTrack() = map { track ->
TrackSelectorAdapter.Track(
title = track.title,
language = track.lang,
codec = track.codec,
selected = track.selected,
playerTrack = track
)
}
private fun View.showPopupWindowAbove(
items: List<TrackSelectorAdapter.Track>,
type: String
): PopupWindow {
val popup = PopupWindow(this.context)
popup.contentView = LayoutInflater.from(context).inflate(R.layout.track_selector, null)
val recyclerView = popup.contentView.findViewById<RecyclerView>(R.id.track_selector)
recyclerView.adapter = TrackSelectorAdapter(items, viewModel, type) { popup.dismiss() }
val startViewCoords = IntArray(2)
getLocationInWindow(startViewCoords)
val itemHeight = resources.getDimension(R.dimen.track_selection_item_height).toInt()
val totalHeight = items.size * itemHeight
popup.showAsDropDown(
binding.root,
startViewCoords.first(),
startViewCoords.last() - totalHeight,
Gravity.TOP
)
return popup
}
}

View file

@ -0,0 +1,122 @@
package dev.jdtech.jellyfin.tv.ui
import android.os.Bundle
import android.view.KeyEvent.KEYCODE_DPAD_DOWN
import android.view.KeyEvent.KEYCODE_DPAD_DOWN_LEFT
import android.view.View
import android.widget.ImageButton
import androidx.fragment.app.viewModels
import androidx.leanback.app.BrowseSupportFragment
import androidx.leanback.widget.ArrayObjectAdapter
import androidx.leanback.widget.HeaderItem
import androidx.leanback.widget.ListRow
import androidx.leanback.widget.ListRowPresenter
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.R
import dev.jdtech.jellyfin.adapters.HomeItem
import dev.jdtech.jellyfin.viewmodels.HomeViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@AndroidEntryPoint
internal class HomeFragment : BrowseSupportFragment() {
private val viewModel: HomeViewModel by viewModels()
private lateinit var rowsAdapter: ArrayObjectAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
headersState = HEADERS_ENABLED
rowsAdapter = ArrayObjectAdapter(ListRowPresenter())
adapter = rowsAdapter
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.findViewById<ImageButton>(R.id.settings).apply {
setOnKeyListener { _, keyCode, _ ->
if (keyCode == KEYCODE_DPAD_DOWN || keyCode == KEYCODE_DPAD_DOWN_LEFT) {
headersSupportFragment.view?.requestFocus()
true
} else {
false
}
}
setOnClickListener { navigateToSettingsFragment() }
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is HomeViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is HomeViewModel.UiState.Loading -> Unit
is HomeViewModel.UiState.Error -> Unit
}
}
}
}
}
private fun bindUiStateNormal(uiState: HomeViewModel.UiState.Normal) {
uiState.apply {
rowsAdapter.clear()
homeItems.map { section -> rowsAdapter.add(section.toListRow()) }
}
}
private fun HomeItem.toListRow(): ListRow {
return ListRow(
toHeader(),
toItems()
)
}
private fun HomeItem.toHeader(): HeaderItem {
return when (this) {
is HomeItem.Section -> HeaderItem(homeSection.name)
is HomeItem.ViewItem -> HeaderItem(
String.format(
resources.getString(R.string.latest_library),
view.name
)
)
}
}
private fun HomeItem.toItems(): ArrayObjectAdapter {
return when (this) {
is HomeItem.Section -> ArrayObjectAdapter(DynamicMediaItemPresenter { item ->
navigateToMediaDetailFragment(item)
}).apply { addAll(0, homeSection.items) }
is HomeItem.ViewItem -> ArrayObjectAdapter(MediaItemPresenter { item ->
navigateToMediaDetailFragment(item)
}).apply { addAll(0, view.items) }
}
}
private fun navigateToMediaDetailFragment(item: BaseItemDto) {
findNavController().navigate(
HomeFragmentDirections.actionHomeFragmentToMediaDetailFragment(
item.id,
item.seriesName ?: item.name,
item.type ?: "Unknown"
)
)
}
private fun navigateToSettingsFragment() {
findNavController().navigate(
HomeFragmentDirections.actionNavigationHomeToSettings()
)
}
}

View file

@ -0,0 +1,223 @@
package dev.jdtech.jellyfin.tv.ui
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 dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.PersonListAdapter
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.bindBaseItemImage
import dev.jdtech.jellyfin.databinding.MediaDetailFragmentBinding
import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel.PlayerItemError
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel.PlayerItems
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint
internal class MediaDetailFragment : Fragment() {
private lateinit var binding: MediaDetailFragmentBinding
private val viewModel: MediaInfoViewModel by viewModels()
private val playerViewModel: PlayerViewModel by viewModels()
private val args: MediaDetailFragmentArgs by navArgs()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.loadData(args.itemId, args.itemType)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = MediaDetailFragmentBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is MediaInfoViewModel.UiState.Normal -> bindUiStateNormal(uiState)
is MediaInfoViewModel.UiState.Loading -> bindUiStateLoading()
is MediaInfoViewModel.UiState.Error -> bindUiStateError(uiState)
}
}
}
}
val seasonsAdapter = ViewItemListAdapter(
fixedWidth = true,
onClickListener = ViewItemListAdapter.OnClickListener {})
binding.seasonsRow.gridView.adapter = seasonsAdapter
binding.seasonsRow.gridView.verticalSpacing = 25
val castAdapter = PersonListAdapter { person ->
Toast.makeText(requireContext(), "Not yet implemented", Toast.LENGTH_SHORT).show()
}
binding.castRow.gridView.adapter = castAdapter
binding.castRow.gridView.verticalSpacing = 25
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
when (playerItems) {
is PlayerItemError -> bindPlayerItemsError(playerItems)
is PlayerItems -> bindPlayerItems(playerItems)
}
}
binding.playButton.setOnClickListener {
binding.playButton.setImageResource(android.R.color.transparent)
binding.progressCircular.isVisible = true
viewModel.item?.let { item ->
playerViewModel.loadPlayerItems(item) {
VideoVersionDialogFragment(item, playerViewModel).show(
parentFragmentManager,
"videoversiondialog"
)
}
}
}
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.checkButton.setOnClickListener {
when (viewModel.played) {
true -> {
viewModel.markAsUnplayed(args.itemId)
binding.checkButton.setImageResource(R.drawable.ic_check)
}
false -> {
viewModel.markAsPlayed(args.itemId)
binding.checkButton.setImageResource(R.drawable.ic_check_filled)
}
}
}
binding.favoriteButton.setOnClickListener {
when (viewModel.favorite) {
true -> {
viewModel.unmarkAsFavorite(args.itemId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart)
}
false -> {
viewModel.markAsFavorite(args.itemId)
binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled)
}
}
}
binding.backButton.setOnClickListener { activity?.onBackPressed() }
}
private fun bindUiStateNormal(uiState: MediaInfoViewModel.UiState.Normal) {
uiState.apply {
binding.seasonTitle.isVisible = seasons.isNotEmpty()
val seasonsAdapter = binding.seasonsRow.gridView.adapter as ViewItemListAdapter
seasonsAdapter.submitList(seasons)
binding.castTitle.isVisible = actors.isNotEmpty()
val actorsAdapter = binding.castRow.gridView.adapter as PersonListAdapter
actorsAdapter.submitList(actors)
// Check icon
val checkDrawable = when (played) {
true -> R.drawable.ic_check_filled
false -> R.drawable.ic_check
}
binding.checkButton.setImageResource(checkDrawable)
// Favorite icon
val favoriteDrawable = when (favorite) {
true -> R.drawable.ic_heart_filled
false -> R.drawable.ic_heart
}
binding.favoriteButton.setImageResource(favoriteDrawable)
binding.title.text = item.name
binding.subtitle.text = item.seriesName
item.seriesName.let {
binding.subtitle.text = it
binding.subtitle.isVisible = true
}
binding.genres.text = genresString
binding.year.text = dateString
binding.playtime.text = runTime
binding.officialRating.text = item.officialRating
binding.communityRating.text = item.communityRating.toString()
binding.description.text = item.overview
bindBaseItemImage(binding.poster, item)
}
}
private fun bindUiStateLoading() {}
private fun bindUiStateError(uiState: MediaInfoViewModel.UiState.Error) {}
private fun bindPlayerItems(items: PlayerItems) {
navigateToPlayerActivity(items.items.toTypedArray())
binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE
}
private fun bindPlayerItemsError(error: PlayerItemError) {
Timber.e(error.message)
binding.errorLayout.errorPanel.isVisible = true
binding.playButton.setImageDrawable(
ContextCompat.getDrawable(
requireActivity(),
R.drawable.ic_play
)
)
binding.progressCircular.visibility = View.INVISIBLE
}
private fun navigateToPlayerActivity(
playerItems: Array<PlayerItem>,
) {
findNavController().navigate(
MediaDetailFragmentDirections.actionMediaDetailFragmentToPlayerActivity(
playerItems
)
)
}
}

View file

@ -0,0 +1,65 @@
package dev.jdtech.jellyfin.tv.ui
import android.content.res.Resources
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.models.ContentType.MOVIE
import org.jellyfin.sdk.model.api.BaseItemDto
import javax.inject.Inject
@HiltViewModel
internal class MediaDetailViewModel @Inject internal constructor() : ViewModel() {
fun transformData(
data: BaseItemDto,
resources: Resources,
transformed: (State) -> Unit
): State {
return State(
dto = data,
description = data.overview.orEmpty(),
year = data.productionYear.toString(),
officialRating = data.officialRating.orEmpty(),
communityRating = data.communityRating.toString(),
runtimeMinutes = String.format(
resources.getString(R.string.runtime_minutes),
data.runTimeTicks?.div(600_000_000)
),
genres = data.genres?.joinToString(" / ").orEmpty(),
trailerUrl = data.remoteTrailers?.firstOrNull()?.url,
isPlayed = data.userData?.played == true,
isFavorite = data.userData?.isFavorite == true,
media = if (data.type == MOVIE.type) {
State.Movie(
title = data.name.orEmpty()
)
} else {
State.TvShow(
episode = data.episodeTitle ?: data.name.orEmpty(),
show = data.seriesName.orEmpty()
)
}
).also(transformed)
}
data class State(
val dto: BaseItemDto,
val description: String,
val year: String,
val officialRating: String,
val communityRating: String,
val runtimeMinutes: String,
val genres: String,
val trailerUrl: String?,
val isPlayed: Boolean,
val isFavorite: Boolean,
val media: Media
) {
sealed class Media
data class Movie(val title: String): Media()
data class TvShow(val episode: String, val show: String): Media()
}
}

View file

@ -0,0 +1,78 @@
package dev.jdtech.jellyfin.tv.ui
import android.content.Context.LAYOUT_INFLATER_SERVICE
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.databinding.DataBindingUtil
import androidx.leanback.widget.Presenter
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.databinding.BaseItemBinding
import dev.jdtech.jellyfin.databinding.HomeEpisodeItemBinding
import org.jellyfin.sdk.model.api.BaseItemDto
class MediaItemPresenter(private val onClick: (BaseItemDto) -> Unit) : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val mediaView =
BaseItemBinding
.inflate(parent.context.getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater)
.root
return ViewHolder(mediaView)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
if (item is BaseItemDto) {
DataBindingUtil.getBinding<BaseItemBinding>(viewHolder.view)?.apply {
this.item = item
this.itemName.text = if (item.type == "Episode") item.seriesName else item.name
this.itemCount.visibility =
if (item.userData?.unplayedItemCount != null && item.userData?.unplayedItemCount!! > 0) View.VISIBLE else View.GONE
this.itemLayout.layoutParams.width =
this.itemLayout.resources.getDimension(R.dimen.overview_media_width).toInt()
(this.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0
viewHolder.view.setOnClickListener { onClick(item) }
}
}
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) = Unit
}
class DynamicMediaItemPresenter(private val onClick: (BaseItemDto) -> Unit) : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val mediaView =
HomeEpisodeItemBinding
.inflate(parent.context.getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater)
.root
return ViewHolder(mediaView)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
if (item is BaseItemDto) {
DataBindingUtil.getBinding<HomeEpisodeItemBinding>(viewHolder.view)?.apply {
episode = item
item.userData?.playedPercentage?.toInt()?.let {
progressBar.layoutParams.width = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
(it.times(2.24)).toFloat(), progressBar.context.resources.displayMetrics).toInt()
progressBar.isVisible = true
}
if (item.type == "Movie") {
primaryName.text = item.name
secondaryName.visibility = View.GONE
} else if (item.type == "Episode") {
primaryName.text = item.seriesName
}
viewHolder.view.setOnClickListener { onClick(item) }
}
}
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) = Unit
}

View file

@ -0,0 +1,71 @@
package dev.jdtech.jellyfin.tv.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.recyclerview.widget.RecyclerView
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.mpv.MPVPlayer
import dev.jdtech.jellyfin.mpv.TrackType
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
class TrackSelectorAdapter(
private val items: List<Track>,
private val viewModel: PlayerActivityViewModel,
private val trackType: String,
private val dismissWindow: () -> Unit
) : RecyclerView.Adapter<TrackSelectorAdapter.TrackSelectorViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackSelectorViewHolder {
return TrackSelectorViewHolder(
LayoutInflater.from(parent.context).inflate(R.layout.track_item, parent, false)
)
}
override fun onBindViewHolder(holder: TrackSelectorViewHolder, position: Int) {
holder.bind(items[position], viewModel, trackType, dismissWindow)
}
override fun getItemCount(): Int = items.size
class TrackSelectorViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind(
item: Track,
viewModel: PlayerActivityViewModel,
trackType: String,
dismissWindow: () -> Unit
) {
view.findViewById<Button>(R.id.track_name).apply {
text = String.format(
view.resources.getString(R.string.track_selection),
item.language,
item.title,
item.codec
)
setOnClickListener {
when (trackType) {
TrackType.AUDIO -> viewModel.switchToTrack(
TrackType.AUDIO,
item.playerTrack
)
TrackType.SUBTITLE -> viewModel.switchToTrack(
TrackType.SUBTITLE,
item.playerTrack
)
}
dismissWindow()
}
}
}
}
data class Track(
val title: String,
val language: String,
val codec: String,
val selected: Boolean,
val playerTrack: MPVPlayer.Companion.Track
)
}

View file

@ -0,0 +1,79 @@
package dev.jdtech.jellyfin.tv.ui
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.databinding.TvAddServerFragmentBinding
import dev.jdtech.jellyfin.viewmodels.AddServerViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint
internal class TvAddServerFragment : Fragment() {
private lateinit var binding: TvAddServerFragmentBinding
private val viewModel: AddServerViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = TvAddServerFragmentBinding.inflate(inflater)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.buttonConnect.setOnClickListener {
val serverAddress = binding.serverAddress.text.toString()
viewModel.checkServer(serverAddress)
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when (uiState) {
is AddServerViewModel.UiState.Normal -> bindUiStateNormal()
is AddServerViewModel.UiState.Error -> bindUiStateError(uiState)
is AddServerViewModel.UiState.Loading -> bindUiStateLoading()
}
}
viewModel.onNavigateToLogin(viewLifecycleOwner.lifecycleScope) {
Timber.d("Navigate to login: $it")
if (it) {
navigateToLoginFragment()
}
}
}
}
return binding.root
}
private fun bindUiStateNormal() {
binding.progressCircular.isVisible = false
}
private fun bindUiStateError(uiState: AddServerViewModel.UiState.Error) {
binding.progressCircular.isVisible = false
binding.serverAddress.error = uiState.message
}
private fun bindUiStateLoading() {
binding.progressCircular.isVisible = true
binding.serverAddress.error = null
}
private fun navigateToLoginFragment() {
findNavController().navigate(TvAddServerFragmentDirections.actionAddServerFragmentToLoginFragment())
}
}

View file

@ -0,0 +1,81 @@
package dev.jdtech.jellyfin.tv.ui
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.databinding.TvLoginFragmentBinding
import dev.jdtech.jellyfin.viewmodels.LoginViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint
class TvLoginFragment : Fragment() {
private lateinit var binding: TvLoginFragmentBinding
private val viewModel: LoginViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = TvLoginFragmentBinding.inflate(inflater)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.buttonLogin.setOnClickListener {
val username = binding.username.text.toString()
val password = binding.password.text.toString()
binding.progressCircular.visibility = View.VISIBLE
viewModel.login(username, password)
}
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
Timber.d("$uiState")
when(uiState) {
is LoginViewModel.UiState.Normal -> bindUiStateNormal()
is LoginViewModel.UiState.Error -> bindUiStateError(uiState)
is LoginViewModel.UiState.Loading -> bindUiStateLoading()
}
}
viewModel.onNavigateToMain(viewLifecycleOwner.lifecycleScope) {
Timber.d("Navigate to MainActivity: $it")
if (it) {
navigateToMainActivity()
}
}
}
}
return binding.root
}
private fun bindUiStateNormal() {
binding.progressCircular.isVisible = false
}
private fun bindUiStateError(uiState: LoginViewModel.UiState.Error) {
binding.progressCircular.isVisible = false
binding.username.error = uiState.message
}
private fun bindUiStateLoading() {
binding.progressCircular.isVisible = true
binding.username.error = null
}
private fun navigateToMainActivity() {
findNavController().navigate(TvLoginFragmentDirections.actionLoginFragmentToNavigationHome())
}
}

View file

@ -0,0 +1,18 @@
package dev.jdtech.jellyfin.utils
import android.content.Context
import android.media.AudioManager
import android.media.AudioManager.ADJUST_LOWER
import android.media.AudioManager.ADJUST_RAISE
import android.media.AudioManager.ADJUST_SAME
import android.media.AudioManager.FLAG_SHOW_UI
import android.media.AudioManager.STREAM_MUSIC
internal class AudioController internal constructor(context: Context) {
private val manager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
fun volumeUp() = manager.adjustStreamVolume(STREAM_MUSIC, ADJUST_RAISE, FLAG_SHOW_UI)
fun volumeDown() = manager.adjustStreamVolume(STREAM_MUSIC, ADJUST_LOWER, FLAG_SHOW_UI)
fun showVolumeSlider() = manager.adjustStreamVolume(STREAM_MUSIC, ADJUST_SAME, FLAG_SHOW_UI)
}

View file

@ -0,0 +1,287 @@
package dev.jdtech.jellyfin.utils
import android.app.DownloadManager
import android.content.Context
import android.net.Uri
import android.os.Environment
import androidx.core.content.getSystemService
import dev.jdtech.jellyfin.models.DownloadMetadata
import dev.jdtech.jellyfin.models.DownloadRequestItem
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.UserItemDataDto
import timber.log.Timber
import java.io.File
import java.util.UUID
var defaultStorage: File? = null
fun requestDownload(uri: Uri, downloadRequestItem: DownloadRequestItem, context: Context) {
val downloadRequest = DownloadManager.Request(uri)
.setTitle(downloadRequestItem.metadata.name)
.setDescription("Downloading")
.setDestinationUri(
Uri.fromFile(
File(
defaultStorage,
downloadRequestItem.itemId.toString()
)
)
)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
if (!File(defaultStorage, downloadRequestItem.itemId.toString()).exists())
downloadFile(downloadRequest, context)
createMetadataFile(
downloadRequestItem.metadata,
downloadRequestItem.itemId)
}
private fun createMetadataFile(metadata: DownloadMetadata, itemId: UUID) {
val metadataFile = File(defaultStorage, "${itemId}.metadata")
metadataFile.writeText("") //This might be necessary to make sure that the metadata file is empty
if (metadata.type == "Episode") {
metadataFile.printWriter().use { out ->
out.println(metadata.id)
out.println(metadata.type.toString())
out.println(metadata.seriesName.toString())
out.println(metadata.name.toString())
out.println(metadata.parentIndexNumber.toString())
out.println(metadata.indexNumber.toString())
out.println(metadata.playbackPosition.toString())
out.println(metadata.playedPercentage.toString())
out.println(metadata.seriesId.toString())
out.println(metadata.played.toString())
out.println(if (metadata.overview != null) metadata.overview.replace("\n", "\\n") else "")
}
} else if (metadata.type == "Movie") {
metadataFile.printWriter().use { out ->
out.println(metadata.id)
out.println(metadata.type.toString())
out.println(metadata.name.toString())
out.println(metadata.playbackPosition.toString())
out.println(metadata.playedPercentage.toString())
out.println(metadata.played.toString())
out.println(if (metadata.overview != null) metadata.overview.replace("\n", "\\n") else "")
}
}
}
private fun downloadFile(request: DownloadManager.Request, context: Context) {
request.apply {
setAllowedOverMetered(false)
setAllowedOverRoaming(false)
}
context.getSystemService<DownloadManager>()?.enqueue(request)
}
fun loadDownloadLocation(context: Context) {
defaultStorage = context.getExternalFilesDir(Environment.DIRECTORY_MOVIES)
}
fun loadDownloadedEpisodes(): List<PlayerItem> {
val items = mutableListOf<PlayerItem>()
defaultStorage?.walk()?.forEach {
if (it.isFile && it.extension == "") {
try {
val metadataFile = File(defaultStorage, "${it.name}.metadata").readLines()
val metadata = parseMetadataFile(metadataFile)
items.add(
PlayerItem(
name = metadata.name,
itemId = UUID.fromString(it.name),
mediaSourceId = "",
playbackPosition = metadata.playbackPosition!!,
mediaSourceUri = it.absolutePath,
metadata = metadata
)
)
} catch (e: Exception) {
it.delete()
Timber.e(e)
}
}
}
return items.toList()
}
fun itemIsDownloaded(itemId: UUID): Boolean {
val file = File(defaultStorage!!, itemId.toString())
if (file.isFile && file.extension == "") {
if (File(defaultStorage, "${itemId}.metadata").exists()){
return true
}
}
return false
}
fun getDownloadPlayerItem(itemId: UUID): PlayerItem? {
val file = File(defaultStorage!!, itemId.toString())
try{
val metadataFile = File(defaultStorage, "${file.name}.metadata").readLines()
val metadata = parseMetadataFile(metadataFile)
return PlayerItem(metadata.name, UUID.fromString(file.name), "", metadata.playbackPosition!!, file.absolutePath, metadata)
} catch (e: Exception) {
file.delete()
Timber.e(e)
}
return null
}
fun deleteDownloadedEpisode(uri: String) {
try {
File(uri).delete()
File("${uri}.metadata").delete()
} catch (e: Exception) {
Timber.e(e)
}
}
fun postDownloadPlaybackProgress(uri: String, playbackPosition: Long, playedPercentage: Double) {
try {
val metadataFile = File("${uri}.metadata")
val metadataArray = metadataFile.readLines().toMutableList()
if (metadataArray[1] == "Episode") {
metadataArray[6] = playbackPosition.toString()
metadataArray[7] = playedPercentage.toString()
} else if (metadataArray[1] == "Movie") {
metadataArray[3] = playbackPosition.toString()
metadataArray[4] = playedPercentage.toString()
}
Timber.d("PLAYEDPERCENTAGE $playedPercentage")
metadataFile.writeText("") //This might be necessary to make sure that the metadata file is empty
metadataFile.printWriter().use { out ->
metadataArray.forEach {
out.println(it)
}
}
} catch (e: Exception) {
Timber.e(e)
}
}
fun downloadMetadataToBaseItemDto(metadata: DownloadMetadata): BaseItemDto {
val userData = UserItemDataDto(
playbackPositionTicks = metadata.playbackPosition ?: 0,
playedPercentage = metadata.playedPercentage,
isFavorite = false,
playCount = 0,
played = false
)
return BaseItemDto(
id = metadata.id,
type = metadata.type,
seriesName = metadata.seriesName,
name = metadata.name,
parentIndexNumber = metadata.parentIndexNumber,
indexNumber = metadata.indexNumber,
userData = userData,
seriesId = metadata.seriesId,
overview = metadata.overview
)
}
fun baseItemDtoToDownloadMetadata(item: BaseItemDto): DownloadMetadata {
return DownloadMetadata(
id = item.id,
type = item.type,
seriesName = item.seriesName,
name = item.name,
parentIndexNumber = item.parentIndexNumber,
indexNumber = item.indexNumber,
playbackPosition = item.userData?.playbackPositionTicks ?: 0,
playedPercentage = item.userData?.playedPercentage,
seriesId = item.seriesId,
played = item.userData?.played,
overview = item.overview
)
}
fun parseMetadataFile(metadataFile: List<String>): DownloadMetadata {
if (metadataFile[1] == "Episode") {
return DownloadMetadata(
id = UUID.fromString(metadataFile[0]),
type = metadataFile[1],
seriesName = metadataFile[2],
name = metadataFile[3],
parentIndexNumber = metadataFile[4].toInt(),
indexNumber = metadataFile[5].toInt(),
playbackPosition = metadataFile[6].toLong(),
playedPercentage = if (metadataFile[7] == "null") {
null
} else {
metadataFile[7].toDouble()
},
seriesId = UUID.fromString(metadataFile[8]),
played = metadataFile[9].toBoolean(),
overview = metadataFile[10].replace("\\n", "\n")
)
} else {
return DownloadMetadata(
id = UUID.fromString(metadataFile[0]),
type = metadataFile[1],
name = metadataFile[2],
playbackPosition = metadataFile[3].toLong(),
playedPercentage = if (metadataFile[4] == "null") {
null
} else {
metadataFile[4].toDouble()
},
played = metadataFile[5].toBoolean(),
overview = metadataFile[6].replace("\\n", "\n")
)
}
}
suspend fun syncPlaybackProgress(jellyfinRepository: JellyfinRepository) {
val items = loadDownloadedEpisodes()
items.forEach() {
try {
val localPlaybackProgress = it.metadata?.playbackPosition
val localPlayedPercentage = it.metadata?.playedPercentage
val item = jellyfinRepository.getItem(it.itemId)
val remotePlaybackProgress = item.userData?.playbackPositionTicks?.div(10000)
val remotePlayedPercentage = item.userData?.playedPercentage
var playbackProgress: Long = 0
var playedPercentage = 0.0
if (it.metadata?.played == true || item.userData?.played == true){
return@forEach
}
if (localPlaybackProgress != null) {
if (localPlaybackProgress > playbackProgress) {
playbackProgress = localPlaybackProgress
playedPercentage = localPlayedPercentage!!
}
}
if (remotePlaybackProgress != null) {
if (remotePlaybackProgress > playbackProgress) {
playbackProgress = remotePlaybackProgress
playedPercentage = remotePlayedPercentage!!
}
}
if (playbackProgress != 0.toLong()) {
postDownloadPlaybackProgress(it.mediaSourceUri, playbackProgress, playedPercentage)
jellyfinRepository.postPlaybackProgress(
it.itemId,
playbackProgress.times(10000),
true
)
Timber.d("Percentage: $playedPercentage")
}
} catch (e: Exception) {
Timber.e(e)
}
}
}

View file

@ -0,0 +1,129 @@
package dev.jdtech.jellyfin.utils
import android.media.AudioManager
import android.provider.Settings
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_OFF
import com.google.android.exoplayer2.ui.PlayerView
import dev.jdtech.jellyfin.PlayerActivity
import timber.log.Timber
import kotlin.math.abs
class PlayerGestureHelper(
private val activity: PlayerActivity,
private val playerView: PlayerView,
private val audioManager: AudioManager
) {
/**
* Tracks a value during a swipe gesture (between multiple onScroll calls).
* When the gesture starts it's reset to an initial value and gets increased or decreased
* (depending on the direction) as the gesture progresses.
*/
private var swipeGestureValueTrackerVolume = -1f
private var swipeGestureValueTrackerBrightness = -1f
private val gestureDetector = GestureDetector(playerView.context, object : GestureDetector.SimpleOnGestureListener() {
override fun onDoubleTap(e: MotionEvent): Boolean {
return true
}
override fun onSingleTapUp(e: MotionEvent?): Boolean {
playerView.apply {
if (!isControllerVisible) showController() else hideController()
}
return true
}
override fun onScroll(firstEvent: MotionEvent, currentEvent: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
// Check whether swipe was oriented vertically
if (abs(distanceY / distanceX) < 2)
return false
val viewCenterX = playerView.measuredWidth / 2
// Distance to swipe to go from min to max
val distanceFull = playerView.measuredHeight
val ratioChange = distanceY / distanceFull
if (firstEvent.x.toInt() > viewCenterX) {
// Swiping on the right, change volume
val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
if (swipeGestureValueTrackerVolume == -1f) swipeGestureValueTrackerVolume = currentVolume.toFloat()
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val change = ratioChange * maxVolume
swipeGestureValueTrackerVolume += change
val toSet = swipeGestureValueTrackerVolume.toInt().coerceIn(0, maxVolume)
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, toSet, 0)
activity.binding.gestureVolumeLayout.visibility = View.VISIBLE
activity.binding.gestureVolumeProgressBar.max = maxVolume
activity.binding.gestureVolumeProgressBar.progress = toSet
activity.binding.gestureVolumeText.text = "${(toSet.toFloat()/maxVolume.toFloat()).times(100).toInt()}%"
} else {
// Swiping on the left, change brightness
val window = activity.window
val brightnessRange = BRIGHTNESS_OVERRIDE_OFF..BRIGHTNESS_OVERRIDE_FULL
// Initialize on first swipe
if (swipeGestureValueTrackerBrightness == -1f) {
val brightness = window.attributes.screenBrightness
Timber.d("Brightness ${Settings.System.getFloat(activity.contentResolver, Settings.System.SCREEN_BRIGHTNESS)}")
swipeGestureValueTrackerBrightness = when (brightness) {
in brightnessRange -> brightness
else -> Settings.System.getFloat(activity.contentResolver, Settings.System.SCREEN_BRIGHTNESS)/255
}
}
swipeGestureValueTrackerBrightness = (swipeGestureValueTrackerBrightness + ratioChange).coerceIn(brightnessRange)
val lp = window.attributes
lp.screenBrightness = swipeGestureValueTrackerBrightness
window.attributes = lp
activity.binding.gestureBrightnessLayout.visibility = View.VISIBLE
activity.binding.gestureBrightnessProgressBar.max = BRIGHTNESS_OVERRIDE_FULL.times(100).toInt()
activity.binding.gestureBrightnessProgressBar.progress = lp.screenBrightness.times(100).toInt()
activity.binding.gestureBrightnessText.text = "${(lp.screenBrightness/BRIGHTNESS_OVERRIDE_FULL).times(100).toInt()}%"
}
return true
}
})
private val hideGestureVolumeIndicatorOverlayAction = Runnable {
activity.binding.gestureVolumeLayout.visibility = View.GONE
}
private val hideGestureBrightnessIndicatorOverlayAction = Runnable {
activity.binding.gestureBrightnessLayout.visibility = View.GONE
}
init {
@Suppress("ClickableViewAccessibility")
playerView.setOnTouchListener { _, event ->
if (playerView.useController) {
when (event.pointerCount) {
1 -> gestureDetector.onTouchEvent(event)
}
}
if(event.action == MotionEvent.ACTION_UP) {
activity.binding.gestureVolumeLayout.apply {
if (visibility == View.VISIBLE) {
removeCallbacks(hideGestureVolumeIndicatorOverlayAction)
postDelayed(hideGestureVolumeIndicatorOverlayAction, 1000)
}
}
activity.binding.gestureBrightnessLayout.apply {
if (visibility == View.VISIBLE) {
removeCallbacks(hideGestureBrightnessIndicatorOverlayAction)
postDelayed(hideGestureBrightnessIndicatorOverlayAction, 1000)
}
}
}
true
}
}
}

View file

@ -0,0 +1,22 @@
package dev.jdtech.jellyfin.utils
enum class SortBy(val SortString: String) {
NAME("SortName"),
IMDB_RATING("CommunityRating"),
PARENTAL_RATING("CriticRating"),
DATE_ADDED("DateCreated"),
DATE_PLAYED("DatePlayed"),
RELEASE_DATE("PremiereDate");
companion object {
val defaultValue = NAME
fun fromString(string: String): SortBy {
return try {
valueOf(string)
} catch (e: IllegalArgumentException) {
defaultValue
}
}
}
}

View file

@ -0,0 +1,8 @@
package dev.jdtech.jellyfin.utils
import android.view.View
import androidx.core.view.isVisible
fun View.toggleVisibility() {
isVisible = !isVisible
}

View file

@ -1,8 +1,12 @@
package dev.jdtech.jellyfin.utils
import android.content.Context
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import dev.jdtech.jellyfin.MainNavigationDirections
import dev.jdtech.jellyfin.AppNavigationDirections
import dev.jdtech.jellyfin.models.ContentType
import dev.jdtech.jellyfin.models.View
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
@ -15,9 +19,20 @@ fun BaseItemDto.toView(): View {
)
}
fun BaseItemDto.contentType() = when (type) {
"Movie" -> ContentType.MOVIE
"Series" -> ContentType.TVSHOW
"Episode" -> ContentType.EPISODE
else -> ContentType.UNKNOWN
}
fun Fragment.checkIfLoginRequired(error: String) {
if (error.contains("401")) {
Timber.d("Login required!")
findNavController().navigate(MainNavigationDirections.actionGlobalLoginFragment())
findNavController().navigate(AppNavigationDirections.actionGlobalLoginFragment())
}
}
inline fun Context.toast(@StringRes text: Int, duration: Int = Toast.LENGTH_SHORT) =
Toast.makeText(this, text, duration).show()

View file

@ -1,19 +1,23 @@
package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import android.content.res.Resources
import android.widget.Toast
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.BaseApplication
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.Server
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.discovery.RecommendedServerIssue
import timber.log.Timber
import javax.inject.Inject
@ -21,58 +25,152 @@ import javax.inject.Inject
class AddServerViewModel
@Inject
constructor(
private val application: BaseApplication,
private val jellyfinApi: JellyfinApi,
private val database: ServerDatabaseDao
) : ViewModel() {
private val resources: Resources = application.resources
private val _navigateToLogin = MutableLiveData<Boolean>()
val navigateToLogin: LiveData<Boolean> = _navigateToLogin
private val uiState = MutableStateFlow<UiState>(UiState.Normal)
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
private val navigateToLogin = MutableSharedFlow<Boolean>()
sealed class UiState {
object Normal : UiState()
object Loading : UiState()
data class Error(val message: String) : UiState()
}
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
fun onNavigateToLogin(scope: LifecycleCoroutineScope, collector: (Boolean) -> Unit) {
scope.launch { navigateToLogin.collect { collector(it) } }
}
/**
* Run multiple check on the server before continuing:
*
* - Connect to server and check if it is a Jellyfin server
* - Check if server is not already in Database
*
* @param inputValue Can be an ip address or hostname
*/
fun checkServer(inputValue: String) {
_error.value = null
viewModelScope.launch {
uiState.emit(UiState.Loading)
try {
// Check if input value is not empty
if (inputValue.isBlank()) {
throw Exception(resources.getString(R.string.add_server_error_empty_address))
}
val candidates = jellyfinApi.jellyfin.discovery.getAddressCandidates(inputValue)
val recommended = jellyfinApi.jellyfin.discovery.getRecommendedServers(
candidates,
RecommendedServerInfoScore.GOOD
RecommendedServerInfoScore.OK
)
val recommendedServer: RecommendedServerInfo
try {
recommendedServer = recommended.first()
} catch (e: NoSuchElementException) {
throw Exception("Server not found")
}
val greatServers = mutableListOf<RecommendedServerInfo>()
val goodServers = mutableListOf<RecommendedServerInfo>()
val okServers = mutableListOf<RecommendedServerInfo>()
jellyfinApi.apply {
api.baseUrl = recommendedServer.address
api.accessToken = null
}
Timber.d("Remote server: ${recommendedServer.systemInfo?.id}")
if (serverAlreadyInDatabase(recommendedServer.systemInfo?.id)) {
_error.value = "Server already added"
_navigateToLogin.value = false
} else {
_error.value = null
_navigateToLogin.value = true
}
recommended
.onCompletion {
if (greatServers.isNotEmpty()) {
connectToServer(greatServers.first())
} else if (goodServers.isNotEmpty()) {
val issuesString = createIssuesString(goodServers.first())
Toast.makeText(
application,
issuesString,
Toast.LENGTH_LONG
).show()
connectToServer(goodServers.first())
} else if (okServers.isNotEmpty()) {
val okServer = okServers.first()
val issuesString = createIssuesString(okServer)
throw Exception(issuesString)
} else {
throw Exception(resources.getString(R.string.add_server_error_not_found))
}
}
.collect { recommendedServerInfo ->
when (recommendedServerInfo.score) {
RecommendedServerInfoScore.GREAT -> greatServers.add(recommendedServerInfo)
RecommendedServerInfoScore.GOOD -> goodServers.add(recommendedServerInfo)
RecommendedServerInfoScore.OK -> okServers.add(recommendedServerInfo)
RecommendedServerInfoScore.BAD -> Unit
}
}
} catch (e: Exception) {
Timber.e(e)
_error.value = e.message
_navigateToLogin.value = false
uiState.emit(
UiState.Error(
e.message ?: resources.getString(R.string.unknown_error)
)
)
}
}
}
private suspend fun connectToServer(recommendedServerInfo: RecommendedServerInfo) {
val serverId = recommendedServerInfo.systemInfo.getOrNull()?.id
?: throw Exception(resources.getString(R.string.add_server_error_no_id))
Timber.d("Connecting to server: $serverId")
if (serverAlreadyInDatabase(serverId)) {
throw Exception(resources.getString(R.string.add_server_error_already_added))
}
jellyfinApi.apply {
api.baseUrl = recommendedServerInfo.address
api.accessToken = null
}
uiState.emit(UiState.Normal)
navigateToLogin.emit(true)
}
/**
* Create a presentable string of issues with a server
*
* @param server The server with issues
* @return A presentable string of issues separated with \n
*/
private fun createIssuesString(server: RecommendedServerInfo): String {
return server.issues.joinToString("\n") {
when (it) {
is RecommendedServerIssue.OutdatedServerVersion -> {
String.format(
resources.getString(R.string.add_server_error_outdated),
it.version
)
}
is RecommendedServerIssue.InvalidProductName -> {
String.format(
resources.getString(R.string.add_server_error_not_jellyfin),
it.productName
)
}
is RecommendedServerIssue.UnsupportedServerVersion -> {
String.format(
resources.getString(R.string.add_server_error_version),
it.version
)
}
is RecommendedServerIssue.SlowResponse -> {
String.format(
resources.getString(R.string.add_server_error_slow),
it.responseTime
)
}
else -> {
resources.getString(R.string.unknown_error)
}
}
}
}
@ -83,22 +181,12 @@ constructor(
* @param id Server ID
* @return True if server is already in database
*/
private suspend fun serverAlreadyInDatabase(id: String?): Boolean {
val servers: List<Server>
private suspend fun serverAlreadyInDatabase(id: String): Boolean {
val server: Server?
withContext(Dispatchers.IO) {
servers = database.getAllServersSync()
}
for (server in servers) {
Timber.d("Database server: ${server.id}")
if (server.id == id) {
Timber.w("Server already in the database")
return true
}
server = database.get(id)
}
if (server != null) return true
return false
}
fun onNavigateToLoginDone() {
_navigateToLogin.value = false
}
}

View file

@ -0,0 +1,63 @@
package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.*
import dev.jdtech.jellyfin.models.DownloadSection
import dev.jdtech.jellyfin.utils.loadDownloadedEpisodes
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.util.*
class DownloadViewModel : ViewModel() {
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
sealed class UiState {
data class Normal(val downloadSections: List<DownloadSection>) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
init {
loadData()
}
fun loadData() {
viewModelScope.launch {
uiState.emit(UiState.Loading)
try {
val items = loadDownloadedEpisodes()
if (items.isEmpty()) {
uiState.emit(UiState.Normal(emptyList()))
return@launch
}
val downloadSections = mutableListOf<DownloadSection>()
withContext(Dispatchers.Default) {
DownloadSection(
UUID.randomUUID(),
"Episodes",
items.filter { it.metadata?.type == "Episode" }).let {
if (it.items.isNotEmpty()) downloadSections.add(
it
)
}
DownloadSection(
UUID.randomUUID(),
"Movies",
items.filter { it.metadata?.type == "Movie" }).let {
if (it.items.isNotEmpty()) downloadSections.add(
it
)
}
}
uiState.emit(UiState.Normal(downloadSections))
} catch (e: Exception) {
uiState.emit(UiState.Error(e.message))
}
}
}
}

View file

@ -1,144 +1,154 @@
package dev.jdtech.jellyfin.viewmodels
import android.app.Application
import android.net.Uri
import android.os.Build
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.*
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.DownloadRequestItem
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.LocationType
import timber.log.Timber
import java.text.DateFormat
import java.time.ZoneOffset
import java.util.*
import java.util.Date
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class EpisodeBottomSheetViewModel
@Inject
constructor(
private val application: Application,
private val jellyfinRepository: JellyfinRepository
) : ViewModel() {
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
private val _item = MutableLiveData<BaseItemDto>()
val item: LiveData<BaseItemDto> = _item
sealed class UiState {
data class Normal(
val episode: BaseItemDto,
val runTime: String,
val dateString: String,
val played: Boolean,
val favorite: Boolean,
val downloaded: Boolean,
val downloadEpisode: Boolean,
) : UiState()
private val _runTime = MutableLiveData<String>()
val runTime: LiveData<String> = _runTime
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
private val _dateString = MutableLiveData<String>()
val dateString: LiveData<String> = _dateString
private val _played = MutableLiveData<Boolean>()
val played: LiveData<Boolean> = _played
private val _favorite = MutableLiveData<Boolean>()
val favorite: LiveData<Boolean> = _favorite
private val _navigateToPlayer = MutableLiveData<Boolean>()
val navigateToPlayer: LiveData<Boolean> = _navigateToPlayer
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
var item: BaseItemDto? = null
var runTime: String = ""
var dateString: String = ""
var played: Boolean = false
var favorite: Boolean = false
var downloaded: Boolean = false
var downloadEpisode: Boolean = false
var playerItems: MutableList<PlayerItem> = mutableListOf()
private val _playerItemsError = MutableLiveData<String>()
val playerItemsError: LiveData<String> = _playerItemsError
lateinit var downloadRequestItem: DownloadRequestItem
fun loadEpisode(episodeId: UUID) {
viewModelScope.launch {
uiState.emit(UiState.Loading)
try {
val item = jellyfinRepository.getItem(episodeId)
_item.value = item
_runTime.value = "${item.runTimeTicks?.div(600000000)} min"
_dateString.value = getDateString(item)
_played.value = item.userData?.played
_favorite.value = item.userData?.isFavorite
val tempItem = jellyfinRepository.getItem(episodeId)
item = tempItem
runTime = "${tempItem.runTimeTicks?.div(600000000)} min"
dateString = getDateString(tempItem)
played = tempItem.userData?.played == true
favorite = tempItem.userData?.isFavorite == true
downloaded = itemIsDownloaded(episodeId)
uiState.emit(
UiState.Normal(
tempItem,
runTime,
dateString,
played,
favorite,
downloaded,
downloadEpisode
)
)
} catch (e: Exception) {
Timber.e(e)
uiState.emit(UiState.Error(e.message))
}
}
}
fun preparePlayerItems() {
_playerItemsError.value = null
fun loadEpisode(playerItem: PlayerItem) {
viewModelScope.launch {
try {
createPlayerItems(_item.value!!)
_navigateToPlayer.value = true
} catch (e: Exception) {
_playerItemsError.value = e.toString()
}
}
}
private suspend fun createPlayerItems(startEpisode: BaseItemDto) {
playerItems.clear()
val playbackPosition = startEpisode.userData?.playbackPositionTicks?.div(10000) ?: 0
// Intros
var introsCount = 0
if (playbackPosition <= 0) {
val intros = jellyfinRepository.getIntros(startEpisode.id)
for (intro in intros) {
if (intro.mediaSources.isNullOrEmpty()) continue
playerItems.add(PlayerItem(intro.name, intro.id, intro.mediaSources?.get(0)?.id!!, 0))
introsCount += 1
}
}
val episodes = jellyfinRepository.getEpisodes(
startEpisode.seriesId!!,
startEpisode.seasonId!!,
startItemId = startEpisode.id,
fields = listOf(ItemFields.MEDIA_SOURCES)
)
for (episode in episodes) {
if (episode.mediaSources.isNullOrEmpty()) continue
if (episode.locationType == LocationType.VIRTUAL) continue
playerItems.add(
PlayerItem(
episode.name,
episode.id,
episode.mediaSources?.get(0)?.id!!,
playbackPosition
uiState.emit(UiState.Loading)
playerItems.add(playerItem)
item = downloadMetadataToBaseItemDto(playerItem.metadata!!)
uiState.emit(
UiState.Normal(
item!!,
runTime,
dateString,
played,
favorite,
downloaded,
downloadEpisode
)
)
}
if (playerItems.isEmpty() || playerItems.count() == introsCount) throw Exception("No playable items found")
}
fun markAsPlayed(itemId: UUID) {
viewModelScope.launch {
jellyfinRepository.markAsPlayed(itemId)
}
_played.value = true
played = true
}
fun markAsUnplayed(itemId: UUID) {
viewModelScope.launch {
jellyfinRepository.markAsUnplayed(itemId)
}
_played.value = false
played = false
}
fun markAsFavorite(itemId: UUID) {
viewModelScope.launch {
jellyfinRepository.markAsFavorite(itemId)
}
_favorite.value = true
favorite = true
}
fun unmarkAsFavorite(itemId: UUID) {
viewModelScope.launch {
jellyfinRepository.unmarkAsFavorite(itemId)
}
_favorite.value = false
favorite = false
}
fun loadDownloadRequestItem(itemId: UUID) {
viewModelScope.launch {
//loadEpisode(itemId)
val episode = item
val uri = jellyfinRepository.getStreamUrl(itemId, episode?.mediaSources?.get(0)?.id!!)
Timber.d(uri)
val metadata = baseItemDtoToDownloadMetadata(episode)
downloadRequestItem = DownloadRequestItem(uri, itemId, metadata)
downloadEpisode = true
requestDownload(Uri.parse(downloadRequestItem.uri), downloadRequestItem, application)
}
}
fun deleteEpisode() {
deleteDownloadedEpisode(playerItems[0].mediaSourceUri)
}
private fun getDateString(item: BaseItemDto): String {
@ -152,7 +162,8 @@ constructor(
}
}
fun doneNavigateToPlayer() {
_navigateToPlayer.value = false
fun doneDownloadEpisode() {
downloadEpisode = false
downloaded = true
}
}

View file

@ -1,16 +1,16 @@
package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.FavoriteSection
import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.*
import javax.inject.Inject
@ -20,40 +20,41 @@ class FavoriteViewModel
constructor(
private val jellyfinRepository: JellyfinRepository
) : ViewModel() {
private val _favoriteSections = MutableLiveData<List<FavoriteSection>>()
val favoriteSections: LiveData<List<FavoriteSection>> = _favoriteSections
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
private val _finishedLoading = MutableLiveData<Boolean>()
val finishedLoading: LiveData<Boolean> = _finishedLoading
sealed class UiState {
data class Normal(val favoriteSections: List<FavoriteSection>) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
init {
loadData()
}
fun loadData() {
_error.value = null
_finishedLoading.value = false
viewModelScope.launch {
uiState.emit(UiState.Loading)
try {
val items = jellyfinRepository.getFavoriteItems()
if (items.isEmpty()) {
_favoriteSections.value = listOf()
_finishedLoading.value = true
uiState.emit(UiState.Normal(emptyList()))
return@launch
}
val tempFavoriteSections = mutableListOf<FavoriteSection>()
val favoriteSections = mutableListOf<FavoriteSection>()
withContext(Dispatchers.Default) {
FavoriteSection(
UUID.randomUUID(),
"Movies",
items.filter { it.type == "Movie" }).let {
if (it.items.isNotEmpty()) tempFavoriteSections.add(
if (it.items.isNotEmpty()) favoriteSections.add(
it
)
}
@ -61,7 +62,7 @@ constructor(
UUID.randomUUID(),
"Shows",
items.filter { it.type == "Series" }).let {
if (it.items.isNotEmpty()) tempFavoriteSections.add(
if (it.items.isNotEmpty()) favoriteSections.add(
it
)
}
@ -69,18 +70,16 @@ constructor(
UUID.randomUUID(),
"Episodes",
items.filter { it.type == "Episode" }).let {
if (it.items.isNotEmpty()) tempFavoriteSections.add(
if (it.items.isNotEmpty()) favoriteSections.add(
it
)
}
}
_favoriteSections.value = tempFavoriteSections
uiState.emit(UiState.Normal(favoriteSections))
} catch (e: Exception) {
Timber.e(e)
_error.value = e.toString()
uiState.emit(UiState.Error(e.message))
}
_finishedLoading.value = true
}
}
}

View file

@ -1,114 +1,92 @@
package dev.jdtech.jellyfin.viewmodels
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.HomeItem
import dev.jdtech.jellyfin.adapters.HomeItem.Section
import dev.jdtech.jellyfin.adapters.HomeItem.ViewItem
import dev.jdtech.jellyfin.models.HomeSection
import dev.jdtech.jellyfin.models.View
import dev.jdtech.jellyfin.models.unsupportedCollections
import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.syncPlaybackProgress
import dev.jdtech.jellyfin.utils.toView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
import java.util.*
import javax.inject.Inject
@HiltViewModel
class HomeViewModel
@Inject
constructor(
application: Application,
private val jellyfinRepository: JellyfinRepository
class HomeViewModel @Inject internal constructor(
private val application: Application,
private val repository: JellyfinRepository
) : ViewModel() {
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
private val continueWatchingString = application.resources.getString(R.string.continue_watching)
private val nextUpString = application.resources.getString(R.string.next_up)
private val _views = MutableLiveData<List<HomeItem>>()
val views: LiveData<List<HomeItem>> = _views
private val _items = MutableLiveData<List<BaseItemDto>>()
val items: LiveData<List<BaseItemDto>> = _items
private val _finishedLoading = MutableLiveData<Boolean>()
val finishedLoading: LiveData<Boolean> = _finishedLoading
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
init {
loadData()
sealed class UiState {
data class Normal(val homeItems: List<HomeItem>) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
fun loadData() {
_error.value = null
_finishedLoading.value = false
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
init {
loadData(updateCapabilities = true)
}
fun refreshData() = loadData(updateCapabilities = false)
private fun loadData(updateCapabilities: Boolean) {
viewModelScope.launch {
uiState.emit(UiState.Loading)
try {
if (updateCapabilities) repository.postCapabilities()
jellyfinRepository.postCapabilities()
val items = mutableListOf<HomeItem>()
val updated = loadDynamicItems() + loadViews()
withContext(Dispatchers.Default) {
val resumeItems = jellyfinRepository.getResumeItems()
val resumeSection =
HomeSection(UUID.randomUUID(), continueWatchingString, resumeItems)
if (!resumeItems.isNullOrEmpty()) {
items.add(HomeItem.Section(resumeSection))
}
val nextUpItems = jellyfinRepository.getNextUp()
val nextUpSection = HomeSection(UUID.randomUUID(), nextUpString, nextUpItems)
if (!nextUpItems.isNullOrEmpty()) {
items.add(HomeItem.Section(nextUpSection))
}
syncPlaybackProgress(repository)
}
_views.value = items
val views: MutableList<View> = mutableListOf()
withContext(Dispatchers.Default) {
val userViews = jellyfinRepository.getUserViews()
for (view in userViews) {
Timber.d("Collection type: ${view.collectionType}")
if (view.collectionType == "homevideos" ||
view.collectionType == "music" ||
view.collectionType == "playlists" ||
view.collectionType == "books" ||
view.collectionType == "livetv"
) continue
val latestItems = jellyfinRepository.getLatestMedia(view.id)
if (latestItems.isEmpty()) continue
val v = view.toView()
v.items = latestItems
views.add(v)
}
}
_views.value = items + views.map { HomeItem.ViewItem(it) }
uiState.emit(UiState.Normal(updated))
} catch (e: Exception) {
Timber.e(e)
_error.value = e.toString()
uiState.emit(UiState.Error(e.message))
}
_finishedLoading.value = true
}
}
private suspend fun loadDynamicItems() = withContext(Dispatchers.IO) {
val resumeItems = repository.getResumeItems()
val nextUpItems = repository.getNextUp()
val items = mutableListOf<HomeSection>()
if (resumeItems.isNotEmpty()) {
items.add(HomeSection(application.resources.getString(R.string.continue_watching), resumeItems))
}
if (nextUpItems.isNotEmpty()) {
items.add(HomeSection(application.resources.getString(R.string.next_up), nextUpItems))
}
items.map { Section(it) }
}
private suspend fun loadViews() = withContext(Dispatchers.IO) {
repository
.getUserViews()
.filter { view -> unsupportedCollections().none { it.type == view.collectionType } }
.map { view -> view to repository.getLatestMedia(view.id) }
.filter { (_, latest) -> latest.isNotEmpty() }
.map { (view, latest) -> view.toView().apply { items = latest } }
.map { ViewItem(it) }
}
}

View file

@ -3,8 +3,12 @@ package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.*
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.SortBy
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.SortOrder
import timber.log.Timber
import java.util.*
import javax.inject.Inject
@ -12,20 +16,27 @@ import javax.inject.Inject
@HiltViewModel
class LibraryViewModel
@Inject
constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
constructor(
private val jellyfinRepository: JellyfinRepository
) : ViewModel() {
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
private val _items = MutableLiveData<List<BaseItemDto>>()
val items: LiveData<List<BaseItemDto>> = _items
sealed class UiState {
data class Normal(val items: List<BaseItemDto>) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
private val _finishedLoading = MutableLiveData<Boolean>()
val finishedLoading: LiveData<Boolean> = _finishedLoading
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
fun loadItems(parentId: UUID, libraryType: String?) {
_error.value = null
_finishedLoading.value = false
fun loadItems(
parentId: UUID,
libraryType: String?,
sortBy: SortBy = SortBy.defaultValue,
sortOrder: SortOrder = SortOrder.ASCENDING
) {
Timber.d("$libraryType")
val itemType = when (libraryType) {
"movies" -> "Movie"
@ -33,17 +44,19 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
else -> null
}
viewModelScope.launch {
uiState.emit(UiState.Loading)
try {
_items.value = jellyfinRepository.getItems(
val items = jellyfinRepository.getItems(
parentId,
includeTypes = if (itemType != null) listOf(itemType) else null,
recursive = true
recursive = true,
sortBy = sortBy,
sortOrder = sortOrder
)
uiState.emit(UiState.Normal(items))
} catch (e: Exception) {
Timber.e(e)
_error.value = e.toString()
uiState.emit(UiState.Error(e.message))
}
_finishedLoading.value = true
}
}
}

View file

@ -1,12 +1,18 @@
package dev.jdtech.jellyfin.viewmodels
import android.content.SharedPreferences
import android.content.res.Resources
import androidx.lifecycle.*
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.BaseApplication
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.Server
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.AuthenticateUserByName
@ -18,17 +24,30 @@ import javax.inject.Inject
class LoginViewModel
@Inject
constructor(
application: BaseApplication,
private val sharedPreferences: SharedPreferences,
private val jellyfinApi: JellyfinApi,
private val database: ServerDatabaseDao
) : ViewModel() {
private val resources: Resources = application.resources
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
private val uiState = MutableStateFlow<UiState>(UiState.Normal)
private val navigateToMain = MutableSharedFlow<Boolean>()
private val _navigateToMain = MutableLiveData<Boolean>()
val navigateToMain: LiveData<Boolean> = _navigateToMain
sealed class UiState {
object Normal : UiState()
object Loading : UiState()
data class Error(val message: String) : UiState()
}
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
fun onNavigateToMain(scope: LifecycleCoroutineScope, collector: (Boolean) -> Unit) {
scope.launch { navigateToMain.collect { collector(it) } }
}
/**
* Send a authentication request to the Jellyfin server
@ -38,6 +57,8 @@ constructor(
*/
fun login(username: String, password: String) {
viewModelScope.launch {
uiState.emit(UiState.Loading)
try {
val authenticationResult by jellyfinApi.userApi.authenticateUserByName(
data = AuthenticateUserByName(
@ -45,8 +66,9 @@ constructor(
pw = password
)
)
_error.value = null
val serverInfo by jellyfinApi.systemApi.getPublicSystemInfo()
val server = Server(
serverInfo.id!!,
serverInfo.serverName!!,
@ -55,18 +77,27 @@ constructor(
authenticationResult.user?.name!!,
authenticationResult.accessToken!!
)
insert(server)
val spEdit = sharedPreferences.edit()
spEdit.putString("selectedServer", server.id)
spEdit.apply()
jellyfinApi.apply {
api.accessToken = authenticationResult.accessToken
userId = authenticationResult.user?.id
}
_navigateToMain.value = true
uiState.emit(UiState.Normal)
navigateToMain.emit(true)
} catch (e: Exception) {
Timber.e(e)
_error.value = e.toString()
val message =
if (e.cause?.message?.contains("401") == true) resources.getString(R.string.login_error_wrong_username_password) else resources.getString(
R.string.unknown_error
)
uiState.emit(UiState.Error(message))
}
}
}
@ -81,8 +112,4 @@ constructor(
database.insert(server)
}
}
fun doneNavigatingToMain() {
_navigateToMain.value = false
}
}

View file

@ -1,28 +1,23 @@
package dev.jdtech.jellyfin.viewmodels
import android.content.SharedPreferences
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.Server
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.*
import javax.inject.Inject
@HiltViewModel
class MainViewModel
@Inject
constructor(
private val sharedPreferences: SharedPreferences,
private val database: ServerDatabaseDao,
private val jellyfinApi: JellyfinApi,
) : ViewModel() {
private val _doneLoading = MutableLiveData<Boolean>()
@ -40,21 +35,10 @@ constructor(
}
if (servers.isEmpty()) {
_navigateToAddServer.value = true
} else {
val serverId = sharedPreferences.getString("selectedServer", null)
val selectedServer = servers.find { server -> server.id == serverId }
Timber.d("Selected server: $selectedServer")
if (selectedServer != null) {
jellyfinApi.apply {
api.baseUrl = selectedServer.address
api.accessToken = selectedServer.accessToken
userId = UUID.fromString(selectedServer.userId)
}
Timber.d("Finish Main")
}
_doneLoading.value = true
}
_doneLoading.value = true
}
_doneLoading.value = true
}
fun doneNavigateToAddServer() {

View file

@ -1,105 +1,162 @@
package dev.jdtech.jellyfin.viewmodels
import android.app.Application
import android.net.Uri
import android.os.Build
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.DownloadRequestItem
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.baseItemDtoToDownloadMetadata
import dev.jdtech.jellyfin.utils.deleteDownloadedEpisode
import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
import dev.jdtech.jellyfin.utils.itemIsDownloaded
import dev.jdtech.jellyfin.utils.requestDownload
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.LocationType
import timber.log.Timber
import java.util.*
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class MediaInfoViewModel
@Inject
constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
constructor(
private val application: Application,
private val jellyfinRepository: JellyfinRepository
) : ViewModel() {
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
private val _item = MutableLiveData<BaseItemDto>()
val item: LiveData<BaseItemDto> = _item
sealed class UiState {
data class Normal(
val item: BaseItemDto,
val actors: List<BaseItemPerson>,
val director: BaseItemPerson?,
val writers: List<BaseItemPerson>,
val writersString: String,
val genresString: String,
val runTime: String,
val dateString: String,
val nextUp: BaseItemDto?,
val seasons: List<BaseItemDto>,
val played: Boolean,
val favorite: Boolean,
val downloaded: Boolean,
) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
private val _actors = MutableLiveData<List<BaseItemPerson>>()
val actors: LiveData<List<BaseItemPerson>> = _actors
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
private val _director = MutableLiveData<BaseItemPerson>()
val director: LiveData<BaseItemPerson> = _director
var item: BaseItemDto? = null
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 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 downloaded: Boolean = false
private var downloadMedia: Boolean = false
private val _writers = MutableLiveData<List<BaseItemPerson>>()
val writers: LiveData<List<BaseItemPerson>> = _writers
private val _writersString = MutableLiveData<String>()
val writersString: LiveData<String> = _writersString
private lateinit var downloadRequestItem: DownloadRequestItem
private val _genresString = MutableLiveData<String>()
val genresString: LiveData<String> = _genresString
private val _runTime = MutableLiveData<String>()
val runTime: LiveData<String> = _runTime
private val _dateString = MutableLiveData<String>()
val dateString: LiveData<String> = _dateString
private val _nextUp = MutableLiveData<BaseItemDto>()
val nextUp: LiveData<BaseItemDto> = _nextUp
private val _seasons = MutableLiveData<List<BaseItemDto>>()
val seasons: LiveData<List<BaseItemDto>> = _seasons
private val _navigateToPlayer = MutableLiveData<Array<PlayerItem>>()
val navigateToPlayer: LiveData<Array<PlayerItem>> = _navigateToPlayer
private val _played = MutableLiveData<Boolean>()
val played: LiveData<Boolean> = _played
private val _favorite = MutableLiveData<Boolean>()
val favorite: LiveData<Boolean> = _favorite
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
var playerItems: MutableList<PlayerItem> = mutableListOf()
private val _playerItemsError = MutableLiveData<String>()
val playerItemsError: LiveData<String> = _playerItemsError
lateinit var playerItem: PlayerItem
fun loadData(itemId: UUID, itemType: String) {
_error.value = null
viewModelScope.launch {
uiState.emit(UiState.Loading)
try {
_item.value = jellyfinRepository.getItem(itemId)
_actors.value = getActors(_item.value!!)
_director.value = getDirector(_item.value!!)
_writers.value = getWriters(_item.value!!)
_writersString.value =
_writers.value?.joinToString(separator = ", ") { it.name.toString() }
_genresString.value = _item.value?.genres?.joinToString(separator = ", ")
_runTime.value = "${_item.value?.runTimeTicks?.div(600000000)} min"
_dateString.value = getDateString(_item.value!!)
_played.value = _item.value?.userData?.played
_favorite.value = _item.value?.userData?.isFavorite
val tempItem = jellyfinRepository.getItem(itemId)
item = tempItem
actors = getActors(tempItem)
director = getDirector(tempItem)
writers = getWriters(tempItem)
writersString = writers.joinToString(separator = ", ") { it.name.toString() }
genresString = tempItem.genres?.joinToString(separator = ", ") ?: ""
runTime = "${tempItem.runTimeTicks?.div(600000000)} min"
dateString = getDateString(tempItem)
played = tempItem.userData?.played ?: false
favorite = tempItem.userData?.isFavorite ?: false
downloaded = itemIsDownloaded(itemId)
if (itemType == "Series") {
_nextUp.value = getNextUp(itemId)
_seasons.value = jellyfinRepository.getSeasons(itemId)
nextUp = getNextUp(itemId)
seasons = jellyfinRepository.getSeasons(itemId)
}
uiState.emit(UiState.Normal(
tempItem,
actors,
director,
writers,
writersString,
genresString,
runTime,
dateString,
nextUp,
seasons,
played,
favorite,
downloaded
))
} catch (e: Exception) {
Timber.e(e)
_error.value = e.toString()
Timber.d(e)
Timber.d(itemId.toString())
uiState.emit(UiState.Error(e.message))
}
}
}
private suspend fun getActors(item: BaseItemDto): List<BaseItemPerson>? {
val actors: List<BaseItemPerson>?
fun loadData(pItem: PlayerItem) {
viewModelScope.launch {
playerItem = pItem
val tempItem = downloadMetadataToBaseItemDto(playerItem.metadata!!)
item = tempItem
actors = getActors(tempItem)
director = getDirector(tempItem)
writers = getWriters(tempItem)
writersString = writers.joinToString(separator = ", ") { it.name.toString() }
genresString = tempItem.genres?.joinToString(separator = ", ") ?: ""
runTime = ""
dateString = ""
played = tempItem.userData?.played ?: false
favorite = tempItem.userData?.isFavorite ?: false
uiState.emit(UiState.Normal(
tempItem,
actors,
director,
writers,
writersString,
genresString,
runTime,
dateString,
nextUp,
seasons,
played,
favorite,
downloaded
))
}
}
private suspend fun getActors(item: BaseItemDto): List<BaseItemPerson> {
val actors: List<BaseItemPerson>
withContext(Dispatchers.Default) {
actors = item.people?.filter { it.type == "Actor" }
actors = item.people?.filter { it.type == "Actor" } ?: emptyList()
}
return actors
}
@ -112,10 +169,10 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
return director
}
private suspend fun getWriters(item: BaseItemDto): List<BaseItemPerson>? {
val writers: List<BaseItemPerson>?
private suspend fun getWriters(item: BaseItemDto): List<BaseItemPerson> {
val writers: List<BaseItemPerson>
withContext(Dispatchers.Default) {
writers = item.people?.filter { it.type == "Writer" }
writers = item.people?.filter { it.type == "Writer" } ?: emptyList()
}
return writers
}
@ -133,28 +190,28 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
viewModelScope.launch {
jellyfinRepository.markAsPlayed(itemId)
}
_played.value = true
played = true
}
fun markAsUnplayed(itemId: UUID) {
viewModelScope.launch {
jellyfinRepository.markAsUnplayed(itemId)
}
_played.value = false
played = false
}
fun markAsFavorite(itemId: UUID) {
viewModelScope.launch {
jellyfinRepository.markAsFavorite(itemId)
}
_favorite.value = true
favorite = true
}
fun unmarkAsFavorite(itemId: UUID) {
viewModelScope.launch {
jellyfinRepository.unmarkAsFavorite(itemId)
}
_favorite.value = false
favorite = false
}
private fun getDateString(item: BaseItemDto): String {
@ -178,96 +235,19 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
}
}
fun preparePlayerItems(mediaSourceIndex: Int? = null) {
_playerItemsError.value = null
fun loadDownloadRequestItem(itemId: UUID) {
viewModelScope.launch {
try {
createPlayerItems(_item.value!!, mediaSourceIndex)
_navigateToPlayer.value = playerItems.toTypedArray()
} catch (e: Exception) {
_playerItemsError.value = e.message
}
val downloadItem = item
val uri =
jellyfinRepository.getStreamUrl(itemId, downloadItem?.mediaSources?.get(0)?.id!!)
val metadata = baseItemDtoToDownloadMetadata(downloadItem)
downloadRequestItem = DownloadRequestItem(uri, itemId, metadata)
downloadMedia = true
requestDownload(Uri.parse(downloadRequestItem.uri), downloadRequestItem, application)
}
}
private suspend fun createPlayerItems(series: BaseItemDto, mediaSourceIndex: Int? = null) {
playerItems.clear()
val playbackPosition = item.value?.userData?.playbackPositionTicks?.div(10000) ?: 0
// Intros
var introsCount = 0
if (playbackPosition <= 0) {
val intros = jellyfinRepository.getIntros(series.id)
for (intro in intros) {
if (intro.mediaSources.isNullOrEmpty()) continue
playerItems.add(PlayerItem(intro.name, intro.id, intro.mediaSources?.get(0)?.id!!, 0))
introsCount += 1
}
}
when (series.type) {
"Movie" -> {
playerItems.add(
PlayerItem(
series.name,
series.id,
series.mediaSources?.get(mediaSourceIndex ?: 0)?.id!!,
playbackPosition
)
)
}
"Series" -> {
if (nextUp.value != null) {
val startEpisode = nextUp.value!!
val episodes = jellyfinRepository.getEpisodes(
startEpisode.seriesId!!,
startEpisode.seasonId!!,
startItemId = startEpisode.id,
fields = listOf(ItemFields.MEDIA_SOURCES)
)
for (episode in episodes) {
if (episode.mediaSources.isNullOrEmpty()) continue
if (episode.locationType == LocationType.VIRTUAL) continue
playerItems.add(
PlayerItem(
episode.name,
episode.id,
episode.mediaSources?.get(0)?.id!!,
0
)
)
}
} else {
for (season in seasons.value!!) {
if (season.indexNumber == 0) continue
val episodes = jellyfinRepository.getEpisodes(
series.id,
season.id,
fields = listOf(ItemFields.MEDIA_SOURCES)
)
for (episode in episodes) {
if (episode.mediaSources.isNullOrEmpty()) continue
if (episode.locationType == LocationType.VIRTUAL) continue
playerItems.add(
PlayerItem(
episode.name,
episode.id,
episode.mediaSources?.get(0)?.id!!,
0
)
)
}
}
}
}
}
if (playerItems.isEmpty() || playerItems.count() == introsCount) throw Exception("No playable items found")
}
fun doneNavigatingToPlayer() {
_navigateToPlayer.value = null
fun deleteItem() {
deleteDownloadedEpisode(playerItem.mediaSourceUri)
}
}

View file

@ -2,10 +2,12 @@ package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.*
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.unsupportedCollections
import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
@ -15,38 +17,35 @@ constructor(
private val jellyfinRepository: JellyfinRepository
) : ViewModel() {
private val _collections = MutableLiveData<List<BaseItemDto>>()
val collections: LiveData<List<BaseItemDto>> = _collections
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
private val _finishedLoading = MutableLiveData<Boolean>()
val finishedLoading: LiveData<Boolean> = _finishedLoading
sealed class UiState {
data class Normal(val collections: List<BaseItemDto>) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
init {
loadData()
}
fun loadData() {
_finishedLoading.value = false
_error.value = null
viewModelScope.launch {
uiState.emit(UiState.Loading)
try {
val items = jellyfinRepository.getItems()
_collections.value =
items.filter {
it.collectionType != "homevideos" &&
it.collectionType != "music" &&
it.collectionType != "playlists" &&
it.collectionType != "boxsets" &&
it.collectionType != "books"
}
val collections =
items.filter { collection -> unsupportedCollections().none { it.type == collection.collectionType } }
uiState.emit(UiState.Normal(collections))
} catch (e: Exception) {
Timber.e(e)
_error.value = e.toString()
uiState.emit(
UiState.Error(e.message)
)
}
_finishedLoading.value = true
}
}
}

View file

@ -0,0 +1,76 @@
package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.ContentType.MOVIE
import dev.jdtech.jellyfin.models.ContentType.TVSHOW
import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.contentType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import java.lang.Exception
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
internal class PersonDetailViewModel @Inject internal constructor(
private val jellyfinRepository: JellyfinRepository
) : ViewModel() {
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
sealed class UiState {
data class Normal(val data: PersonOverview, val starredIn: StarredIn) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
fun loadData(personId: UUID) {
viewModelScope.launch {
uiState.emit(UiState.Loading)
try {
val personDetail = jellyfinRepository.getItem(personId)
val data = PersonOverview(
name = personDetail.name.orEmpty(),
overview = personDetail.overview.orEmpty(),
dto = personDetail
)
val items = jellyfinRepository.getPersonItems(
personIds = listOf(personId),
includeTypes = listOf(MOVIE, TVSHOW),
recursive = true
)
val movies = items.filter { it.contentType() == MOVIE }
val shows = items.filter { it.contentType() == TVSHOW }
val starredIn = StarredIn(movies, shows)
uiState.emit(UiState.Normal(data, starredIn))
} catch (e: Exception) {
uiState.emit(UiState.Error(e.message))
}
}
}
data class PersonOverview(
val name: String,
val overview: String,
val dto: BaseItemDto
)
data class StarredIn(
val movies: List<BaseItemDto>,
val shows: List<BaseItemDto>
)
}

View file

@ -8,17 +8,23 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.preference.PreferenceManager
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.BasePlayer
import com.google.android.exoplayer2.DefaultRenderersFactory
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.mpv.MPVPlayer
import dev.jdtech.jellyfin.mpv.TrackType
import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.postDownloadPlaybackProgress
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.util.*
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
@ -46,14 +52,16 @@ constructor(
val trackSelector = DefaultTrackSelector(application)
var playWhenReady = true
private var playFromDownloads = false
private var currentWindow = 0
private var playbackPosition: Long = 0
var playbackSpeed: Float = 1f
private val sp = PreferenceManager.getDefaultSharedPreferences(application)
init {
val useMpv = sp.getBoolean("mpv_player", false)
val preferredAudioLanguage = sp.getString("audio_language", null) ?: ""
val preferredSubtitleLanguage = sp.getString("subtitle_language", null) ?: ""
@ -93,10 +101,14 @@ constructor(
viewModelScope.launch {
val mediaItems: MutableList<MediaItem> = mutableListOf()
try {
for (item in items) {
val streamUrl = jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId)
val streamUrl = when {
item.mediaSourceUri.isNotEmpty() -> item.mediaSourceUri
else -> jellyfinRepository.getStreamUrl(item.itemId, item.mediaSourceId)
}
playFromDownloads = item.mediaSourceUri.isNotEmpty()
Timber.d("Stream url: $streamUrl")
val mediaItem =
MediaItem.Builder()
@ -110,11 +122,12 @@ constructor(
}
player.setMediaItems(mediaItems, currentWindow, items[0].playbackPosition)
player.prepare()
val useMpv = sp.getBoolean("mpv_player", false)
if(!useMpv || !playFromDownloads)
player.prepare() //TODO: This line causes a crash when playing from downloads with MPV
player.play()
pollPosition(player)
}
pollPosition(player)
}
private fun releasePlayer() {
@ -144,6 +157,9 @@ constructor(
override fun run() {
viewModelScope.launch {
if (player.currentMediaItem != null) {
if(playFromDownloads){
postDownloadPlaybackProgress(items[0].mediaSourceUri, player.currentPosition, (player.currentPosition.toDouble()/player.duration.toDouble()).times(100)) //TODO Automatically use the correct item
}
try {
jellyfinRepository.postPlaybackProgress(
UUID.fromString(player.currentMediaItem!!.mediaId),
@ -225,4 +241,9 @@ constructor(
player.selectTrack(trackType, isExternal = false, index = track.ffIndex)
}
}
fun selectSpeed(speed: Float) {
player.setPlaybackSpeed(speed)
playbackSpeed = speed
}
}

View file

@ -0,0 +1,193 @@
package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.repository.JellyfinRepository
import dev.jdtech.jellyfin.utils.getDownloadPlayerItem
import dev.jdtech.jellyfin.utils.itemIsDownloaded
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.LocationType.VIRTUAL
import org.jellyfin.sdk.model.api.MediaProtocol
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class PlayerViewModel @Inject internal constructor(
private val repository: JellyfinRepository
) : ViewModel() {
private val playerItems = MutableSharedFlow<PlayerItemState>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
fun onPlaybackRequested(scope: LifecycleCoroutineScope, collector: (PlayerItemState) -> Unit) {
scope.launch { playerItems.collect { collector(it) } }
}
fun loadPlayerItems(
item: BaseItemDto,
mediaSourceIndex: Int = 0,
onVersionSelectRequired: () -> Unit = { }
) {
if (itemIsDownloaded(item.id)) {
val playerItem = getDownloadPlayerItem(item.id)
if (playerItem != null) {
loadOfflinePlayerItems(playerItem)
return
}
}
Timber.d("Loading player items for item ${item.id}")
if (item.mediaSources.orEmpty().size > 1) {
onVersionSelectRequired()
}
viewModelScope.launch {
val playbackPosition = item.userData?.playbackPositionTicks?.div(10000) ?: 0
val items = try {
createItems(item, playbackPosition, mediaSourceIndex).let(::PlayerItems)
} catch (e: Exception) {
Timber.d(e)
PlayerItemError(e.toString())
}
playerItems.tryEmit(items)
}
}
fun loadOfflinePlayerItems(
playerItem: PlayerItem
) {
playerItems.tryEmit(PlayerItems(listOf(playerItem)))
}
private suspend fun createItems(
item: BaseItemDto,
playbackPosition: Long,
mediaSourceIndex: Int
) = if (playbackPosition <= 0) {
prepareIntros(item) + prepareMediaPlayerItems(
item,
playbackPosition,
mediaSourceIndex
)
} else {
prepareMediaPlayerItems(item, playbackPosition, mediaSourceIndex)
}
private suspend fun prepareIntros(item: BaseItemDto): List<PlayerItem> {
return repository
.getIntros(item.id)
.filter { it.mediaSources != null && it.mediaSources?.isNotEmpty() == true }
.map { intro -> intro.toPlayerItem(mediaSourceIndex = 0, playbackPosition = 0) }
}
private suspend fun prepareMediaPlayerItems(
item: BaseItemDto,
playbackPosition: Long,
mediaSourceIndex: Int
): List<PlayerItem> = when (item.type) {
"Movie" -> itemToMoviePlayerItems(item, playbackPosition, mediaSourceIndex)
"Series" -> seriesToPlayerItems(item, playbackPosition, mediaSourceIndex)
"Episode" -> episodeToPlayerItems(item, playbackPosition, mediaSourceIndex)
else -> emptyList()
}
private fun itemToMoviePlayerItems(
item: BaseItemDto,
playbackPosition: Long,
mediaSourceIndex: Int
) = listOf(item.toPlayerItem(mediaSourceIndex, playbackPosition))
private suspend fun seriesToPlayerItems(
item: BaseItemDto,
playbackPosition: Long,
mediaSourceIndex: Int
): List<PlayerItem> {
val nextUp = repository.getNextUp(item.id)
return if (nextUp.isEmpty()) {
repository
.getSeasons(item.id)
.flatMap { seasonToPlayerItems(it, playbackPosition, mediaSourceIndex) }
} else {
episodeToPlayerItems(nextUp.first(), playbackPosition, mediaSourceIndex)
}
}
private suspend fun seasonToPlayerItems(
item: BaseItemDto,
playbackPosition: Long,
mediaSourceIndex: Int
): List<PlayerItem> {
return repository
.getEpisodes(
seriesId = item.seriesId!!,
seasonId = item.id,
fields = listOf(ItemFields.MEDIA_SOURCES)
)
.filter { it.mediaSources != null && it.mediaSources?.isNotEmpty() == true }
.filter { it.locationType != VIRTUAL }
.map { episode -> episode.toPlayerItem(mediaSourceIndex, playbackPosition) }
}
private suspend fun episodeToPlayerItems(
item: BaseItemDto,
playbackPosition: Long,
mediaSourceIndex: Int
): List<PlayerItem> {
return repository
.getEpisodes(
seriesId = item.seriesId!!,
seasonId = item.seasonId!!,
fields = listOf(ItemFields.MEDIA_SOURCES),
startItemId = item.id
)
.filter { it.mediaSources != null && it.mediaSources?.isNotEmpty() == true }
.filter { it.locationType != VIRTUAL }
.map { episode -> episode.toPlayerItem(mediaSourceIndex, playbackPosition) }
}
private fun BaseItemDto.toPlayerItem(
mediaSourceIndex: Int,
playbackPosition: Long
): PlayerItem {
val mediaSource = mediaSources!![mediaSourceIndex]
return when (mediaSource.protocol) {
MediaProtocol.FILE -> PlayerItem(
name = name,
itemId = id,
mediaSourceId = mediaSource.id!!,
playbackPosition = playbackPosition
)
MediaProtocol.HTTP -> PlayerItem(
name = name,
itemId = id,
mediaSourceId = mediaSource.id!!,
mediaSourceUri = mediaSource.path!!,
playbackPosition = playbackPosition
)
else -> PlayerItem(
name = name,
itemId = id,
mediaSourceId = mediaSource.id!!,
playbackPosition = playbackPosition
)
}
}
sealed class PlayerItemState
data class PlayerItemError(val message: String) : PlayerItemState()
data class PlayerItems(val items: List<PlayerItem>) : PlayerItemState()
}

View file

@ -1,16 +1,16 @@
package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.models.FavoriteSection
import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.*
import javax.inject.Inject
@ -20,36 +20,37 @@ class SearchResultViewModel
constructor(
private val jellyfinRepository: JellyfinRepository
) : ViewModel() {
private val _sections = MutableLiveData<List<FavoriteSection>>()
val sections: LiveData<List<FavoriteSection>> = _sections
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
private val _finishedLoading = MutableLiveData<Boolean>()
val finishedLoading: LiveData<Boolean> = _finishedLoading
sealed class UiState {
data class Normal(val sections: List<FavoriteSection>) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
fun loadData(query: String) {
_error.value = null
_finishedLoading.value = false
viewModelScope.launch {
uiState.emit(UiState.Loading)
try {
val items = jellyfinRepository.getSearchItems(query)
if (items.isEmpty()) {
_sections.value = listOf()
_finishedLoading.value = true
uiState.emit(UiState.Normal(emptyList()))
return@launch
}
val tempSections = mutableListOf<FavoriteSection>()
val sections = mutableListOf<FavoriteSection>()
withContext(Dispatchers.Default) {
FavoriteSection(
UUID.randomUUID(),
"Movies",
items.filter { it.type == "Movie" }).let {
if (it.items.isNotEmpty()) tempSections.add(
if (it.items.isNotEmpty()) sections.add(
it
)
}
@ -57,7 +58,7 @@ constructor(
UUID.randomUUID(),
"Shows",
items.filter { it.type == "Series" }).let {
if (it.items.isNotEmpty()) tempSections.add(
if (it.items.isNotEmpty()) sections.add(
it
)
}
@ -65,18 +66,16 @@ constructor(
UUID.randomUUID(),
"Episodes",
items.filter { it.type == "Episode" }).let {
if (it.items.isNotEmpty()) tempSections.add(
if (it.items.isNotEmpty()) sections.add(
it
)
}
}
_sections.value = tempSections
uiState.emit(UiState.Normal(sections))
} catch (e: Exception) {
Timber.e(e)
_error.value = e.toString()
uiState.emit(UiState.Error(e.message))
}
_finishedLoading.value = true
}
}
}

View file

@ -1,48 +1,49 @@
package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.*
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.adapters.EpisodeItem
import dev.jdtech.jellyfin.repository.JellyfinRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.ItemFields
import timber.log.Timber
import java.util.*
import javax.inject.Inject
@HiltViewModel
class SeasonViewModel
@Inject
constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
constructor(
private val jellyfinRepository: JellyfinRepository
) : ViewModel() {
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
private val _episodes = MutableLiveData<List<EpisodeItem>>()
val episodes: LiveData<List<EpisodeItem>> = _episodes
sealed class UiState {
data class Normal(val episodes: List<EpisodeItem>) : UiState()
object Loading : UiState()
data class Error(val message: String?) : UiState()
}
private val _finishedLoading = MutableLiveData<Boolean>()
val finishedLoading: LiveData<Boolean> = _finishedLoading
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _error
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
scope.launch { uiState.collect { collector(it) } }
}
fun loadEpisodes(seriesId: UUID, seasonId: UUID) {
_error.value = null
_finishedLoading.value = false
viewModelScope.launch {
uiState.emit(UiState.Loading)
try {
_episodes.value = getEpisodes(seriesId, seasonId)
val episodes = getEpisodes(seriesId, seasonId)
uiState.emit(UiState.Normal(episodes))
} catch (e: Exception) {
Timber.e(e)
_error.value = e.toString()
uiState.emit(UiState.Error(e.message))
}
_finishedLoading.value = true
}
}
private suspend fun getEpisodes(seriesId: UUID, seasonId: UUID): List<EpisodeItem> {
val episodes = jellyfinRepository.getEpisodes(seriesId, seasonId, fields = listOf(ItemFields.OVERVIEW))
val episodes =
jellyfinRepository.getEpisodes(seriesId, seasonId, fields = listOf(ItemFields.OVERVIEW))
return listOf(EpisodeItem.Header) + episodes.map { EpisodeItem.Episode(it) }
}
}

View file

@ -1,8 +1,7 @@
package dev.jdtech.jellyfin.viewmodels
import android.content.SharedPreferences
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@ -10,6 +9,9 @@ import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.Server
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.*
@ -23,12 +25,17 @@ constructor(
private val jellyfinApi: JellyfinApi,
private val database: ServerDatabaseDao,
) : ViewModel() {
val servers = database.getAllServers()
private val _servers = database.getAllServers()
val servers: LiveData<List<Server>> = _servers
private val navigateToMain = MutableSharedFlow<Boolean>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
private val _navigateToMain = MutableLiveData<Boolean>()
val navigateToMain: LiveData<Boolean> = _navigateToMain
fun onNavigateToMain(scope: LifecycleCoroutineScope, collector: (Boolean) -> Unit) {
scope.launch { navigateToMain.collect { collector(it) } }
}
/**
* Delete server from database
@ -54,10 +61,6 @@ constructor(
userId = UUID.fromString(server.userId)
}
_navigateToMain.value = true
}
fun doneNavigatingToMain() {
_navigateToMain.value = false
navigateToMain.tryEmit(true)
}
}

View file

@ -0,0 +1,24 @@
package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.api.JellyfinApi
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.DeviceOptions
import javax.inject.Inject
@HiltViewModel
internal class SettingsViewModel @Inject internal constructor(
private val api: JellyfinApi
) : ViewModel() {
fun updateDeviceName(name: String) {
api.jellyfin.deviceInfo?.id?.let { id ->
viewModelScope.launch(IO) {
api.devicesApi.updateDeviceOptions(id, DeviceOptions(name))
}
}
}
}

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient android:angle="90"
android:startColor="@color/neutral_900"/>
</shape>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/neutral_100">
android:color="?attr/colorControlHighlight">
<item android:id="@android:id/background">
<shape android:shape="rectangle">
<solid android:color="@color/neutral_700" />

View file

@ -1,11 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/primary_variant">
<item android:id="@android:id/background">
<shape android:shape="rectangle">
<solid android:color="@color/primary" />
<corners
android:radius="10dp" />
</shape>
</item>
</ripple>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?attr/colorPrimary" />
<corners android:radius="10dp" />
</shape>

Some files were not shown because too many files have changed in this diff Show more