Merge branch 'main' into fea/music

This commit is contained in:
Damontecres 2026-03-04 19:31:36 -05:00
commit 11587c5fcf
No known key found for this signature in database
27 changed files with 614 additions and 139 deletions

View file

@ -47,7 +47,7 @@ interface SeerrServerDao {
suspend fun deleteUser(
serverId: Int,
jellyfinUserRowId: Int,
)
): Int
suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId)

View file

@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ApplicationContext
@ -40,11 +41,16 @@ class MediaManagementService
val deletedItemFlow: SharedFlow<DeletedItem> = _deletedItemFlow
suspend fun canDelete(item: BaseItem): Boolean {
val appPreferences = userPreferencesService.getCurrent().appPreferences
return canDelete(item, appPreferences)
}
fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean {
Timber.v("canDelete %s: %s", item.id, item.canDelete)
val enabled =
userPreferencesService
.getCurrent()
.appPreferences.interfacePreferences.enableMediaManagement
val enabled = appPreferences.interfacePreferences.enableMediaManagement
return enabled &&
item.canDelete &&
if (item.type == BaseItemKind.RECORDING) {

View file

@ -158,33 +158,58 @@ class RefreshRateService
if (refreshRateSwitch) {
displayModes
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
.filter {
it.refreshRateRounded % streamRate == 0 || // Exact multiple
it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps
}
.filter { frameRateMatches(it.refreshRateRounded, streamRate) }
} else {
displayModes
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
}
// Timber.v("display modes candidates: %s", candidates.joinToString("\n"))
return if (!resolutionSwitch) {
candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
candidates
.filter { it.refreshRateRounded == streamRate }
.maxByOrNull { it.physicalWidth * it.physicalHeight }
?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
} else {
// Exact resolution & frame rate
candidates.firstOrNull {
it.physicalWidth == streamWidth &&
it.physicalHeight == streamHeight &&
it.refreshRateRounded == streamRate
}
?: candidates.firstOrNull {
it.physicalWidth == streamWidth &&
// Next highest resolution, exact frame rate
?: candidates.lastOrNull {
it.physicalWidth >= streamWidth &&
it.physicalHeight >= streamHeight &&
it.refreshRateRounded == streamRate
}
// Exact resolution, acceptable frame rate
?: candidates.lastOrNull {
it.physicalWidth == streamWidth &&
it.physicalHeight == streamHeight &&
frameRateMatches(it.refreshRateRounded, streamRate)
}
// Next highest resolution, acceptable frame rate
?: candidates.lastOrNull {
it.physicalWidth >= streamWidth &&
it.physicalHeight >= streamHeight &&
frameRateMatches(it.refreshRateRounded, streamRate)
}
// Fall back to largest resolution, exact frame rate
?: candidates
.filter { it.refreshRateRounded == streamRate }
.maxByOrNull { it.physicalWidth * it.physicalHeight }
// Finally, just the highest resolution
?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
}
}
fun frameRateMatches(
refreshRateRounded: Int,
streamRate: Int,
): Boolean {
return refreshRateRounded % streamRate == 0 || // Exact multiple
refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps
}
}
}

View file

@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.api.seerr.model.PublicSettings
import com.github.damontecres.wholphin.api.seerr.model.User
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.SeerrServer
@ -24,8 +25,10 @@ import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.supervisorScope
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
@ -162,7 +165,7 @@ class SeerrServerRepository
apiKey,
okHttpClient
.newBuilder()
.connectTimeout(5.seconds)
.connectTimeout(2.seconds)
.readTimeout(6.seconds)
.build(),
)
@ -170,10 +173,11 @@ class SeerrServerRepository
return LoadingState.Success
}
suspend fun removeServer() {
val current = (_connection.value as? SeerrConnectionStatus.Success)?.current ?: return
seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
suspend fun removeServerForCurrentUser(): Boolean {
val current = current.firstOrNull() ?: return false
val rows = seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
clear()
return rows > 0
}
}
@ -266,6 +270,14 @@ class UserSwitchListener
seerrServerRepository.clear()
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
if (user != null) {
switchUser(user)
}
}
}
}
private suspend fun switchUser(user: JellyfinUser) =
supervisorScope {
// Check for home settings
launchIO {
homeSettingsService.loadCurrentSettings(user.id)
@ -293,7 +305,11 @@ class UserSwitchListener
} else {
seerrApi.api.usersApi.authMeGet()
}
seerrServerRepository.set(server, seerrUser, userConfig)
seerrServerRepository.set(
server,
seerrUser,
userConfig,
)
} catch (ex: Exception) {
Timber.w(
ex,
@ -307,6 +323,3 @@ class UserSwitchListener
}
}
}
}
}
}

View file

@ -57,6 +57,7 @@ import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
@ -65,6 +66,7 @@ import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.SlimItemFields
@ -77,6 +79,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.equalsNotNull
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageHeader
@ -84,6 +87,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.FilterUtils
@ -100,6 +104,9 @@ import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
@ -202,18 +209,34 @@ class CollectionFolderViewModel
loading.setValueOnMain(DataLoadingState.Error(ex))
}
}
viewModelScope.launchDefault {
mediaManagementService.deletedItemFlow.collect { deletedItem ->
mediaManagementService.deletedItemFlow
.onEach { deletedItem ->
refreshAfterDelete(position, deletedItem.item)
}.catch { ex ->
Timber.e(ex, "Error refreshing after deleted item")
}.launchIn(viewModelScope)
}
private suspend fun refreshAfterDelete(
position: Int,
deletedItem: BaseItem,
) {
try {
val pager =
((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
position.let {
Timber.v("Item deleted: position=%s, id=%s", it, deletedItem.item.id)
Timber.v("Item deleted: position=%s, id=%s", it, itemId)
val item = pager?.get(it)
if (item?.id == deletedItem.item.id) {
pager.refreshPagesAfter(position)
}
// Exact item deleted (eg a movie) or deleted item was within the series
if (item?.id == deletedItem.id ||
equalsNotNull(item?.data?.id, deletedItem.data.seriesId)
) {
pager?.refreshPagesAfter(position)
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error refreshing after deleted item %s", itemId)
showToast(context, "Error refreshing after item deleted")
}
}
@ -491,6 +514,22 @@ class CollectionFolderViewModel
}
}
}
fun deleteItem(
index: Int,
item: BaseItem,
) {
deleteItem(context, mediaManagementService, item) {
viewModelScope.launchDefault {
refreshAfterDelete(index, item)
}
}
}
fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
}
/**
@ -578,6 +617,7 @@ fun CollectionFolderGrid(
var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<PositionItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
when (val state = loading) {
@ -713,6 +753,7 @@ fun CollectionFolderGrid(
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions =
MoreDialogActions(
navigateTo = { viewModel.navigateTo(it) },
@ -727,6 +768,9 @@ fun CollectionFolderGrid(
showPlaylistDialog.makePresent(it)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = PositionItem(position, item)
},
),
),
onDismissRequest = { moreDialog.makeAbsent() },
@ -751,6 +795,16 @@ fun CollectionFolderGrid(
elevation = 3.dp,
)
}
showDeleteDialog?.let { (position, item) ->
ConfirmDeleteDialog(
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(position, item)
showDeleteDialog = null
},
)
}
}
@OptIn(ExperimentalFoundationApi::class)

View file

@ -72,12 +72,14 @@ fun ExpandablePlayButtons(
resumePosition: Duration,
watched: Boolean,
favorite: Boolean,
canDelete: Boolean,
trailers: List<Trailer>?,
playOnClick: (position: Duration) -> Unit,
watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit,
trailerOnClick: (Trailer) -> Unit,
deleteOnClick: () -> Unit,
buttonOnFocusChanged: (FocusState) -> Unit,
modifier: Modifier = Modifier,
) {
@ -158,6 +160,16 @@ fun ExpandablePlayButtons(
)
}
}
if (canDelete) {
item("delete") {
DeleteButton(
onClick = deleteOnClick,
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged),
)
}
}
// More button
item("more") {
@ -424,6 +436,8 @@ private fun ExpandablePlayButtonsPreview() {
buttonOnFocusChanged = {},
trailers = listOf(),
trailerOnClick = {},
canDelete = true,
deleteOnClick = {},
modifier = Modifier,
)
}

View file

@ -20,11 +20,14 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn
@ -32,6 +35,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.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.nav.Destination
@ -52,6 +56,7 @@ abstract class RecommendedViewModel(
val favoriteWatchManager: FavoriteWatchManager,
val mediaReportService: MediaReportService,
private val backdropService: BackdropService,
private val mediaManagementService: MediaManagementService,
) : ViewModel() {
abstract fun init()
@ -119,6 +124,25 @@ abstract class RecommendedViewModel(
}
update(title, row)
}
fun deleteItem(
position: RowColumn,
item: BaseItem,
) {
deleteItem(context, mediaManagementService, item) {
viewModelScope.launchDefault {
val row = rows.value.getOrNull(position.row)
if (row is HomeRowLoadingState.Success) {
(row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column)
}
}
}
}
fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
}
@Composable
@ -132,6 +156,7 @@ fun RecommendedContent(
val context = LocalContext.current
var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
OneTimeLaunchedEffect {
@ -198,6 +223,7 @@ fun RecommendedContent(
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions =
MoreDialogActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) },
@ -212,6 +238,7 @@ fun RecommendedContent(
showPlaylistDialog.makePresent(it)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = RowColumnItem(position, item) },
),
),
onDismissRequest = { moreDialog.makeAbsent() },
@ -236,9 +263,19 @@ fun RecommendedContent(
elevation = 3.dp,
)
}
showDeleteDialog?.let { (position, item) ->
ConfirmDeleteDialog(
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(position, item)
showDeleteDialog = null
},
)
}
}
private data class RowColumnItem(
data class RowColumnItem(
val position: RowColumn,
val item: BaseItem,
)

View file

@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SuggestionService
@ -63,12 +64,14 @@ class RecommendedMovieViewModel
favoriteWatchManager: FavoriteWatchManager,
mediaReportService: MediaReportService,
backdropService: BackdropService,
mediaManagementService: MediaManagementService,
) : RecommendedViewModel(
context,
navigationManager,
favoriteWatchManager,
mediaReportService,
backdropService,
mediaManagementService,
) {
@AssistedFactory
interface Factory {

View file

@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SuggestionService
@ -67,12 +68,14 @@ class RecommendedTvShowViewModel
favoriteWatchManager: FavoriteWatchManager,
mediaReportService: MediaReportService,
backdropService: BackdropService,
mediaManagementService: MediaManagementService,
) : RecommendedViewModel(
context,
navigationManager,
favoriteWatchManager,
mediaReportService,
backdropService,
mediaManagementService,
) {
@AssistedFactory
interface Factory {

View file

@ -29,7 +29,7 @@ data class MoreDialogActions(
val onClickFavorite: (UUID, Boolean) -> Unit,
val onClickAddPlaylist: (UUID) -> Unit,
val onSendMediaInfo: (UUID) -> Unit,
val onClickDelete: (BaseItem) -> Unit = {},
val onClickDelete: (BaseItem) -> Unit,
)
enum class ClearChosenStreams {
@ -63,7 +63,7 @@ fun buildMoreDialogItems(
watched: Boolean,
favorite: Boolean,
canClearChosenStreams: Boolean,
canDelete: Boolean = false,
canDelete: Boolean,
actions: MoreDialogActions,
onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit,
@ -236,7 +236,7 @@ fun buildMoreDialogItemsForHome(
playbackPosition: Duration,
watched: Boolean,
favorite: Boolean,
canDelete: Boolean = false,
canDelete: Boolean,
actions: MoreDialogActions,
): List<DialogItem> =
buildList {

View file

@ -60,7 +60,6 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -102,15 +101,6 @@ fun DiscoverMovieDetails(
val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k)
val moreActions =
MoreDialogActions(
navigateTo = viewModel::navigateTo,
onClickWatch = { itemId, watched -> },
onClickFavorite = { itemId, favorite -> },
onClickAddPlaylist = { itemId -> },
onSendMediaInfo = {},
)
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state, modifier)

View file

@ -30,6 +30,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -82,6 +83,7 @@ fun EpisodeDetails(
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val moreActions =
@ -98,6 +100,7 @@ fun EpisodeDetails(
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = it },
)
when (val state = loading) {
@ -215,6 +218,7 @@ fun EpisodeDetails(
onClearChosenStreams = {
viewModel.clearChosenStreams(chosenStreams)
},
canDelete = viewModel.canDelete,
),
)
},
@ -224,6 +228,8 @@ fun EpisodeDetails(
favoriteOnClick = {
viewModel.setFavorite(ep.id, !ep.favorite)
},
canDelete = viewModel.canDelete,
deleteOnClick = { showDeleteDialog = ep },
modifier = modifier,
)
}
@ -276,6 +282,16 @@ fun EpisodeDetails(
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item)
showDeleteDialog = null
},
)
}
}
private const val HEADER_ROW = 0
@ -290,6 +306,8 @@ fun EpisodeDetailsContent(
watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit,
canDelete: Boolean,
deleteOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@ -347,6 +365,8 @@ fun EpisodeDetailsContent(
},
trailers = null,
trailerOnClick = {},
canDelete = canDelete,
deleteOnClick = deleteOnClick,
modifier =
Modifier
.fillMaxWidth()

View file

@ -12,11 +12,13 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
@ -54,6 +56,7 @@ class EpisodeViewModel
private val favoriteWatchManager: FavoriteWatchManager,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
private val mediaManagementService: MediaManagementService,
@Assisted val itemId: UUID,
) : ViewModel() {
@AssistedFactory
@ -65,6 +68,9 @@ class EpisodeViewModel
val item = MutableLiveData<BaseItem?>(null)
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
var canDelete: Boolean = false
private set
init {
init()
}
@ -95,6 +101,7 @@ class EpisodeViewModel
) {
val prefs = userPreferencesService.getCurrent()
val item = fetchAndSetItem().await()
canDelete = mediaManagementService.canDelete(item)
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
withContext(Dispatchers.Main) {
this@EpisodeViewModel.item.value = item
@ -199,4 +206,10 @@ class EpisodeViewModel
}
}
}
fun deleteItem(item: BaseItem) {
deleteItem(context, mediaManagementService, item) {
navigationManager.goBack()
}
}
}

View file

@ -315,6 +315,8 @@ fun MovieDetails(
onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination)
},
canDelete = viewModel.canDelete,
deleteOnClick = { showDeleteDialog = movie },
modifier = modifier,
)
}
@ -410,6 +412,8 @@ fun MovieDetailsContent(
onLongClickSimilar: (Int, BaseItem) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit,
onClickDiscover: (Int, DiscoverItem) -> Unit,
canDelete: Boolean,
deleteOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@ -472,6 +476,8 @@ fun MovieDetailsContent(
position = TRAILER_ROW
trailerOnClick.invoke(it)
},
canDelete = canDelete,
deleteOnClick = deleteOnClick,
modifier =
Modifier
.fillMaxWidth()

View file

@ -25,6 +25,8 @@ fun FocusedEpisodeFooter(
moreOnClick: () -> Unit,
watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
canDelete: Boolean,
deleteOnClick: () -> Unit,
modifier: Modifier = Modifier,
buttonOnFocusChanged: (FocusState) -> Unit = {},
) {
@ -47,6 +49,8 @@ fun FocusedEpisodeFooter(
buttonOnFocusChanged = buttonOnFocusChanged,
trailers = null,
trailerOnClick = {},
canDelete = canDelete,
deleteOnClick = deleteOnClick,
modifier = Modifier.fillMaxWidth(),
)
}

View file

@ -584,6 +584,7 @@ fun SeriesDetailsContent(
watched = item.played,
favorite = item.favorite,
actions = moreActions,
canDelete = false,
)
moreDialog =
DialogParams(

View file

@ -383,6 +383,8 @@ fun SeriesOverview(
),
)
},
canDelete = { viewModel.canDelete(it, preferences.appPreferences) },
deleteOnClick = { showDeleteDialog = it },
modifier = modifier,
)
}

View file

@ -88,6 +88,8 @@ fun SeriesOverviewContent(
moreOnClick: () -> Unit,
overviewOnClick: () -> Unit,
personOnClick: (Person) -> Unit,
canDelete: (BaseItem) -> Boolean,
deleteOnClick: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
@ -294,6 +296,8 @@ fun SeriesOverviewContent(
}
}
},
canDelete = canDelete.invoke(ep),
deleteOnClick = { deleteOnClick.invoke(ep) },
modifier =
Modifier
.padding(top = 4.dp)

View file

@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
@ -56,6 +57,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
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
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -229,6 +233,20 @@ class SeriesViewModel
discovered.update { results }
}
}
mediaManagementService.deletedItemFlow
.onEach { deletedItem ->
if (deletedItem.item.data.seriesId == seriesId) {
Timber.d(
"Item %s deleted from series %s",
deletedItem.item.id,
seriesId,
)
val seasons = getSeasons(item, seasonEpisodeIds?.seasonNumber).await()
this@SeriesViewModel.seasons.setValueOnMain(seasons)
}
}.catch { ex ->
Timber.e(ex, "Error refreshing after deleted item")
}.launchIn(viewModelScope)
}
}
@ -614,6 +632,11 @@ class SeriesViewModel
}
suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item)
fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
}
sealed interface EpisodeList {

View file

@ -55,6 +55,7 @@ import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
import com.github.damontecres.wholphin.ui.cards.GenreCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EpisodeName
@ -62,6 +63,7 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.FocusableItemRow
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.RowColumnItem
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
@ -116,6 +118,7 @@ fun HomePage(
LoadingState.Success -> {
var dialog by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var position by rememberPosition()
HomePageContent(
@ -136,6 +139,7 @@ fun HomePage(
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions =
MoreDialogActions(
navigateTo = viewModel.navigationManager::navigateTo,
@ -150,6 +154,9 @@ fun HomePage(
showPlaylistDialog = itemId
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = RowColumnItem(position, item)
},
),
)
dialog =
@ -190,6 +197,16 @@ fun HomePage(
elevation = 3.dp,
)
}
showDeleteDialog?.let { (position, item) ->
ConfirmDeleteDialog(
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(position, item)
showDeleteDialog = null
},
)
}
}
}
}

View file

@ -6,16 +6,21 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -52,6 +57,7 @@ class HomeViewModel
private val datePlayedService: DatePlayedService,
private val backdropService: BackdropService,
private val userPreferencesService: UserPreferencesService,
private val mediaManagementService: MediaManagementService,
) : ViewModel() {
private val _state = MutableStateFlow(HomeState.EMPTY)
val state: StateFlow<HomeState> = _state
@ -189,6 +195,36 @@ class HomeViewModel
backdropService.submit(item)
}
}
fun deleteItem(
position: RowColumn,
item: BaseItem,
) {
deleteItem(context, mediaManagementService, item) {
viewModelScope.launchDefault {
val row = state.value.homeRows.getOrNull(position.row)
if (row is HomeRowLoadingState.Success) {
_state.update {
val newRow =
row.items.toMutableList().apply {
removeAt(position.column)
}
it.copy(
homeRows =
it.homeRows.toMutableList().apply {
set(position.row, row.copy(items = newRow))
},
)
}
}
}
}
}
fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
}
data class HomeState(

View file

@ -554,6 +554,7 @@ fun PreferencesContent(
currentUsername = currentUser?.name,
status = status,
onSubmit = seerrVm::submitServer,
onResetStatus = seerrVm::resetStatus,
onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
)
}

View file

@ -4,7 +4,6 @@ import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
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
@ -66,15 +65,19 @@ fun AddSeerrServerApiKey(
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.align(Alignment.CenterHorizontally),
)
val labelWidth = 90.dp
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(
text = stringResource(R.string.url),
modifier = Modifier.padding(end = 8.dp),
modifier =
Modifier
.width(labelWidth)
.padding(end = 8.dp),
)
EditTextBox(
value = url,
@ -105,7 +108,10 @@ fun AddSeerrServerApiKey(
) {
Text(
text = stringResource(R.string.api_key),
modifier = Modifier.padding(end = 8.dp),
modifier =
Modifier
.width(labelWidth)
.padding(end = 8.dp),
)
EditTextBox(
value = apiKey,
@ -173,7 +179,7 @@ fun AddSeerrServerUsername(
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.align(Alignment.CenterHorizontally),
)
val labelWidth = 90.dp
Row(

View file

@ -1,12 +1,16 @@
package com.github.damontecres.wholphin.ui.setup.seerr
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.ui.components.BasicDialog
@ -20,6 +24,7 @@ fun AddSeerServerDialog(
currentUsername: String?,
status: LoadingState,
onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit,
onResetStatus: () -> Unit,
onDismissRequest: () -> Unit,
) {
var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) }
@ -34,6 +39,7 @@ fun AddSeerServerDialog(
-> {
BasicDialog(
onDismissRequest = { authMethod = null },
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
AddSeerrServerUsername(
onSubmit = { url, username, password ->
@ -41,6 +47,7 @@ fun AddSeerServerDialog(
},
username = currentUsername ?: "",
status = status,
modifier = Modifier.widthIn(min = 320.dp),
)
}
}
@ -54,6 +61,7 @@ fun AddSeerServerDialog(
onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY)
},
status = status,
modifier = Modifier.widthIn(min = 320.dp),
)
}
}
@ -61,7 +69,10 @@ fun AddSeerServerDialog(
null -> {
ChooseSeerrLoginType(
onDismissRequest = onDismissRequest,
onChoose = { authMethod = it },
onChoose = {
onResetStatus.invoke()
authMethod = it
},
)
}
}

View file

@ -50,10 +50,10 @@ class SwitchSeerrViewModel
serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) }
return@launchIO
}
var result: LoadingState = LoadingState.Error("No url")
Timber.v("Urls to try: %s", urls)
val results = mutableMapOf<HttpUrl, LoadingState>()
for (url in urls) {
Timber.d("Trying %s", url)
result =
try {
seerrServerRepository.testConnection(
authMethod = authMethod,
@ -61,20 +61,24 @@ class SwitchSeerrViewModel
username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY },
passwordOrApiKey = passwordOrApiKey,
)
results[url] = LoadingState.Success
break
} catch (ex: ClientException) {
Timber.w(ex, "ClientException logging in")
Timber.w(ex, "ClientException logging in %s", url)
if (ex.statusCode == 401 || ex.statusCode == 403) {
showToast(context, "Invalid credentials")
result = LoadingState.Error("Invalid credentials", ex)
break
results[url] = LoadingState.Error("Invalid credentials", ex)
} else {
LoadingState.Error("Could not connect with URL")
results[url] = LoadingState.Error("Could not connect with URL")
}
} catch (ex: Exception) {
Timber.w(ex, "Exception logging in")
LoadingState.Error(ex)
Timber.w(ex, "ClientException logging in %s", url)
results[url] = LoadingState.Error(ex)
}
if (result is LoadingState.Success) {
}
val result = results.filter { (url, state) -> state is LoadingState.Success }
if (result.isNotEmpty()) {
val url = result.keys.first()
when (authMethod) {
SeerrAuthMethod.LOCAL,
SeerrAuthMethod.JELLYFIN,
@ -94,19 +98,25 @@ class SwitchSeerrViewModel
)
}
}
break
} else {
val message =
results
.map { (url, state) ->
val s = state as? LoadingState.Error
"$url - ${s?.localizedMessage}"
}.joinToString("\n")
showToast(context, "Could not connect")
serverConnectionStatus.update { LoadingState.Error(message) }
}
}
if (result is LoadingState.Error) {
showToast(context, "Error: ${result.message}")
}
serverConnectionStatus.update { result }
}
}
fun removeServer() {
viewModelScope.launchIO {
seerrServerRepository.removeServer()
val result = seerrServerRepository.removeServerForCurrentUser()
if (!result) {
showToast(context, "Could not remove server")
}
}
}

View file

@ -15,7 +15,25 @@ class TestDisplayModeChoice {
val UHD_30 = DisplayMode(4, 3840, 2160, 30f)
val UHD_24 = DisplayMode(5, 3840, 2160, 24f)
val ALL_MODES = listOf(UHD_24, UHD_30, UHD_60, HD_24, HD_30, HD_60)
val HD720_60 = DisplayMode(6, 1280, 720, 60f)
val HD720_30 = DisplayMode(7, 1280, 720, 30f)
val HD720_24 = DisplayMode(8, 1280, 720, 24f)
val ALL_MODES =
listOf(
UHD_24,
UHD_30,
UHD_60,
HD_24,
HD_30,
HD_60,
HD720_60,
HD720_30,
HD720_24,
).sortedWith(
compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight })
.thenBy { it.refreshRateRounded },
)
}
@Test
@ -32,7 +50,7 @@ class TestDisplayModeChoice {
refreshRateSwitch = true,
resolutionSwitch = false,
)
Assert.assertEquals(3, result?.modeId)
Assert.assertEquals(UHD_60.modeId, result?.modeId)
}
@Test
@ -49,7 +67,7 @@ class TestDisplayModeChoice {
refreshRateSwitch = true,
resolutionSwitch = true,
)
Assert.assertEquals(0, result?.modeId)
Assert.assertEquals(HD_60.modeId, result?.modeId)
}
@Test
@ -66,7 +84,7 @@ class TestDisplayModeChoice {
refreshRateSwitch = true,
resolutionSwitch = false,
)
Assert.assertEquals(4, result?.modeId)
Assert.assertEquals(UHD_30.modeId, result?.modeId)
}
@Test
@ -83,14 +101,14 @@ class TestDisplayModeChoice {
refreshRateSwitch = false,
resolutionSwitch = true,
)
Assert.assertEquals(1, result?.modeId)
Assert.assertEquals(HD_30.modeId, result?.modeId)
}
@Test
fun testFraction() {
val streamWidth = 1920
val streamHeight = 1080
val streamRealFrameRate = 30f
val streamRealFrameRate = 29.970f
val displayModes =
listOf(
@ -104,7 +122,7 @@ class TestDisplayModeChoice {
displayModes = displayModes,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = 29.970f,
targetFrameRate = streamRealFrameRate,
refreshRateSwitch = true,
resolutionSwitch = false,
)
@ -119,6 +137,136 @@ class TestDisplayModeChoice {
refreshRateSwitch = true,
resolutionSwitch = false,
)
Assert.assertEquals(1, result2?.modeId)
Assert.assertEquals(HD_30.modeId, result2?.modeId)
}
private fun test(
expected: DisplayMode,
streamWidth: Int,
streamHeight: Int,
streamRealFrameRate: Float,
) {
val result =
RefreshRateService.findDisplayMode(
displayModes = ALL_MODES,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = streamRealFrameRate,
refreshRateSwitch = false,
resolutionSwitch = true,
)
Assert.assertEquals(
"streamWidth=$streamWidth, streamHeight=$streamHeight, streamRealFrameRate=$streamRealFrameRate",
expected.modeId,
result?.modeId,
)
}
@Test
fun `Test choose best resolution`() {
test(HD720_30, 1280, 720, 30f)
test(HD720_30, 1280, 548, 30f)
test(HD720_30, 640, 480, 30f)
test(HD720_30, 960, 720, 30f)
test(HD720_24, 960, 720, 24f)
}
@Test
fun `Test 60fps for 24 without resolution switch`() {
val streamWidth = 1920
val streamHeight = 1080
val streamRealFrameRate = 24f
val displayModes =
listOf(
UHD_60,
HD_60,
HD_30,
)
val result =
RefreshRateService.findDisplayMode(
displayModes = displayModes,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = streamRealFrameRate,
refreshRateSwitch = true,
resolutionSwitch = false,
)
Assert.assertEquals(UHD_60.modeId, result?.modeId)
}
@Test
fun `Test 60fps for 24 with resolution switch`() {
val streamWidth = 1920
val streamHeight = 1080
val streamRealFrameRate = 24f
val displayModes =
listOf(
HD_60,
HD_30,
)
val result =
RefreshRateService.findDisplayMode(
displayModes = displayModes,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = streamRealFrameRate,
refreshRateSwitch = true,
resolutionSwitch = true,
)
Assert.assertEquals(HD_60.modeId, result?.modeId)
}
@Test
fun `Test prefer refresh rate over resolution`() {
val streamWidth = 1280
val streamHeight = 720
val streamRealFrameRate = 24f
// 720@60 is an acceptable refresh rate, but want to prioritize exact refresh rate
val displayModes =
listOf(
HD_24,
HD720_60,
)
val result =
RefreshRateService.findDisplayMode(
displayModes = displayModes,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = streamRealFrameRate,
refreshRateSwitch = true,
resolutionSwitch = true,
)
Assert.assertEquals(HD_24.modeId, result?.modeId)
}
@Test
fun `Test prefer refresh rate over resolution2`() {
val streamWidth = 1280
val streamHeight = 720
val streamRealFrameRate = 30f
// 720@60 is an acceptable refresh rate, but want to prioritize exact refresh rate
val displayModes =
listOf(
HD_30,
HD720_60,
)
val result =
RefreshRateService.findDisplayMode(
displayModes = displayModes,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = streamRealFrameRate,
refreshRateSwitch = true,
resolutionSwitch = true,
)
Assert.assertEquals(HD_30.modeId, result?.modeId)
}
}

View file

@ -62,4 +62,32 @@ class TestSeerr {
)
Assert.assertEquals(expected, urls)
}
@Test
fun testCreateUrls5() {
val urls =
createUrls("10.0.0.2:443")
.map { it.toString() }
val expected =
listOf(
"http://10.0.0.2:443/api/v1",
"https://10.0.0.2/api/v1",
)
Assert.assertEquals(expected, urls)
}
@Test
fun testCreateUrls6() {
val urls =
createUrls("10.0.0.2:8080")
.map { it.toString() }
val expected =
listOf(
"http://10.0.0.2:8080/api/v1",
"https://10.0.0.2:8080/api/v1",
)
Assert.assertEquals(expected, urls)
}
}