Merge remote-tracking branch 'origin/develop'
0
.idea/gradle.properties
Normal file
|
@ -54,15 +54,24 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("androidx.core:core-ktx:1.6.0")
|
implementation("androidx.leanback:leanback:1.2.0-alpha02")
|
||||||
implementation("androidx.core:core-splashscreen:1.0.0-alpha01")
|
|
||||||
implementation("androidx.appcompat:appcompat:1.3.1")
|
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
|
// Material
|
||||||
implementation("com.google.android.material:material:1.4.0")
|
implementation("com.google.android.material:material:1.4.0")
|
||||||
|
|
||||||
// ConstraintLayout
|
// 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
|
// Navigation
|
||||||
val navigationVersion = "2.3.5"
|
val navigationVersion = "2.3.5"
|
||||||
|
@ -84,8 +93,8 @@ dependencies {
|
||||||
implementation("androidx.preference:preference-ktx:$preferenceVersion")
|
implementation("androidx.preference:preference-ktx:$preferenceVersion")
|
||||||
|
|
||||||
// Jellyfin
|
// Jellyfin
|
||||||
val jellyfinVersion = "1.0.3"
|
val jellyfinVersion = "1.1.1"
|
||||||
implementation("org.jellyfin.sdk:jellyfin-platform-android:$jellyfinVersion")
|
implementation("org.jellyfin.sdk:jellyfin-core:$jellyfinVersion")
|
||||||
|
|
||||||
// Glide
|
// Glide
|
||||||
val glideVersion = "4.12.0"
|
val glideVersion = "4.12.0"
|
||||||
|
@ -93,12 +102,12 @@ dependencies {
|
||||||
kapt("com.github.bumptech.glide:compiler:$glideVersion")
|
kapt("com.github.bumptech.glide:compiler:$glideVersion")
|
||||||
|
|
||||||
// Hilt
|
// Hilt
|
||||||
val hiltVersion = "2.38.1"
|
val hiltVersion = "2.40.2"
|
||||||
implementation("com.google.dagger:hilt-android:$hiltVersion")
|
implementation("com.google.dagger:hilt-android:$hiltVersion")
|
||||||
kapt("com.google.dagger:hilt-compiler:$hiltVersion")
|
kapt("com.google.dagger:hilt-compiler:$hiltVersion")
|
||||||
|
|
||||||
// ExoPlayer
|
// 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-core:$exoplayerVersion")
|
||||||
implementation("com.google.android.exoplayer:exoplayer-ui:$exoplayerVersion")
|
implementation("com.google.android.exoplayer:exoplayer-ui:$exoplayerVersion")
|
||||||
implementation(files("libs/extension-ffmpeg-release.aar"))
|
implementation(files("libs/extension-ffmpeg-release.aar"))
|
||||||
|
@ -110,7 +119,7 @@ dependencies {
|
||||||
val timberVersion = "5.0.1"
|
val timberVersion = "5.0.1"
|
||||||
implementation("com.jakewharton.timber:timber:$timberVersion")
|
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-core:$aboutLibrariesVersion")
|
||||||
implementation("com.mikepenz:aboutlibraries:$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-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
|
<application
|
||||||
android:name=".BaseApplication"
|
android:name=".BaseApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:fullBackupOnly="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:banner="@mipmap/ic_banner"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Jellyfin"
|
android:theme="@style/Theme.Findroid"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".PlayerActivity"
|
android:name=".PlayerActivity"
|
||||||
android:screenOrientation="userLandscape" />
|
android:screenOrientation="userLandscape" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".tv.TvPlayerActivity"
|
||||||
|
android:screenOrientation="userLandscape" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.JellyfinSplashScreen"
|
android:theme="@style/Theme.FindroidSplashScreen"
|
||||||
android:windowSoftInputMode="adjustPan">
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
|
|
||||||
</application>
|
</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
|
package dev.jdtech.jellyfin
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import androidx.databinding.BindingAdapter
|
import androidx.databinding.BindingAdapter
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
|
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.api.JellyfinApi
|
||||||
import dev.jdtech.jellyfin.database.Server
|
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.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemPerson
|
import org.jellyfin.sdk.model.api.BaseItemPerson
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import java.util.*
|
import java.util.UUID
|
||||||
|
|
||||||
@BindingAdapter("servers")
|
@BindingAdapter("servers")
|
||||||
fun bindServers(recyclerView: RecyclerView, data: List<Server>?) {
|
fun bindServers(recyclerView: RecyclerView, data: List<Server>?) {
|
||||||
|
@ -20,12 +27,6 @@ fun bindServers(recyclerView: RecyclerView, data: List<Server>?) {
|
||||||
adapter.submitList(data)
|
adapter.submitList(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@BindingAdapter("views")
|
|
||||||
fun bindViews(recyclerView: RecyclerView, data: List<HomeItem>?) {
|
|
||||||
val adapter = recyclerView.adapter as ViewListAdapter
|
|
||||||
adapter.submitList(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
@BindingAdapter("items")
|
@BindingAdapter("items")
|
||||||
fun bindItems(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
|
fun bindItems(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
|
||||||
val adapter = recyclerView.adapter as ViewItemListAdapter
|
val adapter = recyclerView.adapter as ViewItemListAdapter
|
||||||
|
@ -34,50 +35,26 @@ fun bindItems(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
|
||||||
|
|
||||||
@BindingAdapter("itemImage")
|
@BindingAdapter("itemImage")
|
||||||
fun bindItemImage(imageView: ImageView, item: BaseItemDto) {
|
fun bindItemImage(imageView: ImageView, item: BaseItemDto) {
|
||||||
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
|
|
||||||
|
|
||||||
val itemId =
|
val itemId =
|
||||||
if (item.type == "Episode" || item.type == "Season" && item.imageTags.isNullOrEmpty()) item.seriesId else item.id
|
if (item.type == "Episode" || item.type == "Season" && item.imageTags.isNullOrEmpty()) item.seriesId else item.id
|
||||||
|
|
||||||
Glide
|
imageView
|
||||||
.with(imageView.context)
|
.loadImage("/items/$itemId/Images/${ImageType.PRIMARY}")
|
||||||
.load(jellyfinApi.api.baseUrl.plus("/items/${itemId}/Images/${ImageType.PRIMARY}"))
|
.posterDescription(item.name)
|
||||||
.transition(DrawableTransitionOptions.withCrossFade())
|
|
||||||
.placeholder(R.color.neutral_800)
|
|
||||||
.into(imageView)
|
|
||||||
|
|
||||||
imageView.contentDescription = "${item.name} poster"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@BindingAdapter("itemBackdropImage")
|
@BindingAdapter("itemBackdropImage")
|
||||||
fun bindItemBackdropImage(imageView: ImageView, item: BaseItemDto?) {
|
fun bindItemBackdropImage(imageView: ImageView, item: BaseItemDto?) {
|
||||||
if (item == null) return
|
if (item == null) return
|
||||||
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
|
|
||||||
|
|
||||||
Glide
|
imageView
|
||||||
.with(imageView.context)
|
.loadImage("/items/${item.id}/Images/${ImageType.BACKDROP}")
|
||||||
.load(jellyfinApi.api.baseUrl.plus("/items/${item.id}/Images/${ImageType.BACKDROP}"))
|
.backdropDescription(item.name)
|
||||||
.transition(DrawableTransitionOptions.withCrossFade())
|
|
||||||
.into(imageView)
|
|
||||||
|
|
||||||
imageView.contentDescription = "${item.name} backdrop"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@BindingAdapter("itemBackdropById")
|
@BindingAdapter("itemBackdropById")
|
||||||
fun bindItemBackdropById(imageView: ImageView, itemId: UUID) {
|
fun bindItemBackdropById(imageView: ImageView, itemId: UUID) {
|
||||||
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
|
imageView.loadImage("/items/$itemId/Images/${ImageType.BACKDROP}")
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@BindingAdapter("people")
|
@BindingAdapter("people")
|
||||||
|
@ -88,22 +65,9 @@ fun bindPeople(recyclerView: RecyclerView, data: List<BaseItemPerson>?) {
|
||||||
|
|
||||||
@BindingAdapter("personImage")
|
@BindingAdapter("personImage")
|
||||||
fun bindPersonImage(imageView: ImageView, person: BaseItemPerson) {
|
fun bindPersonImage(imageView: ImageView, person: BaseItemPerson) {
|
||||||
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
|
imageView
|
||||||
|
.loadImage("/items/${person.id}/Images/${ImageType.PRIMARY}")
|
||||||
Glide
|
.posterDescription(person.name)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@BindingAdapter("homeEpisodes")
|
@BindingAdapter("homeEpisodes")
|
||||||
|
@ -116,12 +80,10 @@ fun bindHomeEpisodes(recyclerView: RecyclerView, data: List<BaseItemDto>?) {
|
||||||
fun bindBaseItemImage(imageView: ImageView, episode: BaseItemDto?) {
|
fun bindBaseItemImage(imageView: ImageView, episode: BaseItemDto?) {
|
||||||
if (episode == null) return
|
if (episode == null) return
|
||||||
|
|
||||||
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
|
|
||||||
|
|
||||||
var imageItemId = episode.id
|
var imageItemId = episode.id
|
||||||
var imageType = ImageType.PRIMARY
|
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) {
|
when (episode.type) {
|
||||||
"Movie" -> {
|
"Movie" -> {
|
||||||
if (!episode.backdropImageTags.isNullOrEmpty()) {
|
if (!episode.backdropImageTags.isNullOrEmpty()) {
|
||||||
|
@ -141,30 +103,33 @@ fun bindBaseItemImage(imageView: ImageView, episode: BaseItemDto?) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Glide
|
imageView
|
||||||
.with(imageView.context)
|
.loadImage("/items/${imageItemId}/Images/$imageType")
|
||||||
.load(jellyfinApi.api.baseUrl.plus("/items/${imageItemId}/Images/$imageType"))
|
.posterDescription(episode.name)
|
||||||
.transition(DrawableTransitionOptions.withCrossFade())
|
|
||||||
.placeholder(R.color.neutral_800)
|
|
||||||
.into(imageView)
|
|
||||||
|
|
||||||
imageView.contentDescription = "${episode.name} poster"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@BindingAdapter("seasonPoster")
|
@BindingAdapter("seasonPoster")
|
||||||
fun bindSeasonPoster(imageView: ImageView, seasonId: UUID) {
|
fun bindSeasonPoster(imageView: ImageView, seasonId: UUID) {
|
||||||
val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
|
imageView.loadImage("/items/${seasonId}/Images/${ImageType.PRIMARY}")
|
||||||
|
}
|
||||||
|
|
||||||
Glide
|
private fun ImageView.loadImage(url: String, errorPlaceHolderId: Int? = null): View {
|
||||||
.with(imageView.context)
|
val api = JellyfinApi.getInstance(context.applicationContext)
|
||||||
.load(jellyfinApi.api.baseUrl.plus("/items/${seasonId}/Images/${ImageType.PRIMARY}"))
|
|
||||||
|
return Glide
|
||||||
|
.with(context)
|
||||||
|
.load("${api.api.baseUrl}$url")
|
||||||
.transition(DrawableTransitionOptions.withCrossFade())
|
.transition(DrawableTransitionOptions.withCrossFade())
|
||||||
.placeholder(R.color.neutral_800)
|
.placeholder(R.color.neutral_800)
|
||||||
.into(imageView)
|
.also { if (errorPlaceHolderId != null) error(errorPlaceHolderId) }
|
||||||
|
.into(this)
|
||||||
|
.view
|
||||||
}
|
}
|
||||||
|
|
||||||
@BindingAdapter("favoriteSections")
|
private fun View.posterDescription(name: String?) {
|
||||||
fun bindFavoriteSections(recyclerView: RecyclerView, data: List<FavoriteSection>?) {
|
contentDescription = String.format(context.resources.getString(R.string.image_description_poster), name)
|
||||||
val adapter = recyclerView.adapter as FavoritesListAdapter
|
}
|
||||||
adapter.submitList(data)
|
|
||||||
|
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.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.navigation.fragment.NavHostFragment
|
import androidx.navigation.fragment.NavHostFragment
|
||||||
import androidx.navigation.ui.AppBarConfiguration
|
import androidx.navigation.ui.AppBarConfiguration
|
||||||
import androidx.navigation.ui.setupActionBarWithNavController
|
import androidx.navigation.ui.setupActionBarWithNavController
|
||||||
import androidx.navigation.ui.setupWithNavController
|
import androidx.navigation.ui.setupWithNavController
|
||||||
|
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.databinding.ActivityMainBinding
|
import dev.jdtech.jellyfin.databinding.ActivityMainAppBinding
|
||||||
import dev.jdtech.jellyfin.fragments.InitializingFragmentDirections
|
import dev.jdtech.jellyfin.fragments.HomeFragmentDirections
|
||||||
|
import dev.jdtech.jellyfin.utils.loadDownloadLocation
|
||||||
import dev.jdtech.jellyfin.viewmodels.MainViewModel
|
import dev.jdtech.jellyfin.viewmodels.MainViewModel
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private lateinit var binding: ActivityMainBinding
|
private lateinit var binding: ActivityMainAppBinding
|
||||||
private val viewModel: MainViewModel by viewModels()
|
private val viewModel: MainViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
binding = ActivityMainAppBinding.inflate(layoutInflater)
|
||||||
|
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
|
||||||
|
@ -42,7 +43,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
// menu should be considered as top level destinations.
|
// menu should be considered as top level destinations.
|
||||||
val appBarConfiguration = AppBarConfiguration(
|
val appBarConfiguration = AppBarConfiguration(
|
||||||
setOf(
|
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)
|
if (destination.id == R.id.about_libraries_dest) binding.mainToolbar.title = getString(R.string.app_info)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadDownloadLocation(applicationContext)
|
||||||
|
|
||||||
viewModel.navigateToAddServer.observe(this, {
|
viewModel.navigateToAddServer.observe(this, {
|
||||||
if (it) {
|
if (it) {
|
||||||
navController.navigate(InitializingFragmentDirections.actionInitializingFragmentToAddServerFragment3())
|
navController.navigate(HomeFragmentDirections.actionHomeFragmentToAddServerFragment())
|
||||||
viewModel.doneNavigateToAddServer()
|
viewModel.doneNavigateToAddServer()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
viewModel.doneLoading.observe(this, {
|
|
||||||
if (it) {
|
|
||||||
if (navController.currentDestination!!.id == R.id.initializingFragment) {
|
|
||||||
navController.navigate(InitializingFragmentDirections.actionInitializingFragmentToNavigationHome())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSupportNavigateUp(): Boolean {
|
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
|
package dev.jdtech.jellyfin
|
||||||
|
|
||||||
import android.os.Build
|
import android.content.Context
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import android.media.AudioManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.navigation.navArgs
|
import androidx.navigation.navArgs
|
||||||
import com.google.android.exoplayer2.C
|
import com.google.android.exoplayer2.C
|
||||||
import com.google.android.exoplayer2.SimpleExoPlayer
|
import com.google.android.exoplayer2.SimpleExoPlayer
|
||||||
import com.google.android.exoplayer2.trackselection.MappingTrackSelector
|
|
||||||
import com.google.android.exoplayer2.ui.TrackSelectionDialogBuilder
|
import com.google.android.exoplayer2.ui.TrackSelectionDialogBuilder
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.databinding.ActivityPlayerBinding
|
import dev.jdtech.jellyfin.databinding.ActivityPlayerBinding
|
||||||
|
import dev.jdtech.jellyfin.dialogs.SpeedSelectionDialogFragment
|
||||||
import dev.jdtech.jellyfin.dialogs.TrackSelectionDialogFragment
|
import dev.jdtech.jellyfin.dialogs.TrackSelectionDialogFragment
|
||||||
import dev.jdtech.jellyfin.mpv.MPVPlayer
|
import dev.jdtech.jellyfin.mpv.MPVPlayer
|
||||||
import dev.jdtech.jellyfin.mpv.TrackType
|
import dev.jdtech.jellyfin.mpv.TrackType
|
||||||
|
import dev.jdtech.jellyfin.utils.PlayerGestureHelper
|
||||||
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
|
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class PlayerActivity : AppCompatActivity() {
|
class PlayerActivity : BasePlayerActivity() {
|
||||||
private lateinit var binding: ActivityPlayerBinding
|
|
||||||
private val viewModel: PlayerActivityViewModel by viewModels()
|
lateinit var binding: ActivityPlayerBinding
|
||||||
|
private lateinit var playerGestureHelper: PlayerGestureHelper
|
||||||
|
override val viewModel: PlayerActivityViewModel by viewModels()
|
||||||
private val args: PlayerActivityArgs by navArgs()
|
private val args: PlayerActivityArgs by navArgs()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
Timber.d("Creating player activity")
|
Timber.d("Creating player activity")
|
||||||
|
|
||||||
binding = ActivityPlayerBinding.inflate(layoutInflater)
|
binding = ActivityPlayerBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
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)
|
val playerControls = binding.playerView.findViewById<View>(R.id.player_controls)
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
configureInsets(playerControls)
|
||||||
binding.playerView.findViewById<View>(R.id.player_controls)
|
|
||||||
.setOnApplyWindowInsetsListener { _, windowInsets ->
|
playerGestureHelper = PlayerGestureHelper(this, binding.playerView, getSystemService(Context.AUDIO_SERVICE) as AudioManager)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.playerView.findViewById<View>(R.id.back_button).setOnClickListener {
|
binding.playerView.findViewById<View>(R.id.back_button).setOnClickListener {
|
||||||
onBackPressed()
|
onBackPressed()
|
||||||
|
@ -65,6 +58,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||||
|
|
||||||
val audioButton = binding.playerView.findViewById<ImageButton>(R.id.btn_audio_track)
|
val audioButton = binding.playerView.findViewById<ImageButton>(R.id.btn_audio_track)
|
||||||
val subtitleButton = binding.playerView.findViewById<ImageButton>(R.id.btn_subtitle)
|
val subtitleButton = binding.playerView.findViewById<ImageButton>(R.id.btn_subtitle)
|
||||||
|
val speedButton = binding.playerView.findViewById<ImageButton>(R.id.btn_speed)
|
||||||
|
|
||||||
audioButton.isEnabled = false
|
audioButton.isEnabled = false
|
||||||
audioButton.imageAlpha = 75
|
audioButton.imageAlpha = 75
|
||||||
|
@ -72,6 +66,9 @@ class PlayerActivity : AppCompatActivity() {
|
||||||
subtitleButton.isEnabled = false
|
subtitleButton.isEnabled = false
|
||||||
subtitleButton.imageAlpha = 75
|
subtitleButton.imageAlpha = 75
|
||||||
|
|
||||||
|
speedButton.isEnabled = false
|
||||||
|
speedButton.imageAlpha = 75
|
||||||
|
|
||||||
audioButton.setOnClickListener {
|
audioButton.setOnClickListener {
|
||||||
when (viewModel.player) {
|
when (viewModel.player) {
|
||||||
is MPVPlayer -> {
|
is MPVPlayer -> {
|
||||||
|
@ -134,12 +131,21 @@ class PlayerActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
speedButton.setOnClickListener {
|
||||||
|
SpeedSelectionDialogFragment(viewModel).show(
|
||||||
|
supportFragmentManager,
|
||||||
|
"speedselectiondialog"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.fileLoaded.observe(this, {
|
viewModel.fileLoaded.observe(this, {
|
||||||
if (it) {
|
if (it) {
|
||||||
audioButton.isEnabled = true
|
audioButton.isEnabled = true
|
||||||
audioButton.imageAlpha = 255
|
audioButton.imageAlpha = 255
|
||||||
subtitleButton.isEnabled = true
|
subtitleButton.isEnabled = true
|
||||||
subtitleButton.imageAlpha = 255
|
subtitleButton.imageAlpha = 255
|
||||||
|
speedButton.isEnabled = true
|
||||||
|
speedButton.imageAlpha = 255
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -152,44 +158,5 @@ class PlayerActivity : AppCompatActivity() {
|
||||||
viewModel.initializePlayer(args.items)
|
viewModel.initializePlayer(args.items)
|
||||||
hideSystemUI()
|
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 dev.jdtech.jellyfin.databinding.PersonItemBinding
|
||||||
import org.jellyfin.sdk.model.api.BaseItemPerson
|
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) :
|
class PersonViewHolder(private var binding: PersonItemBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(person: BaseItemPerson) {
|
fun bind(person: BaseItemPerson) {
|
||||||
|
@ -40,5 +41,6 @@ class PersonListAdapter :ListAdapter<BaseItemPerson, PersonListAdapter.PersonVie
|
||||||
override fun onBindViewHolder(holder: PersonViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: PersonViewHolder, position: Int) {
|
||||||
val item = getItem(position)
|
val item = getItem(position)
|
||||||
holder.bind(item)
|
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.databinding.ViewItemBinding
|
||||||
import dev.jdtech.jellyfin.models.HomeSection
|
import dev.jdtech.jellyfin.models.HomeSection
|
||||||
import dev.jdtech.jellyfin.models.View
|
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_NEXT_UP = 0
|
||||||
private const val ITEM_VIEW_TYPE_VIEW = 1
|
private const val ITEM_VIEW_TYPE_VIEW = 1
|
||||||
|
@ -20,6 +20,7 @@ class ViewListAdapter(
|
||||||
private val onItemClickListener: ViewItemListAdapter.OnClickListener,
|
private val onItemClickListener: ViewItemListAdapter.OnClickListener,
|
||||||
private val onNextUpClickListener: HomeEpisodeListAdapter.OnClickListener
|
private val onNextUpClickListener: HomeEpisodeListAdapter.OnClickListener
|
||||||
) : ListAdapter<HomeItem, RecyclerView.ViewHolder>(DiffCallback) {
|
) : ListAdapter<HomeItem, RecyclerView.ViewHolder>(DiffCallback) {
|
||||||
|
|
||||||
class ViewViewHolder(private var binding: ViewItemBinding) :
|
class ViewViewHolder(private var binding: ViewItemBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(
|
fun bind(
|
||||||
|
@ -50,7 +51,8 @@ class ViewListAdapter(
|
||||||
|
|
||||||
companion object DiffCallback : DiffUtil.ItemCallback<HomeItem>() {
|
companion object DiffCallback : DiffUtil.ItemCallback<HomeItem>() {
|
||||||
override fun areItemsTheSame(oldItem: HomeItem, newItem: HomeItem): Boolean {
|
override fun areItemsTheSame(oldItem: HomeItem, newItem: HomeItem): Boolean {
|
||||||
return oldItem.id == newItem.id
|
return oldItem.ids.size == newItem.ids.size
|
||||||
|
&& oldItem.ids.mapIndexed { i, old -> old == newItem.ids[i] }.all { it }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItem: HomeItem, newItem: HomeItem): Boolean {
|
override fun areContentsTheSame(oldItem: HomeItem, newItem: HomeItem): Boolean {
|
||||||
|
@ -105,12 +107,12 @@ class ViewListAdapter(
|
||||||
|
|
||||||
sealed class HomeItem {
|
sealed class HomeItem {
|
||||||
data class Section(val homeSection: HomeSection) : 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() {
|
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 android.content.Context
|
||||||
import dev.jdtech.jellyfin.BuildConfig
|
import dev.jdtech.jellyfin.BuildConfig
|
||||||
import org.jellyfin.sdk.Jellyfin
|
import org.jellyfin.sdk.api.client.extensions.devicesApi
|
||||||
import org.jellyfin.sdk.android
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
import org.jellyfin.sdk.api.operations.*
|
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 org.jellyfin.sdk.model.ClientInfo
|
||||||
import java.util.*
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Jellyfin API class using org.jellyfin.sdk:jellyfin-platform-android
|
* 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
|
* @param baseUrl The url of the server
|
||||||
* @constructor Creates a new [JellyfinApi] instance
|
* @constructor Creates a new [JellyfinApi] instance
|
||||||
*/
|
*/
|
||||||
class JellyfinApi(context: Context, baseUrl: String) {
|
class JellyfinApi(androidContext: Context) {
|
||||||
val jellyfin = Jellyfin {
|
val jellyfin = createJellyfin {
|
||||||
clientInfo =
|
clientInfo =
|
||||||
ClientInfo(name = context.applicationInfo.loadLabel(context.packageManager).toString(), version = BuildConfig.VERSION_NAME)
|
ClientInfo(name = androidContext.applicationInfo.loadLabel(androidContext.packageManager).toString(), version = BuildConfig.VERSION_NAME)
|
||||||
android(context)
|
context = androidContext
|
||||||
}
|
}
|
||||||
val api = jellyfin.createApi(baseUrl = baseUrl)
|
val api = jellyfin.createApi()
|
||||||
var userId: UUID? = null
|
var userId: UUID? = null
|
||||||
|
|
||||||
val systemApi = SystemApi(api)
|
val devicesApi = api.devicesApi
|
||||||
val userApi = UserApi(api)
|
val systemApi = api.systemApi
|
||||||
val viewsApi = UserViewsApi(api)
|
val userApi = api.userApi
|
||||||
val itemsApi = ItemsApi(api)
|
val viewsApi = api.userViewsApi
|
||||||
val userLibraryApi = UserLibraryApi(api)
|
val itemsApi = api.itemsApi
|
||||||
val showsApi = TvShowsApi(api)
|
val userLibraryApi = api.userLibraryApi
|
||||||
val sessionApi = SessionApi(api)
|
val showsApi = api.tvShowsApi
|
||||||
val videosApi = VideosApi(api)
|
val sessionApi = api.sessionApi
|
||||||
val mediaInfoApi = MediaInfoApi(api)
|
val videosApi = api.videosApi
|
||||||
val playStateApi = PlayStateApi(api)
|
val mediaInfoApi = api.mediaInfoApi
|
||||||
|
val playStateApi = api.playStateApi
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@Volatile
|
@Volatile
|
||||||
private var INSTANCE: JellyfinApi? = null
|
private var INSTANCE: JellyfinApi? = null
|
||||||
|
|
||||||
/**
|
fun getInstance(context: Context): JellyfinApi {
|
||||||
* 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 {
|
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
var instance = INSTANCE
|
var instance = INSTANCE
|
||||||
if (instance == null) {
|
if (instance == null) {
|
||||||
instance = JellyfinApi(context.applicationContext, baseUrl)
|
instance = JellyfinApi(context.applicationContext)
|
||||||
INSTANCE = instance
|
INSTANCE = instance
|
||||||
}
|
}
|
||||||
return 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)
|
fun update(server: Server)
|
||||||
|
|
||||||
@Query("select * from servers where id = :id")
|
@Query("select * from servers where id = :id")
|
||||||
fun get(id: String): Server
|
fun get(id: String): Server?
|
||||||
|
|
||||||
@Query("delete from servers")
|
@Query("delete from servers")
|
||||||
fun clear()
|
fun clear()
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
package dev.jdtech.jellyfin.di
|
package dev.jdtech.jellyfin.di
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import dev.jdtech.jellyfin.api.JellyfinApi
|
import dev.jdtech.jellyfin.api.JellyfinApi
|
||||||
|
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||||
|
import java.util.UUID
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
@ -14,7 +17,23 @@ import javax.inject.Singleton
|
||||||
object ApiModule {
|
object ApiModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
@Provides
|
@Provides
|
||||||
fun provideJellyfinApi(@ApplicationContext application: Context): JellyfinApi {
|
fun provideJellyfinApi(
|
||||||
return JellyfinApi.getInstance(application, "")
|
@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"
|
"servers"
|
||||||
)
|
)
|
||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration()
|
||||||
|
.allowMainThreadQueries()
|
||||||
.build()
|
.build()
|
||||||
.serverDatabaseDao
|
.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
|
package dev.jdtech.jellyfin.dialogs
|
||||||
|
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.fragment.app.DialogFragment
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dev.jdtech.jellyfin.R
|
import dev.jdtech.jellyfin.R
|
||||||
import dev.jdtech.jellyfin.database.Server
|
import dev.jdtech.jellyfin.database.Server
|
||||||
import dev.jdtech.jellyfin.viewmodels.ServerSelectViewModel
|
import dev.jdtech.jellyfin.viewmodels.ServerSelectViewModel
|
||||||
|
@ -12,7 +12,7 @@ import java.lang.IllegalStateException
|
||||||
class DeleteServerDialogFragment(private val viewModel: ServerSelectViewModel, val server: Server) : DialogFragment() {
|
class DeleteServerDialogFragment(private val viewModel: ServerSelectViewModel, val server: Server) : DialogFragment() {
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
return activity?.let {
|
return activity?.let {
|
||||||
val builder = AlertDialog.Builder(it)
|
val builder = MaterialAlertDialogBuilder(it)
|
||||||
builder.setTitle(getString(R.string.remove_server))
|
builder.setTitle(getString(R.string.remove_server))
|
||||||
.setMessage(getString(R.string.remove_server_dialog_text, server.name))
|
.setMessage(getString(R.string.remove_server_dialog_text, server.name))
|
||||||
.setPositiveButton(getString(R.string.remove)) { _, _ ->
|
.setPositiveButton(getString(R.string.remove)) { _, _ ->
|
||||||
|
|
|
@ -14,9 +14,9 @@ class ErrorDialogFragment(private val errorMessage: String) : DialogFragment() {
|
||||||
val builder = MaterialAlertDialogBuilder(it, R.style.ErrorDialogStyle)
|
val builder = MaterialAlertDialogBuilder(it, R.style.ErrorDialogStyle)
|
||||||
builder
|
builder
|
||||||
.setMessage(errorMessage)
|
.setMessage(errorMessage)
|
||||||
.setPositiveButton("close") { _, _ ->
|
.setPositiveButton(getString(R.string.close)) { _, _ ->
|
||||||
}
|
}
|
||||||
.setNeutralButton("share") { _, _ ->
|
.setNeutralButton(getString(R.string.share)) { _, _ ->
|
||||||
val sendIntent: Intent = Intent().apply {
|
val sendIntent: Intent = Intent().apply {
|
||||||
action = Intent.ACTION_SEND
|
action = Intent.ACTION_SEND
|
||||||
putExtra(Intent.EXTRA_TEXT, errorMessage)
|
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
|
package dev.jdtech.jellyfin.dialogs
|
||||||
|
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.fragment.app.DialogFragment
|
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.mpv.TrackType
|
||||||
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
|
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
|
||||||
import java.lang.IllegalStateException
|
import java.lang.IllegalStateException
|
||||||
|
@ -24,15 +25,16 @@ class TrackSelectionDialogFragment(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return activity?.let { activity ->
|
return activity?.let { activity ->
|
||||||
val builder = AlertDialog.Builder(activity)
|
val builder = MaterialAlertDialogBuilder(activity)
|
||||||
builder.setTitle("Select audio track")
|
builder.setTitle(getString(R.string.select_audio_track))
|
||||||
.setSingleChoiceItems(
|
.setSingleChoiceItems(
|
||||||
trackNames.toTypedArray(),
|
trackNames.toTypedArray(),
|
||||||
viewModel.currentAudioTracks.indexOfFirst { it.selected }) { _, which ->
|
viewModel.currentAudioTracks.indexOfFirst { it.selected }) { dialog, which ->
|
||||||
viewModel.switchToTrack(
|
viewModel.switchToTrack(
|
||||||
TrackType.AUDIO,
|
TrackType.AUDIO,
|
||||||
viewModel.currentAudioTracks[which]
|
viewModel.currentAudioTracks[which]
|
||||||
)
|
)
|
||||||
|
dialog.dismiss()
|
||||||
}
|
}
|
||||||
builder.create()
|
builder.create()
|
||||||
} ?: throw IllegalStateException("Activity cannot be null")
|
} ?: throw IllegalStateException("Activity cannot be null")
|
||||||
|
@ -46,27 +48,22 @@ class TrackSelectionDialogFragment(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return activity?.let { activity ->
|
return activity?.let { activity ->
|
||||||
val builder = AlertDialog.Builder(activity)
|
val builder = MaterialAlertDialogBuilder(activity)
|
||||||
builder.setTitle("Select subtitle track")
|
builder.setTitle(getString(R.string.select_subtile_track))
|
||||||
.setSingleChoiceItems(
|
.setSingleChoiceItems(
|
||||||
trackNames.toTypedArray(),
|
trackNames.toTypedArray(),
|
||||||
viewModel.currentSubtitleTracks.indexOfFirst { it.selected }) { _, which ->
|
viewModel.currentSubtitleTracks.indexOfFirst { it.selected }) { dialog, which ->
|
||||||
viewModel.switchToTrack(
|
viewModel.switchToTrack(
|
||||||
TrackType.SUBTITLE,
|
TrackType.SUBTITLE,
|
||||||
viewModel.currentSubtitleTracks[which]
|
viewModel.currentSubtitleTracks[which]
|
||||||
)
|
)
|
||||||
|
dialog.dismiss()
|
||||||
}
|
}
|
||||||
builder.create()
|
builder.create()
|
||||||
} ?: throw IllegalStateException("Activity cannot be null")
|
} ?: throw IllegalStateException("Activity cannot be null")
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
trackNames = listOf()
|
throw IllegalStateException("TrackType must be AUDIO or SUBTITLE")
|
||||||
return activity?.let {
|
|
||||||
val builder = AlertDialog.Builder(it)
|
|
||||||
builder.setTitle("Select ? track")
|
|
||||||
.setMessage("Unknown track type")
|
|
||||||
builder.create()
|
|
||||||
} ?: throw IllegalStateException("Activity cannot be null")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,24 +1,27 @@
|
||||||
package dev.jdtech.jellyfin.dialogs
|
package dev.jdtech.jellyfin.dialogs
|
||||||
|
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.fragment.app.DialogFragment
|
import androidx.fragment.app.DialogFragment
|
||||||
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import java.lang.IllegalStateException
|
import java.lang.IllegalStateException
|
||||||
|
import dev.jdtech.jellyfin.R
|
||||||
|
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
|
||||||
class VideoVersionDialogFragment(
|
class VideoVersionDialogFragment(
|
||||||
private val viewModel: MediaInfoViewModel
|
private val item: BaseItemDto,
|
||||||
|
private val viewModel: PlayerViewModel
|
||||||
) : DialogFragment() {
|
) : DialogFragment() {
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
val items = viewModel.item.value?.mediaSources?.map { it.name }
|
val items = item.mediaSources?.map { it.name }?.toTypedArray()
|
||||||
return activity?.let {
|
return activity?.let { activity ->
|
||||||
val builder = AlertDialog.Builder(it)
|
MaterialAlertDialogBuilder(activity)
|
||||||
builder.setTitle("Select a version")
|
.setTitle(R.string.select_a_version)
|
||||||
.setItems(items?.toTypedArray()) { _, which ->
|
.setItems(items) { _, which ->
|
||||||
viewModel.preparePlayerItems(which)
|
viewModel.loadPlayerItems(item, which)
|
||||||
}
|
}.create()
|
||||||
builder.create()
|
|
||||||
} ?: throw IllegalStateException("Activity cannot be null")
|
} ?: throw IllegalStateException("Activity cannot be null")
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,15 +1,22 @@
|
||||||
package dev.jdtech.jellyfin.fragments
|
package dev.jdtech.jellyfin.fragments
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
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.findNavController
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.databinding.FragmentAddServerBinding
|
import dev.jdtech.jellyfin.databinding.FragmentAddServerBinding
|
||||||
import dev.jdtech.jellyfin.viewmodels.AddServerViewModel
|
import dev.jdtech.jellyfin.viewmodels.AddServerViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class AddServerFragment : Fragment() {
|
class AddServerFragment : Fragment() {
|
||||||
|
@ -23,35 +30,62 @@ class AddServerFragment : Fragment() {
|
||||||
): View {
|
): View {
|
||||||
binding = FragmentAddServerBinding.inflate(inflater)
|
binding = FragmentAddServerBinding.inflate(inflater)
|
||||||
|
|
||||||
binding.lifecycleOwner = viewLifecycleOwner
|
binding.editTextServerAddress.setOnEditorActionListener { _, actionId, _ ->
|
||||||
binding.viewModel = viewModel
|
return@setOnEditorActionListener when (actionId) {
|
||||||
|
EditorInfo.IME_ACTION_GO -> {
|
||||||
binding.buttonConnect.setOnClickListener {
|
connectToServer()
|
||||||
val serverAddress = binding.editTextServerAddress.text.toString()
|
true
|
||||||
if (serverAddress.isNotBlank()) {
|
}
|
||||||
viewModel.checkServer(serverAddress)
|
else -> false
|
||||||
binding.progressCircular.visibility = View.VISIBLE
|
|
||||||
} else {
|
|
||||||
binding.editTextServerAddressLayout.error = "Empty server address"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.navigateToLogin.observe(viewLifecycleOwner, {
|
binding.buttonConnect.setOnClickListener {
|
||||||
if (it) {
|
connectToServer()
|
||||||
navigateToLoginFragment()
|
}
|
||||||
}
|
|
||||||
binding.progressCircular.visibility = View.GONE
|
|
||||||
})
|
|
||||||
|
|
||||||
viewModel.error.observe(viewLifecycleOwner, {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
binding.editTextServerAddressLayout.error = it
|
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
|
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() {
|
private fun navigateToLoginFragment() {
|
||||||
findNavController().navigate(AddServerFragmentDirections.actionAddServerFragment3ToLoginFragment2())
|
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.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.viewModels
|
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.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.R
|
import dev.jdtech.jellyfin.R
|
||||||
|
import dev.jdtech.jellyfin.bindBaseItemImage
|
||||||
import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding
|
import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
import dev.jdtech.jellyfin.models.PlayerItem
|
||||||
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel
|
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
|
@AndroidEntryPoint
|
||||||
class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
@ -23,6 +33,7 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
private lateinit var binding: EpisodeBottomSheetBinding
|
private lateinit var binding: EpisodeBottomSheetBinding
|
||||||
private val viewModel: EpisodeBottomSheetViewModel by viewModels()
|
private val viewModel: EpisodeBottomSheetViewModel by viewModels()
|
||||||
|
private val playerViewModel: PlayerViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
|
@ -31,102 +42,173 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
||||||
): View {
|
): View {
|
||||||
binding = EpisodeBottomSheetBinding.inflate(inflater, container, false)
|
binding = EpisodeBottomSheetBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
binding.lifecycleOwner = viewLifecycleOwner
|
|
||||||
binding.viewModel = viewModel
|
|
||||||
|
|
||||||
binding.playButton.setOnClickListener {
|
binding.playButton.setOnClickListener {
|
||||||
binding.playButton.setImageResource(android.R.color.transparent)
|
binding.playButton.setImageResource(android.R.color.transparent)
|
||||||
binding.progressCircular.visibility = View.VISIBLE
|
binding.progressCircular.isVisible = true
|
||||||
viewModel.preparePlayerItems()
|
viewModel.item?.let {
|
||||||
}
|
if (!args.isOffline) {
|
||||||
|
playerViewModel.loadPlayerItems(it)
|
||||||
binding.checkButton.setOnClickListener {
|
} else {
|
||||||
when (viewModel.played.value) {
|
playerViewModel.loadOfflinePlayerItems(viewModel.playerItems[0])
|
||||||
true -> viewModel.markAsUnplayed(args.episodeId)
|
}
|
||||||
false -> viewModel.markAsPlayed(args.episodeId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.favoriteButton.setOnClickListener {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
when (viewModel.favorite.value) {
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
true -> viewModel.unmarkAsFavorite(args.episodeId)
|
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
|
||||||
false -> viewModel.markAsFavorite(args.episodeId)
|
Timber.d("$uiState")
|
||||||
|
when (uiState) {
|
||||||
|
is EpisodeBottomSheetViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
||||||
|
is EpisodeBottomSheetViewModel.UiState.Loading -> bindUiStateLoading()
|
||||||
|
is EpisodeBottomSheetViewModel.UiState.Error -> bindUiStateError(uiState)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.item.observe(viewLifecycleOwner, { episode ->
|
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
|
||||||
|
when (playerItems) {
|
||||||
|
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
|
||||||
|
is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!args.isOffline) {
|
||||||
|
val episodeId: UUID = args.episodeId
|
||||||
|
|
||||||
|
binding.checkButton.setOnClickListener {
|
||||||
|
when (viewModel.played) {
|
||||||
|
true -> {
|
||||||
|
viewModel.markAsUnplayed(episodeId)
|
||||||
|
binding.checkButton.setImageResource(R.drawable.ic_check)
|
||||||
|
}
|
||||||
|
false -> {
|
||||||
|
viewModel.markAsPlayed(episodeId)
|
||||||
|
binding.checkButton.setImageResource(R.drawable.ic_check_filled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.favoriteButton.setOnClickListener {
|
||||||
|
when (viewModel.favorite) {
|
||||||
|
true -> {
|
||||||
|
viewModel.unmarkAsFavorite(episodeId)
|
||||||
|
binding.favoriteButton.setImageResource(R.drawable.ic_heart)
|
||||||
|
}
|
||||||
|
false -> {
|
||||||
|
viewModel.markAsFavorite(episodeId)
|
||||||
|
binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.downloadButton.setOnClickListener {
|
||||||
|
binding.downloadButton.isEnabled = false
|
||||||
|
viewModel.loadDownloadRequestItem(episodeId)
|
||||||
|
binding.downloadButton.setImageResource(android.R.color.transparent)
|
||||||
|
binding.progressDownload.isVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.deleteButton.isVisible = false
|
||||||
|
|
||||||
|
viewModel.loadEpisode(episodeId)
|
||||||
|
} else {
|
||||||
|
val playerItem = args.playerItem!!
|
||||||
|
viewModel.loadEpisode(playerItem)
|
||||||
|
|
||||||
|
binding.deleteButton.setOnClickListener {
|
||||||
|
viewModel.deleteEpisode()
|
||||||
|
dismiss()
|
||||||
|
findNavController().navigate(R.id.downloadFragment)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.checkButton.isVisible = false
|
||||||
|
binding.favoriteButton.isVisible = false
|
||||||
|
binding.downloadButtonWrapper.isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindUiStateNormal(uiState: EpisodeBottomSheetViewModel.UiState.Normal) {
|
||||||
|
uiState.apply {
|
||||||
if (episode.userData?.playedPercentage != null) {
|
if (episode.userData?.playedPercentage != null) {
|
||||||
binding.progressBar.layoutParams.width = TypedValue.applyDimension(
|
binding.progressBar.layoutParams.width = TypedValue.applyDimension(
|
||||||
TypedValue.COMPLEX_UNIT_DIP,
|
TypedValue.COMPLEX_UNIT_DIP,
|
||||||
(episode.userData?.playedPercentage?.times(1.26))!!.toFloat(),
|
(episode.userData?.playedPercentage?.times(1.26))!!.toFloat(),
|
||||||
context?.resources?.displayMetrics
|
context?.resources?.displayMetrics
|
||||||
).toInt()
|
).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, {
|
// Check icon
|
||||||
val drawable = when (it) {
|
val checkDrawable = when (played) {
|
||||||
true -> R.drawable.ic_check_filled
|
true -> R.drawable.ic_check_filled
|
||||||
false -> R.drawable.ic_check
|
false -> R.drawable.ic_check
|
||||||
}
|
}
|
||||||
|
binding.checkButton.setImageResource(checkDrawable)
|
||||||
|
|
||||||
binding.checkButton.setImageResource(drawable)
|
// Favorite icon
|
||||||
})
|
val favoriteDrawable = when (favorite) {
|
||||||
|
|
||||||
viewModel.favorite.observe(viewLifecycleOwner, {
|
|
||||||
val drawable = when (it) {
|
|
||||||
true -> R.drawable.ic_heart_filled
|
true -> R.drawable.ic_heart_filled
|
||||||
false -> R.drawable.ic_heart
|
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
|
||||||
viewModel.navigateToPlayer.observe(viewLifecycleOwner, {
|
false -> R.drawable.ic_download
|
||||||
if (it) {
|
|
||||||
navigateToPlayerActivity(
|
|
||||||
viewModel.playerItems.toTypedArray(),
|
|
||||||
)
|
|
||||||
viewModel.doneNavigateToPlayer()
|
|
||||||
binding.playButton.setImageDrawable(
|
|
||||||
ContextCompat.getDrawable(
|
|
||||||
requireActivity(),
|
|
||||||
R.drawable.ic_play
|
|
||||||
)
|
|
||||||
)
|
|
||||||
binding.progressCircular.visibility = View.INVISIBLE
|
|
||||||
}
|
}
|
||||||
})
|
binding.downloadButton.setImageResource(downloadDrawable)
|
||||||
|
|
||||||
viewModel.playerItemsError.observe(viewLifecycleOwner, { errorMessage ->
|
binding.episodeName.text = String.format(getString(R.string.episode_name_extended), episode.parentIndexNumber, episode.indexNumber, episode.name)
|
||||||
if (errorMessage != null) {
|
binding.overview.text = episode.overview
|
||||||
binding.playerItemsError.visibility = View.VISIBLE
|
binding.year.text = dateString
|
||||||
binding.playButton.setImageDrawable(
|
binding.playtime.text = runTime
|
||||||
ContextCompat.getDrawable(
|
binding.communityRating.isVisible = episode.communityRating != null
|
||||||
requireActivity(),
|
binding.communityRating.text = episode.communityRating.toString()
|
||||||
R.drawable.ic_play
|
binding.missingIcon.isVisible = episode.locationType == LocationType.VIRTUAL
|
||||||
)
|
bindBaseItemImage(binding.episodeImage, episode)
|
||||||
)
|
|
||||||
binding.progressCircular.visibility = View.INVISIBLE
|
|
||||||
} else {
|
|
||||||
binding.playerItemsError.visibility = View.GONE
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
binding.playerItemsErrorDetails.setOnClickListener {
|
|
||||||
ErrorDialogFragment(
|
|
||||||
viewModel.playerItemsError.value ?: getString(R.string.unknown_error)
|
|
||||||
).show(parentFragmentManager, "errordialog")
|
|
||||||
}
|
}
|
||||||
|
binding.loadingIndicator.isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.loadEpisode(args.episodeId)
|
private fun bindUiStateLoading() {
|
||||||
|
binding.loadingIndicator.isVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
return binding.root
|
private fun bindUiStateError(uiState: EpisodeBottomSheetViewModel.UiState.Error) {
|
||||||
|
binding.loadingIndicator.isVisible = false
|
||||||
|
binding.overview.text = uiState.message
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
|
||||||
|
navigateToPlayerActivity(items.items.toTypedArray())
|
||||||
|
binding.playButton.setImageDrawable(
|
||||||
|
ContextCompat.getDrawable(
|
||||||
|
requireActivity(),
|
||||||
|
R.drawable.ic_play
|
||||||
|
)
|
||||||
|
)
|
||||||
|
binding.progressCircular.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
|
||||||
|
Timber.e(error.message)
|
||||||
|
|
||||||
|
binding.playerItemsError.isVisible = true
|
||||||
|
binding.playButton.setImageDrawable(
|
||||||
|
ContextCompat.getDrawable(
|
||||||
|
requireActivity(),
|
||||||
|
R.drawable.ic_play
|
||||||
|
)
|
||||||
|
)
|
||||||
|
binding.progressCircular.visibility = View.INVISIBLE
|
||||||
|
binding.playerItemsErrorDetails.setOnClickListener {
|
||||||
|
ErrorDialogFragment(error.message).show(parentFragmentManager, "errordialog")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToPlayerActivity(
|
private fun navigateToPlayerActivity(
|
||||||
|
|
|
@ -5,7 +5,11 @@ import androidx.fragment.app.Fragment
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.viewModels
|
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.findNavController
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.R
|
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.dialogs.ErrorDialogFragment
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
import dev.jdtech.jellyfin.viewmodels.FavoriteViewModel
|
import dev.jdtech.jellyfin.viewmodels.FavoriteViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class FavoriteFragment : Fragment() {
|
class FavoriteFragment : Fragment() {
|
||||||
|
@ -24,14 +30,14 @@ class FavoriteFragment : Fragment() {
|
||||||
private lateinit var binding: FragmentFavoriteBinding
|
private lateinit var binding: FragmentFavoriteBinding
|
||||||
private val viewModel: FavoriteViewModel by viewModels()
|
private val viewModel: FavoriteViewModel by viewModels()
|
||||||
|
|
||||||
|
private lateinit var errorDialog: ErrorDialogFragment
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?,
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
binding = FragmentFavoriteBinding.inflate(inflater, container, false)
|
binding = FragmentFavoriteBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
binding.lifecycleOwner = viewLifecycleOwner
|
|
||||||
binding.viewModel = viewModel
|
|
||||||
binding.favoritesRecyclerView.adapter = FavoritesListAdapter(
|
binding.favoritesRecyclerView.adapter = FavoritesListAdapter(
|
||||||
ViewItemListAdapter.OnClickListener { item ->
|
ViewItemListAdapter.OnClickListener { item ->
|
||||||
navigateToMediaInfoFragment(item)
|
navigateToMediaInfoFragment(item)
|
||||||
|
@ -39,40 +45,56 @@ class FavoriteFragment : Fragment() {
|
||||||
navigateToEpisodeBottomSheetFragment(item)
|
navigateToEpisodeBottomSheetFragment(item)
|
||||||
})
|
})
|
||||||
|
|
||||||
viewModel.finishedLoading.observe(viewLifecycleOwner, { isFinished ->
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
binding.loadingIndicator.visibility = if (isFinished) View.GONE else View.VISIBLE
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
})
|
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
|
||||||
|
Timber.d("$uiState")
|
||||||
viewModel.error.observe(viewLifecycleOwner, { error ->
|
when (uiState) {
|
||||||
if (error != null) {
|
is FavoriteViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
||||||
checkIfLoginRequired(error)
|
is FavoriteViewModel.UiState.Loading -> bindUiStateLoading()
|
||||||
binding.errorLayout.errorPanel.visibility = View.VISIBLE
|
is FavoriteViewModel.UiState.Error -> bindUiStateError(uiState)
|
||||||
binding.favoritesRecyclerView.visibility = View.GONE
|
}
|
||||||
} else {
|
}
|
||||||
binding.errorLayout.errorPanel.visibility = View.GONE
|
|
||||||
binding.favoritesRecyclerView.visibility = View.VISIBLE
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
binding.errorLayout.errorRetryButton.setOnClickListener {
|
binding.errorLayout.errorRetryButton.setOnClickListener {
|
||||||
viewModel.loadData()
|
viewModel.loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
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
|
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) {
|
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
|
||||||
findNavController().navigate(
|
findNavController().navigate(
|
||||||
FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment(
|
FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment(
|
||||||
|
|
|
@ -1,9 +1,20 @@
|
||||||
package dev.jdtech.jellyfin.fragments
|
package dev.jdtech.jellyfin.fragments
|
||||||
|
|
||||||
import android.os.Bundle
|
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.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
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.findNavController
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.R
|
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.adapters.ViewListAdapter
|
||||||
import dev.jdtech.jellyfin.databinding.FragmentHomeBinding
|
import dev.jdtech.jellyfin.databinding.FragmentHomeBinding
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||||
|
import dev.jdtech.jellyfin.models.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.checkIfLoginRequired
|
||||||
|
import dev.jdtech.jellyfin.utils.contentType
|
||||||
import dev.jdtech.jellyfin.viewmodels.HomeViewModel
|
import dev.jdtech.jellyfin.viewmodels.HomeViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class HomeFragment : Fragment() {
|
class HomeFragment : Fragment() {
|
||||||
|
@ -22,6 +40,8 @@ class HomeFragment : Fragment() {
|
||||||
private lateinit var binding: FragmentHomeBinding
|
private lateinit var binding: FragmentHomeBinding
|
||||||
private val viewModel: HomeViewModel by viewModels()
|
private val viewModel: HomeViewModel by viewModels()
|
||||||
|
|
||||||
|
private lateinit var errorDialog: ErrorDialogFragment
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setHasOptionsMenu(true)
|
setHasOptionsMenu(true)
|
||||||
|
@ -50,51 +70,85 @@ class HomeFragment : Fragment() {
|
||||||
): View {
|
): View {
|
||||||
binding = FragmentHomeBinding.inflate(inflater, container, false)
|
binding = FragmentHomeBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
binding.lifecycleOwner = viewLifecycleOwner
|
setupView()
|
||||||
binding.viewModel = viewModel
|
bindState()
|
||||||
binding.viewsRecyclerView.adapter = ViewListAdapter(ViewListAdapter.OnClickListener {
|
|
||||||
navigateToLibraryFragment(it)
|
return binding.root
|
||||||
}, ViewItemListAdapter.OnClickListener {
|
}
|
||||||
navigateToMediaInfoFragment(it)
|
|
||||||
}, HomeEpisodeListAdapter.OnClickListener { item ->
|
override fun onResume() {
|
||||||
when (item.type) {
|
super.onResume()
|
||||||
"Episode" -> {
|
|
||||||
navigateToEpisodeBottomSheetFragment(item)
|
viewModel.refreshData()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupView() {
|
||||||
|
binding.refreshLayout.setOnRefreshListener {
|
||||||
|
viewModel.refreshData()
|
||||||
|
// binding.refreshLayout.isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.viewsRecyclerView.adapter = ViewListAdapter(
|
||||||
|
onClickListener = ViewListAdapter.OnClickListener { navigateToLibraryFragment(it) },
|
||||||
|
onItemClickListener = ViewItemListAdapter.OnClickListener {
|
||||||
|
navigateToMediaInfoFragment(it)
|
||||||
|
},
|
||||||
|
onNextUpClickListener = HomeEpisodeListAdapter.OnClickListener { item ->
|
||||||
|
when (item.contentType()) {
|
||||||
|
EPISODE -> navigateToEpisodeBottomSheetFragment(item)
|
||||||
|
MOVIE -> navigateToMediaInfoFragment(item)
|
||||||
|
else -> Toast.makeText(requireContext(), R.string.unknown_error, LENGTH_LONG)
|
||||||
|
.show()
|
||||||
}
|
}
|
||||||
"Movie" -> {
|
})
|
||||||
navigateToMediaInfoFragment(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
viewModel.finishedLoading.observe(viewLifecycleOwner, {
|
|
||||||
binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE
|
|
||||||
})
|
|
||||||
|
|
||||||
viewModel.error.observe(viewLifecycleOwner, { error ->
|
|
||||||
if (error != null) {
|
|
||||||
checkIfLoginRequired(error)
|
|
||||||
binding.errorLayout.errorPanel.visibility = View.VISIBLE
|
|
||||||
binding.viewsRecyclerView.visibility = View.GONE
|
|
||||||
} else {
|
|
||||||
binding.errorLayout.errorPanel.visibility = View.GONE
|
|
||||||
binding.viewsRecyclerView.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
binding.errorLayout.errorRetryButton.setOnClickListener {
|
binding.errorLayout.errorRetryButton.setOnClickListener {
|
||||||
viewModel.loadData()
|
viewModel.refreshData()
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
||||||
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(
|
errorDialog.show(parentFragmentManager, "errordialog")
|
||||||
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) {
|
private fun navigateToLibraryFragment(view: dev.jdtech.jellyfin.models.View) {
|
||||||
|
@ -108,12 +162,12 @@ class HomeFragment : Fragment() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
|
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
|
||||||
if (item.type == "Episode") {
|
if (item.contentType() == EPISODE) {
|
||||||
findNavController().navigate(
|
findNavController().navigate(
|
||||||
HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment(
|
HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment(
|
||||||
item.seriesId!!,
|
item.seriesId!!,
|
||||||
item.seriesName,
|
item.seriesName,
|
||||||
"Series"
|
TVSHOW.type
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
@ -121,7 +175,7 @@ class HomeFragment : Fragment() {
|
||||||
HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment(
|
HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment(
|
||||||
item.id,
|
item.id,
|
||||||
item.name,
|
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
|
package dev.jdtech.jellyfin.fragments
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.*
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.fragment.app.viewModels
|
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.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
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.adapters.ViewItemListAdapter
|
||||||
import dev.jdtech.jellyfin.databinding.FragmentLibraryBinding
|
import dev.jdtech.jellyfin.databinding.FragmentLibraryBinding
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||||
|
import dev.jdtech.jellyfin.dialogs.SortDialogFragment
|
||||||
|
import dev.jdtech.jellyfin.utils.SortBy
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
|
import java.lang.IllegalArgumentException
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class LibraryFragment : Fragment() {
|
class LibraryFragment : Fragment() {
|
||||||
|
|
||||||
private lateinit var binding: FragmentLibraryBinding
|
private lateinit var binding: FragmentLibraryBinding
|
||||||
private val viewModel: LibraryViewModel by viewModels()
|
private val viewModel: LibraryViewModel by viewModels()
|
||||||
|
|
||||||
private val args: LibraryFragmentArgs by navArgs()
|
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(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?,
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
binding = FragmentLibraryBinding.inflate(inflater, container, false)
|
binding = FragmentLibraryBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
binding.lifecycleOwner = viewLifecycleOwner
|
|
||||||
|
|
||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
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 {
|
binding.errorLayout.errorRetryButton.setOnClickListener {
|
||||||
viewModel.loadItems(args.libraryId, args.libraryType)
|
viewModel.loadItems(args.libraryId, args.libraryType)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
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 =
|
binding.itemsRecyclerView.adapter =
|
||||||
ViewItemListAdapter(ViewItemListAdapter.OnClickListener { item ->
|
ViewItemListAdapter(ViewItemListAdapter.OnClickListener { item ->
|
||||||
navigateToMediaInfoFragment(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) {
|
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
|
||||||
|
|
|
@ -4,50 +4,90 @@ import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
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.findNavController
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.databinding.FragmentLoginBinding
|
import dev.jdtech.jellyfin.databinding.FragmentLoginBinding
|
||||||
import dev.jdtech.jellyfin.viewmodels.LoginViewModel
|
import dev.jdtech.jellyfin.viewmodels.LoginViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class LoginFragment : Fragment() {
|
class LoginFragment : Fragment() {
|
||||||
|
|
||||||
|
private lateinit var binding: FragmentLoginBinding
|
||||||
private val viewModel: LoginViewModel by viewModels()
|
private val viewModel: LoginViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?,
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
val binding = FragmentLoginBinding.inflate(inflater)
|
binding = FragmentLoginBinding.inflate(inflater)
|
||||||
binding.lifecycleOwner = viewLifecycleOwner
|
|
||||||
binding.viewModel = viewModel
|
|
||||||
|
|
||||||
binding.buttonLogin.setOnClickListener {
|
binding.editTextPassword.setOnEditorActionListener { _, actionId, _ ->
|
||||||
val username = binding.editTextUsername.text.toString()
|
return@setOnEditorActionListener when (actionId) {
|
||||||
val password = binding.editTextPassword.text.toString()
|
EditorInfo.IME_ACTION_GO -> {
|
||||||
|
login()
|
||||||
binding.progressCircular.visibility = View.VISIBLE
|
true
|
||||||
viewModel.login(username, password)
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.error.observe(viewLifecycleOwner, {
|
binding.buttonLogin.setOnClickListener {
|
||||||
binding.progressCircular.visibility = View.GONE
|
login()
|
||||||
binding.editTextUsernameLayout.error = it
|
}
|
||||||
})
|
|
||||||
|
|
||||||
viewModel.navigateToMain.observe(viewLifecycleOwner, {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
if (it) {
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
navigateToMainActivity()
|
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
|
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() {
|
private fun navigateToMainActivity() {
|
||||||
findNavController().navigate(LoginFragmentDirections.actionLoginFragment2ToNavigationHome())
|
findNavController().navigate(LoginFragmentDirections.actionLoginFragment2ToNavigationHome())
|
||||||
viewModel.doneNavigatingToMain()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -3,8 +3,12 @@ package dev.jdtech.jellyfin.fragments
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.*
|
import android.view.*
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
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.findNavController
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.R
|
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.dialogs.ErrorDialogFragment
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
import dev.jdtech.jellyfin.viewmodels.MediaViewModel
|
import dev.jdtech.jellyfin.viewmodels.MediaViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MediaFragment : Fragment() {
|
class MediaFragment : Fragment() {
|
||||||
|
@ -21,6 +27,10 @@ class MediaFragment : Fragment() {
|
||||||
private lateinit var binding: FragmentMediaBinding
|
private lateinit var binding: FragmentMediaBinding
|
||||||
private val viewModel: MediaViewModel by viewModels()
|
private val viewModel: MediaViewModel by viewModels()
|
||||||
|
|
||||||
|
private var originalSoftInputMode: Int? = null
|
||||||
|
|
||||||
|
private lateinit var errorDialog: ErrorDialogFragment
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setHasOptionsMenu(true)
|
setHasOptionsMenu(true)
|
||||||
|
@ -31,9 +41,9 @@ class MediaFragment : Fragment() {
|
||||||
|
|
||||||
val search = menu.findItem(R.id.action_search)
|
val search = menu.findItem(R.id.action_search)
|
||||||
val searchView = search.actionView as SearchView
|
val searchView = search.actionView as SearchView
|
||||||
searchView.queryHint = "Search movies, shows, episodes..."
|
searchView.queryHint = getString(R.string.search_hint)
|
||||||
|
|
||||||
searchView.setOnQueryTextListener(object: SearchView.OnQueryTextListener {
|
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||||
override fun onQueryTextSubmit(p0: String?): Boolean {
|
override fun onQueryTextSubmit(p0: String?): Boolean {
|
||||||
if (p0 != null) {
|
if (p0 != null) {
|
||||||
navigateToSearchResultFragment(p0)
|
navigateToSearchResultFragment(p0)
|
||||||
|
@ -54,39 +64,71 @@ class MediaFragment : Fragment() {
|
||||||
): View {
|
): View {
|
||||||
binding = FragmentMediaBinding.inflate(inflater, container, false)
|
binding = FragmentMediaBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
binding.lifecycleOwner = viewLifecycleOwner
|
|
||||||
binding.viewModel = viewModel
|
|
||||||
binding.viewsRecyclerView.adapter =
|
binding.viewsRecyclerView.adapter =
|
||||||
CollectionListAdapter(CollectionListAdapter.OnClickListener { library ->
|
CollectionListAdapter(CollectionListAdapter.OnClickListener { library ->
|
||||||
navigateToLibraryFragment(library)
|
navigateToLibraryFragment(library)
|
||||||
})
|
})
|
||||||
|
|
||||||
viewModel.finishedLoading.observe(viewLifecycleOwner, {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
})
|
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
|
||||||
|
Timber.d("$uiState")
|
||||||
viewModel.error.observe(viewLifecycleOwner, { error ->
|
when (uiState) {
|
||||||
if (error != null) {
|
is MediaViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
||||||
checkIfLoginRequired(error)
|
is MediaViewModel.UiState.Loading -> bindUiStateLoading()
|
||||||
binding.errorLayout.errorPanel.visibility = View.VISIBLE
|
is MediaViewModel.UiState.Error -> bindUiStateError(uiState)
|
||||||
binding.viewsRecyclerView.visibility = View.GONE
|
}
|
||||||
} else {
|
}
|
||||||
binding.errorLayout.errorPanel.visibility = View.GONE
|
|
||||||
binding.viewsRecyclerView.visibility = View.VISIBLE
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
binding.errorLayout.errorRetryButton.setOnClickListener {
|
binding.errorLayout.errorRetryButton.setOnClickListener {
|
||||||
viewModel.loadData()
|
viewModel.loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
||||||
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog")
|
errorDialog.show(parentFragmentManager, "errordialog")
|
||||||
}
|
}
|
||||||
|
|
||||||
return binding.root
|
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) {
|
private fun navigateToLibraryFragment(library: BaseItemDto) {
|
||||||
findNavController().navigate(
|
findNavController().navigate(
|
||||||
MediaFragmentDirections.actionNavigationMediaToLibraryFragment(
|
MediaFragmentDirections.actionNavigationMediaToLibraryFragment(
|
||||||
|
|
|
@ -6,199 +6,276 @@ import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
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.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.R
|
import dev.jdtech.jellyfin.R
|
||||||
import dev.jdtech.jellyfin.adapters.PersonListAdapter
|
import dev.jdtech.jellyfin.adapters.PersonListAdapter
|
||||||
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
|
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.databinding.FragmentMediaInfoBinding
|
||||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||||
import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
|
import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
import dev.jdtech.jellyfin.models.PlayerItem
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
|
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.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MediaInfoFragment : Fragment() {
|
class MediaInfoFragment : Fragment() {
|
||||||
|
|
||||||
private lateinit var binding: FragmentMediaInfoBinding
|
private lateinit var binding: FragmentMediaInfoBinding
|
||||||
private val viewModel: MediaInfoViewModel by viewModels()
|
private val viewModel: MediaInfoViewModel by viewModels()
|
||||||
|
private val playerViewModel: PlayerViewModel by viewModels()
|
||||||
private val args: MediaInfoFragmentArgs by navArgs()
|
private val args: MediaInfoFragmentArgs by navArgs()
|
||||||
|
|
||||||
|
lateinit var errorDialog: ErrorDialogFragment
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?,
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
binding = FragmentMediaInfoBinding.inflate(inflater, container, false)
|
binding = FragmentMediaInfoBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
binding.lifecycleOwner = viewLifecycleOwner
|
|
||||||
|
|
||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
binding.viewModel = viewModel
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
viewModel.error.observe(viewLifecycleOwner, { error ->
|
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
|
||||||
if (error != null) {
|
Timber.d("$uiState")
|
||||||
checkIfLoginRequired(error)
|
when (uiState) {
|
||||||
binding.errorLayout.errorPanel.visibility = View.VISIBLE
|
is MediaInfoViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
||||||
binding.mediaInfoScrollview.visibility = View.GONE
|
is MediaInfoViewModel.UiState.Loading -> bindUiStateLoading()
|
||||||
} else {
|
is MediaInfoViewModel.UiState.Error -> bindUiStateError(uiState)
|
||||||
binding.errorLayout.errorPanel.visibility = View.GONE
|
}
|
||||||
binding.mediaInfoScrollview.visibility = View.VISIBLE
|
}
|
||||||
|
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 {
|
binding.errorLayout.errorRetryButton.setOnClickListener {
|
||||||
viewModel.loadData(args.itemId, args.itemType)
|
viewModel.loadData(args.itemId, args.itemType)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
|
||||||
ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(
|
when (playerItems) {
|
||||||
parentFragmentManager,
|
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
|
||||||
"errordialog"
|
is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.item.observe(viewLifecycleOwner, { item ->
|
|
||||||
if (item.originalTitle != item.name) {
|
|
||||||
binding.originalTitle.visibility = View.VISIBLE
|
|
||||||
} else {
|
|
||||||
binding.originalTitle.visibility = View.GONE
|
|
||||||
}
|
}
|
||||||
if (item.remoteTrailers.isNullOrEmpty()) {
|
|
||||||
binding.trailerButton.visibility = View.GONE
|
|
||||||
}
|
|
||||||
binding.communityRating.visibility = when (item.communityRating != null) {
|
|
||||||
true -> View.VISIBLE
|
|
||||||
false -> View.GONE
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
viewModel.actors.observe(viewLifecycleOwner, { actors ->
|
|
||||||
when (actors.isNullOrEmpty()) {
|
|
||||||
false -> binding.actors.visibility = View.VISIBLE
|
|
||||||
true -> binding.actors.visibility = View.GONE
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
viewModel.navigateToPlayer.observe(viewLifecycleOwner, { playerItems ->
|
|
||||||
if (playerItems != null) {
|
|
||||||
navigateToPlayerActivity(
|
|
||||||
playerItems
|
|
||||||
)
|
|
||||||
viewModel.doneNavigatingToPlayer()
|
|
||||||
binding.playButton.setImageDrawable(
|
|
||||||
ContextCompat.getDrawable(
|
|
||||||
requireActivity(),
|
|
||||||
R.drawable.ic_play
|
|
||||||
)
|
|
||||||
)
|
|
||||||
binding.progressCircular.visibility = View.INVISIBLE
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
viewModel.played.observe(viewLifecycleOwner, {
|
|
||||||
val drawable = when (it) {
|
|
||||||
true -> R.drawable.ic_check_filled
|
|
||||||
false -> R.drawable.ic_check
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.checkButton.setImageResource(drawable)
|
|
||||||
})
|
|
||||||
|
|
||||||
viewModel.favorite.observe(viewLifecycleOwner, {
|
|
||||||
val drawable = when (it) {
|
|
||||||
true -> R.drawable.ic_heart_filled
|
|
||||||
false -> R.drawable.ic_heart
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.favoriteButton.setImageResource(drawable)
|
|
||||||
})
|
|
||||||
|
|
||||||
viewModel.playerItemsError.observe(viewLifecycleOwner, { errorMessage ->
|
|
||||||
if (errorMessage != null) {
|
|
||||||
binding.playerItemsError.visibility = View.VISIBLE
|
|
||||||
binding.playButton.setImageDrawable(
|
|
||||||
ContextCompat.getDrawable(
|
|
||||||
requireActivity(),
|
|
||||||
R.drawable.ic_play
|
|
||||||
)
|
|
||||||
)
|
|
||||||
binding.progressCircular.visibility = View.INVISIBLE
|
|
||||||
} else {
|
|
||||||
binding.playerItemsError.visibility = View.GONE
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
binding.playerItemsErrorDetails.setOnClickListener {
|
|
||||||
ErrorDialogFragment(
|
|
||||||
viewModel.playerItemsError.value ?: getString(R.string.unknown_error)
|
|
||||||
).show(parentFragmentManager, "errordialog")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.trailerButton.setOnClickListener {
|
binding.trailerButton.setOnClickListener {
|
||||||
if (viewModel.item.value?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
|
if (viewModel.item?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
|
||||||
val intent = Intent(
|
val intent = Intent(
|
||||||
Intent.ACTION_VIEW,
|
Intent.ACTION_VIEW,
|
||||||
Uri.parse(viewModel.item.value?.remoteTrailers?.get(0)?.url)
|
Uri.parse(viewModel.item?.remoteTrailers?.get(0)?.url)
|
||||||
)
|
)
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.nextUp.setOnClickListener {
|
binding.nextUp.setOnClickListener {
|
||||||
navigateToEpisodeBottomSheetFragment(viewModel.nextUp.value!!)
|
navigateToEpisodeBottomSheetFragment(viewModel.nextUp!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.seasonsRecyclerView.adapter =
|
binding.seasonsRecyclerView.adapter =
|
||||||
ViewItemListAdapter(ViewItemListAdapter.OnClickListener { season ->
|
ViewItemListAdapter(ViewItemListAdapter.OnClickListener { season ->
|
||||||
navigateToSeasonFragment(season)
|
navigateToSeasonFragment(season)
|
||||||
}, fixedWidth = true)
|
}, fixedWidth = true)
|
||||||
binding.peopleRecyclerView.adapter = PersonListAdapter()
|
binding.peopleRecyclerView.adapter = PersonListAdapter { person ->
|
||||||
|
val uuid = person.id?.toUUID()
|
||||||
|
if (uuid != null) {
|
||||||
|
navigateToPersonDetail(uuid)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(requireContext(), R.string.error_getting_person_id, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
binding.playButton.setOnClickListener {
|
binding.playButton.setOnClickListener {
|
||||||
binding.playButton.setImageResource(android.R.color.transparent)
|
binding.playButton.setImageResource(android.R.color.transparent)
|
||||||
binding.progressCircular.visibility = View.VISIBLE
|
binding.progressCircular.isVisible = true
|
||||||
if (args.itemType == "Movie") {
|
viewModel.item?.let { item ->
|
||||||
if (viewModel.item.value?.mediaSources != null) {
|
if (!args.isOffline) {
|
||||||
if (viewModel.item.value?.mediaSources?.size!! > 1) {
|
playerViewModel.loadPlayerItems(item) {
|
||||||
VideoVersionDialogFragment(viewModel).show(
|
VideoVersionDialogFragment(item, playerViewModel).show(
|
||||||
parentFragmentManager,
|
parentFragmentManager,
|
||||||
"videoversiondialog"
|
"videoversiondialog"
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
viewModel.preparePlayerItems()
|
} else {
|
||||||
|
playerViewModel.loadOfflinePlayerItems(args.playerItem!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.isOffline) {
|
||||||
|
binding.errorLayout.errorRetryButton.setOnClickListener {
|
||||||
|
viewModel.loadData(args.itemId, args.itemType)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.checkButton.setOnClickListener {
|
||||||
|
when (viewModel.played) {
|
||||||
|
true -> {
|
||||||
|
viewModel.markAsUnplayed(args.itemId)
|
||||||
|
binding.checkButton.setImageResource(R.drawable.ic_check)
|
||||||
|
}
|
||||||
|
false -> {
|
||||||
|
viewModel.markAsPlayed(args.itemId)
|
||||||
|
binding.checkButton.setImageResource(R.drawable.ic_check_filled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (args.itemType == "Series") {
|
}
|
||||||
viewModel.preparePlayerItems()
|
|
||||||
|
binding.favoriteButton.setOnClickListener {
|
||||||
|
when (viewModel.favorite) {
|
||||||
|
true -> {
|
||||||
|
viewModel.unmarkAsFavorite(args.itemId)
|
||||||
|
binding.favoriteButton.setImageResource(R.drawable.ic_heart)
|
||||||
|
}
|
||||||
|
false -> {
|
||||||
|
viewModel.markAsFavorite(args.itemId)
|
||||||
|
binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.downloadButton.setOnClickListener {
|
||||||
|
viewModel.loadDownloadRequestItem(args.itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.deleteButton.isVisible = false
|
||||||
|
} else {
|
||||||
|
binding.favoriteButton.isVisible = false
|
||||||
|
binding.checkButton.isVisible = false
|
||||||
|
binding.downloadButton.isVisible = false
|
||||||
|
|
||||||
|
binding.deleteButton.setOnClickListener {
|
||||||
|
viewModel.deleteItem()
|
||||||
|
findNavController().navigate(R.id.downloadFragment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
binding.checkButton.setOnClickListener {
|
private fun bindUiStateNormal(uiState: MediaInfoViewModel.UiState.Normal) {
|
||||||
when (viewModel.played.value) {
|
uiState.apply {
|
||||||
true -> viewModel.markAsUnplayed(args.itemId)
|
binding.originalTitle.isVisible = item.originalTitle != item.name
|
||||||
false -> viewModel.markAsPlayed(args.itemId)
|
if (item.remoteTrailers.isNullOrEmpty()) {
|
||||||
|
binding.trailerButton.isVisible = false
|
||||||
}
|
}
|
||||||
}
|
binding.communityRating.isVisible = item.communityRating != null
|
||||||
|
binding.actors.isVisible = actors.isNotEmpty()
|
||||||
|
|
||||||
binding.favoriteButton.setOnClickListener {
|
// Check icon
|
||||||
when (viewModel.favorite.value) {
|
val checkDrawable = when (played) {
|
||||||
true -> viewModel.unmarkAsFavorite(args.itemId)
|
true -> R.drawable.ic_check_filled
|
||||||
false -> viewModel.markAsFavorite(args.itemId)
|
false -> R.drawable.ic_check
|
||||||
}
|
}
|
||||||
}
|
binding.checkButton.setImageResource(checkDrawable)
|
||||||
|
|
||||||
viewModel.loadData(args.itemId, args.itemType)
|
// Favorite icon
|
||||||
|
val favoriteDrawable = when (favorite) {
|
||||||
|
true -> R.drawable.ic_heart_filled
|
||||||
|
false -> R.drawable.ic_heart
|
||||||
|
}
|
||||||
|
binding.favoriteButton.setImageResource(favoriteDrawable)
|
||||||
|
|
||||||
|
// Download icon
|
||||||
|
val downloadDrawable = when (downloaded) {
|
||||||
|
true -> R.drawable.ic_download_filled
|
||||||
|
false -> R.drawable.ic_download
|
||||||
|
}
|
||||||
|
binding.downloadButton.setImageResource(downloadDrawable)
|
||||||
|
binding.name.text = item.name
|
||||||
|
binding.originalTitle.text = item.originalTitle
|
||||||
|
if (dateString.isEmpty()) {
|
||||||
|
binding.year.isVisible = false
|
||||||
|
} else {
|
||||||
|
binding.year.text = dateString
|
||||||
|
}
|
||||||
|
if (runTime.isEmpty()) {
|
||||||
|
binding.playtime.isVisible = false
|
||||||
|
} else {
|
||||||
|
binding.playtime.text = runTime
|
||||||
|
}
|
||||||
|
binding.officialRating.text = item.officialRating
|
||||||
|
binding.communityRating.text = item.communityRating.toString()
|
||||||
|
binding.genresLayout.isVisible = item.genres?.isNotEmpty() ?: false
|
||||||
|
binding.genres.text = genresString
|
||||||
|
binding.directorLayout.isVisible = director != null
|
||||||
|
binding.director.text = director?.name
|
||||||
|
binding.writersLayout.isVisible = writers.isNotEmpty()
|
||||||
|
binding.writers.text = writersString
|
||||||
|
binding.description.text = item.overview
|
||||||
|
binding.nextUpLayout.isVisible = nextUp != null
|
||||||
|
binding.nextUpName.text = String.format(getString(R.string.episode_name_extended), nextUp?.parentIndexNumber, nextUp?.indexNumber, nextUp?.name)
|
||||||
|
binding.seasonsLayout.isVisible = seasons.isNotEmpty()
|
||||||
|
val seasonsAdapter = binding.seasonsRecyclerView.adapter as ViewItemListAdapter
|
||||||
|
seasonsAdapter.submitList(seasons)
|
||||||
|
val actorsAdapter = binding.peopleRecyclerView.adapter as PersonListAdapter
|
||||||
|
actorsAdapter.submitList(actors)
|
||||||
|
bindItemBackdropImage(binding.itemBanner, item)
|
||||||
|
bindBaseItemImage(binding.nextUpImage, nextUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindUiStateLoading() {}
|
||||||
|
|
||||||
|
private fun bindUiStateError(uiState: MediaInfoViewModel.UiState.Error) {
|
||||||
|
val error = uiState.message ?: getString(R.string.unknown_error)
|
||||||
|
binding.mediaInfoScrollview.isVisible = false
|
||||||
|
binding.errorLayout.errorPanel.isVisible = true
|
||||||
|
checkIfLoginRequired(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
|
||||||
|
navigateToPlayerActivity(items.items.toTypedArray())
|
||||||
|
binding.playButton.setImageDrawable(
|
||||||
|
ContextCompat.getDrawable(
|
||||||
|
requireActivity(),
|
||||||
|
R.drawable.ic_play
|
||||||
|
)
|
||||||
|
)
|
||||||
|
binding.progressCircular.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
|
||||||
|
Timber.e(error.message)
|
||||||
|
binding.playerItemsError.visibility = View.VISIBLE
|
||||||
|
binding.playButton.setImageDrawable(
|
||||||
|
ContextCompat.getDrawable(
|
||||||
|
requireActivity(),
|
||||||
|
R.drawable.ic_play
|
||||||
|
)
|
||||||
|
)
|
||||||
|
binding.progressCircular.visibility = View.INVISIBLE
|
||||||
|
binding.playerItemsErrorDetails.setOnClickListener {
|
||||||
|
ErrorDialogFragment(error.message).show(parentFragmentManager, "errordialog")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {
|
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {
|
||||||
|
@ -225,8 +302,14 @@ class MediaInfoFragment : Fragment() {
|
||||||
) {
|
) {
|
||||||
findNavController().navigate(
|
findNavController().navigate(
|
||||||
MediaInfoFragmentDirections.actionMediaInfoFragmentToPlayerActivity(
|
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.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.viewModels
|
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.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
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.dialogs.ErrorDialogFragment
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
import dev.jdtech.jellyfin.viewmodels.SearchResultViewModel
|
import dev.jdtech.jellyfin.viewmodels.SearchResultViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class SearchResultFragment : Fragment() {
|
class SearchResultFragment : Fragment() {
|
||||||
|
|
||||||
private lateinit var binding: FragmentSearchResultBinding
|
private lateinit var binding: FragmentSearchResultBinding
|
||||||
private val viewModel: SearchResultViewModel by viewModels()
|
private val viewModel: SearchResultViewModel by viewModels()
|
||||||
|
|
||||||
private val args: SearchResultFragmentArgs by navArgs()
|
private val args: SearchResultFragmentArgs by navArgs()
|
||||||
|
|
||||||
|
private lateinit var errorDialog: ErrorDialogFragment
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?,
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
binding = FragmentSearchResultBinding.inflate(inflater, container, false)
|
binding = FragmentSearchResultBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
binding.lifecycleOwner = viewLifecycleOwner
|
|
||||||
binding.viewModel = viewModel
|
|
||||||
binding.searchResultsRecyclerView.adapter = FavoritesListAdapter(
|
binding.searchResultsRecyclerView.adapter = FavoritesListAdapter(
|
||||||
ViewItemListAdapter.OnClickListener { item ->
|
ViewItemListAdapter.OnClickListener { item ->
|
||||||
navigateToMediaInfoFragment(item)
|
navigateToMediaInfoFragment(item)
|
||||||
|
@ -42,42 +47,58 @@ class SearchResultFragment : Fragment() {
|
||||||
navigateToEpisodeBottomSheetFragment(item)
|
navigateToEpisodeBottomSheetFragment(item)
|
||||||
})
|
})
|
||||||
|
|
||||||
viewModel.finishedLoading.observe(viewLifecycleOwner, { isFinished ->
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
binding.loadingIndicator.visibility = if (isFinished) View.GONE else View.VISIBLE
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
})
|
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
|
||||||
|
Timber.d("$uiState")
|
||||||
viewModel.error.observe(viewLifecycleOwner, { error ->
|
when (uiState) {
|
||||||
if (error != null) {
|
is SearchResultViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
||||||
checkIfLoginRequired(error)
|
is SearchResultViewModel.UiState.Loading -> bindUiStateLoading()
|
||||||
binding.errorLayout.errorPanel.visibility = View.VISIBLE
|
is SearchResultViewModel.UiState.Error -> bindUiStateError(uiState)
|
||||||
binding.searchResultsRecyclerView.visibility = View.GONE
|
}
|
||||||
} else {
|
}
|
||||||
binding.errorLayout.errorPanel.visibility = View.GONE
|
viewModel.loadData(args.query)
|
||||||
binding.searchResultsRecyclerView.visibility = View.VISIBLE
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
binding.errorLayout.errorRetryButton.setOnClickListener {
|
binding.errorLayout.errorRetryButton.setOnClickListener {
|
||||||
viewModel.loadData(args.query)
|
viewModel.loadData(args.query)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
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
|
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) {
|
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
|
||||||
findNavController().navigate(
|
findNavController().navigate(
|
||||||
FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment(
|
FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment(
|
||||||
|
|
|
@ -5,7 +5,11 @@ import androidx.fragment.app.Fragment
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.viewModels
|
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.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
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.dialogs.ErrorDialogFragment
|
||||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||||
import dev.jdtech.jellyfin.viewmodels.SeasonViewModel
|
import dev.jdtech.jellyfin.viewmodels.SeasonViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class SeasonFragment : Fragment() {
|
class SeasonFragment : Fragment() {
|
||||||
|
|
||||||
private lateinit var binding: FragmentSeasonBinding
|
private lateinit var binding: FragmentSeasonBinding
|
||||||
private val viewModel: SeasonViewModel by viewModels()
|
private val viewModel: SeasonViewModel by viewModels()
|
||||||
|
|
||||||
private val args: SeasonFragmentArgs by navArgs()
|
private val args: SeasonFragmentArgs by navArgs()
|
||||||
|
|
||||||
|
private lateinit var errorDialog: ErrorDialogFragment
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?,
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
binding = FragmentSeasonBinding.inflate(inflater, container, false)
|
binding = FragmentSeasonBinding.inflate(inflater, container, false)
|
||||||
binding.lifecycleOwner = viewLifecycleOwner
|
|
||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
binding.viewModel = viewModel
|
|
||||||
|
|
||||||
viewModel.error.observe(viewLifecycleOwner, { error ->
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
if (error != null) {
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
checkIfLoginRequired(error)
|
viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
|
||||||
binding.errorLayout.errorPanel.visibility = View.VISIBLE
|
Timber.d("$uiState")
|
||||||
binding.episodesRecyclerView.visibility = View.GONE
|
when (uiState) {
|
||||||
} else {
|
is SeasonViewModel.UiState.Normal -> bindUiStateNormal(uiState)
|
||||||
binding.errorLayout.errorPanel.visibility = View.GONE
|
is SeasonViewModel.UiState.Loading -> bindUiStateLoading()
|
||||||
binding.episodesRecyclerView.visibility = View.VISIBLE
|
is SeasonViewModel.UiState.Error -> bindUiStateError(uiState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModel.loadEpisodes(args.seriesId, args.seasonId)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
binding.errorLayout.errorRetryButton.setOnClickListener {
|
binding.errorLayout.errorRetryButton.setOnClickListener {
|
||||||
viewModel.loadEpisodes(args.seriesId, args.seasonId)
|
viewModel.loadEpisodes(args.seriesId, args.seasonId)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
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 =
|
binding.episodesRecyclerView.adapter =
|
||||||
EpisodeListAdapter(EpisodeListAdapter.OnClickListener { episode ->
|
EpisodeListAdapter(EpisodeListAdapter.OnClickListener { episode ->
|
||||||
navigateToEpisodeBottomSheetFragment(episode)
|
navigateToEpisodeBottomSheetFragment(episode)
|
||||||
}, args.seriesId, args.seriesName, args.seasonId, args.seasonName)
|
}, 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) {
|
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {
|
||||||
|
|
|
@ -6,12 +6,16 @@ import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.fragment.app.viewModels
|
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.findNavController
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.databinding.FragmentServerSelectBinding
|
import dev.jdtech.jellyfin.databinding.FragmentServerSelectBinding
|
||||||
import dev.jdtech.jellyfin.dialogs.DeleteServerDialogFragment
|
import dev.jdtech.jellyfin.dialogs.DeleteServerDialogFragment
|
||||||
import dev.jdtech.jellyfin.adapters.ServerGridAdapter
|
import dev.jdtech.jellyfin.adapters.ServerGridAdapter
|
||||||
import dev.jdtech.jellyfin.viewmodels.ServerSelectViewModel
|
import dev.jdtech.jellyfin.viewmodels.ServerSelectViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class ServerSelectFragment : Fragment() {
|
class ServerSelectFragment : Fragment() {
|
||||||
|
@ -44,11 +48,15 @@ class ServerSelectFragment : Fragment() {
|
||||||
navigateToAddServerFragment()
|
navigateToAddServerFragment()
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.navigateToMain.observe(viewLifecycleOwner, {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
if (it) {
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
navigateToMainActivity()
|
viewModel.onNavigateToMain(viewLifecycleOwner.lifecycleScope) {
|
||||||
|
if (it) {
|
||||||
|
navigateToMainActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
@ -61,6 +69,5 @@ class ServerSelectFragment : Fragment() {
|
||||||
|
|
||||||
private fun navigateToMainActivity() {
|
private fun navigateToMainActivity() {
|
||||||
findNavController().navigate(ServerSelectFragmentDirections.actionServerSelectFragmentToHomeFragment())
|
findNavController().navigate(ServerSelectFragmentDirections.actionServerSelectFragmentToHomeFragment())
|
||||||
viewModel.doneNavigatingToMain()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -3,14 +3,26 @@ package dev.jdtech.jellyfin.fragments
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
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.navigation.fragment.findNavController
|
||||||
|
import androidx.preference.EditTextPreference
|
||||||
import androidx.preference.ListPreference
|
import androidx.preference.ListPreference
|
||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.jdtech.jellyfin.R
|
import dev.jdtech.jellyfin.R
|
||||||
|
import dev.jdtech.jellyfin.viewmodels.SettingsViewModel
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class SettingsFragment: PreferenceFragmentCompat() {
|
||||||
|
|
||||||
|
private val viewModel: SettingsViewModel by viewModels()
|
||||||
|
|
||||||
class SettingsFragment : PreferenceFragmentCompat() {
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
setPreferencesFromResource(R.xml.fragment_settings, rootKey)
|
setPreferencesFromResource(R.xml.fragment_settings, rootKey)
|
||||||
|
|
||||||
|
@ -41,5 +53,14 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
||||||
findNavController().navigate(SettingsFragmentDirections.actionSettingsFragmentToAboutLibraries())
|
findNavController().navigate(SettingsFragmentDirections.actionSettingsFragmentToAboutLibraries())
|
||||||
true
|
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
|
package dev.jdtech.jellyfin.models
|
||||||
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
data class HomeSection(
|
data class HomeSection(
|
||||||
val id: UUID,
|
val name: String,
|
||||||
val name: String?,
|
var items: List<BaseItemDto>
|
||||||
var items: List<BaseItemDto>? = null
|
|
||||||
)
|
)
|
|
@ -2,12 +2,14 @@ package dev.jdtech.jellyfin.models
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import java.util.*
|
import java.util.UUID
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class PlayerItem(
|
data class PlayerItem(
|
||||||
val name: String?,
|
val name: String?,
|
||||||
val itemId: UUID,
|
val itemId: UUID,
|
||||||
val mediaSourceId: String,
|
val mediaSourceId: String,
|
||||||
val playbackPosition: Long
|
val playbackPosition: Long,
|
||||||
|
val mediaSourceUri: String = "",
|
||||||
|
val metadata: DownloadMetadata? = null
|
||||||
) : Parcelable
|
) : Parcelable
|
|
@ -1,7 +1,7 @@
|
||||||
package dev.jdtech.jellyfin.models
|
package dev.jdtech.jellyfin.models
|
||||||
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import java.util.*
|
import java.util.UUID
|
||||||
|
|
||||||
data class View(
|
data class View(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
|
|
|
@ -32,8 +32,10 @@ import kotlinx.parcelize.Parcelize
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
import java.lang.IllegalArgumentException
|
||||||
import java.util.concurrent.CopyOnWriteArraySet
|
import java.util.concurrent.CopyOnWriteArraySet
|
||||||
|
|
||||||
@Suppress("SpellCheckingInspection")
|
@Suppress("SpellCheckingInspection")
|
||||||
|
@ -1199,22 +1201,22 @@ class MPVPlayer(
|
||||||
* @param volume The volume to set.
|
* @param volume The volume to set.
|
||||||
*/
|
*/
|
||||||
override fun setDeviceVolume(volume: Int) {
|
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. */
|
/** Increases the volume of the device. */
|
||||||
override fun increaseDeviceVolume() {
|
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. */
|
/** Decreases the volume of the device. */
|
||||||
override fun decreaseDeviceVolume() {
|
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. */
|
/** Sets the mute state of the device. */
|
||||||
override fun setDeviceMuted(muted: Boolean) {
|
override fun setDeviceMuted(muted: Boolean) {
|
||||||
TODO("Not yet implemented")
|
throw IllegalArgumentException("You should use global volume controls. Check out AUDIO_SERVICE.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private class CurrentTrackSelection(
|
private class CurrentTrackSelection(
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
package dev.jdtech.jellyfin.repository
|
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.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.ItemFields
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
interface JellyfinRepository {
|
interface JellyfinRepository {
|
||||||
|
@ -13,7 +17,15 @@ interface JellyfinRepository {
|
||||||
suspend fun getItems(
|
suspend fun getItems(
|
||||||
parentId: UUID? = null,
|
parentId: UUID? = null,
|
||||||
includeTypes: List<String>? = 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>
|
): List<BaseItemDto>
|
||||||
|
|
||||||
suspend fun getFavoriteItems(): List<BaseItemDto>
|
suspend fun getFavoriteItems(): List<BaseItemDto>
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package dev.jdtech.jellyfin.repository
|
package dev.jdtech.jellyfin.repository
|
||||||
|
|
||||||
import dev.jdtech.jellyfin.api.JellyfinApi
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.model.api.*
|
import org.jellyfin.sdk.model.api.*
|
||||||
|
@ -12,7 +14,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
|
||||||
val views: List<BaseItemDto>
|
val views: List<BaseItemDto>
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
views =
|
views =
|
||||||
jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items ?: listOf()
|
jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items ?: emptyList()
|
||||||
}
|
}
|
||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
|
@ -28,16 +30,38 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
|
||||||
override suspend fun getItems(
|
override suspend fun getItems(
|
||||||
parentId: UUID?,
|
parentId: UUID?,
|
||||||
includeTypes: List<String>?,
|
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
|
recursive: Boolean
|
||||||
): List<BaseItemDto> {
|
): List<BaseItemDto> {
|
||||||
val items: List<BaseItemDto>
|
val items: List<BaseItemDto>
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
items = jellyfinApi.itemsApi.getItems(
|
items = jellyfinApi.itemsApi.getItems(
|
||||||
jellyfinApi.userId!!,
|
jellyfinApi.userId!!,
|
||||||
parentId = parentId,
|
personIds = personIds,
|
||||||
includeItemTypes = includeTypes,
|
includeItemTypes = includeTypes?.map { it.type },
|
||||||
recursive = recursive
|
recursive = recursive
|
||||||
).content.items ?: listOf()
|
).content.items ?: emptyList()
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
@ -50,7 +74,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
|
||||||
filters = listOf(ItemFilter.IS_FAVORITE),
|
filters = listOf(ItemFilter.IS_FAVORITE),
|
||||||
includeItemTypes = listOf("Movie", "Series", "Episode"),
|
includeItemTypes = listOf("Movie", "Series", "Episode"),
|
||||||
recursive = true
|
recursive = true
|
||||||
).content.items ?: listOf()
|
).content.items ?: emptyList()
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
@ -63,7 +87,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
|
||||||
searchTerm = searchQuery,
|
searchTerm = searchQuery,
|
||||||
includeItemTypes = listOf("Movie", "Series", "Episode"),
|
includeItemTypes = listOf("Movie", "Series", "Episode"),
|
||||||
recursive = true
|
recursive = true
|
||||||
).content.items ?: listOf()
|
).content.items ?: emptyList()
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
@ -75,7 +99,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
|
||||||
jellyfinApi.itemsApi.getResumeItems(
|
jellyfinApi.itemsApi.getResumeItems(
|
||||||
jellyfinApi.userId!!,
|
jellyfinApi.userId!!,
|
||||||
includeItemTypes = listOf("Movie", "Episode"),
|
includeItemTypes = listOf("Movie", "Episode"),
|
||||||
).content.items ?: listOf()
|
).content.items ?: emptyList()
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
@ -95,7 +119,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
|
||||||
val seasons: List<BaseItemDto>
|
val seasons: List<BaseItemDto>
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
seasons = jellyfinApi.showsApi.getSeasons(seriesId, jellyfinApi.userId!!).content.items
|
seasons = jellyfinApi.showsApi.getSeasons(seriesId, jellyfinApi.userId!!).content.items
|
||||||
?: listOf()
|
?: emptyList()
|
||||||
}
|
}
|
||||||
return seasons
|
return seasons
|
||||||
}
|
}
|
||||||
|
@ -106,7 +130,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
|
||||||
nextUpItems = jellyfinApi.showsApi.getNextUp(
|
nextUpItems = jellyfinApi.showsApi.getNextUp(
|
||||||
jellyfinApi.userId!!,
|
jellyfinApi.userId!!,
|
||||||
seriesId = seriesId?.toString(),
|
seriesId = seriesId?.toString(),
|
||||||
).content.items ?: listOf()
|
).content.items ?: emptyList()
|
||||||
}
|
}
|
||||||
return nextUpItems
|
return nextUpItems
|
||||||
}
|
}
|
||||||
|
@ -125,7 +149,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
|
||||||
seasonId = seasonId,
|
seasonId = seasonId,
|
||||||
fields = fields,
|
fields = fields,
|
||||||
startItemId = startItemId
|
startItemId = startItemId
|
||||||
).content.items ?: listOf()
|
).content.items ?: emptyList()
|
||||||
}
|
}
|
||||||
return episodes
|
return episodes
|
||||||
}
|
}
|
||||||
|
@ -139,15 +163,15 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
|
||||||
name = "Direct play all",
|
name = "Direct play all",
|
||||||
maxStaticBitrate = 1_000_000_000,
|
maxStaticBitrate = 1_000_000_000,
|
||||||
maxStreamingBitrate = 1_000_000_000,
|
maxStreamingBitrate = 1_000_000_000,
|
||||||
codecProfiles = listOf(),
|
codecProfiles = emptyList(),
|
||||||
containerProfiles = listOf(),
|
containerProfiles = emptyList(),
|
||||||
directPlayProfiles = listOf(
|
directPlayProfiles = listOf(
|
||||||
DirectPlayProfile(
|
DirectPlayProfile(
|
||||||
type = DlnaProfileType.VIDEO
|
type = DlnaProfileType.VIDEO
|
||||||
), DirectPlayProfile(type = DlnaProfileType.AUDIO)
|
), DirectPlayProfile(type = DlnaProfileType.AUDIO)
|
||||||
),
|
),
|
||||||
transcodingProfiles = listOf(),
|
transcodingProfiles = emptyList(),
|
||||||
responseProfiles = listOf(),
|
responseProfiles = emptyList(),
|
||||||
enableAlbumArtInDidl = false,
|
enableAlbumArtInDidl = false,
|
||||||
enableMsMediaReceiverRegistrar = false,
|
enableMsMediaReceiverRegistrar = false,
|
||||||
enableSingleAlbumArtLimit = false,
|
enableSingleAlbumArtLimit = false,
|
||||||
|
@ -165,7 +189,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
|
||||||
maxStreamingBitrate = 1_000_000_000,
|
maxStreamingBitrate = 1_000_000_000,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
mediaSourceInfoList = mediaInfo.mediaSources ?: listOf()
|
mediaSourceInfoList = mediaInfo.mediaSources ?: emptyList()
|
||||||
return mediaSourceInfoList
|
return mediaSourceInfoList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -272,7 +296,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
intros =
|
intros =
|
||||||
jellyfinApi.userLibraryApi.getIntros(jellyfinApi.userId!!, itemId).content.items
|
jellyfinApi.userLibraryApi.getIntros(jellyfinApi.userId!!, itemId).content.items
|
||||||
?: listOf()
|
?: emptyList()
|
||||||
}
|
}
|
||||||
return intros
|
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
|
package dev.jdtech.jellyfin.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.navigation.fragment.findNavController
|
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 dev.jdtech.jellyfin.models.View
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import timber.log.Timber
|
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) {
|
fun Fragment.checkIfLoginRequired(error: String) {
|
||||||
if (error.contains("401")) {
|
if (error.contains("401")) {
|
||||||
Timber.d("Login required!")
|
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
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
import android.content.res.Resources
|
||||||
import androidx.lifecycle.MutableLiveData
|
import android.widget.Toast
|
||||||
|
import androidx.lifecycle.LifecycleCoroutineScope
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dev.jdtech.jellyfin.BaseApplication
|
||||||
|
import dev.jdtech.jellyfin.R
|
||||||
import dev.jdtech.jellyfin.api.JellyfinApi
|
import dev.jdtech.jellyfin.api.JellyfinApi
|
||||||
import dev.jdtech.jellyfin.database.Server
|
import dev.jdtech.jellyfin.database.Server
|
||||||
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
||||||
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||||
|
import org.jellyfin.sdk.discovery.RecommendedServerIssue
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ -21,58 +25,152 @@ import javax.inject.Inject
|
||||||
class AddServerViewModel
|
class AddServerViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
|
private val application: BaseApplication,
|
||||||
private val jellyfinApi: JellyfinApi,
|
private val jellyfinApi: JellyfinApi,
|
||||||
private val database: ServerDatabaseDao
|
private val database: ServerDatabaseDao
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
private val resources: Resources = application.resources
|
||||||
|
|
||||||
private val _navigateToLogin = MutableLiveData<Boolean>()
|
private val uiState = MutableStateFlow<UiState>(UiState.Normal)
|
||||||
val navigateToLogin: LiveData<Boolean> = _navigateToLogin
|
|
||||||
|
|
||||||
private val _error = MutableLiveData<String>()
|
private val navigateToLogin = MutableSharedFlow<Boolean>()
|
||||||
val error: LiveData<String> = _error
|
|
||||||
|
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:
|
* Run multiple check on the server before continuing:
|
||||||
*
|
*
|
||||||
* - Connect to server and check if it is a Jellyfin server
|
* - Connect to server and check if it is a Jellyfin server
|
||||||
* - Check if server is not already in Database
|
* - Check if server is not already in Database
|
||||||
|
*
|
||||||
|
* @param inputValue Can be an ip address or hostname
|
||||||
*/
|
*/
|
||||||
fun checkServer(inputValue: String) {
|
fun checkServer(inputValue: String) {
|
||||||
_error.value = null
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
uiState.emit(UiState.Loading)
|
||||||
|
|
||||||
try {
|
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 candidates = jellyfinApi.jellyfin.discovery.getAddressCandidates(inputValue)
|
||||||
val recommended = jellyfinApi.jellyfin.discovery.getRecommendedServers(
|
val recommended = jellyfinApi.jellyfin.discovery.getRecommendedServers(
|
||||||
candidates,
|
candidates,
|
||||||
RecommendedServerInfoScore.GOOD
|
RecommendedServerInfoScore.OK
|
||||||
)
|
)
|
||||||
val recommendedServer: RecommendedServerInfo
|
|
||||||
|
|
||||||
try {
|
val greatServers = mutableListOf<RecommendedServerInfo>()
|
||||||
recommendedServer = recommended.first()
|
val goodServers = mutableListOf<RecommendedServerInfo>()
|
||||||
} catch (e: NoSuchElementException) {
|
val okServers = mutableListOf<RecommendedServerInfo>()
|
||||||
throw Exception("Server not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
jellyfinApi.apply {
|
recommended
|
||||||
api.baseUrl = recommendedServer.address
|
.onCompletion {
|
||||||
api.accessToken = null
|
if (greatServers.isNotEmpty()) {
|
||||||
}
|
connectToServer(greatServers.first())
|
||||||
|
} else if (goodServers.isNotEmpty()) {
|
||||||
Timber.d("Remote server: ${recommendedServer.systemInfo?.id}")
|
val issuesString = createIssuesString(goodServers.first())
|
||||||
|
Toast.makeText(
|
||||||
if (serverAlreadyInDatabase(recommendedServer.systemInfo?.id)) {
|
application,
|
||||||
_error.value = "Server already added"
|
issuesString,
|
||||||
_navigateToLogin.value = false
|
Toast.LENGTH_LONG
|
||||||
} else {
|
).show()
|
||||||
_error.value = null
|
connectToServer(goodServers.first())
|
||||||
_navigateToLogin.value = true
|
} 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) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e)
|
uiState.emit(
|
||||||
_error.value = e.message
|
UiState.Error(
|
||||||
_navigateToLogin.value = false
|
e.message ?: resources.getString(R.string.unknown_error)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun connectToServer(recommendedServerInfo: RecommendedServerInfo) {
|
||||||
|
val serverId = recommendedServerInfo.systemInfo.getOrNull()?.id
|
||||||
|
?: throw Exception(resources.getString(R.string.add_server_error_no_id))
|
||||||
|
|
||||||
|
Timber.d("Connecting to server: $serverId")
|
||||||
|
|
||||||
|
if (serverAlreadyInDatabase(serverId)) {
|
||||||
|
throw Exception(resources.getString(R.string.add_server_error_already_added))
|
||||||
|
}
|
||||||
|
|
||||||
|
jellyfinApi.apply {
|
||||||
|
api.baseUrl = recommendedServerInfo.address
|
||||||
|
api.accessToken = null
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.emit(UiState.Normal)
|
||||||
|
navigateToLogin.emit(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a presentable string of issues with a server
|
||||||
|
*
|
||||||
|
* @param server The server with issues
|
||||||
|
* @return A presentable string of issues separated with \n
|
||||||
|
*/
|
||||||
|
private fun createIssuesString(server: RecommendedServerInfo): String {
|
||||||
|
return server.issues.joinToString("\n") {
|
||||||
|
when (it) {
|
||||||
|
is RecommendedServerIssue.OutdatedServerVersion -> {
|
||||||
|
String.format(
|
||||||
|
resources.getString(R.string.add_server_error_outdated),
|
||||||
|
it.version
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is RecommendedServerIssue.InvalidProductName -> {
|
||||||
|
String.format(
|
||||||
|
resources.getString(R.string.add_server_error_not_jellyfin),
|
||||||
|
it.productName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is RecommendedServerIssue.UnsupportedServerVersion -> {
|
||||||
|
String.format(
|
||||||
|
resources.getString(R.string.add_server_error_version),
|
||||||
|
it.version
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is RecommendedServerIssue.SlowResponse -> {
|
||||||
|
String.format(
|
||||||
|
resources.getString(R.string.add_server_error_slow),
|
||||||
|
it.responseTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
resources.getString(R.string.unknown_error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -83,22 +181,12 @@ constructor(
|
||||||
* @param id Server ID
|
* @param id Server ID
|
||||||
* @return True if server is already in database
|
* @return True if server is already in database
|
||||||
*/
|
*/
|
||||||
private suspend fun serverAlreadyInDatabase(id: String?): Boolean {
|
private suspend fun serverAlreadyInDatabase(id: String): Boolean {
|
||||||
val servers: List<Server>
|
val server: Server?
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
servers = database.getAllServersSync()
|
server = database.get(id)
|
||||||
}
|
|
||||||
for (server in servers) {
|
|
||||||
Timber.d("Database server: ${server.id}")
|
|
||||||
if (server.id == id) {
|
|
||||||
Timber.w("Server already in the database")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (server != null) return true
|
||||||
return false
|
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
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.*
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dev.jdtech.jellyfin.models.DownloadRequestItem
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
import dev.jdtech.jellyfin.models.PlayerItem
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
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 kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
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 timber.log.Timber
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.util.*
|
import java.util.Date
|
||||||
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class EpisodeBottomSheetViewModel
|
class EpisodeBottomSheetViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
|
private val application: Application,
|
||||||
private val jellyfinRepository: JellyfinRepository
|
private val jellyfinRepository: JellyfinRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||||
|
|
||||||
private val _item = MutableLiveData<BaseItemDto>()
|
sealed class UiState {
|
||||||
val item: LiveData<BaseItemDto> = _item
|
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>()
|
object Loading : UiState()
|
||||||
val runTime: LiveData<String> = _runTime
|
data class Error(val message: String?) : UiState()
|
||||||
|
}
|
||||||
|
|
||||||
private val _dateString = MutableLiveData<String>()
|
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
|
||||||
val dateString: LiveData<String> = _dateString
|
scope.launch { uiState.collect { collector(it) } }
|
||||||
|
}
|
||||||
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
|
|
||||||
|
|
||||||
|
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()
|
var playerItems: MutableList<PlayerItem> = mutableListOf()
|
||||||
|
|
||||||
private val _playerItemsError = MutableLiveData<String>()
|
lateinit var downloadRequestItem: DownloadRequestItem
|
||||||
val playerItemsError: LiveData<String> = _playerItemsError
|
|
||||||
|
|
||||||
fun loadEpisode(episodeId: UUID) {
|
fun loadEpisode(episodeId: UUID) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
uiState.emit(UiState.Loading)
|
||||||
try {
|
try {
|
||||||
val item = jellyfinRepository.getItem(episodeId)
|
val tempItem = jellyfinRepository.getItem(episodeId)
|
||||||
_item.value = item
|
item = tempItem
|
||||||
_runTime.value = "${item.runTimeTicks?.div(600000000)} min"
|
runTime = "${tempItem.runTimeTicks?.div(600000000)} min"
|
||||||
_dateString.value = getDateString(item)
|
dateString = getDateString(tempItem)
|
||||||
_played.value = item.userData?.played
|
played = tempItem.userData?.played == true
|
||||||
_favorite.value = item.userData?.isFavorite
|
favorite = tempItem.userData?.isFavorite == true
|
||||||
|
downloaded = itemIsDownloaded(episodeId)
|
||||||
|
uiState.emit(
|
||||||
|
UiState.Normal(
|
||||||
|
tempItem,
|
||||||
|
runTime,
|
||||||
|
dateString,
|
||||||
|
played,
|
||||||
|
favorite,
|
||||||
|
downloaded,
|
||||||
|
downloadEpisode
|
||||||
|
)
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e)
|
uiState.emit(UiState.Error(e.message))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun preparePlayerItems() {
|
fun loadEpisode(playerItem: PlayerItem) {
|
||||||
_playerItemsError.value = null
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
uiState.emit(UiState.Loading)
|
||||||
createPlayerItems(_item.value!!)
|
playerItems.add(playerItem)
|
||||||
_navigateToPlayer.value = true
|
item = downloadMetadataToBaseItemDto(playerItem.metadata!!)
|
||||||
} catch (e: Exception) {
|
uiState.emit(
|
||||||
_playerItemsError.value = e.toString()
|
UiState.Normal(
|
||||||
}
|
item!!,
|
||||||
}
|
runTime,
|
||||||
}
|
dateString,
|
||||||
|
played,
|
||||||
private suspend fun createPlayerItems(startEpisode: BaseItemDto) {
|
favorite,
|
||||||
playerItems.clear()
|
downloaded,
|
||||||
|
downloadEpisode
|
||||||
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
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playerItems.isEmpty() || playerItems.count() == introsCount) throw Exception("No playable items found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun markAsPlayed(itemId: UUID) {
|
fun markAsPlayed(itemId: UUID) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
jellyfinRepository.markAsPlayed(itemId)
|
jellyfinRepository.markAsPlayed(itemId)
|
||||||
}
|
}
|
||||||
_played.value = true
|
played = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun markAsUnplayed(itemId: UUID) {
|
fun markAsUnplayed(itemId: UUID) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
jellyfinRepository.markAsUnplayed(itemId)
|
jellyfinRepository.markAsUnplayed(itemId)
|
||||||
}
|
}
|
||||||
_played.value = false
|
played = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun markAsFavorite(itemId: UUID) {
|
fun markAsFavorite(itemId: UUID) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
jellyfinRepository.markAsFavorite(itemId)
|
jellyfinRepository.markAsFavorite(itemId)
|
||||||
}
|
}
|
||||||
_favorite.value = true
|
favorite = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unmarkAsFavorite(itemId: UUID) {
|
fun unmarkAsFavorite(itemId: UUID) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
jellyfinRepository.unmarkAsFavorite(itemId)
|
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 {
|
private fun getDateString(item: BaseItemDto): String {
|
||||||
|
@ -152,7 +162,8 @@ constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun doneNavigateToPlayer() {
|
fun doneDownloadEpisode() {
|
||||||
_navigateToPlayer.value = false
|
downloadEpisode = false
|
||||||
|
downloaded = true
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,16 +1,16 @@
|
||||||
package dev.jdtech.jellyfin.viewmodels
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LifecycleCoroutineScope
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.jdtech.jellyfin.models.FavoriteSection
|
import dev.jdtech.jellyfin.models.FavoriteSection
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ -20,40 +20,41 @@ class FavoriteViewModel
|
||||||
constructor(
|
constructor(
|
||||||
private val jellyfinRepository: JellyfinRepository
|
private val jellyfinRepository: JellyfinRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _favoriteSections = MutableLiveData<List<FavoriteSection>>()
|
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||||
val favoriteSections: LiveData<List<FavoriteSection>> = _favoriteSections
|
|
||||||
|
|
||||||
private val _finishedLoading = MutableLiveData<Boolean>()
|
sealed class UiState {
|
||||||
val finishedLoading: LiveData<Boolean> = _finishedLoading
|
data class Normal(val favoriteSections: List<FavoriteSection>) : UiState()
|
||||||
|
object Loading : UiState()
|
||||||
|
data class Error(val message: String?) : UiState()
|
||||||
|
}
|
||||||
|
|
||||||
private val _error = MutableLiveData<String>()
|
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
|
||||||
val error: LiveData<String> = _error
|
scope.launch { uiState.collect { collector(it) } }
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadData() {
|
fun loadData() {
|
||||||
_error.value = null
|
|
||||||
_finishedLoading.value = false
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
uiState.emit(UiState.Loading)
|
||||||
try {
|
try {
|
||||||
val items = jellyfinRepository.getFavoriteItems()
|
val items = jellyfinRepository.getFavoriteItems()
|
||||||
|
|
||||||
if (items.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
_favoriteSections.value = listOf()
|
uiState.emit(UiState.Normal(emptyList()))
|
||||||
_finishedLoading.value = true
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val tempFavoriteSections = mutableListOf<FavoriteSection>()
|
val favoriteSections = mutableListOf<FavoriteSection>()
|
||||||
|
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
FavoriteSection(
|
FavoriteSection(
|
||||||
UUID.randomUUID(),
|
UUID.randomUUID(),
|
||||||
"Movies",
|
"Movies",
|
||||||
items.filter { it.type == "Movie" }).let {
|
items.filter { it.type == "Movie" }).let {
|
||||||
if (it.items.isNotEmpty()) tempFavoriteSections.add(
|
if (it.items.isNotEmpty()) favoriteSections.add(
|
||||||
it
|
it
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -61,7 +62,7 @@ constructor(
|
||||||
UUID.randomUUID(),
|
UUID.randomUUID(),
|
||||||
"Shows",
|
"Shows",
|
||||||
items.filter { it.type == "Series" }).let {
|
items.filter { it.type == "Series" }).let {
|
||||||
if (it.items.isNotEmpty()) tempFavoriteSections.add(
|
if (it.items.isNotEmpty()) favoriteSections.add(
|
||||||
it
|
it
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -69,18 +70,16 @@ constructor(
|
||||||
UUID.randomUUID(),
|
UUID.randomUUID(),
|
||||||
"Episodes",
|
"Episodes",
|
||||||
items.filter { it.type == "Episode" }).let {
|
items.filter { it.type == "Episode" }).let {
|
||||||
if (it.items.isNotEmpty()) tempFavoriteSections.add(
|
if (it.items.isNotEmpty()) favoriteSections.add(
|
||||||
it
|
it
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_favoriteSections.value = tempFavoriteSections
|
uiState.emit(UiState.Normal(favoriteSections))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e)
|
uiState.emit(UiState.Error(e.message))
|
||||||
_error.value = e.toString()
|
|
||||||
}
|
}
|
||||||
_finishedLoading.value = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,114 +1,92 @@
|
||||||
package dev.jdtech.jellyfin.viewmodels
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LifecycleCoroutineScope
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.jdtech.jellyfin.R
|
import dev.jdtech.jellyfin.R
|
||||||
import dev.jdtech.jellyfin.adapters.HomeItem
|
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.HomeSection
|
||||||
import dev.jdtech.jellyfin.models.View
|
import dev.jdtech.jellyfin.models.unsupportedCollections
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
|
import dev.jdtech.jellyfin.utils.syncPlaybackProgress
|
||||||
import dev.jdtech.jellyfin.utils.toView
|
import dev.jdtech.jellyfin.utils.toView
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.*
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class HomeViewModel
|
class HomeViewModel @Inject internal constructor(
|
||||||
@Inject
|
private val application: Application,
|
||||||
constructor(
|
private val repository: JellyfinRepository
|
||||||
application: Application,
|
|
||||||
private val jellyfinRepository: JellyfinRepository
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||||
|
|
||||||
private val continueWatchingString = application.resources.getString(R.string.continue_watching)
|
sealed class UiState {
|
||||||
private val nextUpString = application.resources.getString(R.string.next_up)
|
data class Normal(val homeItems: List<HomeItem>) : UiState()
|
||||||
|
object Loading : UiState()
|
||||||
private val _views = MutableLiveData<List<HomeItem>>()
|
data class Error(val message: String?) : UiState()
|
||||||
val views: LiveData<List<HomeItem>> = _views
|
|
||||||
|
|
||||||
private val _items = MutableLiveData<List<BaseItemDto>>()
|
|
||||||
val items: LiveData<List<BaseItemDto>> = _items
|
|
||||||
|
|
||||||
private val _finishedLoading = MutableLiveData<Boolean>()
|
|
||||||
val finishedLoading: LiveData<Boolean> = _finishedLoading
|
|
||||||
|
|
||||||
private val _error = MutableLiveData<String>()
|
|
||||||
val error: LiveData<String> = _error
|
|
||||||
|
|
||||||
init {
|
|
||||||
loadData()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadData() {
|
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
|
||||||
_error.value = null
|
scope.launch { uiState.collect { collector(it) } }
|
||||||
_finishedLoading.value = false
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadData(updateCapabilities = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshData() = loadData(updateCapabilities = false)
|
||||||
|
|
||||||
|
private fun loadData(updateCapabilities: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
uiState.emit(UiState.Loading)
|
||||||
try {
|
try {
|
||||||
|
if (updateCapabilities) repository.postCapabilities()
|
||||||
|
|
||||||
|
val updated = loadDynamicItems() + loadViews()
|
||||||
jellyfinRepository.postCapabilities()
|
|
||||||
|
|
||||||
val items = mutableListOf<HomeItem>()
|
|
||||||
|
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
|
syncPlaybackProgress(repository)
|
||||||
val resumeItems = jellyfinRepository.getResumeItems()
|
|
||||||
val resumeSection =
|
|
||||||
HomeSection(UUID.randomUUID(), continueWatchingString, resumeItems)
|
|
||||||
|
|
||||||
if (!resumeItems.isNullOrEmpty()) {
|
|
||||||
items.add(HomeItem.Section(resumeSection))
|
|
||||||
}
|
|
||||||
|
|
||||||
val nextUpItems = jellyfinRepository.getNextUp()
|
|
||||||
val nextUpSection = HomeSection(UUID.randomUUID(), nextUpString, nextUpItems)
|
|
||||||
|
|
||||||
if (!nextUpItems.isNullOrEmpty()) {
|
|
||||||
items.add(HomeItem.Section(nextUpSection))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
uiState.emit(UiState.Normal(updated))
|
||||||
_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) }
|
|
||||||
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e)
|
uiState.emit(UiState.Error(e.message))
|
||||||
_error.value = e.toString()
|
|
||||||
}
|
}
|
||||||
_finishedLoading.value = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun loadDynamicItems() = withContext(Dispatchers.IO) {
|
||||||
|
val resumeItems = repository.getResumeItems()
|
||||||
|
val nextUpItems = repository.getNextUp()
|
||||||
|
|
||||||
|
val items = mutableListOf<HomeSection>()
|
||||||
|
if (resumeItems.isNotEmpty()) {
|
||||||
|
items.add(HomeSection(application.resources.getString(R.string.continue_watching), resumeItems))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextUpItems.isNotEmpty()) {
|
||||||
|
items.add(HomeSection(application.resources.getString(R.string.next_up), nextUpItems))
|
||||||
|
}
|
||||||
|
|
||||||
|
items.map { Section(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadViews() = withContext(Dispatchers.IO) {
|
||||||
|
repository
|
||||||
|
.getUserViews()
|
||||||
|
.filter { view -> unsupportedCollections().none { it.type == view.collectionType } }
|
||||||
|
.map { view -> view to repository.getLatestMedia(view.id) }
|
||||||
|
.filter { (_, latest) -> latest.isNotEmpty() }
|
||||||
|
.map { (view, latest) -> view.toView().apply { items = latest } }
|
||||||
|
.map { ViewItem(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -3,8 +3,12 @@ package dev.jdtech.jellyfin.viewmodels
|
||||||
import androidx.lifecycle.*
|
import androidx.lifecycle.*
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
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 kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
@ -12,20 +16,27 @@ import javax.inject.Inject
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class LibraryViewModel
|
class LibraryViewModel
|
||||||
@Inject
|
@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>>()
|
sealed class UiState {
|
||||||
val items: LiveData<List<BaseItemDto>> = _items
|
data class Normal(val items: List<BaseItemDto>) : UiState()
|
||||||
|
object Loading : UiState()
|
||||||
|
data class Error(val message: String?) : UiState()
|
||||||
|
}
|
||||||
|
|
||||||
private val _finishedLoading = MutableLiveData<Boolean>()
|
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
|
||||||
val finishedLoading: LiveData<Boolean> = _finishedLoading
|
scope.launch { uiState.collect { collector(it) } }
|
||||||
|
}
|
||||||
|
|
||||||
private val _error = MutableLiveData<String>()
|
fun loadItems(
|
||||||
val error: LiveData<String> = _error
|
parentId: UUID,
|
||||||
|
libraryType: String?,
|
||||||
fun loadItems(parentId: UUID, libraryType: String?) {
|
sortBy: SortBy = SortBy.defaultValue,
|
||||||
_error.value = null
|
sortOrder: SortOrder = SortOrder.ASCENDING
|
||||||
_finishedLoading.value = false
|
) {
|
||||||
Timber.d("$libraryType")
|
Timber.d("$libraryType")
|
||||||
val itemType = when (libraryType) {
|
val itemType = when (libraryType) {
|
||||||
"movies" -> "Movie"
|
"movies" -> "Movie"
|
||||||
|
@ -33,17 +44,19 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
uiState.emit(UiState.Loading)
|
||||||
try {
|
try {
|
||||||
_items.value = jellyfinRepository.getItems(
|
val items = jellyfinRepository.getItems(
|
||||||
parentId,
|
parentId,
|
||||||
includeTypes = if (itemType != null) listOf(itemType) else null,
|
includeTypes = if (itemType != null) listOf(itemType) else null,
|
||||||
recursive = true
|
recursive = true,
|
||||||
|
sortBy = sortBy,
|
||||||
|
sortOrder = sortOrder
|
||||||
)
|
)
|
||||||
|
uiState.emit(UiState.Normal(items))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e)
|
uiState.emit(UiState.Error(e.message))
|
||||||
_error.value = e.toString()
|
|
||||||
}
|
}
|
||||||
_finishedLoading.value = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,12 +1,18 @@
|
||||||
package dev.jdtech.jellyfin.viewmodels
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import android.content.res.Resources
|
||||||
import androidx.lifecycle.*
|
import androidx.lifecycle.*
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
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.api.JellyfinApi
|
||||||
import dev.jdtech.jellyfin.database.Server
|
import dev.jdtech.jellyfin.database.Server
|
||||||
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||||
import kotlinx.coroutines.Dispatchers
|
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.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.model.api.AuthenticateUserByName
|
import org.jellyfin.sdk.model.api.AuthenticateUserByName
|
||||||
|
@ -18,17 +24,30 @@ import javax.inject.Inject
|
||||||
class LoginViewModel
|
class LoginViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
|
application: BaseApplication,
|
||||||
private val sharedPreferences: SharedPreferences,
|
private val sharedPreferences: SharedPreferences,
|
||||||
private val jellyfinApi: JellyfinApi,
|
private val jellyfinApi: JellyfinApi,
|
||||||
private val database: ServerDatabaseDao
|
private val database: ServerDatabaseDao
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
private val resources: Resources = application.resources
|
||||||
|
|
||||||
private val _error = MutableLiveData<String>()
|
private val uiState = MutableStateFlow<UiState>(UiState.Normal)
|
||||||
val error: LiveData<String> = _error
|
|
||||||
|
|
||||||
|
private val navigateToMain = MutableSharedFlow<Boolean>()
|
||||||
|
|
||||||
private val _navigateToMain = MutableLiveData<Boolean>()
|
sealed class UiState {
|
||||||
val navigateToMain: LiveData<Boolean> = _navigateToMain
|
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
|
* Send a authentication request to the Jellyfin server
|
||||||
|
@ -38,6 +57,8 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun login(username: String, password: String) {
|
fun login(username: String, password: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
uiState.emit(UiState.Loading)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val authenticationResult by jellyfinApi.userApi.authenticateUserByName(
|
val authenticationResult by jellyfinApi.userApi.authenticateUserByName(
|
||||||
data = AuthenticateUserByName(
|
data = AuthenticateUserByName(
|
||||||
|
@ -45,8 +66,9 @@ constructor(
|
||||||
pw = password
|
pw = password
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
_error.value = null
|
|
||||||
val serverInfo by jellyfinApi.systemApi.getPublicSystemInfo()
|
val serverInfo by jellyfinApi.systemApi.getPublicSystemInfo()
|
||||||
|
|
||||||
val server = Server(
|
val server = Server(
|
||||||
serverInfo.id!!,
|
serverInfo.id!!,
|
||||||
serverInfo.serverName!!,
|
serverInfo.serverName!!,
|
||||||
|
@ -55,18 +77,27 @@ constructor(
|
||||||
authenticationResult.user?.name!!,
|
authenticationResult.user?.name!!,
|
||||||
authenticationResult.accessToken!!
|
authenticationResult.accessToken!!
|
||||||
)
|
)
|
||||||
|
|
||||||
insert(server)
|
insert(server)
|
||||||
|
|
||||||
val spEdit = sharedPreferences.edit()
|
val spEdit = sharedPreferences.edit()
|
||||||
spEdit.putString("selectedServer", server.id)
|
spEdit.putString("selectedServer", server.id)
|
||||||
spEdit.apply()
|
spEdit.apply()
|
||||||
|
|
||||||
jellyfinApi.apply {
|
jellyfinApi.apply {
|
||||||
api.accessToken = authenticationResult.accessToken
|
api.accessToken = authenticationResult.accessToken
|
||||||
userId = authenticationResult.user?.id
|
userId = authenticationResult.user?.id
|
||||||
}
|
}
|
||||||
_navigateToMain.value = true
|
|
||||||
|
uiState.emit(UiState.Normal)
|
||||||
|
navigateToMain.emit(true)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e)
|
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)
|
database.insert(server)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun doneNavigatingToMain() {
|
|
||||||
_navigateToMain.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,28 +1,23 @@
|
||||||
package dev.jdtech.jellyfin.viewmodels
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.jdtech.jellyfin.api.JellyfinApi
|
|
||||||
import dev.jdtech.jellyfin.database.Server
|
import dev.jdtech.jellyfin.database.Server
|
||||||
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.*
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class MainViewModel
|
class MainViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val sharedPreferences: SharedPreferences,
|
|
||||||
private val database: ServerDatabaseDao,
|
private val database: ServerDatabaseDao,
|
||||||
private val jellyfinApi: JellyfinApi,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _doneLoading = MutableLiveData<Boolean>()
|
private val _doneLoading = MutableLiveData<Boolean>()
|
||||||
|
@ -40,21 +35,10 @@ constructor(
|
||||||
}
|
}
|
||||||
if (servers.isEmpty()) {
|
if (servers.isEmpty()) {
|
||||||
_navigateToAddServer.value = true
|
_navigateToAddServer.value = true
|
||||||
} else {
|
|
||||||
val serverId = sharedPreferences.getString("selectedServer", null)
|
|
||||||
val selectedServer = servers.find { server -> server.id == serverId }
|
|
||||||
Timber.d("Selected server: $selectedServer")
|
|
||||||
if (selectedServer != null) {
|
|
||||||
jellyfinApi.apply {
|
|
||||||
api.baseUrl = selectedServer.address
|
|
||||||
api.accessToken = selectedServer.accessToken
|
|
||||||
userId = UUID.fromString(selectedServer.userId)
|
|
||||||
}
|
|
||||||
Timber.d("Finish Main")
|
|
||||||
}
|
|
||||||
_doneLoading.value = true
|
|
||||||
}
|
}
|
||||||
|
_doneLoading.value = true
|
||||||
}
|
}
|
||||||
|
_doneLoading.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun doneNavigateToAddServer() {
|
fun doneNavigateToAddServer() {
|
||||||
|
|
|
@ -1,105 +1,162 @@
|
||||||
package dev.jdtech.jellyfin.viewmodels
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LifecycleCoroutineScope
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dev.jdtech.jellyfin.models.DownloadRequestItem
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
import dev.jdtech.jellyfin.models.PlayerItem
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
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.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemPerson
|
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 timber.log.Timber
|
||||||
import java.util.*
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class MediaInfoViewModel
|
class MediaInfoViewModel
|
||||||
@Inject
|
@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>()
|
sealed class UiState {
|
||||||
val item: LiveData<BaseItemDto> = _item
|
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>>()
|
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
|
||||||
val actors: LiveData<List<BaseItemPerson>> = _actors
|
scope.launch { uiState.collect { collector(it) } }
|
||||||
|
}
|
||||||
|
|
||||||
private val _director = MutableLiveData<BaseItemPerson>()
|
var item: BaseItemDto? = null
|
||||||
val director: LiveData<BaseItemPerson> = _director
|
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>>()
|
private lateinit var downloadRequestItem: DownloadRequestItem
|
||||||
val writers: LiveData<List<BaseItemPerson>> = _writers
|
|
||||||
private val _writersString = MutableLiveData<String>()
|
|
||||||
val writersString: LiveData<String> = _writersString
|
|
||||||
|
|
||||||
private val _genresString = MutableLiveData<String>()
|
lateinit var playerItem: PlayerItem
|
||||||
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
|
|
||||||
|
|
||||||
fun loadData(itemId: UUID, itemType: String) {
|
fun loadData(itemId: UUID, itemType: String) {
|
||||||
_error.value = null
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
uiState.emit(UiState.Loading)
|
||||||
try {
|
try {
|
||||||
_item.value = jellyfinRepository.getItem(itemId)
|
val tempItem = jellyfinRepository.getItem(itemId)
|
||||||
_actors.value = getActors(_item.value!!)
|
item = tempItem
|
||||||
_director.value = getDirector(_item.value!!)
|
actors = getActors(tempItem)
|
||||||
_writers.value = getWriters(_item.value!!)
|
director = getDirector(tempItem)
|
||||||
_writersString.value =
|
writers = getWriters(tempItem)
|
||||||
_writers.value?.joinToString(separator = ", ") { it.name.toString() }
|
writersString = writers.joinToString(separator = ", ") { it.name.toString() }
|
||||||
_genresString.value = _item.value?.genres?.joinToString(separator = ", ")
|
genresString = tempItem.genres?.joinToString(separator = ", ") ?: ""
|
||||||
_runTime.value = "${_item.value?.runTimeTicks?.div(600000000)} min"
|
runTime = "${tempItem.runTimeTicks?.div(600000000)} min"
|
||||||
_dateString.value = getDateString(_item.value!!)
|
dateString = getDateString(tempItem)
|
||||||
_played.value = _item.value?.userData?.played
|
played = tempItem.userData?.played ?: false
|
||||||
_favorite.value = _item.value?.userData?.isFavorite
|
favorite = tempItem.userData?.isFavorite ?: false
|
||||||
|
downloaded = itemIsDownloaded(itemId)
|
||||||
if (itemType == "Series") {
|
if (itemType == "Series") {
|
||||||
_nextUp.value = getNextUp(itemId)
|
nextUp = getNextUp(itemId)
|
||||||
_seasons.value = jellyfinRepository.getSeasons(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) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e)
|
Timber.d(e)
|
||||||
_error.value = e.toString()
|
Timber.d(itemId.toString())
|
||||||
|
uiState.emit(UiState.Error(e.message))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getActors(item: BaseItemDto): List<BaseItemPerson>? {
|
fun loadData(pItem: PlayerItem) {
|
||||||
val actors: List<BaseItemPerson>?
|
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) {
|
withContext(Dispatchers.Default) {
|
||||||
actors = item.people?.filter { it.type == "Actor" }
|
actors = item.people?.filter { it.type == "Actor" } ?: emptyList()
|
||||||
}
|
}
|
||||||
return actors
|
return actors
|
||||||
}
|
}
|
||||||
|
@ -112,10 +169,10 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
|
||||||
return director
|
return director
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getWriters(item: BaseItemDto): List<BaseItemPerson>? {
|
private suspend fun getWriters(item: BaseItemDto): List<BaseItemPerson> {
|
||||||
val writers: List<BaseItemPerson>?
|
val writers: List<BaseItemPerson>
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
writers = item.people?.filter { it.type == "Writer" }
|
writers = item.people?.filter { it.type == "Writer" } ?: emptyList()
|
||||||
}
|
}
|
||||||
return writers
|
return writers
|
||||||
}
|
}
|
||||||
|
@ -133,28 +190,28 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
jellyfinRepository.markAsPlayed(itemId)
|
jellyfinRepository.markAsPlayed(itemId)
|
||||||
}
|
}
|
||||||
_played.value = true
|
played = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun markAsUnplayed(itemId: UUID) {
|
fun markAsUnplayed(itemId: UUID) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
jellyfinRepository.markAsUnplayed(itemId)
|
jellyfinRepository.markAsUnplayed(itemId)
|
||||||
}
|
}
|
||||||
_played.value = false
|
played = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun markAsFavorite(itemId: UUID) {
|
fun markAsFavorite(itemId: UUID) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
jellyfinRepository.markAsFavorite(itemId)
|
jellyfinRepository.markAsFavorite(itemId)
|
||||||
}
|
}
|
||||||
_favorite.value = true
|
favorite = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unmarkAsFavorite(itemId: UUID) {
|
fun unmarkAsFavorite(itemId: UUID) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
jellyfinRepository.unmarkAsFavorite(itemId)
|
jellyfinRepository.unmarkAsFavorite(itemId)
|
||||||
}
|
}
|
||||||
_favorite.value = false
|
favorite = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDateString(item: BaseItemDto): String {
|
private fun getDateString(item: BaseItemDto): String {
|
||||||
|
@ -178,96 +235,19 @@ constructor(private val jellyfinRepository: JellyfinRepository) : ViewModel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun preparePlayerItems(mediaSourceIndex: Int? = null) {
|
fun loadDownloadRequestItem(itemId: UUID) {
|
||||||
_playerItemsError.value = null
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
val downloadItem = item
|
||||||
createPlayerItems(_item.value!!, mediaSourceIndex)
|
val uri =
|
||||||
_navigateToPlayer.value = playerItems.toTypedArray()
|
jellyfinRepository.getStreamUrl(itemId, downloadItem?.mediaSources?.get(0)?.id!!)
|
||||||
} catch (e: Exception) {
|
val metadata = baseItemDtoToDownloadMetadata(downloadItem)
|
||||||
_playerItemsError.value = e.message
|
downloadRequestItem = DownloadRequestItem(uri, itemId, metadata)
|
||||||
}
|
downloadMedia = true
|
||||||
|
requestDownload(Uri.parse(downloadRequestItem.uri), downloadRequestItem, application)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun createPlayerItems(series: BaseItemDto, mediaSourceIndex: Int? = null) {
|
fun deleteItem() {
|
||||||
playerItems.clear()
|
deleteDownloadedEpisode(playerItem.mediaSourceUri)
|
||||||
|
|
||||||
val playbackPosition = item.value?.userData?.playbackPositionTicks?.div(10000) ?: 0
|
|
||||||
|
|
||||||
// Intros
|
|
||||||
var introsCount = 0
|
|
||||||
|
|
||||||
if (playbackPosition <= 0) {
|
|
||||||
val intros = jellyfinRepository.getIntros(series.id)
|
|
||||||
for (intro in intros) {
|
|
||||||
if (intro.mediaSources.isNullOrEmpty()) continue
|
|
||||||
playerItems.add(PlayerItem(intro.name, intro.id, intro.mediaSources?.get(0)?.id!!, 0))
|
|
||||||
introsCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
when (series.type) {
|
|
||||||
"Movie" -> {
|
|
||||||
playerItems.add(
|
|
||||||
PlayerItem(
|
|
||||||
series.name,
|
|
||||||
series.id,
|
|
||||||
series.mediaSources?.get(mediaSourceIndex ?: 0)?.id!!,
|
|
||||||
playbackPosition
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
"Series" -> {
|
|
||||||
if (nextUp.value != null) {
|
|
||||||
val startEpisode = nextUp.value!!
|
|
||||||
val episodes = jellyfinRepository.getEpisodes(
|
|
||||||
startEpisode.seriesId!!,
|
|
||||||
startEpisode.seasonId!!,
|
|
||||||
startItemId = startEpisode.id,
|
|
||||||
fields = listOf(ItemFields.MEDIA_SOURCES)
|
|
||||||
)
|
|
||||||
for (episode in episodes) {
|
|
||||||
if (episode.mediaSources.isNullOrEmpty()) continue
|
|
||||||
if (episode.locationType == LocationType.VIRTUAL) continue
|
|
||||||
playerItems.add(
|
|
||||||
PlayerItem(
|
|
||||||
episode.name,
|
|
||||||
episode.id,
|
|
||||||
episode.mediaSources?.get(0)?.id!!,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (season in seasons.value!!) {
|
|
||||||
if (season.indexNumber == 0) continue
|
|
||||||
val episodes = jellyfinRepository.getEpisodes(
|
|
||||||
series.id,
|
|
||||||
season.id,
|
|
||||||
fields = listOf(ItemFields.MEDIA_SOURCES)
|
|
||||||
)
|
|
||||||
for (episode in episodes) {
|
|
||||||
if (episode.mediaSources.isNullOrEmpty()) continue
|
|
||||||
if (episode.locationType == LocationType.VIRTUAL) continue
|
|
||||||
playerItems.add(
|
|
||||||
PlayerItem(
|
|
||||||
episode.name,
|
|
||||||
episode.id,
|
|
||||||
episode.mediaSources?.get(0)?.id!!,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playerItems.isEmpty() || playerItems.count() == introsCount) throw Exception("No playable items found")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun doneNavigatingToPlayer() {
|
|
||||||
_navigateToPlayer.value = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -2,10 +2,12 @@ package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
import androidx.lifecycle.*
|
import androidx.lifecycle.*
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dev.jdtech.jellyfin.models.unsupportedCollections
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import timber.log.Timber
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
@ -15,38 +17,35 @@ constructor(
|
||||||
private val jellyfinRepository: JellyfinRepository
|
private val jellyfinRepository: JellyfinRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _collections = MutableLiveData<List<BaseItemDto>>()
|
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||||
val collections: LiveData<List<BaseItemDto>> = _collections
|
|
||||||
|
|
||||||
private val _finishedLoading = MutableLiveData<Boolean>()
|
sealed class UiState {
|
||||||
val finishedLoading: LiveData<Boolean> = _finishedLoading
|
data class Normal(val collections: List<BaseItemDto>) : UiState()
|
||||||
|
object Loading : UiState()
|
||||||
|
data class Error(val message: String?) : UiState()
|
||||||
|
}
|
||||||
|
|
||||||
private val _error = MutableLiveData<String>()
|
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
|
||||||
val error: LiveData<String> = _error
|
scope.launch { uiState.collect { collector(it) } }
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadData() {
|
fun loadData() {
|
||||||
_finishedLoading.value = false
|
|
||||||
_error.value = null
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
uiState.emit(UiState.Loading)
|
||||||
try {
|
try {
|
||||||
val items = jellyfinRepository.getItems()
|
val items = jellyfinRepository.getItems()
|
||||||
_collections.value =
|
val collections =
|
||||||
items.filter {
|
items.filter { collection -> unsupportedCollections().none { it.type == collection.collectionType } }
|
||||||
it.collectionType != "homevideos" &&
|
uiState.emit(UiState.Normal(collections))
|
||||||
it.collectionType != "music" &&
|
|
||||||
it.collectionType != "playlists" &&
|
|
||||||
it.collectionType != "boxsets" &&
|
|
||||||
it.collectionType != "books"
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e)
|
uiState.emit(
|
||||||
_error.value = e.toString()
|
UiState.Error(e.message)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
_finishedLoading.value = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.preference.PreferenceManager
|
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 com.google.android.exoplayer2.trackselection.DefaultTrackSelector
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.jdtech.jellyfin.models.PlayerItem
|
import dev.jdtech.jellyfin.models.PlayerItem
|
||||||
import dev.jdtech.jellyfin.mpv.MPVPlayer
|
import dev.jdtech.jellyfin.mpv.MPVPlayer
|
||||||
import dev.jdtech.jellyfin.mpv.TrackType
|
import dev.jdtech.jellyfin.mpv.TrackType
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
|
import dev.jdtech.jellyfin.utils.postDownloadPlaybackProgress
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.*
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
@ -46,14 +52,16 @@ constructor(
|
||||||
|
|
||||||
val trackSelector = DefaultTrackSelector(application)
|
val trackSelector = DefaultTrackSelector(application)
|
||||||
var playWhenReady = true
|
var playWhenReady = true
|
||||||
|
private var playFromDownloads = false
|
||||||
private var currentWindow = 0
|
private var currentWindow = 0
|
||||||
private var playbackPosition: Long = 0
|
private var playbackPosition: Long = 0
|
||||||
|
|
||||||
|
var playbackSpeed: Float = 1f
|
||||||
|
|
||||||
private val sp = PreferenceManager.getDefaultSharedPreferences(application)
|
private val sp = PreferenceManager.getDefaultSharedPreferences(application)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val useMpv = sp.getBoolean("mpv_player", false)
|
val useMpv = sp.getBoolean("mpv_player", false)
|
||||||
|
|
||||||
val preferredAudioLanguage = sp.getString("audio_language", null) ?: ""
|
val preferredAudioLanguage = sp.getString("audio_language", null) ?: ""
|
||||||
val preferredSubtitleLanguage = sp.getString("subtitle_language", null) ?: ""
|
val preferredSubtitleLanguage = sp.getString("subtitle_language", null) ?: ""
|
||||||
|
|
||||||
|
@ -93,10 +101,14 @@ constructor(
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val mediaItems: MutableList<MediaItem> = mutableListOf()
|
val mediaItems: MutableList<MediaItem> = mutableListOf()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (item in items) {
|
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")
|
Timber.d("Stream url: $streamUrl")
|
||||||
val mediaItem =
|
val mediaItem =
|
||||||
MediaItem.Builder()
|
MediaItem.Builder()
|
||||||
|
@ -110,11 +122,12 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
player.setMediaItems(mediaItems, currentWindow, items[0].playbackPosition)
|
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()
|
player.play()
|
||||||
|
pollPosition(player)
|
||||||
}
|
}
|
||||||
|
|
||||||
pollPosition(player)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun releasePlayer() {
|
private fun releasePlayer() {
|
||||||
|
@ -144,6 +157,9 @@ constructor(
|
||||||
override fun run() {
|
override fun run() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (player.currentMediaItem != null) {
|
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 {
|
try {
|
||||||
jellyfinRepository.postPlaybackProgress(
|
jellyfinRepository.postPlaybackProgress(
|
||||||
UUID.fromString(player.currentMediaItem!!.mediaId),
|
UUID.fromString(player.currentMediaItem!!.mediaId),
|
||||||
|
@ -225,4 +241,9 @@ constructor(
|
||||||
player.selectTrack(trackType, isExternal = false, index = track.ffIndex)
|
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
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LifecycleCoroutineScope
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.jdtech.jellyfin.models.FavoriteSection
|
import dev.jdtech.jellyfin.models.FavoriteSection
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ -20,36 +20,37 @@ class SearchResultViewModel
|
||||||
constructor(
|
constructor(
|
||||||
private val jellyfinRepository: JellyfinRepository
|
private val jellyfinRepository: JellyfinRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _sections = MutableLiveData<List<FavoriteSection>>()
|
private val uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||||
val sections: LiveData<List<FavoriteSection>> = _sections
|
|
||||||
|
|
||||||
private val _finishedLoading = MutableLiveData<Boolean>()
|
sealed class UiState {
|
||||||
val finishedLoading: LiveData<Boolean> = _finishedLoading
|
data class Normal(val sections: List<FavoriteSection>) : UiState()
|
||||||
|
object Loading : UiState()
|
||||||
|
data class Error(val message: String?) : UiState()
|
||||||
|
}
|
||||||
|
|
||||||
private val _error = MutableLiveData<String>()
|
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
|
||||||
val error: LiveData<String> = _error
|
scope.launch { uiState.collect { collector(it) } }
|
||||||
|
}
|
||||||
|
|
||||||
fun loadData(query: String) {
|
fun loadData(query: String) {
|
||||||
_error.value = null
|
|
||||||
_finishedLoading.value = false
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
uiState.emit(UiState.Loading)
|
||||||
try {
|
try {
|
||||||
val items = jellyfinRepository.getSearchItems(query)
|
val items = jellyfinRepository.getSearchItems(query)
|
||||||
|
|
||||||
if (items.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
_sections.value = listOf()
|
uiState.emit(UiState.Normal(emptyList()))
|
||||||
_finishedLoading.value = true
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val tempSections = mutableListOf<FavoriteSection>()
|
val sections = mutableListOf<FavoriteSection>()
|
||||||
|
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
FavoriteSection(
|
FavoriteSection(
|
||||||
UUID.randomUUID(),
|
UUID.randomUUID(),
|
||||||
"Movies",
|
"Movies",
|
||||||
items.filter { it.type == "Movie" }).let {
|
items.filter { it.type == "Movie" }).let {
|
||||||
if (it.items.isNotEmpty()) tempSections.add(
|
if (it.items.isNotEmpty()) sections.add(
|
||||||
it
|
it
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -57,7 +58,7 @@ constructor(
|
||||||
UUID.randomUUID(),
|
UUID.randomUUID(),
|
||||||
"Shows",
|
"Shows",
|
||||||
items.filter { it.type == "Series" }).let {
|
items.filter { it.type == "Series" }).let {
|
||||||
if (it.items.isNotEmpty()) tempSections.add(
|
if (it.items.isNotEmpty()) sections.add(
|
||||||
it
|
it
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -65,18 +66,16 @@ constructor(
|
||||||
UUID.randomUUID(),
|
UUID.randomUUID(),
|
||||||
"Episodes",
|
"Episodes",
|
||||||
items.filter { it.type == "Episode" }).let {
|
items.filter { it.type == "Episode" }).let {
|
||||||
if (it.items.isNotEmpty()) tempSections.add(
|
if (it.items.isNotEmpty()) sections.add(
|
||||||
it
|
it
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_sections.value = tempSections
|
uiState.emit(UiState.Normal(sections))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e)
|
uiState.emit(UiState.Error(e.message))
|
||||||
_error.value = e.toString()
|
|
||||||
}
|
}
|
||||||
_finishedLoading.value = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,48 +1,49 @@
|
||||||
package dev.jdtech.jellyfin.viewmodels
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.*
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.jdtech.jellyfin.adapters.EpisodeItem
|
import dev.jdtech.jellyfin.adapters.EpisodeItem
|
||||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.ItemFields
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SeasonViewModel
|
class SeasonViewModel
|
||||||
@Inject
|
@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>>()
|
sealed class UiState {
|
||||||
val episodes: LiveData<List<EpisodeItem>> = _episodes
|
data class Normal(val episodes: List<EpisodeItem>) : UiState()
|
||||||
|
object Loading : UiState()
|
||||||
|
data class Error(val message: String?) : UiState()
|
||||||
|
}
|
||||||
|
|
||||||
private val _finishedLoading = MutableLiveData<Boolean>()
|
fun onUiState(scope: LifecycleCoroutineScope, collector: (UiState) -> Unit) {
|
||||||
val finishedLoading: LiveData<Boolean> = _finishedLoading
|
scope.launch { uiState.collect { collector(it) } }
|
||||||
|
}
|
||||||
private val _error = MutableLiveData<String>()
|
|
||||||
val error: LiveData<String> = _error
|
|
||||||
|
|
||||||
fun loadEpisodes(seriesId: UUID, seasonId: UUID) {
|
fun loadEpisodes(seriesId: UUID, seasonId: UUID) {
|
||||||
_error.value = null
|
|
||||||
_finishedLoading.value = false
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
uiState.emit(UiState.Loading)
|
||||||
try {
|
try {
|
||||||
_episodes.value = getEpisodes(seriesId, seasonId)
|
val episodes = getEpisodes(seriesId, seasonId)
|
||||||
|
uiState.emit(UiState.Normal(episodes))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e)
|
uiState.emit(UiState.Error(e.message))
|
||||||
_error.value = e.toString()
|
|
||||||
}
|
}
|
||||||
_finishedLoading.value = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getEpisodes(seriesId: UUID, seasonId: UUID): List<EpisodeItem> {
|
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) }
|
return listOf(EpisodeItem.Header) + episodes.map { EpisodeItem.Episode(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,8 +1,7 @@
|
||||||
package dev.jdtech.jellyfin.viewmodels
|
package dev.jdtech.jellyfin.viewmodels
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LifecycleCoroutineScope
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
@ -10,6 +9,9 @@ import dev.jdtech.jellyfin.api.JellyfinApi
|
||||||
import dev.jdtech.jellyfin.database.Server
|
import dev.jdtech.jellyfin.database.Server
|
||||||
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||||
import kotlinx.coroutines.Dispatchers
|
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.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
@ -23,12 +25,17 @@ constructor(
|
||||||
private val jellyfinApi: JellyfinApi,
|
private val jellyfinApi: JellyfinApi,
|
||||||
private val database: ServerDatabaseDao,
|
private val database: ServerDatabaseDao,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
val servers = database.getAllServers()
|
||||||
|
|
||||||
private val _servers = database.getAllServers()
|
private val navigateToMain = MutableSharedFlow<Boolean>(
|
||||||
val servers: LiveData<List<Server>> = _servers
|
replay = 0,
|
||||||
|
extraBufferCapacity = 1,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||||
|
)
|
||||||
|
|
||||||
private val _navigateToMain = MutableLiveData<Boolean>()
|
fun onNavigateToMain(scope: LifecycleCoroutineScope, collector: (Boolean) -> Unit) {
|
||||||
val navigateToMain: LiveData<Boolean> = _navigateToMain
|
scope.launch { navigateToMain.collect { collector(it) } }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete server from database
|
* Delete server from database
|
||||||
|
@ -54,10 +61,6 @@ constructor(
|
||||||
userId = UUID.fromString(server.userId)
|
userId = UUID.fromString(server.userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
_navigateToMain.value = true
|
navigateToMain.tryEmit(true)
|
||||||
}
|
|
||||||
|
|
||||||
fun doneNavigatingToMain() {
|
|
||||||
_navigateToMain.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
<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">
|
<item android:id="@android:id/background">
|
||||||
<shape android:shape="rectangle">
|
<shape android:shape="rectangle">
|
||||||
<solid android:color="@color/neutral_700" />
|
<solid android:color="@color/neutral_700" />
|
||||||
|
|
|
@ -1,11 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:color="@color/primary_variant">
|
android:shape="rectangle">
|
||||||
<item android:id="@android:id/background">
|
<solid android:color="?attr/colorPrimary" />
|
||||||
<shape android:shape="rectangle">
|
<corners android:radius="10dp" />
|
||||||
<solid android:color="@color/primary" />
|
</shape>
|
||||||
<corners
|
|
||||||
android:radius="10dp" />
|
|
||||||
</shape>
|
|
||||||
</item>
|
|
||||||
</ripple>
|
|
||||||
|
|