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

View file

@ -158,33 +158,58 @@ class RefreshRateService
if (refreshRateSwitch) { if (refreshRateSwitch) {
displayModes displayModes
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
.filter { .filter { frameRateMatches(it.refreshRateRounded, streamRate) }
it.refreshRateRounded % streamRate == 0 || // Exact multiple
it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps
}
} else { } else {
displayModes displayModes
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
} }
// Timber.v("display modes candidates: %s", candidates.joinToString("\n")) // Timber.v("display modes candidates: %s", candidates.joinToString("\n"))
return if (!resolutionSwitch) { 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 { } else {
// Exact resolution & frame rate
candidates.firstOrNull { candidates.firstOrNull {
it.physicalWidth == streamWidth && it.physicalWidth == streamWidth &&
it.physicalHeight == streamHeight && it.physicalHeight == streamHeight &&
it.refreshRateRounded == streamRate it.refreshRateRounded == streamRate
} }
?: candidates.firstOrNull { // Next highest resolution, exact frame rate
it.physicalWidth == streamWidth && ?: candidates.lastOrNull {
it.physicalWidth >= streamWidth &&
it.physicalHeight >= streamHeight && it.physicalHeight >= streamHeight &&
it.refreshRateRounded == streamRate 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 ?: candidates
.filter { it.refreshRateRounded == streamRate } .filter { it.refreshRateRounded == streamRate }
.maxByOrNull { it.physicalWidth * it.physicalHeight } .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.api.seerr.model.User
import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerRepository 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.SeerrAuthMethod
import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.SeerrServer 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.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.supervisorScope
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -162,7 +165,7 @@ class SeerrServerRepository
apiKey, apiKey,
okHttpClient okHttpClient
.newBuilder() .newBuilder()
.connectTimeout(5.seconds) .connectTimeout(2.seconds)
.readTimeout(6.seconds) .readTimeout(6.seconds)
.build(), .build(),
) )
@ -170,10 +173,11 @@ class SeerrServerRepository
return LoadingState.Success return LoadingState.Success
} }
suspend fun removeServer() { suspend fun removeServerForCurrentUser(): Boolean {
val current = (_connection.value as? SeerrConnectionStatus.Success)?.current ?: return val current = current.firstOrNull() ?: return false
seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) val rows = seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
clear() clear()
return rows > 0
} }
} }
@ -266,47 +270,56 @@ class UserSwitchListener
seerrServerRepository.clear() seerrServerRepository.clear()
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY } homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
if (user != null) { if (user != null) {
// Check for home settings switchUser(user)
launchIO {
homeSettingsService.loadCurrentSettings(user.id)
}
// Check for seerr server
launchIO {
seerrServerDao
.getUsersByJellyfinUser(user.rowId)
.lastOrNull()
?.let { seerrUser ->
val server =
seerrServerDao.getServer(seerrUser.serverId)?.server
if (server != null) {
Timber.i("Found a seerr user & server")
try {
seerrApi.update(server.url, seerrUser.credential)
val userConfig =
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
login(
seerrApi.api,
seerrUser.authMethod,
seerrUser.username,
seerrUser.password,
)
} else {
seerrApi.api.usersApi.authMeGet()
}
seerrServerRepository.set(server, seerrUser, userConfig)
} catch (ex: Exception) {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.error(server.url, ex)
}
}
}
}
} }
} }
} }
} }
private suspend fun switchUser(user: JellyfinUser) =
supervisorScope {
// Check for home settings
launchIO {
homeSettingsService.loadCurrentSettings(user.id)
}
// Check for seerr server
launchIO {
seerrServerDao
.getUsersByJellyfinUser(user.rowId)
.lastOrNull()
?.let { seerrUser ->
val server =
seerrServerDao.getServer(seerrUser.serverId)?.server
if (server != null) {
Timber.i("Found a seerr user & server")
try {
seerrApi.update(server.url, seerrUser.credential)
val userConfig =
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
login(
seerrApi.api,
seerrUser.authMethod,
seerrUser.username,
seerrUser.password,
)
} else {
seerrApi.api.usersApi.authMeGet()
}
seerrServerRepository.set(
server,
seerrUser,
userConfig,
)
} catch (ex: Exception) {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.error(server.url, ex)
}
}
}
}
}
} }

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.GetItemsFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo 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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.NavigationManager
import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService 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.AspectRatios
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.SlimItemFields 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.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.equalsNotNull
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageHeader 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.playback.scale
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.setValueOnMain 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.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.FilterUtils 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers 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.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -202,18 +209,34 @@ class CollectionFolderViewModel
loading.setValueOnMain(DataLoadingState.Error(ex)) loading.setValueOnMain(DataLoadingState.Error(ex))
} }
} }
viewModelScope.launchDefault { mediaManagementService.deletedItemFlow
mediaManagementService.deletedItemFlow.collect { deletedItem -> .onEach { deletedItem ->
val pager = refreshAfterDelete(position, deletedItem.item)
((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) }.catch { ex ->
position.let { Timber.e(ex, "Error refreshing after deleted item")
Timber.v("Item deleted: position=%s, id=%s", it, deletedItem.item.id) }.launchIn(viewModelScope)
val item = pager?.get(it) }
if (item?.id == deletedItem.item.id) {
pager.refreshPagesAfter(position) 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, itemId)
val item = pager?.get(it)
// 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 moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(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) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
when (val state = loading) { when (val state = loading) {
@ -713,6 +753,7 @@ fun CollectionFolderGrid(
playbackPosition = item.playbackPosition, playbackPosition = item.playbackPosition,
watched = item.played, watched = item.played,
favorite = item.favorite, favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions = actions =
MoreDialogActions( MoreDialogActions(
navigateTo = { viewModel.navigateTo(it) }, navigateTo = { viewModel.navigateTo(it) },
@ -727,6 +768,9 @@ fun CollectionFolderGrid(
showPlaylistDialog.makePresent(it) showPlaylistDialog.makePresent(it)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = PositionItem(position, item)
},
), ),
), ),
onDismissRequest = { moreDialog.makeAbsent() }, onDismissRequest = { moreDialog.makeAbsent() },
@ -751,6 +795,16 @@ fun CollectionFolderGrid(
elevation = 3.dp, 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) @OptIn(ExperimentalFoundationApi::class)

View file

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

View file

@ -20,11 +20,14 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions 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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager 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.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn 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.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.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -52,6 +56,7 @@ abstract class RecommendedViewModel(
val favoriteWatchManager: FavoriteWatchManager, val favoriteWatchManager: FavoriteWatchManager,
val mediaReportService: MediaReportService, val mediaReportService: MediaReportService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val mediaManagementService: MediaManagementService,
) : ViewModel() { ) : ViewModel() {
abstract fun init() abstract fun init()
@ -119,6 +124,25 @@ abstract class RecommendedViewModel(
} }
update(title, row) 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 @Composable
@ -132,6 +156,7 @@ fun RecommendedContent(
val context = LocalContext.current val context = LocalContext.current
var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) } var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(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) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
@ -198,6 +223,7 @@ fun RecommendedContent(
playbackPosition = item.playbackPosition, playbackPosition = item.playbackPosition,
watched = item.played, watched = item.played,
favorite = item.favorite, favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions = actions =
MoreDialogActions( MoreDialogActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) }, navigateTo = { viewModel.navigationManager.navigateTo(it) },
@ -212,6 +238,7 @@ fun RecommendedContent(
showPlaylistDialog.makePresent(it) showPlaylistDialog.makePresent(it)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = RowColumnItem(position, item) },
), ),
), ),
onDismissRequest = { moreDialog.makeAbsent() }, onDismissRequest = { moreDialog.makeAbsent() },
@ -236,9 +263,19 @@ fun RecommendedContent(
elevation = 3.dp, 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 position: RowColumn,
val item: BaseItem, 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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SuggestionService import com.github.damontecres.wholphin.services.SuggestionService
@ -63,12 +64,14 @@ class RecommendedMovieViewModel
favoriteWatchManager: FavoriteWatchManager, favoriteWatchManager: FavoriteWatchManager,
mediaReportService: MediaReportService, mediaReportService: MediaReportService,
backdropService: BackdropService, backdropService: BackdropService,
mediaManagementService: MediaManagementService,
) : RecommendedViewModel( ) : RecommendedViewModel(
context, context,
navigationManager, navigationManager,
favoriteWatchManager, favoriteWatchManager,
mediaReportService, mediaReportService,
backdropService, backdropService,
mediaManagementService,
) { ) {
@AssistedFactory @AssistedFactory
interface Factory { 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.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.LatestNextUpService 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.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SuggestionService import com.github.damontecres.wholphin.services.SuggestionService
@ -67,12 +68,14 @@ class RecommendedTvShowViewModel
favoriteWatchManager: FavoriteWatchManager, favoriteWatchManager: FavoriteWatchManager,
mediaReportService: MediaReportService, mediaReportService: MediaReportService,
backdropService: BackdropService, backdropService: BackdropService,
mediaManagementService: MediaManagementService,
) : RecommendedViewModel( ) : RecommendedViewModel(
context, context,
navigationManager, navigationManager,
favoriteWatchManager, favoriteWatchManager,
mediaReportService, mediaReportService,
backdropService, backdropService,
mediaManagementService,
) { ) {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {

View file

@ -29,7 +29,7 @@ data class MoreDialogActions(
val onClickFavorite: (UUID, Boolean) -> Unit, val onClickFavorite: (UUID, Boolean) -> Unit,
val onClickAddPlaylist: (UUID) -> Unit, val onClickAddPlaylist: (UUID) -> Unit,
val onSendMediaInfo: (UUID) -> Unit, val onSendMediaInfo: (UUID) -> Unit,
val onClickDelete: (BaseItem) -> Unit = {}, val onClickDelete: (BaseItem) -> Unit,
) )
enum class ClearChosenStreams { enum class ClearChosenStreams {
@ -63,7 +63,7 @@ fun buildMoreDialogItems(
watched: Boolean, watched: Boolean,
favorite: Boolean, favorite: Boolean,
canClearChosenStreams: Boolean, canClearChosenStreams: Boolean,
canDelete: Boolean = false, canDelete: Boolean,
actions: MoreDialogActions, actions: MoreDialogActions,
onChooseVersion: () -> Unit, onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit, onChooseTracks: (MediaStreamType) -> Unit,
@ -236,7 +236,7 @@ fun buildMoreDialogItemsForHome(
playbackPosition: Duration, playbackPosition: Duration,
watched: Boolean, watched: Boolean,
favorite: Boolean, favorite: Boolean,
canDelete: Boolean = false, canDelete: Boolean,
actions: MoreDialogActions, actions: MoreDialogActions,
): List<DialogItem> = ): List<DialogItem> =
buildList { 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.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo 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.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -102,15 +101,6 @@ fun DiscoverMovieDetails(
val requestStr = stringResource(R.string.request) val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k) 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) { when (val state = loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
ErrorMessage(state, modifier) 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.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus 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.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -82,6 +83,7 @@ fun EpisodeDetails(
var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } 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 playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val moreActions = val moreActions =
@ -98,6 +100,7 @@ fun EpisodeDetails(
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = it },
) )
when (val state = loading) { when (val state = loading) {
@ -215,6 +218,7 @@ fun EpisodeDetails(
onClearChosenStreams = { onClearChosenStreams = {
viewModel.clearChosenStreams(chosenStreams) viewModel.clearChosenStreams(chosenStreams)
}, },
canDelete = viewModel.canDelete,
), ),
) )
}, },
@ -224,6 +228,8 @@ fun EpisodeDetails(
favoriteOnClick = { favoriteOnClick = {
viewModel.setFavorite(ep.id, !ep.favorite) viewModel.setFavorite(ep.id, !ep.favorite)
}, },
canDelete = viewModel.canDelete,
deleteOnClick = { showDeleteDialog = ep },
modifier = modifier, modifier = modifier,
) )
} }
@ -276,6 +282,16 @@ fun EpisodeDetails(
elevation = 3.dp, 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 private const val HEADER_ROW = 0
@ -290,6 +306,8 @@ fun EpisodeDetailsContent(
watchOnClick: () -> Unit, watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit, favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit, moreOnClick: () -> Unit,
canDelete: Boolean,
deleteOnClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -347,6 +365,8 @@ fun EpisodeDetailsContent(
}, },
trailers = null, trailers = null,
trailerOnClick = {}, trailerOnClick = {},
canDelete = canDelete,
deleteOnClick = deleteOnClick,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .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.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService 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.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
@ -54,6 +56,7 @@ class EpisodeViewModel
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val mediaManagementService: MediaManagementService,
@Assisted val itemId: UUID, @Assisted val itemId: UUID,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
@ -65,6 +68,9 @@ class EpisodeViewModel
val item = MutableLiveData<BaseItem?>(null) val item = MutableLiveData<BaseItem?>(null)
val chosenStreams = MutableLiveData<ChosenStreams?>(null) val chosenStreams = MutableLiveData<ChosenStreams?>(null)
var canDelete: Boolean = false
private set
init { init {
init() init()
} }
@ -95,6 +101,7 @@ class EpisodeViewModel
) { ) {
val prefs = userPreferencesService.getCurrent() val prefs = userPreferencesService.getCurrent()
val item = fetchAndSetItem().await() val item = fetchAndSetItem().await()
canDelete = mediaManagementService.canDelete(item)
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs) val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@EpisodeViewModel.item.value = item 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 -> onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination) viewModel.navigateTo(item.destination)
}, },
canDelete = viewModel.canDelete,
deleteOnClick = { showDeleteDialog = movie },
modifier = modifier, modifier = modifier,
) )
} }
@ -410,6 +412,8 @@ fun MovieDetailsContent(
onLongClickSimilar: (Int, BaseItem) -> Unit, onLongClickSimilar: (Int, BaseItem) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit,
onClickDiscover: (Int, DiscoverItem) -> Unit, onClickDiscover: (Int, DiscoverItem) -> Unit,
canDelete: Boolean,
deleteOnClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -472,6 +476,8 @@ fun MovieDetailsContent(
position = TRAILER_ROW position = TRAILER_ROW
trailerOnClick.invoke(it) trailerOnClick.invoke(it)
}, },
canDelete = canDelete,
deleteOnClick = deleteOnClick,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()

View file

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

View file

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

View file

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

View file

@ -88,6 +88,8 @@ fun SeriesOverviewContent(
moreOnClick: () -> Unit, moreOnClick: () -> Unit,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
personOnClick: (Person) -> Unit, personOnClick: (Person) -> Unit,
canDelete: (BaseItem) -> Boolean,
deleteOnClick: (BaseItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -294,6 +296,8 @@ fun SeriesOverviewContent(
} }
} }
}, },
canDelete = canDelete.invoke(ep),
deleteOnClick = { deleteOnClick.invoke(ep) },
modifier = modifier =
Modifier Modifier
.padding(top = 4.dp) .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.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer 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.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
@ -56,6 +57,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow 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.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -229,6 +233,20 @@ class SeriesViewModel
discovered.update { results } 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) suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item)
fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
} }
sealed interface EpisodeList { 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.GenreCard
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress 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.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EpisodeName 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.FocusableItemRow
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.QuickDetails 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.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
@ -116,6 +118,7 @@ fun HomePage(
LoadingState.Success -> { LoadingState.Success -> {
var dialog by remember { mutableStateOf<DialogParams?>(null) } var dialog by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) } var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var position by rememberPosition() var position by rememberPosition()
HomePageContent( HomePageContent(
@ -136,6 +139,7 @@ fun HomePage(
playbackPosition = item.playbackPosition, playbackPosition = item.playbackPosition,
watched = item.played, watched = item.played,
favorite = item.favorite, favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions = actions =
MoreDialogActions( MoreDialogActions(
navigateTo = viewModel.navigationManager::navigateTo, navigateTo = viewModel.navigationManager::navigateTo,
@ -150,6 +154,9 @@ fun HomePage(
showPlaylistDialog = itemId showPlaylistDialog = itemId
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = RowColumnItem(position, item)
},
), ),
) )
dialog = dialog =
@ -190,6 +197,16 @@ fun HomePage(
elevation = 3.dp, 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.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowConfig 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.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomePageResolvedSettings
import com.github.damontecres.wholphin.services.HomeSettingsService 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.MediaReportService
import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.UserPreferencesService 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.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.launchIO
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -52,6 +57,7 @@ class HomeViewModel
private val datePlayedService: DatePlayedService, private val datePlayedService: DatePlayedService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val mediaManagementService: MediaManagementService,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(HomeState.EMPTY) private val _state = MutableStateFlow(HomeState.EMPTY)
val state: StateFlow<HomeState> = _state val state: StateFlow<HomeState> = _state
@ -189,6 +195,36 @@ class HomeViewModel
backdropService.submit(item) 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( data class HomeState(

View file

@ -554,6 +554,7 @@ fun PreferencesContent(
currentUsername = currentUser?.name, currentUsername = currentUser?.name,
status = status, status = status,
onSubmit = seerrVm::submitServer, onSubmit = seerrVm::submitServer,
onResetStatus = seerrVm::resetStatus,
onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, 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.Arrangement
import androidx.compose.foundation.layout.Column 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.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@ -66,15 +65,19 @@ fun AddSeerrServerApiKey(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.align(Alignment.CenterHorizontally),
) )
val labelWidth = 90.dp
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
) { ) {
Text( Text(
text = stringResource(R.string.url), text = stringResource(R.string.url),
modifier = Modifier.padding(end = 8.dp), modifier =
Modifier
.width(labelWidth)
.padding(end = 8.dp),
) )
EditTextBox( EditTextBox(
value = url, value = url,
@ -105,7 +108,10 @@ fun AddSeerrServerApiKey(
) { ) {
Text( Text(
text = stringResource(R.string.api_key), text = stringResource(R.string.api_key),
modifier = Modifier.padding(end = 8.dp), modifier =
Modifier
.width(labelWidth)
.padding(end = 8.dp),
) )
EditTextBox( EditTextBox(
value = apiKey, value = apiKey,
@ -173,7 +179,7 @@ fun AddSeerrServerUsername(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.align(Alignment.CenterHorizontally),
) )
val labelWidth = 90.dp val labelWidth = 90.dp
Row( Row(

View file

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

View file

@ -50,63 +50,73 @@ class SwitchSeerrViewModel
serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) } serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) }
return@launchIO 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) { for (url in urls) {
Timber.d("Trying %s", url) Timber.d("Trying %s", url)
result = try {
try { seerrServerRepository.testConnection(
seerrServerRepository.testConnection( authMethod = authMethod,
authMethod = authMethod, url = url.toString(),
url = url.toString(), username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY },
username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY }, passwordOrApiKey = passwordOrApiKey,
passwordOrApiKey = passwordOrApiKey, )
) results[url] = LoadingState.Success
} catch (ex: ClientException) {
Timber.w(ex, "ClientException logging in")
if (ex.statusCode == 401 || ex.statusCode == 403) {
showToast(context, "Invalid credentials")
result = LoadingState.Error("Invalid credentials", ex)
break
} else {
LoadingState.Error("Could not connect with URL")
}
} catch (ex: Exception) {
Timber.w(ex, "Exception logging in")
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 break
} catch (ex: ClientException) {
Timber.w(ex, "ClientException logging in %s", url)
if (ex.statusCode == 401 || ex.statusCode == 403) {
showToast(context, "Invalid credentials")
results[url] = LoadingState.Error("Invalid credentials", ex)
} else {
results[url] = LoadingState.Error("Could not connect with URL")
}
} catch (ex: Exception) {
Timber.w(ex, "ClientException logging in %s", url)
results[url] = LoadingState.Error(ex)
} }
} }
if (result is LoadingState.Error) { val result = results.filter { (url, state) -> state is LoadingState.Success }
showToast(context, "Error: ${result.message}") if (result.isNotEmpty()) {
val url = result.keys.first()
when (authMethod) {
SeerrAuthMethod.LOCAL,
SeerrAuthMethod.JELLYFIN,
-> {
seerrServerRepository.addAndChangeServer(
url.toString(),
authMethod,
username,
passwordOrApiKey,
)
}
SeerrAuthMethod.API_KEY -> {
seerrServerRepository.addAndChangeServer(
url.toString(),
passwordOrApiKey,
)
}
}
} 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) }
} }
serverConnectionStatus.update { result }
} }
} }
fun removeServer() { fun removeServer() {
viewModelScope.launchIO { 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_30 = DisplayMode(4, 3840, 2160, 30f)
val UHD_24 = DisplayMode(5, 3840, 2160, 24f) 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 @Test
@ -32,7 +50,7 @@ class TestDisplayModeChoice {
refreshRateSwitch = true, refreshRateSwitch = true,
resolutionSwitch = false, resolutionSwitch = false,
) )
Assert.assertEquals(3, result?.modeId) Assert.assertEquals(UHD_60.modeId, result?.modeId)
} }
@Test @Test
@ -49,7 +67,7 @@ class TestDisplayModeChoice {
refreshRateSwitch = true, refreshRateSwitch = true,
resolutionSwitch = true, resolutionSwitch = true,
) )
Assert.assertEquals(0, result?.modeId) Assert.assertEquals(HD_60.modeId, result?.modeId)
} }
@Test @Test
@ -66,7 +84,7 @@ class TestDisplayModeChoice {
refreshRateSwitch = true, refreshRateSwitch = true,
resolutionSwitch = false, resolutionSwitch = false,
) )
Assert.assertEquals(4, result?.modeId) Assert.assertEquals(UHD_30.modeId, result?.modeId)
} }
@Test @Test
@ -83,14 +101,14 @@ class TestDisplayModeChoice {
refreshRateSwitch = false, refreshRateSwitch = false,
resolutionSwitch = true, resolutionSwitch = true,
) )
Assert.assertEquals(1, result?.modeId) Assert.assertEquals(HD_30.modeId, result?.modeId)
} }
@Test @Test
fun testFraction() { fun testFraction() {
val streamWidth = 1920 val streamWidth = 1920
val streamHeight = 1080 val streamHeight = 1080
val streamRealFrameRate = 30f val streamRealFrameRate = 29.970f
val displayModes = val displayModes =
listOf( listOf(
@ -104,7 +122,7 @@ class TestDisplayModeChoice {
displayModes = displayModes, displayModes = displayModes,
streamWidth = streamWidth, streamWidth = streamWidth,
streamHeight = streamHeight, streamHeight = streamHeight,
targetFrameRate = 29.970f, targetFrameRate = streamRealFrameRate,
refreshRateSwitch = true, refreshRateSwitch = true,
resolutionSwitch = false, resolutionSwitch = false,
) )
@ -119,6 +137,136 @@ class TestDisplayModeChoice {
refreshRateSwitch = true, refreshRateSwitch = true,
resolutionSwitch = false, 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) 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)
}
} }