mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Merge branch 'main' into fea/customize-home
This commit is contained in:
commit
d99c947068
20 changed files with 290 additions and 132 deletions
|
|
@ -31,7 +31,7 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
|
|||
### User interface
|
||||
|
||||
- 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
|
||||
- Show Movie/TV Show titles when browsing libraries
|
||||
- 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!
|
||||
|
||||
Jellyseerr integration is tested with `v2.7.3`. Older versions may not work.
|
||||
|
||||
## Contributions
|
||||
|
||||
Issues and pull requests are always welcome! Please check before submitting that your issue or pull request is not a duplicate.
|
||||
|
|
|
|||
|
|
@ -419,8 +419,12 @@ class MainActivityViewModel
|
|||
prefs.currentUserId?.toUUIDOrNull(),
|
||||
)
|
||||
if (current != null) {
|
||||
// Restored
|
||||
navigationManager.navigateTo(SetupDestination.AppContent(current))
|
||||
if (current.user.hasPin) {
|
||||
navigationManager.navigateTo(SetupDestination.UserList(current.server))
|
||||
} else {
|
||||
// Restored
|
||||
navigationManager.navigateTo(SetupDestination.AppContent(current))
|
||||
}
|
||||
} else {
|
||||
// Did not restore
|
||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||
|
|
|
|||
|
|
@ -789,7 +789,7 @@ sealed interface AppPreference<Pref, T> {
|
|||
val MpvGpuNext =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.mpv_use_gpu_next,
|
||||
defaultValue = true,
|
||||
defaultValue = false,
|
||||
getter = { it.playbackPreferences.mpvOptions.useGpuNext },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateMpvOptions { useGpuNext = value }
|
||||
|
|
|
|||
|
|
@ -209,4 +209,12 @@ suspend fun upgradeApp(
|
|||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import okhttp3.OkHttpClient
|
|||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
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.
|
||||
|
|
@ -137,7 +138,17 @@ class SeerrServerRepository
|
|||
username: String?,
|
||||
passwordOrApiKey: String,
|
||||
): 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)
|
||||
return LoadingState.Success
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ fun DiscoverMovieDetails(
|
|||
val people by viewModel.people.observeAsState(listOf())
|
||||
val trailers by viewModel.trailers.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 userConfig by viewModel.userConfig.collectAsState(null)
|
||||
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
|
||||
|
|
@ -365,7 +365,7 @@ fun DiscoverMovieDetailsContent(
|
|||
item {
|
||||
ItemRow(
|
||||
title = stringResource(R.string.recommended),
|
||||
items = similar,
|
||||
items = recommended,
|
||||
onClickItem = { index, item ->
|
||||
position = RECOMMENDED_ROW
|
||||
onClickItem.invoke(index, item)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.update
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import timber.log.Timber
|
||||
|
||||
@HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class)
|
||||
class DiscoverMovieViewModel
|
||||
|
|
@ -67,8 +68,8 @@ class DiscoverMovieViewModel
|
|||
|
||||
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
||||
val people = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||
val similar = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||
val recommended = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||
val similar = MutableLiveData<List<DiscoverItem>>()
|
||||
val recommended = MutableLiveData<List<DiscoverItem>>()
|
||||
val canCancelRequest = MutableStateFlow(false)
|
||||
|
||||
val userConfig = seerrServerRepository.current.map { it?.config }
|
||||
|
|
@ -99,6 +100,7 @@ class DiscoverMovieViewModel
|
|||
"Error fetching movie",
|
||||
),
|
||||
) {
|
||||
Timber.v("Init for movie %s", item.id)
|
||||
val movie = fetchAndSetItem().await()
|
||||
val discoveredItem = DiscoverItem(movie)
|
||||
backdropService.submit(discoveredItem)
|
||||
|
|
@ -116,7 +118,7 @@ class DiscoverMovieViewModel
|
|||
viewModelScope.launchIO {
|
||||
val result =
|
||||
seerrService.api.moviesApi
|
||||
.movieMovieIdSimilarGet(movieId = item.id, page = 2)
|
||||
.movieMovieIdSimilarGet(movieId = item.id, page = 1)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
|
|
@ -125,11 +127,11 @@ class DiscoverMovieViewModel
|
|||
viewModelScope.launchIO {
|
||||
val result =
|
||||
seerrService.api.moviesApi
|
||||
.movieMovieIdRecommendationsGet(movieId = item.id, page = 2)
|
||||
.movieMovieIdRecommendationsGet(movieId = item.id, page = 1)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
similar.setValueOnMain(result)
|
||||
recommended.setValueOnMain(result)
|
||||
}
|
||||
}
|
||||
val people =
|
||||
|
|
@ -147,7 +149,7 @@ class DiscoverMovieViewModel
|
|||
?.filter { it.type == RelatedVideo.Type.TRAILER }
|
||||
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
|
||||
?.map {
|
||||
RemoteTrailer(it.name!!, it.url!!, null)
|
||||
RemoteTrailer(it.name!!, it.url!!, it.site)
|
||||
}.orEmpty()
|
||||
this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ fun DiscoverSeriesDetails(
|
|||
val item by viewModel.tvSeries.observeAsState()
|
||||
val seasons by viewModel.seasons.observeAsState(listOf())
|
||||
val people by viewModel.people.observeAsState(listOf())
|
||||
val trailers by viewModel.trailers.observeAsState(listOf())
|
||||
val similar by viewModel.similar.observeAsState(listOf())
|
||||
val recommended by viewModel.recommended.observeAsState(listOf())
|
||||
val userConfig by viewModel.userConfig.collectAsState(null)
|
||||
|
|
@ -155,7 +156,7 @@ fun DiscoverSeriesDetails(
|
|||
trailerOnClick = {
|
||||
TrailerService.onClick(context, it, viewModel::navigateTo)
|
||||
},
|
||||
trailers = listOf(),
|
||||
trailers = trailers,
|
||||
requestOnClick = {
|
||||
item.id?.let { id ->
|
||||
if (request4kEnabled) {
|
||||
|
|
@ -255,7 +256,7 @@ fun DiscoverSeriesDetailsContent(
|
|||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
|
||||
var position by rememberInt()
|
||||
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
|
||||
val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } }
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
||||
|
|
@ -398,7 +399,7 @@ fun DiscoverSeriesDetailsContent(
|
|||
item {
|
||||
ItemRow(
|
||||
title = stringResource(R.string.recommended),
|
||||
items = similar,
|
||||
items = recommended,
|
||||
onClickItem = { index, item ->
|
||||
position = RECOMMENDED_ROW
|
||||
onClickItem.invoke(index, item)
|
||||
|
|
|
|||
|
|
@ -4,17 +4,20 @@ import android.content.Context
|
|||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
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.Season
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
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.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||
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.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
|
|
@ -36,6 +39,7 @@ import kotlinx.coroutines.flow.update
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import timber.log.Timber
|
||||
|
||||
@HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class)
|
||||
class DiscoverSeriesViewModel
|
||||
|
|
@ -62,8 +66,8 @@ class DiscoverSeriesViewModel
|
|||
val seasons = MutableLiveData<List<Season>>(listOf())
|
||||
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
||||
val people = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||
val similar = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||
val recommended = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||
val similar = MutableLiveData<List<DiscoverItem>>()
|
||||
val recommended = MutableLiveData<List<DiscoverItem>>()
|
||||
val canCancelRequest = MutableStateFlow(false)
|
||||
|
||||
val userConfig = seerrServerRepository.current.map { it?.config }
|
||||
|
|
@ -94,6 +98,7 @@ class DiscoverSeriesViewModel
|
|||
"Error fetching movie",
|
||||
),
|
||||
) {
|
||||
Timber.v("Init for tv %s", item.id)
|
||||
val tv = fetchAndSetItem().await()
|
||||
val discoveredItem = DiscoverItem(tv)
|
||||
backdropService.submit(discoveredItem)
|
||||
|
|
@ -110,8 +115,8 @@ class DiscoverSeriesViewModel
|
|||
if (!similar.isInitialized) {
|
||||
viewModelScope.launchIO {
|
||||
val result =
|
||||
seerrService.api.moviesApi
|
||||
.movieMovieIdSimilarGet(movieId = item.id, page = 2)
|
||||
seerrService.api.tvApi
|
||||
.tvTvIdSimilarGet(tvId = item.id, page = 1)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
|
|
@ -119,12 +124,12 @@ class DiscoverSeriesViewModel
|
|||
}
|
||||
viewModelScope.launchIO {
|
||||
val result =
|
||||
seerrService.api.moviesApi
|
||||
.movieMovieIdRecommendationsGet(movieId = item.id, page = 2)
|
||||
seerrService.api.tvApi
|
||||
.tvTvIdRecommendationsGet(tvId = item.id, page = 1)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
similar.setValueOnMain(result)
|
||||
recommended.setValueOnMain(result)
|
||||
}
|
||||
}
|
||||
val people =
|
||||
|
|
@ -137,6 +142,15 @@ class DiscoverSeriesViewModel
|
|||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
|
@ -112,6 +113,9 @@ class SeerrRequestsViewModel
|
|||
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.PlaylistLoadingState
|
||||
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.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
|
||||
|
|
@ -206,15 +207,15 @@ fun HomePageContent(
|
|||
var firstFocused by remember { mutableStateOf(false) }
|
||||
if (takeFocus) {
|
||||
LaunchedEffect(homeRows) {
|
||||
if (!firstFocused) {
|
||||
if (!firstFocused && homeRows.isNotEmpty()) {
|
||||
if (position.row >= 0) {
|
||||
rowFocusRequesters[position.row].tryRequestFocus()
|
||||
val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex)
|
||||
rowFocusRequesters.getOrNull(index)?.tryRequestFocus()
|
||||
firstFocused = true
|
||||
} else {
|
||||
// Waiting for the first home row to load, then focus on it
|
||||
homeRows
|
||||
.indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
|
||||
.takeIf { it >= 0 }
|
||||
.indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
|
||||
?.let {
|
||||
rowFocusRequesters[it].tryRequestFocus()
|
||||
firstFocused = true
|
||||
|
|
|
|||
|
|
@ -219,18 +219,22 @@ class PlaybackViewModel
|
|||
PlayerBackend.PREFER_MPV -> if (isHdr) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV
|
||||
}
|
||||
|
||||
Timber.i("Selected backend: %s", playerBackend)
|
||||
withContext(Dispatchers.Main) {
|
||||
disconnectPlayer()
|
||||
}
|
||||
Timber.d("Selected backend: %s", playerBackend)
|
||||
if (currentPlayer.value?.backend != playerBackend) {
|
||||
Timber.i("Switching player backend to %s", playerBackend)
|
||||
withContext(Dispatchers.Main) {
|
||||
disconnectPlayer()
|
||||
}
|
||||
|
||||
player =
|
||||
playerFactory.createVideoPlayer(
|
||||
playerBackend,
|
||||
preferences.appPreferences.playbackPreferences,
|
||||
)
|
||||
currentPlayer.update {
|
||||
PlayerState(player, playerBackend)
|
||||
player =
|
||||
playerFactory.createVideoPlayer(
|
||||
playerBackend,
|
||||
preferences.appPreferences.playbackPreferences,
|
||||
)
|
||||
currentPlayer.update {
|
||||
PlayerState(player, playerBackend)
|
||||
}
|
||||
configurePlayer()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -429,7 +433,6 @@ class PlaybackViewModel
|
|||
|
||||
// Create the correct player for the media
|
||||
createPlayer(videoStream?.hdr == true)
|
||||
configurePlayer()
|
||||
|
||||
val subtitleStreams =
|
||||
mediaSource.mediaStreams
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ import androidx.tv.material3.surfaceColorAtElevation
|
|||
import coil3.SingletonImageLoader
|
||||
import coil3.imageLoader
|
||||
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.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
|
||||
|
|
@ -504,13 +503,7 @@ fun PreferencesContent(
|
|||
AddSeerServerDialog(
|
||||
currentUsername = currentUser?.name,
|
||||
status = status,
|
||||
onSubmit = { url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod ->
|
||||
if (method == SeerrAuthMethod.API_KEY) {
|
||||
seerrVm.submitServer(url, passwordOrApiKey)
|
||||
} else {
|
||||
seerrVm.submitServer(url, username ?: "", passwordOrApiKey, method)
|
||||
}
|
||||
},
|
||||
onSubmit = seerrVm::submitServer,
|
||||
onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -30,6 +32,7 @@ import androidx.tv.material3.MaterialTheme
|
|||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
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.TextButton
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
|
|
@ -240,7 +243,7 @@ fun AddSeerrServerUsername(
|
|||
},
|
||||
),
|
||||
isInputValid = { true },
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
modifier = Modifier.focusRequester(usernameFocusRequester),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
|
|
@ -283,12 +286,23 @@ fun AddSeerrServerUsername(
|
|||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
stringRes = R.string.submit,
|
||||
Button(
|
||||
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),
|
||||
)
|
||||
) {
|
||||
if (status != LoadingState.Loading) {
|
||||
Text(text = stringResource(R.string.submit))
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.border,
|
||||
modifier =
|
||||
Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import com.github.damontecres.wholphin.util.LoadingState
|
|||
fun AddSeerServerDialog(
|
||||
currentUsername: String?,
|
||||
status: LoadingState,
|
||||
onSubmit: (url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit,
|
||||
onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) }
|
||||
|
|
@ -49,7 +49,7 @@ fun AddSeerServerDialog(
|
|||
) {
|
||||
AddSeerrServerApiKey(
|
||||
onSubmit = { url, apiKey ->
|
||||
onSubmit.invoke(url, null, apiKey, SeerrAuthMethod.API_KEY)
|
||||
onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY)
|
||||
},
|
||||
status = status,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin.ui.setup.seerr
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.SeerrService
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import jakarta.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import timber.log.Timber
|
||||
|
||||
@HiltViewModel
|
||||
class SwitchSeerrViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val seerrService: SeerrService,
|
||||
private val serverRepository: ServerRepository,
|
||||
|
|
@ -28,75 +33,70 @@ class SwitchSeerrViewModel
|
|||
|
||||
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(
|
||||
url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
passwordOrApiKey: String,
|
||||
authMethod: SeerrAuthMethod,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
val url = cleanUrl(url)
|
||||
val result =
|
||||
serverConnectionStatus.update { LoadingState.Loading }
|
||||
val urls =
|
||||
try {
|
||||
seerrServerRepository.testConnection(
|
||||
authMethod = authMethod,
|
||||
url = url,
|
||||
username = username,
|
||||
passwordOrApiKey = password,
|
||||
)
|
||||
} catch (ex: ClientException) {
|
||||
Timber.w(ex, "Error logging in via %s", authMethod)
|
||||
if (ex.statusCode == 401 || ex.statusCode == 403) {
|
||||
LoadingState.Error("Invalid credentials", ex)
|
||||
} else {
|
||||
createUrls(url)
|
||||
} catch (ex: IllegalArgumentException) {
|
||||
showToast(context, "Invalid URL")
|
||||
serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) }
|
||||
return@launchIO
|
||||
}
|
||||
var result: LoadingState = LoadingState.Error("No url")
|
||||
for (url in urls) {
|
||||
Timber.d("Trying %s", url)
|
||||
result =
|
||||
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)
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
|
@ -112,3 +112,39 @@ class SwitchSeerrViewModel
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,17 +39,9 @@ object MPVLib {
|
|||
|
||||
external fun create(appctx: Context)
|
||||
|
||||
fun initialize() {
|
||||
synchronized(this) { init() }
|
||||
}
|
||||
external fun init()
|
||||
|
||||
private external fun init()
|
||||
|
||||
fun tearDown() {
|
||||
synchronized(this) { destroy() }
|
||||
}
|
||||
|
||||
private external fun destroy()
|
||||
external fun destroy()
|
||||
|
||||
external fun attachSurface(surface: Surface)
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ class MpvPlayer(
|
|||
SurfaceHolder.Callback {
|
||||
companion object {
|
||||
private const val DEBUG = false
|
||||
|
||||
private val initLock = Any()
|
||||
}
|
||||
|
||||
private var surface: Surface? = null
|
||||
|
|
@ -131,7 +133,7 @@ class MpvPlayer(
|
|||
MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}")
|
||||
|
||||
Timber.v("Initializing MPVLib")
|
||||
MPVLib.initialize()
|
||||
MPVLib.init()
|
||||
|
||||
MPVLib.setOptionString("force-window", "no")
|
||||
MPVLib.setOptionString("idle", "yes")
|
||||
|
|
@ -1086,14 +1088,18 @@ class MpvPlayer(
|
|||
}
|
||||
|
||||
MpvCommand.INITIALIZE -> {
|
||||
init()
|
||||
synchronized(initLock) {
|
||||
init()
|
||||
}
|
||||
}
|
||||
|
||||
MpvCommand.DESTROY -> {
|
||||
clearVideoSurfaceView(null)
|
||||
MPVLib.removeLogObserver(mpvLogger)
|
||||
MPVLib.tearDown()
|
||||
Timber.d("MPVLib destroyed")
|
||||
synchronized(initLock) {
|
||||
MPVLib.setPropertyBoolean("pause", true)
|
||||
MPVLib.removeLogObserver(mpvLogger)
|
||||
MPVLib.destroy()
|
||||
Timber.d("MPVLib destroyed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -897,8 +897,6 @@ components:
|
|||
- Bloopers
|
||||
site:
|
||||
type: string
|
||||
enum:
|
||||
- 'YouTube'
|
||||
MovieDetails:
|
||||
type: object
|
||||
properties:
|
||||
|
|
@ -1226,6 +1224,10 @@ components:
|
|||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/WatchProviders'
|
||||
relatedVideos:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RelatedVideo'
|
||||
MediaRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue