Merge remote-tracking branch 'origin/develop'
0
.idea/gradle.properties
Normal 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")
|
||||
|
||||
|
|
BIN
app/src/debug/ic_launcher-playstore.png
Normal file
After Width: | Height: | Size: 39 KiB |
27
app/src/debug/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
5
app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
|
@ -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>
|
BIN
app/src/debug/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
app/src/debug/res/mipmap-hdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 4.3 KiB |
BIN
app/src/debug/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
app/src/debug/res/mipmap-mdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
app/src/debug/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 6.3 KiB |
BIN
app/src/debug/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 6.3 KiB |
BIN
app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 8.9 KiB |
BIN
app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 15 KiB |
4
app/src/debug/res/values/ic_launcher_background.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#000000</color>
|
||||
</resources>
|
4
app/src/debug/res/values/strings.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Findroid Debug</string>
|
||||
</resources>
|
|
@ -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>
|
||||
|
|
69
app/src/main/java/dev/jdtech/jellyfin/BasePlayerActivity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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 {
|
||||
|
|
37
app/src/main/java/dev/jdtech/jellyfin/MainActivityTv.kt
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -23,6 +23,7 @@ object DatabaseModule {
|
|||
"servers"
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
.serverDatabaseDao
|
||||
}
|
||||
|
|
51
app/src/main/java/dev/jdtech/jellyfin/di/GlideModule.kt
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)) { _, _ ->
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
||||
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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.editTextServerAddress.setOnEditorActionListener { _, actionId, _ ->
|
||||
return@setOnEditorActionListener when (actionId) {
|
||||
EditorInfo.IME_ACTION_GO -> {
|
||||
connectToServer()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
connectToServer()
|
||||
}
|
||||
|
||||
viewModel.navigateToLogin.observe(viewLifecycleOwner, {
|
||||
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()
|
||||
}
|
||||
binding.progressCircular.visibility = View.GONE
|
||||
})
|
||||
|
||||
viewModel.error.observe(viewLifecycleOwner, {
|
||||
binding.editTextServerAddressLayout.error = it
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,68 +42,150 @@ 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.progressCircular.isVisible = true
|
||||
viewModel.item?.let {
|
||||
if (!args.isOffline) {
|
||||
playerViewModel.loadPlayerItems(it)
|
||||
} else {
|
||||
playerViewModel.loadOfflinePlayerItems(viewModel.playerItems[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.value) {
|
||||
true -> viewModel.markAsUnplayed(args.episodeId)
|
||||
false -> viewModel.markAsPlayed(args.episodeId)
|
||||
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.value) {
|
||||
true -> viewModel.unmarkAsFavorite(args.episodeId)
|
||||
false -> viewModel.markAsFavorite(args.episodeId)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.item.observe(viewLifecycleOwner, { episode ->
|
||||
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)
|
||||
})
|
||||
// Download icon
|
||||
val downloadDrawable = when (downloaded) {
|
||||
true -> R.drawable.ic_download_filled
|
||||
false -> R.drawable.ic_download
|
||||
}
|
||||
binding.downloadButton.setImageResource(downloadDrawable)
|
||||
|
||||
viewModel.navigateToPlayer.observe(viewLifecycleOwner, {
|
||||
if (it) {
|
||||
navigateToPlayerActivity(
|
||||
viewModel.playerItems.toTypedArray(),
|
||||
)
|
||||
viewModel.doneNavigateToPlayer()
|
||||
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
|
||||
}
|
||||
|
||||
private fun bindUiStateLoading() {
|
||||
binding.loadingIndicator.isVisible = true
|
||||
}
|
||||
|
||||
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(),
|
||||
|
@ -101,11 +194,11 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
)
|
||||
binding.progressCircular.visibility = View.INVISIBLE
|
||||
}
|
||||
})
|
||||
|
||||
viewModel.playerItemsError.observe(viewLifecycleOwner, { errorMessage ->
|
||||
if (errorMessage != null) {
|
||||
binding.playerItemsError.visibility = View.VISIBLE
|
||||
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
|
||||
Timber.e(error.message)
|
||||
|
||||
binding.playerItemsError.isVisible = true
|
||||
binding.playButton.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
requireActivity(),
|
||||
|
@ -113,20 +206,9 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
)
|
||||
)
|
||||
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")
|
||||
ErrorDialogFragment(error.message).show(parentFragmentManager, "errordialog")
|
||||
}
|
||||
|
||||
viewModel.loadEpisode(args.episodeId)
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
private fun navigateToPlayerActivity(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 {
|
||||
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)
|
||||
}, HomeEpisodeListAdapter.OnClickListener { item ->
|
||||
when (item.type) {
|
||||
"Episode" -> {
|
||||
navigateToEpisodeBottomSheetFragment(item)
|
||||
}
|
||||
"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
|
||||
},
|
||||
onNextUpClickListener = HomeEpisodeListAdapter.OnClickListener { item ->
|
||||
when (item.contentType()) {
|
||||
EPISODE -> navigateToEpisodeBottomSheetFragment(item)
|
||||
MOVIE -> navigateToMediaInfoFragment(item)
|
||||
else -> Toast.makeText(requireContext(), R.string.unknown_error, LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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, {
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -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,7 +41,7 @@ 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 {
|
||||
override fun onQueryTextSubmit(p0: String?): Boolean {
|
||||
|
@ -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(
|
||||
|
|
|
@ -6,97 +6,254 @@ 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"
|
||||
)
|
||||
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
|
||||
when (playerItems) {
|
||||
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
|
||||
is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.item.observe(viewLifecycleOwner, { item ->
|
||||
if (item.originalTitle != item.name) {
|
||||
binding.originalTitle.visibility = View.VISIBLE
|
||||
binding.trailerButton.setOnClickListener {
|
||||
if (viewModel.item?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
|
||||
val intent = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse(viewModel.item?.remoteTrailers?.get(0)?.url)
|
||||
)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
binding.nextUp.setOnClickListener {
|
||||
navigateToEpisodeBottomSheetFragment(viewModel.nextUp!!)
|
||||
}
|
||||
|
||||
binding.seasonsRecyclerView.adapter =
|
||||
ViewItemListAdapter(ViewItemListAdapter.OnClickListener { season ->
|
||||
navigateToSeasonFragment(season)
|
||||
}, fixedWidth = true)
|
||||
binding.peopleRecyclerView.adapter = PersonListAdapter { person ->
|
||||
val uuid = person.id?.toUUID()
|
||||
if (uuid != null) {
|
||||
navigateToPersonDetail(uuid)
|
||||
} else {
|
||||
binding.originalTitle.visibility = View.GONE
|
||||
Toast.makeText(requireContext(), R.string.error_getting_person_id, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
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
|
||||
binding.playButton.setOnClickListener {
|
||||
binding.playButton.setImageResource(android.R.color.transparent)
|
||||
binding.progressCircular.isVisible = true
|
||||
viewModel.item?.let { item ->
|
||||
if (!args.isOffline) {
|
||||
playerViewModel.loadPlayerItems(item) {
|
||||
VideoVersionDialogFragment(item, playerViewModel).show(
|
||||
parentFragmentManager,
|
||||
"videoversiondialog"
|
||||
)
|
||||
viewModel.doneNavigatingToPlayer()
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// 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)
|
||||
|
||||
// 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(),
|
||||
|
@ -105,28 +262,9 @@ class MediaInfoFragment : Fragment() {
|
|||
)
|
||||
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) {
|
||||
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
|
||||
Timber.e(error.message)
|
||||
binding.playerItemsError.visibility = View.VISIBLE
|
||||
binding.playButton.setImageDrawable(
|
||||
ContextCompat.getDrawable(
|
||||
|
@ -135,70 +273,9 @@ class MediaInfoFragment : Fragment() {
|
|||
)
|
||||
)
|
||||
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")
|
||||
ErrorDialogFragment(error.message).show(parentFragmentManager, "errordialog")
|
||||
}
|
||||
|
||||
binding.trailerButton.setOnClickListener {
|
||||
if (viewModel.item.value?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
|
||||
val intent = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse(viewModel.item.value?.remoteTrailers?.get(0)?.url)
|
||||
)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
binding.nextUp.setOnClickListener {
|
||||
navigateToEpisodeBottomSheetFragment(viewModel.nextUp.value!!)
|
||||
}
|
||||
|
||||
binding.seasonsRecyclerView.adapter =
|
||||
ViewItemListAdapter(ViewItemListAdapter.OnClickListener { season ->
|
||||
navigateToSeasonFragment(season)
|
||||
}, fixedWidth = true)
|
||||
binding.peopleRecyclerView.adapter = PersonListAdapter()
|
||||
|
||||
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(
|
||||
parentFragmentManager,
|
||||
"videoversiondialog"
|
||||
)
|
||||
} else {
|
||||
viewModel.preparePlayerItems()
|
||||
}
|
||||
}
|
||||
} else if (args.itemType == "Series") {
|
||||
viewModel.preparePlayerItems()
|
||||
}
|
||||
}
|
||||
|
||||
binding.checkButton.setOnClickListener {
|
||||
when (viewModel.played.value) {
|
||||
true -> viewModel.markAsUnplayed(args.itemId)
|
||||
false -> viewModel.markAsPlayed(args.itemId)
|
||||
}
|
||||
}
|
||||
|
||||
binding.favoriteButton.setOnClickListener {
|
||||
when (viewModel.favorite.value) {
|
||||
true -> viewModel.unmarkAsFavorite(args.itemId)
|
||||
false -> viewModel.markAsFavorite(args.itemId)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.loadData(args.itemId, args.itemType)
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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, {
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -0,0 +1,8 @@
|
|||
package dev.jdtech.jellyfin.models
|
||||
|
||||
enum class ContentType(val type: String) {
|
||||
MOVIE("Movie"),
|
||||
TVSHOW("Series"),
|
||||
EPISODE("Episode"),
|
||||
UNKNOWN("")
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
)
|
|
@ -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>
|
||||
)
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
155
app/src/main/java/dev/jdtech/jellyfin/tv/TvPlayerActivity.kt
Normal 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
|
||||
}
|
||||
}
|
122
app/src/main/java/dev/jdtech/jellyfin/tv/ui/HomeFragment.kt
Normal 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()
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
287
app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
22
app/src/main/java/dev/jdtech/jellyfin/utils/SortBy.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package dev.jdtech.jellyfin.utils
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
|
||||
fun View.toggleVisibility() {
|
||||
isVisible = !isVisible
|
||||
}
|
|
@ -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()
|
|
@ -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>()
|
||||
|
||||
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) {
|
||||
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 = recommendedServer.address
|
||||
api.baseUrl = recommendedServerInfo.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
|
||||
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)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
_error.value = e.message
|
||||
_navigateToLogin.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
_finishedLoading.value = true
|
||||
uiState.emit(UiState.Error(e.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
sealed class UiState {
|
||||
data class Normal(val homeItems: List<HomeItem>) : UiState()
|
||||
object Loading : UiState()
|
||||
data class Error(val message: String?) : UiState()
|
||||
}
|
||||
|
||||
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
|
||||
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
|
||||
scope.launch { uiState.collect { collector(it) } }
|
||||
}
|
||||
|
||||
init {
|
||||
loadData()
|
||||
loadData(updateCapabilities = true)
|
||||
}
|
||||
|
||||
fun loadData() {
|
||||
_error.value = null
|
||||
_finishedLoading.value = false
|
||||
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))
|
||||
syncPlaybackProgress(repository)
|
||||
}
|
||||
|
||||
val nextUpItems = jellyfinRepository.getNextUp()
|
||||
val nextUpSection = HomeSection(UUID.randomUUID(), nextUpString, nextUpItems)
|
||||
|
||||
if (!nextUpItems.isNullOrEmpty()) {
|
||||
items.add(HomeItem.Section(nextUpSection))
|
||||
}
|
||||
}
|
||||
|
||||
_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()
|
||||
}
|
||||
_finishedLoading.value = true
|
||||
uiState.emit(UiState.Error(e.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
_finishedLoading.value = true
|
||||
uiState.emit(UiState.Error(e.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
fun doneNavigateToAddServer() {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
_finishedLoading.value = true
|
||||
uiState.emit(
|
||||
UiState.Error(e.message)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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,12 +122,13 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releasePlayer() {
|
||||
player.let { player ->
|
||||
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
_finishedLoading.value = true
|
||||
uiState.emit(UiState.Error(e.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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" />
|
||||
|
|
|
@ -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 xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="?attr/colorPrimary" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
|
|