Merge branch 'main' into fea/customize-home

This commit is contained in:
Damontecres 2026-01-27 11:47:22 -05:00
commit d99c947068
No known key found for this signature in database
20 changed files with 290 additions and 132 deletions

View file

@ -31,7 +31,7 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
### User interface ### User interface
- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app - A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app
- Integration with [Seerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows - Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows
- Option to combine Continue Watching & Next Up rows - Option to combine Continue Watching & Next Up rows
- Show Movie/TV Show titles when browsing libraries - Show Movie/TV Show titles when browsing libraries
- Play theme music, if available - Play theme music, if available
@ -93,6 +93,8 @@ Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x
The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue! The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue!
Jellyseerr integration is tested with `v2.7.3`. Older versions may not work.
## Contributions ## Contributions
Issues and pull requests are always welcome! Please check before submitting that your issue or pull request is not a duplicate. Issues and pull requests are always welcome! Please check before submitting that your issue or pull request is not a duplicate.

View file

@ -419,8 +419,12 @@ class MainActivityViewModel
prefs.currentUserId?.toUUIDOrNull(), prefs.currentUserId?.toUUIDOrNull(),
) )
if (current != null) { if (current != null) {
// Restored if (current.user.hasPin) {
navigationManager.navigateTo(SetupDestination.AppContent(current)) navigationManager.navigateTo(SetupDestination.UserList(current.server))
} else {
// Restored
navigationManager.navigateTo(SetupDestination.AppContent(current))
}
} else { } else {
// Did not restore // Did not restore
navigationManager.navigateTo(SetupDestination.ServerList) navigationManager.navigateTo(SetupDestination.ServerList)

View file

@ -789,7 +789,7 @@ sealed interface AppPreference<Pref, T> {
val MpvGpuNext = val MpvGpuNext =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.mpv_use_gpu_next, title = R.string.mpv_use_gpu_next,
defaultValue = true, defaultValue = false,
getter = { it.playbackPreferences.mpvOptions.useGpuNext }, getter = { it.playbackPreferences.mpvOptions.useGpuNext },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updateMpvOptions { useGpuNext = value } prefs.updateMpvOptions { useGpuNext = value }

View file

@ -209,4 +209,12 @@ suspend fun upgradeApp(
} }
showToast(context, context.getString(R.string.upgrade_mpv_toast), Toast.LENGTH_LONG) showToast(context, context.getString(R.string.upgrade_mpv_toast), Toast.LENGTH_LONG)
} }
if (previous.isEqualOrBefore(Version.fromString("0.4.0-2-g0"))) {
appPreferences.updateData {
it.updateMpvOptions {
useGpuNext = false
}
}
}
} }

View file

@ -30,6 +30,7 @@ import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
/** /**
* Manages saves/loading Seerr servers from the local DB. Also will update the current [SeerrApi] as needed. * Manages saves/loading Seerr servers from the local DB. Also will update the current [SeerrApi] as needed.
@ -137,7 +138,17 @@ class SeerrServerRepository
username: String?, username: String?,
passwordOrApiKey: String, passwordOrApiKey: String,
): LoadingState { ): LoadingState {
val api = SeerrApiClient(url, passwordOrApiKey, okHttpClient) val apiKey = passwordOrApiKey.takeIf { authMethod == SeerrAuthMethod.API_KEY }
val api =
SeerrApiClient(
url,
apiKey,
okHttpClient
.newBuilder()
.connectTimeout(5.seconds)
.readTimeout(6.seconds)
.build(),
)
login(api, authMethod, username, passwordOrApiKey) login(api, authMethod, username, passwordOrApiKey)
return LoadingState.Success return LoadingState.Success
} }

View file

@ -90,7 +90,7 @@ fun DiscoverMovieDetails(
val people by viewModel.people.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf()) val trailers by viewModel.trailers.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf())
val recommended by viewModel.similar.observeAsState(listOf()) val recommended by viewModel.recommended.observeAsState(listOf())
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val userConfig by viewModel.userConfig.collectAsState(null) val userConfig by viewModel.userConfig.collectAsState(null)
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
@ -365,7 +365,7 @@ fun DiscoverMovieDetailsContent(
item { item {
ItemRow( ItemRow(
title = stringResource(R.string.recommended), title = stringResource(R.string.recommended),
items = similar, items = recommended,
onClickItem = { index, item -> onClickItem = { index, item ->
position = RECOMMENDED_ROW position = RECOMMENDED_ROW
onClickItem.invoke(index, item) onClickItem.invoke(index, item)

View file

@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import timber.log.Timber
@HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class) @HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class)
class DiscoverMovieViewModel class DiscoverMovieViewModel
@ -67,8 +68,8 @@ class DiscoverMovieViewModel
val trailers = MutableLiveData<List<Trailer>>(listOf()) val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<DiscoverItem>>(listOf()) val people = MutableLiveData<List<DiscoverItem>>(listOf())
val similar = MutableLiveData<List<DiscoverItem>>(listOf()) val similar = MutableLiveData<List<DiscoverItem>>()
val recommended = MutableLiveData<List<DiscoverItem>>(listOf()) val recommended = MutableLiveData<List<DiscoverItem>>()
val canCancelRequest = MutableStateFlow(false) val canCancelRequest = MutableStateFlow(false)
val userConfig = seerrServerRepository.current.map { it?.config } val userConfig = seerrServerRepository.current.map { it?.config }
@ -99,6 +100,7 @@ class DiscoverMovieViewModel
"Error fetching movie", "Error fetching movie",
), ),
) { ) {
Timber.v("Init for movie %s", item.id)
val movie = fetchAndSetItem().await() val movie = fetchAndSetItem().await()
val discoveredItem = DiscoverItem(movie) val discoveredItem = DiscoverItem(movie)
backdropService.submit(discoveredItem) backdropService.submit(discoveredItem)
@ -116,7 +118,7 @@ class DiscoverMovieViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
seerrService.api.moviesApi seerrService.api.moviesApi
.movieMovieIdSimilarGet(movieId = item.id, page = 2) .movieMovieIdSimilarGet(movieId = item.id, page = 1)
.results .results
?.map(::DiscoverItem) ?.map(::DiscoverItem)
.orEmpty() .orEmpty()
@ -125,11 +127,11 @@ class DiscoverMovieViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
seerrService.api.moviesApi seerrService.api.moviesApi
.movieMovieIdRecommendationsGet(movieId = item.id, page = 2) .movieMovieIdRecommendationsGet(movieId = item.id, page = 1)
.results .results
?.map(::DiscoverItem) ?.map(::DiscoverItem)
.orEmpty() .orEmpty()
similar.setValueOnMain(result) recommended.setValueOnMain(result)
} }
} }
val people = val people =
@ -147,7 +149,7 @@ class DiscoverMovieViewModel
?.filter { it.type == RelatedVideo.Type.TRAILER } ?.filter { it.type == RelatedVideo.Type.TRAILER }
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
?.map { ?.map {
RemoteTrailer(it.name!!, it.url!!, null) RemoteTrailer(it.name!!, it.url!!, it.site)
}.orEmpty() }.orEmpty()
this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers) this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers)
} }

View file

@ -89,6 +89,7 @@ fun DiscoverSeriesDetails(
val item by viewModel.tvSeries.observeAsState() val item by viewModel.tvSeries.observeAsState()
val seasons by viewModel.seasons.observeAsState(listOf()) val seasons by viewModel.seasons.observeAsState(listOf())
val people by viewModel.people.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf())
val recommended by viewModel.recommended.observeAsState(listOf()) val recommended by viewModel.recommended.observeAsState(listOf())
val userConfig by viewModel.userConfig.collectAsState(null) val userConfig by viewModel.userConfig.collectAsState(null)
@ -155,7 +156,7 @@ fun DiscoverSeriesDetails(
trailerOnClick = { trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo) TrailerService.onClick(context, it, viewModel::navigateTo)
}, },
trailers = listOf(), trailers = trailers,
requestOnClick = { requestOnClick = {
item.id?.let { id -> item.id?.let { id ->
if (request4kEnabled) { if (request4kEnabled) {
@ -255,7 +256,7 @@ fun DiscoverSeriesDetailsContent(
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
var position by rememberInt() var position by rememberInt()
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } }
val playFocusRequester = remember { FocusRequester() } val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequesters.getOrNull(position)?.tryRequestFocus() focusRequesters.getOrNull(position)?.tryRequestFocus()
@ -398,7 +399,7 @@ fun DiscoverSeriesDetailsContent(
item { item {
ItemRow( ItemRow(
title = stringResource(R.string.recommended), title = stringResource(R.string.recommended),
items = similar, items = recommended,
onClickItem = { index, item -> onClickItem = { index, item ->
position = RECOMMENDED_ROW position = RECOMMENDED_ROW
onClickItem.invoke(index, item) onClickItem.invoke(index, item)

View file

@ -4,17 +4,20 @@ import android.content.Context
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 com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
import com.github.damontecres.wholphin.api.seerr.model.Season import com.github.damontecres.wholphin.api.seerr.model.Season
import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
@ -36,6 +39,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import timber.log.Timber
@HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class) @HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class)
class DiscoverSeriesViewModel class DiscoverSeriesViewModel
@ -62,8 +66,8 @@ class DiscoverSeriesViewModel
val seasons = MutableLiveData<List<Season>>(listOf()) val seasons = MutableLiveData<List<Season>>(listOf())
val trailers = MutableLiveData<List<Trailer>>(listOf()) val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<DiscoverItem>>(listOf()) val people = MutableLiveData<List<DiscoverItem>>(listOf())
val similar = MutableLiveData<List<DiscoverItem>>(listOf()) val similar = MutableLiveData<List<DiscoverItem>>()
val recommended = MutableLiveData<List<DiscoverItem>>(listOf()) val recommended = MutableLiveData<List<DiscoverItem>>()
val canCancelRequest = MutableStateFlow(false) val canCancelRequest = MutableStateFlow(false)
val userConfig = seerrServerRepository.current.map { it?.config } val userConfig = seerrServerRepository.current.map { it?.config }
@ -94,6 +98,7 @@ class DiscoverSeriesViewModel
"Error fetching movie", "Error fetching movie",
), ),
) { ) {
Timber.v("Init for tv %s", item.id)
val tv = fetchAndSetItem().await() val tv = fetchAndSetItem().await()
val discoveredItem = DiscoverItem(tv) val discoveredItem = DiscoverItem(tv)
backdropService.submit(discoveredItem) backdropService.submit(discoveredItem)
@ -110,8 +115,8 @@ class DiscoverSeriesViewModel
if (!similar.isInitialized) { if (!similar.isInitialized) {
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
seerrService.api.moviesApi seerrService.api.tvApi
.movieMovieIdSimilarGet(movieId = item.id, page = 2) .tvTvIdSimilarGet(tvId = item.id, page = 1)
.results .results
?.map(::DiscoverItem) ?.map(::DiscoverItem)
.orEmpty() .orEmpty()
@ -119,12 +124,12 @@ class DiscoverSeriesViewModel
} }
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
seerrService.api.moviesApi seerrService.api.tvApi
.movieMovieIdRecommendationsGet(movieId = item.id, page = 2) .tvTvIdRecommendationsGet(tvId = item.id, page = 1)
.results .results
?.map(::DiscoverItem) ?.map(::DiscoverItem)
.orEmpty() .orEmpty()
similar.setValueOnMain(result) recommended.setValueOnMain(result)
} }
} }
val people = val people =
@ -137,6 +142,15 @@ class DiscoverSeriesViewModel
?.map(::DiscoverItem) ?.map(::DiscoverItem)
.orEmpty() .orEmpty()
this@DiscoverSeriesViewModel.people.setValueOnMain(people) this@DiscoverSeriesViewModel.people.setValueOnMain(people)
val trailers =
tv.relatedVideos
?.filter { it.type == RelatedVideo.Type.TRAILER }
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
?.map {
RemoteTrailer(it.name!!, it.url!!, it.site)
}.orEmpty()
this@DiscoverSeriesViewModel.trailers.setValueOnMain(trailers)
} }
fun navigateTo(destination: Destination) { fun navigateTo(destination: Destination) {

View file

@ -40,6 +40,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -112,6 +113,9 @@ class SeerrRequestsViewModel
state.update { it.copy(requests = DataLoadingState.Success(results)) } state.update { it.copy(requests = DataLoadingState.Success(results)) }
} }
}.catch { ex ->
Timber.e(ex, "Error fetching requests")
state.update { it.copy(requests = DataLoadingState.Error(ex)) }
}.launchIn(viewModelScope) }.launchIn(viewModelScope)
} }

View file

@ -64,6 +64,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
@ -206,15 +207,15 @@ fun HomePageContent(
var firstFocused by remember { mutableStateOf(false) } var firstFocused by remember { mutableStateOf(false) }
if (takeFocus) { if (takeFocus) {
LaunchedEffect(homeRows) { LaunchedEffect(homeRows) {
if (!firstFocused) { if (!firstFocused && homeRows.isNotEmpty()) {
if (position.row >= 0) { if (position.row >= 0) {
rowFocusRequesters[position.row].tryRequestFocus() val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex)
rowFocusRequesters.getOrNull(index)?.tryRequestFocus()
firstFocused = true firstFocused = true
} else { } else {
// Waiting for the first home row to load, then focus on it // Waiting for the first home row to load, then focus on it
homeRows homeRows
.indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
.takeIf { it >= 0 }
?.let { ?.let {
rowFocusRequesters[it].tryRequestFocus() rowFocusRequesters[it].tryRequestFocus()
firstFocused = true firstFocused = true

View file

@ -219,18 +219,22 @@ class PlaybackViewModel
PlayerBackend.PREFER_MPV -> if (isHdr) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV PlayerBackend.PREFER_MPV -> if (isHdr) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV
} }
Timber.i("Selected backend: %s", playerBackend) Timber.d("Selected backend: %s", playerBackend)
withContext(Dispatchers.Main) { if (currentPlayer.value?.backend != playerBackend) {
disconnectPlayer() Timber.i("Switching player backend to %s", playerBackend)
} withContext(Dispatchers.Main) {
disconnectPlayer()
}
player = player =
playerFactory.createVideoPlayer( playerFactory.createVideoPlayer(
playerBackend, playerBackend,
preferences.appPreferences.playbackPreferences, preferences.appPreferences.playbackPreferences,
) )
currentPlayer.update { currentPlayer.update {
PlayerState(player, playerBackend) PlayerState(player, playerBackend)
}
configurePlayer()
} }
} }
@ -429,7 +433,6 @@ class PlaybackViewModel
// Create the correct player for the media // Create the correct player for the media
createPlayer(videoStream?.hdr == true) createPlayer(videoStream?.hdr == true)
configurePlayer()
val subtitleStreams = val subtitleStreams =
mediaSource.mediaStreams mediaSource.mediaStreams

View file

@ -43,7 +43,6 @@ import androidx.tv.material3.surfaceColorAtElevation
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.imageLoader import coil3.imageLoader
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
@ -504,13 +503,7 @@ fun PreferencesContent(
AddSeerServerDialog( AddSeerServerDialog(
currentUsername = currentUser?.name, currentUsername = currentUser?.name,
status = status, status = status,
onSubmit = { url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod -> onSubmit = seerrVm::submitServer,
if (method == SeerrAuthMethod.API_KEY) {
seerrVm.submitServer(url, passwordOrApiKey)
} else {
seerrVm.submitServer(url, username ?: "", passwordOrApiKey, method)
}
},
onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
) )
} }

View file

@ -6,10 +6,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -30,6 +32,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -240,7 +243,7 @@ fun AddSeerrServerUsername(
}, },
), ),
isInputValid = { true }, isInputValid = { true },
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(usernameFocusRequester),
) )
} }
Row( Row(
@ -283,12 +286,23 @@ fun AddSeerrServerUsername(
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
) )
} }
TextButton( Button(
stringRes = R.string.submit,
onClick = { onSubmit.invoke(url, username, password) }, onClick = { onSubmit.invoke(url, username, password) },
enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() && password.isNotNullOrBlank(), enabled =
error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() &&
status != LoadingState.Loading,
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
) ) {
if (status != LoadingState.Loading) {
Text(text = stringResource(R.string.submit))
} else {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier =
Modifier.size(24.dp),
)
}
}
} }
} }

View file

@ -17,7 +17,7 @@ import com.github.damontecres.wholphin.util.LoadingState
fun AddSeerServerDialog( fun AddSeerServerDialog(
currentUsername: String?, currentUsername: String?,
status: LoadingState, status: LoadingState,
onSubmit: (url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) } var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) }
@ -49,7 +49,7 @@ fun AddSeerServerDialog(
) { ) {
AddSeerrServerApiKey( AddSeerrServerApiKey(
onSubmit = { url, apiKey -> onSubmit = { url, apiKey ->
onSubmit.invoke(url, null, apiKey, SeerrAuthMethod.API_KEY) onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY)
}, },
status = status, status = status,
) )

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.setup.seerr package com.github.damontecres.wholphin.ui.setup.seerr
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
@ -8,18 +9,22 @@ import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import jakarta.inject.Inject import jakarta.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import timber.log.Timber import timber.log.Timber
@HiltViewModel @HiltViewModel
class SwitchSeerrViewModel class SwitchSeerrViewModel
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context,
private val seerrServerRepository: SeerrServerRepository, private val seerrServerRepository: SeerrServerRepository,
private val seerrService: SeerrService, private val seerrService: SeerrService,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
@ -28,75 +33,70 @@ class SwitchSeerrViewModel
val serverConnectionStatus = MutableStateFlow<LoadingState>(LoadingState.Pending) val serverConnectionStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
private fun cleanUrl(url: String) =
if (!url.endsWith("/api/v1")) {
url
.toHttpUrlOrNull()
?.newBuilder()
?.apply {
addPathSegment("api")
addPathSegment("v1")
}?.build()
.toString()
} else {
url
}
fun submitServer(
url: String,
apiKey: String,
) {
viewModelScope.launchIO {
val url = cleanUrl(url)
val result =
try {
seerrServerRepository.testConnection(
authMethod = SeerrAuthMethod.API_KEY,
url = url,
username = null,
passwordOrApiKey = apiKey,
)
} catch (ex: ClientException) {
Timber.w(ex, "Error logging in via API Key")
if (ex.statusCode == 401 || ex.statusCode == 403) {
LoadingState.Error("Invalid credentials", ex)
} else {
LoadingState.Error(ex)
}
}
if (result is LoadingState.Success) {
seerrServerRepository.addAndChangeServer(url, apiKey)
}
serverConnectionStatus.update { result }
}
}
fun submitServer( fun submitServer(
url: String, url: String,
username: String, username: String,
password: String, passwordOrApiKey: String,
authMethod: SeerrAuthMethod, authMethod: SeerrAuthMethod,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val url = cleanUrl(url) serverConnectionStatus.update { LoadingState.Loading }
val result = val urls =
try { try {
seerrServerRepository.testConnection( createUrls(url)
authMethod = authMethod, } catch (ex: IllegalArgumentException) {
url = url, showToast(context, "Invalid URL")
username = username, serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) }
passwordOrApiKey = password, return@launchIO
) }
} catch (ex: ClientException) { var result: LoadingState = LoadingState.Error("No url")
Timber.w(ex, "Error logging in via %s", authMethod) for (url in urls) {
if (ex.statusCode == 401 || ex.statusCode == 403) { Timber.d("Trying %s", url)
LoadingState.Error("Invalid credentials", ex) result =
} else { try {
seerrServerRepository.testConnection(
authMethod = authMethod,
url = url.toString(),
username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY },
passwordOrApiKey = passwordOrApiKey,
)
} catch (ex: ClientException) {
Timber.w(ex, "Error logging in")
if (ex.statusCode == 401 || ex.statusCode == 403) {
showToast(context, "Invalid credentials")
LoadingState.Error("Invalid credentials", ex)
break
} else {
LoadingState.Error("Could not connect with URL")
}
} catch (ex: Exception) {
LoadingState.Error(ex) LoadingState.Error(ex)
} }
if (result is LoadingState.Success) {
when (authMethod) {
SeerrAuthMethod.LOCAL,
SeerrAuthMethod.JELLYFIN,
-> {
seerrServerRepository.addAndChangeServer(
url.toString(),
authMethod,
username,
passwordOrApiKey,
)
}
SeerrAuthMethod.API_KEY -> {
seerrServerRepository.addAndChangeServer(
url.toString(),
passwordOrApiKey,
)
}
}
break
} }
if (result is LoadingState.Success) { }
seerrServerRepository.addAndChangeServer(url, authMethod, username, password) if (result is LoadingState.Error) {
showToast(context, "Error: ${result.message}")
} }
serverConnectionStatus.update { result } serverConnectionStatus.update { result }
} }
@ -112,3 +112,39 @@ class SwitchSeerrViewModel
serverConnectionStatus.update { LoadingState.Pending } serverConnectionStatus.update { LoadingState.Pending }
} }
} }
fun createUrls(url: String): List<HttpUrl> {
val urls = mutableListOf<String>()
if (url.startsWith("http://") || url.startsWith("https://")) {
urls.add(url)
val httpUrl = url.toHttpUrl()
if (HttpUrl.defaultPort(httpUrl.scheme) == httpUrl.port) {
urls.add("$url:5055")
}
} else {
urls.add("http://$url")
val httpUrl = "http://$url".toHttpUrl()
if (httpUrl.port == 80) {
urls.add("https://$url")
urls.add("http://$url:5055")
urls.add("https://$url:5055")
} else {
urls.add("https://$url")
}
}
return urls.map { cleanUrl(it).toHttpUrl() }
}
private fun cleanUrl(url: String) =
if (!url.endsWith("/api/v1")) {
url
.toHttpUrl()
.newBuilder()
.apply {
addPathSegment("api")
addPathSegment("v1")
}.build()
.toString()
} else {
url
}

View file

@ -39,17 +39,9 @@ object MPVLib {
external fun create(appctx: Context) external fun create(appctx: Context)
fun initialize() { external fun init()
synchronized(this) { init() }
}
private external fun init() external fun destroy()
fun tearDown() {
synchronized(this) { destroy() }
}
private external fun destroy()
external fun attachSurface(surface: Surface) external fun attachSurface(surface: Surface)

View file

@ -74,6 +74,8 @@ class MpvPlayer(
SurfaceHolder.Callback { SurfaceHolder.Callback {
companion object { companion object {
private const val DEBUG = false private const val DEBUG = false
private val initLock = Any()
} }
private var surface: Surface? = null private var surface: Surface? = null
@ -131,7 +133,7 @@ class MpvPlayer(
MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}")
Timber.v("Initializing MPVLib") Timber.v("Initializing MPVLib")
MPVLib.initialize() MPVLib.init()
MPVLib.setOptionString("force-window", "no") MPVLib.setOptionString("force-window", "no")
MPVLib.setOptionString("idle", "yes") MPVLib.setOptionString("idle", "yes")
@ -1086,14 +1088,18 @@ class MpvPlayer(
} }
MpvCommand.INITIALIZE -> { MpvCommand.INITIALIZE -> {
init() synchronized(initLock) {
init()
}
} }
MpvCommand.DESTROY -> { MpvCommand.DESTROY -> {
clearVideoSurfaceView(null) synchronized(initLock) {
MPVLib.removeLogObserver(mpvLogger) MPVLib.setPropertyBoolean("pause", true)
MPVLib.tearDown() MPVLib.removeLogObserver(mpvLogger)
Timber.d("MPVLib destroyed") MPVLib.destroy()
Timber.d("MPVLib destroyed")
}
} }
} }
} }

View file

@ -897,8 +897,6 @@ components:
- Bloopers - Bloopers
site: site:
type: string type: string
enum:
- 'YouTube'
MovieDetails: MovieDetails:
type: object type: object
properties: properties:
@ -1226,6 +1224,10 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/WatchProviders' $ref: '#/components/schemas/WatchProviders'
relatedVideos:
type: array
items:
$ref: '#/components/schemas/RelatedVideo'
MediaRequest: MediaRequest:
type: object type: object
properties: properties:

View file

@ -0,0 +1,65 @@
package com.github.damontecres.wholphin.test
import com.github.damontecres.wholphin.ui.setup.seerr.createUrls
import org.junit.Assert
import org.junit.Test
class TestSeerr {
@Test
fun testCreateUrls() {
val urls =
createUrls("jellyseerr.com")
.map { it.toString() }
val expected =
listOf(
"http://jellyseerr.com/api/v1",
"https://jellyseerr.com/api/v1",
"http://jellyseerr.com:5055/api/v1",
"https://jellyseerr.com:5055/api/v1",
)
Assert.assertEquals(expected, urls)
}
@Test
fun testCreateUrls2() {
val urls =
createUrls("https://jellyseerr.com")
.map { it.toString() }
val expected =
listOf(
"https://jellyseerr.com/api/v1",
"https://jellyseerr.com:5055/api/v1",
)
Assert.assertEquals(expected, urls)
}
@Test
fun testCreateUrls3() {
val urls =
createUrls("http://jellyseerr.com")
.map { it.toString() }
val expected =
listOf(
"http://jellyseerr.com/api/v1",
"http://jellyseerr.com:5055/api/v1",
)
Assert.assertEquals(expected, urls)
}
@Test
fun testCreateUrls4() {
val urls =
createUrls("jellyseerr.com:5055")
.map { it.toString() }
val expected =
listOf(
"http://jellyseerr.com:5055/api/v1",
"https://jellyseerr.com:5055/api/v1",
)
Assert.assertEquals(expected, urls)
}
}