Support delete

This commit is contained in:
Damontecres 2026-03-21 16:38:11 -04:00
parent 803780894c
commit 64c6e4db79
No known key found for this signature in database
6 changed files with 216 additions and 22 deletions

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.detail
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
@ -60,8 +61,10 @@ import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
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.MusicService
import com.github.damontecres.wholphin.services.MusicServiceState
@ -70,6 +73,7 @@ import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
import com.github.damontecres.wholphin.ui.components.BasicDialog
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
@ -108,6 +112,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
@ -128,16 +133,18 @@ import kotlin.time.Duration
class PlaylistViewModel
@AssistedInject
constructor(
@ApplicationContext context: Context,
api: ApiClient,
navigationManager: NavigationManager,
musicService: MusicService,
mediaManagementService: MediaManagementService,
private val backdropService: BackdropService,
private val serverRepository: ServerRepository,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
private val favoriteWatchManager: FavoriteWatchManager,
private val mediaReportService: MediaReportService,
@Assisted val itemId: UUID,
) : MusicViewModel(api, musicService, navigationManager) {
@Assisted itemId: UUID,
) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
@AssistedFactory
interface Factory {
fun create(itemId: UUID): PlaylistViewModel
@ -147,6 +154,10 @@ class PlaylistViewModel
val musicState = musicService.state
init {
init()
}
override fun init() {
state.update { it.copy(loading = LoadingState.Loading) }
viewModelScope.launchDefault {
try {
@ -347,6 +358,7 @@ data class PlaylistDetailsState(
@Composable
fun PlaylistDetails(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: PlaylistViewModel =
@ -392,7 +404,7 @@ fun PlaylistDetails(
}
}
}
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val musicMoreActions =
MusicMoreDialogActions(
onNavigate = { viewModel.navigationManager.navigateTo(it) },
@ -405,6 +417,7 @@ fun PlaylistDetails(
showPlaylistDialog.makePresent(itemId)
},
onClickRemoveFromQueue = {},
onClickDelete = { showDeleteDialog = it },
)
val moreActions =
MoreDialogActions(
@ -416,7 +429,7 @@ fun PlaylistDetails(
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel::sendMediaReport,
onClickDelete = {},
onClickDelete = { showDeleteDialog = it },
)
PlaylistDetailsContent(
@ -446,6 +459,7 @@ fun PlaylistDetails(
item = item,
index = index,
canRemove = false,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
)
} else {
buildMoreDialogItemsForHome(
@ -455,7 +469,7 @@ fun PlaylistDetails(
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
canDelete = false,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions = moreActions,
)
},
@ -495,6 +509,16 @@ fun PlaylistDetails(
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = item.title ?: "",
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item)
showDeleteDialog = null
},
)
}
}
@Composable

View file

@ -33,6 +33,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@ -46,9 +47,11 @@ import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
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.ImageUrlService
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
@ -58,6 +61,7 @@ import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
import com.github.damontecres.wholphin.ui.cards.ItemRow
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
@ -107,18 +111,19 @@ import java.util.UUID
class AlbumViewModel
@AssistedInject
constructor(
@ApplicationContext context: Context,
api: ApiClient,
musicService: MusicService,
navigationManager: NavigationManager,
@param:ApplicationContext private val context: Context,
mediaManagementService: MediaManagementService,
val serverRepository: ServerRepository,
val mediaReportService: MediaReportService,
private val favoriteWatchManager: FavoriteWatchManager,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
private val imageUrlService: ImageUrlService,
@Assisted val itemId: UUID,
) : MusicViewModel(api, musicService, navigationManager) {
@Assisted itemId: UUID,
) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
@AssistedFactory
interface Factory {
fun create(itemId: UUID): AlbumViewModel
@ -130,6 +135,10 @@ class AlbumViewModel
val currentMusic = musicService.state
init {
init()
}
override fun init() {
viewModelScope.launchIO {
try {
val itemDeferred =
@ -280,6 +289,7 @@ private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1
@Composable
fun AlbumDetailsPage(
itemId: UUID,
preferences: UserPreferences,
modifier: Modifier = Modifier,
viewModel: AlbumViewModel =
hiltViewModel<AlbumViewModel, AlbumViewModel.Factory>(
@ -292,9 +302,14 @@ fun AlbumDetailsPage(
val state by viewModel.state.collectAsState()
val currentMusic by viewModel.currentMusic.collectAsState()
var position by rememberPosition(0, 0)
val focusRequesters =
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
val focusManager = LocalFocusManager.current
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val moreDialogActions =
remember {
MusicMoreDialogActions(
@ -308,6 +323,7 @@ fun AlbumDetailsPage(
showPlaylistDialog.makePresent(itemId)
},
onClickRemoveFromQueue = {},
onClickDelete = { showDeleteDialog = it },
)
}
when (val loading = state.loading) {
@ -323,15 +339,13 @@ fun AlbumDetailsPage(
LoadingState.Success -> {
val album = state.album!!
var position by rememberPosition(0, 0)
val firstFocusRequester = remember { FocusRequester() }
val firstBringIntoViewRequester = remember { BringIntoViewRequester() }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val listState = rememberLazyListState()
val itemsBefore = 2
val focusRequesters =
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
val songFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (position.row == SONG_ROW) {
@ -399,6 +413,11 @@ fun AlbumDetailsPage(
item = album,
index = 0,
canRemove = false,
canDelete =
viewModel.canDelete(
album,
preferences.appPreferences,
),
),
)
},
@ -451,6 +470,11 @@ fun AlbumDetailsPage(
item = song,
index = index,
canRemove = false,
canDelete =
viewModel.canDelete(
song,
preferences.appPreferences,
),
),
)
}
@ -468,6 +492,11 @@ fun AlbumDetailsPage(
item = song,
index = index,
canRemove = false,
canDelete =
viewModel.canDelete(
song,
preferences.appPreferences,
),
),
)
}
@ -527,7 +556,24 @@ fun AlbumDetailsPage(
viewModel.navigationManager.navigateTo(item.destination())
},
onLongClickItem = { index, item ->
// TODO
moreDialog =
DialogParams(
fromLongClick = true,
title = item.name ?: "",
items =
buildMoreDialogForMusic(
context = context,
actions = moreDialogActions,
item = item,
index = index,
canRemove = false,
canDelete =
viewModel.canDelete(
item,
preferences.appPreferences,
),
),
)
},
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
BannerCardWithTitle(
@ -575,6 +621,17 @@ fun AlbumDetailsPage(
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = item.title ?: "",
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item)
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
showDeleteDialog = null
},
)
}
}
@Composable

View file

@ -31,6 +31,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@ -44,9 +45,11 @@ import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
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.ImageUrlService
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
@ -56,6 +59,7 @@ import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
import com.github.damontecres.wholphin.ui.cards.ItemRow
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
@ -106,18 +110,19 @@ import java.util.UUID
class ArtistViewModel
@AssistedInject
constructor(
@ApplicationContext context: Context,
api: ApiClient,
musicService: MusicService,
navigationManager: NavigationManager,
@param:ApplicationContext private val context: Context,
mediaManagementService: MediaManagementService,
val serverRepository: ServerRepository,
val mediaReportService: MediaReportService,
private val favoriteWatchManager: FavoriteWatchManager,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
private val imageUrlService: ImageUrlService,
@Assisted val itemId: UUID,
) : MusicViewModel(api, musicService, navigationManager) {
@Assisted itemId: UUID,
) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
@AssistedFactory
interface Factory {
fun create(itemId: UUID): ArtistViewModel
@ -129,6 +134,10 @@ class ArtistViewModel
val currentMusic = musicService.state
init {
init()
}
override fun init() {
viewModelScope.launchIO {
try {
val itemDeferred =
@ -230,7 +239,7 @@ class ArtistViewModel
}
}
fun init() {
fun refresh() {
viewModelScope.launchDefault {
state.value.artist?.let {
backdropService.submit(it)
@ -283,6 +292,7 @@ private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1
@Composable
fun ArtistDetailsPage(
preferences: UserPreferences,
itemId: UUID,
modifier: Modifier = Modifier,
viewModel: ArtistViewModel =
@ -292,13 +302,18 @@ fun ArtistDetailsPage(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
val state by viewModel.state.collectAsState()
val currentMusic by viewModel.currentMusic.collectAsState()
var position by rememberPosition(0, 0)
val focusRequesters =
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val moreDialogActions =
remember {
MusicMoreDialogActions(
@ -312,6 +327,7 @@ fun ArtistDetailsPage(
showPlaylistDialog.makePresent(itemId)
},
onClickRemoveFromQueue = {},
onClickDelete = { showDeleteDialog = it },
)
}
@ -327,10 +343,7 @@ fun ArtistDetailsPage(
}
LoadingState.Success -> {
var position by rememberPosition(0, 0)
val artist = state.artist!!
val focusRequesters =
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
val songFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (position.row == SONG_ROW) {
@ -338,7 +351,7 @@ fun ArtistDetailsPage(
} else {
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
}
viewModel.init()
viewModel.refresh()
}
val bringIntoViewRequester = remember { BringIntoViewRequester() }
Box(modifier = modifier) {
@ -389,6 +402,11 @@ fun ArtistDetailsPage(
item = artist,
index = 0,
canRemove = false,
canDelete =
viewModel.canDelete(
artist,
preferences.appPreferences,
),
),
)
},
@ -438,6 +456,11 @@ fun ArtistDetailsPage(
item = song,
index = index,
canRemove = false,
canDelete =
viewModel.canDelete(
song,
preferences.appPreferences,
),
),
)
}
@ -455,6 +478,11 @@ fun ArtistDetailsPage(
item = song,
index = index,
canRemove = false,
canDelete =
viewModel.canDelete(
song,
preferences.appPreferences,
),
),
)
}
@ -482,7 +510,24 @@ fun ArtistDetailsPage(
viewModel.navigationManager.navigateTo(album.destination())
},
onLongClickItem = { index, album ->
// TODO
moreDialog =
DialogParams(
fromLongClick = true,
title = album.name ?: "",
items =
buildMoreDialogForMusic(
context = context,
actions = moreDialogActions,
item = album,
index = index,
canRemove = false,
canDelete =
viewModel.canDelete(
album,
preferences.appPreferences,
),
),
)
},
cardContent = { index: Int, album: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
BannerCardWithTitle(
@ -534,7 +579,24 @@ fun ArtistDetailsPage(
viewModel.navigationManager.navigateTo(item.destination())
},
onLongClickItem = { index, item ->
// TODO
moreDialog =
DialogParams(
fromLongClick = true,
title = item.name ?: "",
items =
buildMoreDialogForMusic(
context = context,
actions = moreDialogActions,
item = item,
index = index,
canRemove = false,
canDelete =
viewModel.canDelete(
item,
preferences.appPreferences,
),
),
)
},
cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
BannerCardWithTitle(
@ -582,6 +644,17 @@ fun ArtistDetailsPage(
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = item.title ?: "",
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item)
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
showDeleteDialog = null
},
)
}
}
@Composable

View file

@ -1,10 +1,14 @@
package com.github.damontecres.wholphin.ui.detail.music
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
@ -17,9 +21,12 @@ import timber.log.Timber
import java.util.UUID
abstract class MusicViewModel(
internal val itemId: UUID,
internal val context: Context,
internal val api: ApiClient,
internal val musicService: MusicService,
internal val navigationManager: NavigationManager,
internal val mediaManagementService: MediaManagementService,
) : ViewModel() {
fun play(
pager: ApiRequestPager<*>,
@ -115,4 +122,21 @@ abstract class MusicViewModel(
navigationManager.navigateTo(Destination.NowPlaying)
}
}
fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
fun deleteItem(item: BaseItem) {
deleteItem(context, mediaManagementService, item) {
if (item.id == itemId) {
navigationManager.goBack()
} else {
init()
}
}
}
internal abstract fun init()
}

View file

@ -37,6 +37,7 @@ data class MusicMoreDialogActions(
onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST))
},
val onClickRemoveFromQueue: (Int) -> Unit,
val onClickDelete: (BaseItem) -> Unit,
)
fun buildMoreDialogForMusic(
@ -45,6 +46,7 @@ fun buildMoreDialogForMusic(
item: BaseItem,
index: Int,
canRemove: Boolean,
canDelete: Boolean,
): List<DialogItem> =
buildList {
add(
@ -91,6 +93,17 @@ fun buildMoreDialogForMusic(
actions.onClickAddPlaylist.invoke(item.id)
},
)
if (canDelete) {
add(
DialogItem(
context.getString(R.string.delete),
Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f),
) {
actions.onClickDelete.invoke(item)
},
)
}
add(
DialogItem(
text = if (item.favorite) R.string.remove_favorite else R.string.add_favorite,

View file

@ -158,6 +158,7 @@ fun DestinationContent(
BaseItemKind.PLAYLIST -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
PlaylistDetails(
preferences = preferences,
destination = destination,
modifier = modifier,
)
@ -221,6 +222,7 @@ fun DestinationContent(
BaseItemKind.MUSIC_ALBUM -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
AlbumDetailsPage(
preferences = preferences,
itemId = destination.itemId,
modifier = modifier,
)
@ -229,6 +231,7 @@ fun DestinationContent(
BaseItemKind.MUSIC_ARTIST -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
ArtistDetailsPage(
preferences = preferences,
itemId = destination.itemId,
modifier = modifier,
)