diff --git a/app/src/main/java/com/makd/afinity/data/manager/AdminChangeBroadcaster.kt b/app/src/main/java/com/makd/afinity/data/manager/AdminChangeBroadcaster.kt new file mode 100644 index 00000000..127bd348 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/manager/AdminChangeBroadcaster.kt @@ -0,0 +1,17 @@ +package com.makd.afinity.data.manager + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AdminChangeBroadcaster @Inject constructor() { + private val _itemChanged = MutableSharedFlow(extraBufferCapacity = 1) + val itemChanged: SharedFlow = _itemChanged.asSharedFlow() + + fun notifyItemChanged(itemId: String) { + _itemChanged.tryEmit(itemId) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/data/models/admin/EditableItem.kt b/app/src/main/java/com/makd/afinity/data/models/admin/EditableItem.kt new file mode 100644 index 00000000..1f1e09cb --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/models/admin/EditableItem.kt @@ -0,0 +1,33 @@ +package com.makd.afinity.data.models.admin + +data class EditableItem( + val id: String, + val name: String, + val originalTitle: String?, + val overview: String?, + val productionYear: Int?, + val premiereDate: String?, + val officialRating: String?, + val customRating: String?, + val communityRating: Double?, + val genres: List, + val tags: List, + val studios: List, + val people: List, + val indexNumber: Int?, + val parentIndexNumber: Int?, + val status: String?, + val displayOrder: String?, + val lockData: Boolean, + val lockedFields: List, + val type: String, + val path: String?, + val availableParentalRatings: List = emptyList(), +) + +data class EditablePerson( + val id: String?, + val name: String, + val type: String, + val role: String?, +) \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/data/models/admin/IdentifyModels.kt b/app/src/main/java/com/makd/afinity/data/models/admin/IdentifyModels.kt new file mode 100644 index 00000000..0236df22 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/models/admin/IdentifyModels.kt @@ -0,0 +1,17 @@ +package com.makd.afinity.data.models.admin + +data class IdentifyResult( + val name: String, + val year: Int?, + val imageUrl: String?, + val searchProviderName: String?, + val providerIds: Map, + val overview: String?, + val premiereDate: String?, +) + +data class ExternalIdProvider( + val name: String, + val key: String, + val urlFormatString: String?, +) \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/data/models/admin/ItemImage.kt b/app/src/main/java/com/makd/afinity/data/models/admin/ItemImage.kt new file mode 100644 index 00000000..6891079f --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/models/admin/ItemImage.kt @@ -0,0 +1,15 @@ +package com.makd.afinity.data.models.admin + +data class ItemImage( + val imageType: String, + val imageIndex: Int?, + val url: String?, + val providerName: String?, + val width: Int, + val height: Int, + val communityRating: Double?, + val voteCount: Int?, + val language: String?, + val isServerImage: Boolean, + val remoteUrl: String?, +) \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/data/repository/admin/AdminRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/admin/AdminRepository.kt new file mode 100644 index 00000000..90148287 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/repository/admin/AdminRepository.kt @@ -0,0 +1,70 @@ +package com.makd.afinity.data.repository.admin + +import com.makd.afinity.data.models.admin.EditableItem +import com.makd.afinity.data.models.admin.ExternalIdProvider +import com.makd.afinity.data.models.admin.IdentifyResult +import com.makd.afinity.data.models.admin.ItemImage + +interface AdminRepository { + + suspend fun getEditableItem(itemId: String): EditableItem? + + suspend fun updateItemMetadata(itemId: String, item: EditableItem): Result + + suspend fun getExternalIdProviders(itemId: String): List + + suspend fun searchMovie( + itemId: String, + name: String, + year: Int?, + providerIds: Map, + ): List + + suspend fun searchSeries( + itemId: String, + name: String, + year: Int?, + providerIds: Map, + ): List + + suspend fun applyIdentifyResult( + itemId: String, + result: IdentifyResult, + replaceAllImages: Boolean, + ): Result + + suspend fun getItemImages(itemId: String): List + + suspend fun getRemoteImages( + itemId: String, + imageType: String, + includeAllLanguages: Boolean, + ): List + + suspend fun downloadRemoteImage( + itemId: String, + imageType: String, + imageUrl: String, + ): Result + + suspend fun uploadImage( + itemId: String, + imageType: String, + imageData: ByteArray, + mimeType: String, + ): Result + + suspend fun deleteImage( + itemId: String, + imageType: String, + imageIndex: Int?, + ): Result + + suspend fun refreshItem( + itemId: String, + metadataRefreshMode: String, + imageRefreshMode: String, + replaceAllMetadata: Boolean, + replaceAllImages: Boolean, + ): Result +} diff --git a/app/src/main/java/com/makd/afinity/data/repository/admin/JellyfinAdminRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/admin/JellyfinAdminRepository.kt new file mode 100644 index 00000000..881cf60b --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/repository/admin/JellyfinAdminRepository.kt @@ -0,0 +1,526 @@ +package com.makd.afinity.data.repository.admin + +import com.makd.afinity.data.manager.AdminChangeBroadcaster +import com.makd.afinity.data.manager.SessionManager +import com.makd.afinity.data.models.admin.EditableItem +import com.makd.afinity.data.models.admin.EditablePerson +import com.makd.afinity.data.models.admin.ExternalIdProvider +import com.makd.afinity.data.models.admin.IdentifyResult +import com.makd.afinity.data.models.admin.ItemImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.jellyfin.sdk.api.operations.ImageApi +import org.jellyfin.sdk.api.operations.ItemLookupApi +import org.jellyfin.sdk.api.operations.ItemRefreshApi +import org.jellyfin.sdk.api.operations.ItemUpdateApi +import org.jellyfin.sdk.api.operations.RemoteImageApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.model.FileInfo +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemPerson +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.MetadataField +import org.jellyfin.sdk.model.api.MetadataRefreshMode +import org.jellyfin.sdk.model.api.MovieInfo +import org.jellyfin.sdk.model.api.MovieInfoRemoteSearchQuery +import org.jellyfin.sdk.model.api.NameGuidPair +import org.jellyfin.sdk.model.api.PersonKind +import org.jellyfin.sdk.model.api.RemoteSearchResult +import org.jellyfin.sdk.model.api.SeriesInfo +import org.jellyfin.sdk.model.api.SeriesInfoRemoteSearchQuery +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class JellyfinAdminRepository +@Inject +constructor( + private val sessionManager: SessionManager, + private val adminChangeBroadcaster: AdminChangeBroadcaster, +) : AdminRepository { + + private fun getApiClient() = sessionManager.getCurrentApiClient() + + private fun getUserId(): UUID? = sessionManager.currentSession.value?.userId + + override suspend fun getEditableItem(itemId: String): EditableItem? = + withContext(Dispatchers.IO) { + try { + val apiClient = getApiClient() ?: return@withContext null + val userId = getUserId() ?: return@withContext null + val userLibraryApi = UserLibraryApi(apiClient) + val itemUpdateApi = ItemUpdateApi(apiClient) + val itemUuid = UUID.fromString(itemId) + + val itemResponse = userLibraryApi.getItem(userId = userId, itemId = itemUuid) + val dto = itemResponse.content + + val editorResponse = itemUpdateApi.getMetadataEditorInfo(itemId = itemUuid) + val editorInfo = editorResponse.content + val availableRatings = editorInfo.parentalRatingOptions.mapNotNull { it.name } + + dto.toEditableItem(availableRatings) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to get editable item $itemId") + null + } catch (e: Exception) { + Timber.e(e, "Unexpected error getting editable item $itemId") + null + } + } + + override suspend fun updateItemMetadata(itemId: String, item: EditableItem): Result = + withContext(Dispatchers.IO) { + try { + val apiClient = + getApiClient() + ?: return@withContext Result.failure(IllegalStateException("No API client")) + val api = ItemUpdateApi(apiClient) + val userId = + getUserId() + ?: return@withContext Result.failure(IllegalStateException("No user")) + + val userLibraryApi = UserLibraryApi(apiClient) + val existing = + userLibraryApi + .getItem( + userId = userId, + itemId = UUID.fromString(itemId), + ) + .content + + val updated = + existing.copy( + name = item.name, + originalTitle = item.originalTitle, + overview = item.overview, + productionYear = item.productionYear, + officialRating = item.officialRating, + customRating = item.customRating, + communityRating = item.communityRating?.toFloat(), + genres = item.genres, + tags = item.tags, + studios = + item.studios.map { NameGuidPair(name = it, id = UUID.randomUUID()) }, + people = item.people.map { it.toBaseItemPerson() }, + indexNumber = item.indexNumber, + parentIndexNumber = item.parentIndexNumber, + status = item.status, + displayOrder = item.displayOrder, + lockData = item.lockData, + lockedFields = + item.lockedFields.mapNotNull { MetadataField.fromNameOrNull(it) }, + ) + api.updateItem(itemId = UUID.fromString(itemId), data = updated) + adminChangeBroadcaster.notifyItemChanged(itemId) + Result.success(Unit) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to update item $itemId") + Result.failure(e) + } catch (e: Exception) { + Timber.e(e, "Unexpected error updating item $itemId") + Result.failure(e) + } + } + + override suspend fun getExternalIdProviders(itemId: String): List = + withContext(Dispatchers.IO) { + try { + val api = ItemLookupApi(getApiClient() ?: return@withContext emptyList()) + val response = api.getExternalIdInfos(itemId = UUID.fromString(itemId)) + response.content.map { + ExternalIdProvider( + name = it.name, + key = it.key, + urlFormatString = it.urlFormatString, + ) + } + } catch (e: ApiClientException) { + Timber.e(e, "Failed to get external ID providers for $itemId") + emptyList() + } catch (e: Exception) { + Timber.e(e, "Unexpected error getting external ID providers for $itemId") + emptyList() + } + } + + override suspend fun searchMovie( + itemId: String, + name: String, + year: Int?, + providerIds: Map, + ): List = + withContext(Dispatchers.IO) { + try { + val api = ItemLookupApi(getApiClient() ?: return@withContext emptyList()) + val query = + MovieInfoRemoteSearchQuery( + itemId = UUID.fromString(itemId), + searchInfo = + MovieInfo( + name = name, + year = year, + providerIds = providerIds, + isAutomated = false, + ), + includeDisabledProviders = false, + ) + api.getMovieRemoteSearchResults(data = query).content.map { it.toIdentifyResult() } + } catch (e: ApiClientException) { + Timber.e(e, "Failed to search movie for $itemId") + emptyList() + } catch (e: Exception) { + Timber.e(e, "Unexpected error searching movie for $itemId") + emptyList() + } + } + + override suspend fun searchSeries( + itemId: String, + name: String, + year: Int?, + providerIds: Map, + ): List = + withContext(Dispatchers.IO) { + try { + val api = ItemLookupApi(getApiClient() ?: return@withContext emptyList()) + val query = + SeriesInfoRemoteSearchQuery( + itemId = UUID.fromString(itemId), + searchInfo = + SeriesInfo( + name = name, + year = year, + providerIds = providerIds, + isAutomated = false, + ), + includeDisabledProviders = false, + ) + api.getSeriesRemoteSearchResults(data = query).content.map { it.toIdentifyResult() } + } catch (e: ApiClientException) { + Timber.e(e, "Failed to search series for $itemId") + emptyList() + } catch (e: Exception) { + Timber.e(e, "Unexpected error searching series for $itemId") + emptyList() + } + } + + override suspend fun applyIdentifyResult( + itemId: String, + result: IdentifyResult, + replaceAllImages: Boolean, + ): Result = + withContext(Dispatchers.IO) { + try { + val api = + ItemLookupApi( + getApiClient() + ?: return@withContext Result.failure( + IllegalStateException("No API client") + ) + ) + val body = + RemoteSearchResult( + name = result.name, + productionYear = result.year, + imageUrl = result.imageUrl, + searchProviderName = result.searchProviderName, + providerIds = result.providerIds, + overview = result.overview, + ) + api.applySearchCriteria( + itemId = UUID.fromString(itemId), + replaceAllImages = replaceAllImages, + data = body, + ) + adminChangeBroadcaster.notifyItemChanged(itemId) + Result.success(Unit) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to apply identify result to $itemId") + Result.failure(e) + } catch (e: Exception) { + Timber.e(e, "Unexpected error applying identify result to $itemId") + Result.failure(e) + } + } + + override suspend fun getItemImages(itemId: String): List = + withContext(Dispatchers.IO) { + try { + val apiClient = getApiClient() ?: return@withContext emptyList() + val api = ImageApi(apiClient) + val baseUrl = sessionManager.currentSession.value?.serverUrl ?: "" + val response = api.getItemImageInfos(itemId = UUID.fromString(itemId)) + response.content.map { info -> + val imageUrl = + if (baseUrl.isNotEmpty()) { + val base = + "$baseUrl/Items/$itemId/Images/${info.imageType.serialName}" + + if (info.imageIndex != null) "/${info.imageIndex}" else "" + if (info.imageTag != null) "$base?tag=${info.imageTag}" else base + } else null + ItemImage( + imageType = info.imageType.serialName, + imageIndex = info.imageIndex, + url = imageUrl, + providerName = null, + width = info.width ?: 0, + height = info.height ?: 0, + communityRating = null, + voteCount = null, + language = null, + isServerImage = true, + remoteUrl = null, + ) + } + } catch (e: ApiClientException) { + Timber.e(e, "Failed to get images for $itemId") + emptyList() + } catch (e: Exception) { + Timber.e(e, "Unexpected error getting images for $itemId") + emptyList() + } + } + + override suspend fun getRemoteImages( + itemId: String, + imageType: String, + includeAllLanguages: Boolean, + ): List = + withContext(Dispatchers.IO) { + try { + val api = RemoteImageApi(getApiClient() ?: return@withContext emptyList()) + val type = ImageType.fromNameOrNull(imageType) + val response = + api.getRemoteImages( + itemId = UUID.fromString(itemId), + type = type, + includeAllLanguages = includeAllLanguages, + limit = 50, + ) + response.content.images?.map { info -> + ItemImage( + imageType = info.type.serialName, + imageIndex = null, + url = info.thumbnailUrl ?: info.url, + providerName = info.providerName, + width = info.width ?: 0, + height = info.height ?: 0, + communityRating = info.communityRating, + voteCount = info.voteCount, + language = info.language, + isServerImage = false, + remoteUrl = info.url, + ) + } ?: emptyList() + } catch (e: ApiClientException) { + Timber.e(e, "Failed to get remote images for $itemId") + emptyList() + } catch (e: Exception) { + Timber.e(e, "Unexpected error getting remote images for $itemId") + emptyList() + } + } + + override suspend fun downloadRemoteImage( + itemId: String, + imageType: String, + imageUrl: String, + ): Result = + withContext(Dispatchers.IO) { + try { + val api = + RemoteImageApi( + getApiClient() + ?: return@withContext Result.failure( + IllegalStateException("No API client") + ) + ) + val type = + ImageType.fromNameOrNull(imageType) + ?: return@withContext Result.failure( + IllegalArgumentException("Unknown image type: $imageType") + ) + api.downloadRemoteImage( + itemId = UUID.fromString(itemId), + type = type, + imageUrl = imageUrl, + ) + adminChangeBroadcaster.notifyItemChanged(itemId) + Result.success(Unit) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to download remote image for $itemId") + Result.failure(e) + } catch (e: Exception) { + Timber.e(e, "Unexpected error downloading remote image for $itemId") + Result.failure(e) + } + } + + override suspend fun uploadImage( + itemId: String, + imageType: String, + imageData: ByteArray, + mimeType: String, + ): Result = + withContext(Dispatchers.IO) { + try { + val api = + ImageApi( + getApiClient() + ?: return@withContext Result.failure( + IllegalStateException("No API client") + ) + ) + val type = + ImageType.fromNameOrNull(imageType) + ?: return@withContext Result.failure( + IllegalArgumentException("Unknown image type: $imageType") + ) + api.setItemImage( + itemId = UUID.fromString(itemId), + imageType = type, + data = FileInfo(content = imageData, mediaType = mimeType), + ) + adminChangeBroadcaster.notifyItemChanged(itemId) + Result.success(Unit) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to upload image for $itemId") + Result.failure(e) + } catch (e: Exception) { + Timber.e(e, "Unexpected error uploading image for $itemId") + Result.failure(e) + } + } + + override suspend fun deleteImage( + itemId: String, + imageType: String, + imageIndex: Int?, + ): Result = + withContext(Dispatchers.IO) { + try { + val api = + ImageApi( + getApiClient() + ?: return@withContext Result.failure( + IllegalStateException("No API client") + ) + ) + val type = + ImageType.fromNameOrNull(imageType) + ?: return@withContext Result.failure( + IllegalArgumentException("Unknown image type: $imageType") + ) + api.deleteItemImage( + itemId = UUID.fromString(itemId), + imageType = type, + imageIndex = imageIndex, + ) + adminChangeBroadcaster.notifyItemChanged(itemId) + Result.success(Unit) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to delete image for $itemId") + Result.failure(e) + } catch (e: Exception) { + Timber.e(e, "Unexpected error deleting image for $itemId") + Result.failure(e) + } + } + + override suspend fun refreshItem( + itemId: String, + metadataRefreshMode: String, + imageRefreshMode: String, + replaceAllMetadata: Boolean, + replaceAllImages: Boolean, + ): Result = + withContext(Dispatchers.IO) { + try { + val api = + ItemRefreshApi( + getApiClient() + ?: return@withContext Result.failure( + IllegalStateException("No API client") + ) + ) + api.refreshItem( + itemId = UUID.fromString(itemId), + metadataRefreshMode = + MetadataRefreshMode.fromNameOrNull(metadataRefreshMode) + ?: MetadataRefreshMode.DEFAULT, + imageRefreshMode = + MetadataRefreshMode.fromNameOrNull(imageRefreshMode) + ?: MetadataRefreshMode.DEFAULT, + replaceAllMetadata = replaceAllMetadata, + replaceAllImages = replaceAllImages, + ) + Result.success(Unit) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to refresh item $itemId") + Result.failure(e) + } catch (e: Exception) { + Timber.e(e, "Unexpected error refreshing item $itemId") + Result.failure(e) + } + } + + private fun BaseItemDto.toEditableItem(availableRatings: List): EditableItem = + EditableItem( + id = id.toString(), + name = name ?: "", + originalTitle = originalTitle, + overview = overview, + productionYear = productionYear, + premiereDate = premiereDate?.toString(), + officialRating = officialRating, + customRating = customRating, + communityRating = communityRating?.toDouble(), + genres = genres ?: emptyList(), + tags = tags ?: emptyList(), + studios = studios?.mapNotNull { it.name } ?: emptyList(), + people = + people?.map { person -> + EditablePerson( + id = person.id.toString(), + name = person.name ?: "", + type = person.type.serialName, + role = person.role, + ) + } ?: emptyList(), + indexNumber = indexNumber, + parentIndexNumber = parentIndexNumber, + status = status, + displayOrder = displayOrder, + lockData = lockData ?: false, + lockedFields = lockedFields?.map { it.serialName } ?: emptyList(), + type = type.serialName, + path = path, + availableParentalRatings = availableRatings, + ) + + private fun EditablePerson.toBaseItemPerson(): BaseItemPerson = + BaseItemPerson( + id = if (id != null) UUID.fromString(id) else UUID.randomUUID(), + name = name, + role = role, + type = PersonKind.fromNameOrNull(type) ?: PersonKind.UNKNOWN, + ) + + private fun RemoteSearchResult.toIdentifyResult(): IdentifyResult = + IdentifyResult( + name = name ?: "", + year = productionYear, + imageUrl = imageUrl, + searchProviderName = searchProviderName, + providerIds = + providerIds?.mapValues { it.value ?: "" }?.filterValues { it.isNotEmpty() } + ?: emptyMap(), + overview = overview, + premiereDate = premiereDate?.toString(), + ) +} diff --git a/app/src/main/java/com/makd/afinity/di/AdminModule.kt b/app/src/main/java/com/makd/afinity/di/AdminModule.kt new file mode 100644 index 00000000..4889e5ad --- /dev/null +++ b/app/src/main/java/com/makd/afinity/di/AdminModule.kt @@ -0,0 +1,20 @@ +package com.makd.afinity.di + +import com.makd.afinity.data.repository.admin.AdminRepository +import com.makd.afinity.data.repository.admin.JellyfinAdminRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class AdminModule { + + @Binds + @Singleton + abstract fun bindAdminRepository( + jellyfinAdminRepository: JellyfinAdminRepository, + ): AdminRepository +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/navigation/Destination.kt b/app/src/main/java/com/makd/afinity/navigation/Destination.kt index b90ac46a..5eca1667 100644 --- a/app/src/main/java/com/makd/afinity/navigation/Destination.kt +++ b/app/src/main/java/com/makd/afinity/navigation/Destination.kt @@ -70,6 +70,15 @@ enum class Destination( const val LOGIN_ROUTE = "login?serverUrl={serverUrl}" + const val EDIT_METADATA_ROUTE = "admin/edit_metadata/{itemId}" + const val IDENTIFY_ITEM_ROUTE = "admin/identify/{itemId}/{itemType}" + const val EDIT_IMAGES_ROUTE = "admin/edit_images/{itemId}" + + fun createEditMetadataRoute(itemId: String): String = "admin/edit_metadata/$itemId" + fun createIdentifyItemRoute(itemId: String, itemType: String): String = + "admin/identify/$itemId/$itemType" + fun createEditImagesRoute(itemId: String): String = "admin/edit_images/$itemId" + const val AUDIOBOOKSHELF_LOGIN_ROUTE = "audiobookshelf/login" const val AUDIOBOOKSHELF_LIBRARIES_ROUTE = "audiobookshelf/libraries" diff --git a/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt b/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt index fc2faab4..4cb919fb 100644 --- a/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt +++ b/app/src/main/java/com/makd/afinity/navigation/MainNavigation.kt @@ -57,6 +57,9 @@ import com.makd.afinity.data.repository.JellyseerrRepository import com.makd.afinity.data.repository.watchlist.WatchlistRepository import com.makd.afinity.data.updater.UpdateManager import com.makd.afinity.data.websocket.WebSocketState +import com.makd.afinity.ui.admin.identify.IdentifyScreen +import com.makd.afinity.ui.admin.images.EditImagesScreen +import com.makd.afinity.ui.admin.metadata.EditMetadataScreen import com.makd.afinity.ui.audiobookshelf.genre.AudiobookshelfGenreResultsScreen import com.makd.afinity.ui.audiobookshelf.item.AudiobookshelfItemScreen import com.makd.afinity.ui.audiobookshelf.item.series.AudiobookshelfSeriesScreen @@ -167,7 +170,8 @@ fun MainNavigation( !route.startsWith("audiobookshelf/item/") && !route.startsWith("audiobookshelf/series/") && !route.startsWith("audiobookshelf/genre/") && - !route.startsWith("audiobookshelf/player/") + !route.startsWith("audiobookshelf/player/") && + !route.startsWith("admin/") } ?: true val useNavRail = widthSizeClass != WindowWidthSizeClass.Compact @@ -398,7 +402,9 @@ fun MainNavigation( arguments = listOf( navArgument("libraryId") { type = NavType.StringType }, - navArgument("libraryName") { type = NavType.StringType }, + navArgument("libraryName") { + type = NavType.StringType + }, ), ) { LibraryContentScreen( @@ -543,6 +549,39 @@ fun MainNavigation( ) } + composable( + route = Destination.EDIT_METADATA_ROUTE, + arguments = + listOf(navArgument("itemId") { type = NavType.StringType }), + ) { + EditMetadataScreen( + onNavigateUp = { navController.navigateUp() }, + onSaveSuccess = { navController.navigateUp() }, + ) + } + + composable( + route = Destination.IDENTIFY_ITEM_ROUTE, + arguments = + listOf( + navArgument("itemId") { type = NavType.StringType }, + navArgument("itemType") { type = NavType.StringType }, + ), + ) { + IdentifyScreen( + onNavigateUp = { navController.navigateUp() }, + onApplySuccess = { navController.navigateUp() }, + ) + } + + composable( + route = Destination.EDIT_IMAGES_ROUTE, + arguments = + listOf(navArgument("itemId") { type = NavType.StringType }), + ) { + EditImagesScreen(onNavigateUp = { navController.navigateUp() }) + } + composable(Destination.FAVORITES.route) { FavoritesScreen( onItemClick = { item -> diff --git a/app/src/main/java/com/makd/afinity/ui/admin/identify/IdentifyScreen.kt b/app/src/main/java/com/makd/afinity/ui/admin/identify/IdentifyScreen.kt new file mode 100644 index 00000000..33683bf3 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/admin/identify/IdentifyScreen.kt @@ -0,0 +1,396 @@ +package com.makd.afinity.ui.admin.identify + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.makd.afinity.R +import com.makd.afinity.data.models.admin.IdentifyResult + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun IdentifyScreen( + onNavigateUp: () -> Unit, + onApplySuccess: () -> Unit = onNavigateUp, + viewModel: IdentifyViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val focusManager = LocalFocusManager.current + + var hasSearched by remember { mutableStateOf(false) } + var applyingResult by remember { mutableStateOf(null) } + + LaunchedEffect(uiState.applied) { + if (uiState.applied) { + snackbarHostState.showSnackbar("Identity applied successfully") + onApplySuccess() + } + } + + LaunchedEffect(uiState.error) { + uiState.error?.let { + snackbarHostState.showSnackbar(it) + applyingResult = null + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.admin_identify_title), style = MaterialTheme.typography.titleLarge) }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(painterResource(R.drawable.ic_close), contentDescription = stringResource(R.string.action_close)) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding).imePadding(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + item { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SleekTextField( + value = uiState.searchName, + onValueChange = viewModel::updateSearchName, + label = stringResource(R.string.admin_identify_field_name), + modifier = Modifier.weight(2f), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + SleekTextField( + value = uiState.year, + onValueChange = viewModel::updateYear, + label = stringResource(R.string.admin_identify_field_year), + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + ) + } + + if (uiState.providers.isNotEmpty()) { + Text( + text = stringResource(R.string.admin_identify_provider_ids), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp), + ) + uiState.providers.forEach { provider -> + val idValue = uiState.providerIds[provider.key] ?: "" + SleekTextField( + value = idValue, + onValueChange = { viewModel.updateProviderId(provider.key, it) }, + label = provider.name, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { + focusManager.clearFocus() + if (!uiState.searching) { + hasSearched = true + viewModel.search() + } + } + ), + ) + } + } + + SwitchRowSimple( + label = stringResource(R.string.admin_identify_replace_images), + checked = uiState.replaceAllImages, + onToggle = viewModel::toggleReplaceImages, + ) + + Button( + onClick = { + focusManager.clearFocus() + hasSearched = true + viewModel.search() + }, + modifier = Modifier.fillMaxWidth().height(56.dp).padding(top = 8.dp), + shape = RoundedCornerShape(16.dp), + enabled = !uiState.searching && uiState.searchName.isNotBlank(), + ) { + if (uiState.searching) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text(stringResource(R.string.admin_identify_search), style = MaterialTheme.typography.titleMedium) + } + } + } + } + + item { + AnimatedVisibility(visible = uiState.results.isNotEmpty() || uiState.searching) { + Text( + text = stringResource(R.string.admin_identify_results), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + } + + items(uiState.results) { result -> + IdentifyResultCard( + result = result, + isApplying = applyingResult == result && uiState.applying, + onClick = { + applyingResult = result + viewModel.applyResult(result) + }, + ) + } + + item { + AnimatedVisibility( + visible = !uiState.searching && uiState.results.isEmpty() && hasSearched, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.admin_identify_no_results), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun IdentifyResultCard( + result: IdentifyResult, + isApplying: Boolean, + onClick: () -> Unit, +) { + Card( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable(enabled = !isApplying, onClick = onClick), + shape = RoundedCornerShape(16.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = + Modifier.width(80.dp) + .aspectRatio(2f / 3f) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + if (result.imageUrl != null) { + AsyncImage( + model = result.imageUrl, + contentDescription = result.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } else { + Icon( + painterResource(R.drawable.ic_broken_image), + contentDescription = stringResource(R.string.cd_admin_no_image), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.align(Alignment.Center).size(24.dp), + ) + } + if (isApplying) { + Box( + modifier = + Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.White, + strokeWidth = 2.dp, + ) + } + } + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = result.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = + buildString { + if (result.year != null) append(result.year) + if (result.year != null && result.searchProviderName != null) + append(" • ") + if (result.searchProviderName != null) append(result.searchProviderName) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + ) + + if (!result.overview.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = result.overview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@Composable +private fun SleekTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + singleLine: Boolean = false, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, +) { + TextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + modifier = modifier, + singleLine = singleLine, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + shape = RoundedCornerShape(12.dp), + colors = + TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + ) +} + +@Composable +private fun SwitchRowSimple(label: String, checked: Boolean, onToggle: () -> Unit) { + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .clickable { onToggle() } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface, + ) + Switch( + checked = checked, + onCheckedChange = { onToggle() }, + ) + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/admin/identify/IdentifyViewModel.kt b/app/src/main/java/com/makd/afinity/ui/admin/identify/IdentifyViewModel.kt new file mode 100644 index 00000000..e3255267 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/admin/identify/IdentifyViewModel.kt @@ -0,0 +1,94 @@ +package com.makd.afinity.ui.admin.identify + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.makd.afinity.data.models.admin.ExternalIdProvider +import com.makd.afinity.data.models.admin.IdentifyResult +import com.makd.afinity.data.repository.admin.AdminRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class IdentifyUiState( + val searchName: String = "", + val year: String = "", + val providers: List = emptyList(), + val providerIds: Map = emptyMap(), + val results: List = emptyList(), + val searching: Boolean = false, + val applying: Boolean = false, + val applied: Boolean = false, + val replaceAllImages: Boolean = false, + val error: String? = null, +) + +@HiltViewModel +class IdentifyViewModel +@Inject +constructor( + private val adminRepository: AdminRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + val itemId: String = checkNotNull(savedStateHandle["itemId"]) + val itemType: String = savedStateHandle["itemType"] ?: "Movie" + + private val _uiState = MutableStateFlow(IdentifyUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadProviders() + } + + private fun loadProviders() { + viewModelScope.launch { + val providers = adminRepository.getExternalIdProviders(itemId) + _uiState.update { it.copy(providers = providers) } + } + } + + fun updateSearchName(name: String) = _uiState.update { it.copy(searchName = name) } + + fun updateYear(year: String) = _uiState.update { it.copy(year = year) } + + fun updateProviderId(key: String, value: String) = + _uiState.update { it.copy(providerIds = it.providerIds + (key to value)) } + + fun toggleReplaceImages() = _uiState.update { it.copy(replaceAllImages = !it.replaceAllImages) } + + fun search() { + viewModelScope.launch { + _uiState.update { it.copy(searching = true, results = emptyList(), error = null) } + val state = _uiState.value + val year = state.year.toIntOrNull() + val results = when (itemType) { + "Series" -> adminRepository.searchSeries(itemId, state.searchName, year, state.providerIds) + else -> adminRepository.searchMovie(itemId, state.searchName, year, state.providerIds) + } + _uiState.update { it.copy(searching = false, results = results) } + } + } + + fun applyResult(result: IdentifyResult) { + viewModelScope.launch { + _uiState.update { it.copy(applying = true, error = null) } + val res = adminRepository.applyIdentifyResult( + itemId = itemId, + result = result, + replaceAllImages = _uiState.value.replaceAllImages, + ) + _uiState.update { + it.copy( + applying = false, + applied = res.isSuccess, + error = res.exceptionOrNull()?.message, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/ui/admin/images/EditImagesScreen.kt b/app/src/main/java/com/makd/afinity/ui/admin/images/EditImagesScreen.kt new file mode 100644 index 00000000..4b403436 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/admin/images/EditImagesScreen.kt @@ -0,0 +1,382 @@ +package com.makd.afinity.ui.admin.images + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.makd.afinity.R +import com.makd.afinity.data.models.admin.ItemImage + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun EditImagesScreen( + onNavigateUp: () -> Unit, + onChangeMade: () -> Unit = {}, + viewModel: EditImagesViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + var imageToDelete by remember { mutableStateOf(null) } + + val imagePicker = + rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { + uri: Uri? -> + uri ?: return@rememberLauncherForActivityResult + val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg" + val bytes = + context.contentResolver.openInputStream(uri)?.readBytes() + ?: return@rememberLauncherForActivityResult + viewModel.uploadImage(uiState.selectedType, bytes, mimeType) + } + + LaunchedEffect(uiState.error) { + uiState.error?.let { snackbarHostState.showSnackbar(it) } + } + LaunchedEffect(uiState.actionSuccess) { + if (uiState.actionSuccess) { + snackbarHostState.showSnackbar("Done") + onChangeMade() + viewModel.clearActionSuccess() + } + } + + if (imageToDelete != null) { + AlertDialog( + onDismissRequest = { imageToDelete = null }, + title = { Text(stringResource(R.string.admin_delete_image_title), style = MaterialTheme.typography.titleLarge) }, + text = { + Text( + stringResource(R.string.admin_delete_image_message), + style = MaterialTheme.typography.bodyMedium, + ) + }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + confirmButton = { + TextButton( + onClick = { + imageToDelete?.let { viewModel.deleteImage(it) } + imageToDelete = null + }, + colors = + ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ), + ) { + Text(stringResource(R.string.action_delete)) + } + }, + dismissButton = { + TextButton(onClick = { imageToDelete = null }) { Text(stringResource(R.string.action_cancel)) } + }, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.admin_edit_images_title), style = MaterialTheme.typography.titleLarge) }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(painterResource(R.drawable.ic_close), contentDescription = stringResource(R.string.action_close)) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { imagePicker.launch("image/*") }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + shape = RoundedCornerShape(16.dp), + icon = { Icon(painterResource(R.drawable.ic_add), contentDescription = null) }, + text = { Text(stringResource(R.string.admin_upload_image)) }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + val imageTypes = listOf( + "Primary" to stringResource(R.string.admin_image_type_primary), + "Backdrop" to stringResource(R.string.admin_image_type_backdrop), + "Logo" to stringResource(R.string.admin_image_type_logo), + ) + imageTypes.forEach { (type, label) -> + FilterChip( + selected = uiState.selectedType == type, + onClick = { viewModel.selectType(type) }, + label = { Text(label) }, + shape = CircleShape, + border = null, + colors = + FilterChipDefaults.filterChipColors( + selectedContainerColor = + MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = + MaterialTheme.colorScheme.onPrimaryContainer, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + } + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .clickable { viewModel.toggleIncludeAllLanguages() } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.admin_include_all_languages), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Switch( + checked = uiState.includeAllLanguages, + onCheckedChange = { viewModel.toggleIncludeAllLanguages() }, + ) + } + } + + if (uiState.applying) { + Box( + modifier = Modifier.fillMaxWidth().padding(8.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 140.dp), + contentPadding = + PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 88.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize(), + ) { + if (uiState.serverImages.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + SectionLabel(stringResource(R.string.admin_images_on_server)) + } + items(uiState.serverImages) { image -> + ImageCard( + image = image, + isServerImage = true, + onClick = {}, + onLongClick = { imageToDelete = image }, + ) + } + } + + if (uiState.loadingRemote) { + item(span = { GridItemSpan(maxLineSpan) }) { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } else if (uiState.remoteImages.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Spacer(modifier = Modifier.height(8.dp)) + SectionLabel(stringResource(R.string.admin_images_from_providers)) + } + items(uiState.remoteImages) { image -> + ImageCard( + image = image, + isServerImage = false, + onClick = { viewModel.applyRemoteImage(image) }, + onLongClick = {}, + ) + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ImageCard( + image: ItemImage, + isServerImage: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + val borderColor = + if (isServerImage) MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) + else Color.Transparent + + val isLandscape = image.imageType in listOf("Backdrop", "Thumb", "Logo", "Art", "Banner") + val cardAspectRatio = if (isLandscape) 16f / 9f else 2f / 3f + val scaleType = if (image.imageType == "Logo") ContentScale.Fit else ContentScale.Crop + val imagePadding = if (image.imageType == "Logo") 12.dp else 0.dp + + Box( + modifier = + Modifier.aspectRatio(cardAspectRatio) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .border(if (isServerImage) 3.dp else 0.dp, borderColor, RoundedCornerShape(16.dp)) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + ) { + val url = image.url ?: image.remoteUrl + if (url != null) { + AsyncImage( + model = url, + contentDescription = null, + contentScale = scaleType, + modifier = Modifier.fillMaxSize().padding(imagePadding), + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + painterResource(R.drawable.ic_broken_image), + contentDescription = stringResource(R.string.cd_admin_no_image), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.size(32.dp), + ) + } + } + + val meta = buildString { + if (image.width > 0 && image.height > 0) append("${image.width}×${image.height}") + image.providerName?.let { + if (isNotEmpty()) append(" • ") + append(it) + } + } + + if (meta.isNotEmpty()) { + Box( + modifier = + Modifier.align(Alignment.BottomCenter) + .padding(bottom = 8.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.6f)) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + text = meta, + style = MaterialTheme.typography.labelSmall, + color = Color.White, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + if (isServerImage) { + Box( + modifier = + Modifier.align(Alignment.TopEnd) + .padding(8.dp) + .size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.9f)) + .clickable { onLongClick() }, + contentAlignment = Alignment.Center, + ) { + Icon( + painterResource(R.drawable.ic_delete), + contentDescription = stringResource(R.string.action_delete), + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(16.dp), + ) + } + } + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(vertical = 8.dp, horizontal = 4.dp), + ) +} diff --git a/app/src/main/java/com/makd/afinity/ui/admin/images/EditImagesViewModel.kt b/app/src/main/java/com/makd/afinity/ui/admin/images/EditImagesViewModel.kt new file mode 100644 index 00000000..85bf0858 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/admin/images/EditImagesViewModel.kt @@ -0,0 +1,127 @@ +package com.makd.afinity.ui.admin.images + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.makd.afinity.data.models.admin.ItemImage +import com.makd.afinity.data.repository.admin.AdminRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class EditImagesUiState( + val loading: Boolean = false, + val loadingRemote: Boolean = false, + val applying: Boolean = false, + val serverImages: List = emptyList(), + val remoteImages: List = emptyList(), + val selectedType: String = "Primary", + val includeAllLanguages: Boolean = false, + val error: String? = null, + val actionSuccess: Boolean = false, +) + +@HiltViewModel +class EditImagesViewModel +@Inject +constructor( + private val adminRepository: AdminRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + val itemId: String = checkNotNull(savedStateHandle["itemId"]) + + private val _uiState = MutableStateFlow(EditImagesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadServerImages() + loadRemoteImages() + } + + private fun loadServerImages() { + viewModelScope.launch { + _uiState.update { it.copy(loading = true) } + val images = adminRepository.getItemImages(itemId) + _uiState.update { it.copy(loading = false, serverImages = images) } + } + } + + fun selectType(imageType: String) { + _uiState.update { it.copy(selectedType = imageType, remoteImages = emptyList()) } + loadRemoteImages(imageType) + } + + fun loadRemoteImages(imageType: String = _uiState.value.selectedType) { + viewModelScope.launch { + _uiState.update { it.copy(loadingRemote = true) } + val remote = adminRepository.getRemoteImages( + itemId = itemId, + imageType = imageType, + includeAllLanguages = _uiState.value.includeAllLanguages, + ) + _uiState.update { it.copy(loadingRemote = false, remoteImages = remote) } + } + } + + fun toggleIncludeAllLanguages() { + _uiState.update { it.copy(includeAllLanguages = !it.includeAllLanguages) } + loadRemoteImages() + } + + fun applyRemoteImage(image: ItemImage) { + val url = image.remoteUrl ?: return + viewModelScope.launch { + _uiState.update { it.copy(applying = true, error = null) } + val result = adminRepository.downloadRemoteImage( + itemId = itemId, + imageType = image.imageType, + imageUrl = url, + ) + _uiState.update { + it.copy( + applying = false, + actionSuccess = result.isSuccess, + error = result.exceptionOrNull()?.message, + ) + } + if (result.isSuccess) loadServerImages() + } + } + + fun uploadImage(imageType: String, data: ByteArray, mimeType: String) { + viewModelScope.launch { + _uiState.update { it.copy(applying = true, error = null) } + val result = adminRepository.uploadImage(itemId, imageType, data, mimeType) + _uiState.update { + it.copy( + applying = false, + actionSuccess = result.isSuccess, + error = result.exceptionOrNull()?.message, + ) + } + if (result.isSuccess) loadServerImages() + } + } + + fun deleteImage(image: ItemImage) { + viewModelScope.launch { + _uiState.update { it.copy(applying = true, error = null) } + val result = adminRepository.deleteImage(itemId, image.imageType, image.imageIndex) + _uiState.update { + it.copy( + applying = false, + actionSuccess = result.isSuccess, + error = result.exceptionOrNull()?.message, + ) + } + if (result.isSuccess) loadServerImages() + } + } + + fun clearActionSuccess() = _uiState.update { it.copy(actionSuccess = false) } +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/ui/admin/metadata/EditMetadataScreen.kt b/app/src/main/java/com/makd/afinity/ui/admin/metadata/EditMetadataScreen.kt new file mode 100644 index 00000000..cf758a84 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/admin/metadata/EditMetadataScreen.kt @@ -0,0 +1,686 @@ +package com.makd.afinity.ui.admin.metadata + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.InputChip +import androidx.compose.material3.InputChipDefaults +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.makd.afinity.R +import com.makd.afinity.data.models.admin.EditablePerson + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditMetadataScreen( + onNavigateUp: () -> Unit, + onSaveSuccess: () -> Unit = onNavigateUp, + viewModel: EditMetadataViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + var selectedTab by remember { mutableIntStateOf(0) } + + LaunchedEffect(uiState.saveSuccess) { + when (uiState.saveSuccess) { + true -> { + snackbarHostState.showSnackbar("Saved successfully") + onSaveSuccess() + } + false -> snackbarHostState.showSnackbar(uiState.error ?: "Save failed") + null -> Unit + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.admin_edit_metadata_title), style = MaterialTheme.typography.titleLarge) }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(painterResource(R.drawable.ic_close), contentDescription = stringResource(R.string.action_close)) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) + }, + floatingActionButton = { + if (uiState.edited != null) { + ExtendedFloatingActionButton( + onClick = { if (!uiState.saving) viewModel.save() }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + shape = RoundedCornerShape(16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (uiState.saving) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + Text(if (uiState.saving) stringResource(R.string.admin_saving) else stringResource(R.string.admin_save_changes)) + } + } + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + when { + uiState.loading -> + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + + uiState.error != null && uiState.edited == null -> + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + Text(uiState.error!!, color = MaterialTheme.colorScheme.error) + } + + uiState.edited != null -> { + val item = uiState.edited!! + Column(modifier = Modifier.padding(padding)) { + SecondaryTabRow( + selectedTabIndex = selectedTab, + containerColor = Color.Transparent, + divider = {}, + indicator = { + TabRowDefaults.SecondaryIndicator( + modifier = Modifier.tabIndicatorOffset(selectedTab), + color = MaterialTheme.colorScheme.primary, + height = 3.dp, + ) + }, + ) { + listOf( + stringResource(R.string.admin_tab_general), + stringResource(R.string.admin_tab_people), + stringResource(R.string.admin_tab_advanced), + ).forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { + Text( + title, + style = + if (selectedTab == index) + MaterialTheme.typography.titleSmall + else MaterialTheme.typography.bodyMedium, + ) + }, + selectedContentColor = MaterialTheme.colorScheme.primary, + unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + when (selectedTab) { + 0 -> GeneralTab(item = item, viewModel = viewModel) + 1 -> PeopleTab(item = item, viewModel = viewModel) + 2 -> AdvancedTab(item = item, viewModel = viewModel) + } + } + } + } + } +} + +@Composable +fun SleekTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + singleLine: Boolean = false, + minLines: Int = 1, + maxLines: Int = Int.MAX_VALUE, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, +) { + TextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + modifier = modifier, + singleLine = singleLine, + minLines = minLines, + maxLines = maxLines, + keyboardOptions = keyboardOptions, + shape = RoundedCornerShape(12.dp), + colors = + TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun GeneralTab( + item: com.makd.afinity.data.models.admin.EditableItem, + viewModel: EditMetadataViewModel, +) { + Column( + modifier = + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .imePadding() + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SleekTextField( + value = item.name, + onValueChange = { viewModel.updateName(it) }, + label = stringResource(R.string.admin_field_title), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + SleekTextField( + value = item.originalTitle ?: "", + onValueChange = { viewModel.updateOriginalTitle(it) }, + label = stringResource(R.string.admin_field_original_title), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + SleekTextField( + value = item.overview ?: "", + onValueChange = { viewModel.updateOverview(it) }, + label = stringResource(R.string.admin_field_overview), + modifier = Modifier.fillMaxWidth(), + minLines = 4, + maxLines = 8, + ) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SleekTextField( + value = item.productionYear?.toString() ?: "", + onValueChange = { viewModel.updateYear(it) }, + label = stringResource(R.string.admin_field_year), + modifier = Modifier.weight(1f), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + ) + SleekTextField( + value = item.officialRating ?: "", + onValueChange = { viewModel.updateOfficialRating(it) }, + label = stringResource(R.string.admin_field_rating), + modifier = Modifier.weight(1f), + singleLine = true, + ) + } + + SectionHeader(stringResource(R.string.admin_section_genres)) + ChipInput( + chips = item.genres, + onAdd = { viewModel.addGenre(it) }, + onRemove = { viewModel.removeGenre(it) }, + ) + + SectionHeader(stringResource(R.string.admin_section_tags)) + ChipInput( + chips = item.tags, + onAdd = { viewModel.addTag(it) }, + onRemove = { viewModel.removeTag(it) }, + ) + + SectionHeader(stringResource(R.string.admin_section_studios)) + ChipInput( + chips = item.studios, + onAdd = { viewModel.addStudio(it) }, + onRemove = { viewModel.removeStudio(it) }, + ) + + Spacer(modifier = Modifier.height(88.dp)) + } +} + +@Composable +private fun PeopleTab( + item: com.makd.afinity.data.models.admin.EditableItem, + viewModel: EditMetadataViewModel, +) { + var showAddPerson by remember { mutableStateOf(false) } + var newPersonName by remember { mutableStateOf("") } + var newPersonType by remember { mutableStateOf("Actor") } + var newPersonRole by remember { mutableStateOf("") } + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.admin_section_cast), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + TextButton( + onClick = { showAddPerson = !showAddPerson }, + colors = + ButtonDefaults.textButtonColors( + containerColor = + if (showAddPerson) MaterialTheme.colorScheme.secondaryContainer + else Color.Transparent + ), + shape = RoundedCornerShape(12.dp), + ) { + Icon( + painterResource(if (showAddPerson) R.drawable.ic_close else R.drawable.ic_add), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(if (showAddPerson) stringResource(R.string.admin_close_form) else stringResource(R.string.admin_add_person)) + } + } + + if (showAddPerson) { + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SleekTextField( + value = newPersonName, + onValueChange = { newPersonName = it }, + label = stringResource(R.string.admin_field_person_name), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + SleekTextField( + value = newPersonType, + onValueChange = { newPersonType = it }, + label = stringResource(R.string.admin_field_person_type), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + SleekTextField( + value = newPersonRole, + onValueChange = { newPersonRole = it }, + label = stringResource(R.string.admin_field_person_role), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth(), + ) { + Button( + onClick = { + if (newPersonName.isNotBlank()) { + viewModel.addPerson( + EditablePerson( + id = null, + name = newPersonName.trim(), + type = newPersonType.trim().ifBlank { "Actor" }, + role = newPersonRole.trim().ifBlank { null }, + ) + ) + newPersonName = "" + newPersonRole = "" + showAddPerson = false + } + }, + shape = RoundedCornerShape(12.dp), + ) { + Text(stringResource(R.string.admin_add_to_list)) + } + } + } + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 88.dp), + ) { + items(item.people.indices.toList()) { index -> + val person = item.people[index] + ListItem( + headlineContent = { + Text(person.name, style = MaterialTheme.typography.bodyLarge) + }, + supportingContent = { + Text( + buildString { + append(person.type) + if (!person.role.isNullOrBlank()) append(" · ${person.role}") + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingContent = { + IconButton( + onClick = { viewModel.removePerson(index) }, + colors = + IconButtonDefaults.iconButtonColors( + containerColor = + MaterialTheme.colorScheme.errorContainer.copy( + alpha = 0.15f + ), + contentColor = MaterialTheme.colorScheme.error, + ), + modifier = Modifier.size(36.dp), + ) { + Icon( + painterResource(R.drawable.ic_delete), + contentDescription = stringResource(R.string.cd_admin_remove), + modifier = Modifier.size(18.dp), + ) + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.5f), + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun AdvancedTab( + item: com.makd.afinity.data.models.admin.EditableItem, + viewModel: EditMetadataViewModel, +) { + val allFields = + listOf( + "Cast", + "Genres", + "ProductionLocations", + "Studios", + "Tags", + "Name", + "Overview", + "Runtime", + "OfficialRating", + ) + + Column( + modifier = + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .imePadding() + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SleekTextField( + value = item.communityRating?.toString() ?: "", + onValueChange = { viewModel.updateCommunityRating(it) }, + label = stringResource(R.string.admin_field_community_rating), + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + ) + SleekTextField( + value = item.customRating ?: "", + onValueChange = { viewModel.updateCustomRating(it) }, + label = stringResource(R.string.admin_field_custom_rating), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + if (item.type == "Series") { + SleekTextField( + value = item.status ?: "", + onValueChange = { viewModel.updateStatus(it) }, + label = stringResource(R.string.admin_field_series_status), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + SleekTextField( + value = item.displayOrder ?: "", + onValueChange = { viewModel.updateDisplayOrder(it) }, + label = stringResource(R.string.admin_field_display_order), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + + SwitchRowSimple( + label = stringResource(R.string.admin_lock_data), + checked = item.lockData, + onToggle = { viewModel.toggleLockData() }, + ) + + if (item.lockData) { + SectionHeader(stringResource(R.string.admin_section_locked_fields)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + allFields.forEach { field -> + FilterChip( + selected = field in item.lockedFields, + onClick = { viewModel.toggleLockedField(field) }, + label = { Text(field) }, + shape = CircleShape, + border = null, + colors = + FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledContainerColor = + MaterialTheme.colorScheme.surfaceContainerLow, + ), + ) + } + } + } + + Spacer(modifier = Modifier.height(88.dp)) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ChipInput( + chips: List, + onAdd: (String) -> Unit, + onRemove: (String) -> Unit, +) { + var text by remember { mutableStateOf("") } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + TextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.weight(1f), + placeholder = { Text(stringResource(R.string.admin_add_item_placeholder)) }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = + TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + ) + IconButton( + onClick = { + if (text.isNotBlank()) { + onAdd(text.trim()) + text = "" + } + }, + modifier = + Modifier.background(MaterialTheme.colorScheme.primaryContainer, CircleShape) + .size(48.dp), + ) { + Icon( + painterResource(R.drawable.ic_add), + contentDescription = stringResource(R.string.cd_admin_add), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + chips.forEach { chip -> + InputChip( + selected = false, + onClick = {}, + label = { Text(chip, style = MaterialTheme.typography.bodyMedium) }, + shape = CircleShape, + colors = + InputChipDefaults.inputChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + ), + border = null, + trailingIcon = { + IconButton( + onClick = { onRemove(chip) }, + modifier = Modifier.size(16.dp), + ) { + Icon( + painterResource(R.drawable.ic_close), + contentDescription = stringResource(R.string.cd_admin_remove), + modifier = Modifier.size(12.dp), + ) + } + }, + ) + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), + ) +} + +@Composable +private fun SwitchRowSimple(label: String, checked: Boolean, onToggle: () -> Unit) { + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .clickable { onToggle() } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface, + ) + androidx.compose.material3.Switch( + checked = checked, + onCheckedChange = { onToggle() }, + ) + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/admin/metadata/EditMetadataViewModel.kt b/app/src/main/java/com/makd/afinity/ui/admin/metadata/EditMetadataViewModel.kt new file mode 100644 index 00000000..cd94adb8 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/admin/metadata/EditMetadataViewModel.kt @@ -0,0 +1,106 @@ +package com.makd.afinity.ui.admin.metadata + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.makd.afinity.data.models.admin.EditableItem +import com.makd.afinity.data.models.admin.EditablePerson +import com.makd.afinity.data.repository.admin.AdminRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class EditMetadataUiState( + val loading: Boolean = false, + val saving: Boolean = false, + val item: EditableItem? = null, + val edited: EditableItem? = null, + val saveSuccess: Boolean? = null, + val error: String? = null, +) + +@HiltViewModel +class EditMetadataViewModel +@Inject +constructor( + private val adminRepository: AdminRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + val itemId: String = checkNotNull(savedStateHandle["itemId"]) + + private val _uiState = MutableStateFlow(EditMetadataUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadItem() + } + + private fun loadItem() { + viewModelScope.launch { + _uiState.update { it.copy(loading = true, error = null) } + val item = adminRepository.getEditableItem(itemId) + if (item != null) { + _uiState.update { it.copy(loading = false, item = item, edited = item) } + } else { + _uiState.update { it.copy(loading = false, error = "Failed to load item") } + } + } + } + + fun updateName(value: String) = update { copy(name = value) } + fun updateOriginalTitle(value: String) = update { copy(originalTitle = value.ifBlank { null }) } + fun updateOverview(value: String) = update { copy(overview = value.ifBlank { null }) } + fun updateYear(value: String) = update { copy(productionYear = value.toIntOrNull()) } + fun updateOfficialRating(value: String) = update { copy(officialRating = value.ifBlank { null }) } + fun updateCustomRating(value: String) = update { copy(customRating = value.ifBlank { null }) } + fun updateCommunityRating(value: String) = update { copy(communityRating = value.toDoubleOrNull()) } + fun updateStatus(value: String) = update { copy(status = value.ifBlank { null }) } + fun updateDisplayOrder(value: String) = update { copy(displayOrder = value.ifBlank { null }) } + fun toggleLockData() = update { copy(lockData = !lockData) } + + fun addGenre(genre: String) = update { copy(genres = genres + genre) } + fun removeGenre(genre: String) = update { copy(genres = genres - genre) } + + fun addTag(tag: String) = update { copy(tags = tags + tag) } + fun removeTag(tag: String) = update { copy(tags = tags - tag) } + + fun addStudio(studio: String) = update { copy(studios = studios + studio) } + fun removeStudio(studio: String) = update { copy(studios = studios - studio) } + + fun addPerson(person: EditablePerson) = update { copy(people = people + person) } + fun removePerson(index: Int) = update { copy(people = people.toMutableList().also { it.removeAt(index) }) } + + fun toggleLockedField(field: String) = update { + val updated = if (field in lockedFields) lockedFields - field else lockedFields + field + copy(lockedFields = updated) + } + + fun save() { + val edited = _uiState.value.edited ?: return + viewModelScope.launch { + _uiState.update { it.copy(saving = true, error = null) } + val result = adminRepository.updateItemMetadata(itemId, edited) + _uiState.update { + it.copy( + saving = false, + saveSuccess = result.isSuccess, + error = if (result.isFailure) result.exceptionOrNull()?.message else null, + ) + } + } + } + + val isDirty: Boolean + get() = _uiState.value.item != _uiState.value.edited + + private fun update(block: EditableItem.() -> EditableItem) { + _uiState.update { state -> + state.copy(edited = state.edited?.block()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/ui/admin/refresh/RefreshMetadataDialog.kt b/app/src/main/java/com/makd/afinity/ui/admin/refresh/RefreshMetadataDialog.kt new file mode 100644 index 00000000..9789bcde --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/admin/refresh/RefreshMetadataDialog.kt @@ -0,0 +1,157 @@ +package com.makd.afinity.ui.admin.refresh + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.makd.afinity.R + +@Composable +fun RefreshMetadataDialog( + itemId: String, + onDismiss: () -> Unit, + viewModel: RefreshMetadataViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(uiState.done) { + if (uiState.done) onDismiss() + } + + AlertDialog( + onDismissRequest = { if (!uiState.refreshing) onDismiss() }, + title = { Text(stringResource(R.string.admin_refresh_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.admin_refresh_mode_label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column(modifier = Modifier.selectableGroup()) { + RefreshModeOption( + label = stringResource(R.string.admin_refresh_mode_default), + description = stringResource(R.string.admin_refresh_mode_default_desc), + selected = uiState.mode == RefreshMode.Default, + onSelect = { viewModel.setMode(RefreshMode.Default) }, + ) + RefreshModeOption( + label = stringResource(R.string.admin_refresh_mode_validate), + description = stringResource(R.string.admin_refresh_mode_validate_desc), + selected = uiState.mode == RefreshMode.Validate, + onSelect = { viewModel.setMode(RefreshMode.Validate) }, + ) + RefreshModeOption( + label = stringResource(R.string.admin_refresh_mode_full), + description = stringResource(R.string.admin_refresh_mode_full_desc), + selected = uiState.mode == RefreshMode.Full, + onSelect = { viewModel.setMode(RefreshMode.Full) }, + ) + } + + if (uiState.mode != RefreshMode.Default) { + HorizontalDivider() + Spacer(modifier = Modifier.height(4.dp)) + + SwitchRow( + label = stringResource(R.string.admin_refresh_replace_images), + checked = uiState.replaceImages, + onToggle = { viewModel.toggleReplaceImages() }, + ) + + if (uiState.mode == RefreshMode.Full) { + SwitchRow( + label = stringResource(R.string.admin_refresh_replace_metadata), + checked = uiState.replaceMetadata, + onToggle = { viewModel.toggleReplaceMetadata() }, + ) + } + } + + uiState.error?.let { error -> + Text( + text = error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + confirmButton = { + if (uiState.refreshing) { + CircularProgressIndicator(modifier = Modifier.padding(horizontal = 16.dp)) + } else { + TextButton(onClick = { viewModel.refresh() }) { + Text(stringResource(R.string.admin_btn_refresh)) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !uiState.refreshing) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + +@Composable +private fun RefreshModeOption( + label: String, + description: String, + selected: Boolean, + onSelect: () -> Unit, +) { + Row( + modifier = + Modifier.fillMaxWidth() + .selectable(selected = selected, onClick = onSelect, role = Role.RadioButton) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + RadioButton(selected = selected, onClick = null) + Column { + Text(text = label, style = MaterialTheme.typography.bodyMedium) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun SwitchRow(label: String, checked: Boolean, onToggle: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = label, style = MaterialTheme.typography.bodyMedium) + Switch(checked = checked, onCheckedChange = { onToggle() }) + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/admin/refresh/RefreshMetadataViewModel.kt b/app/src/main/java/com/makd/afinity/ui/admin/refresh/RefreshMetadataViewModel.kt new file mode 100644 index 00000000..9073d3c1 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/admin/refresh/RefreshMetadataViewModel.kt @@ -0,0 +1,77 @@ +package com.makd.afinity.ui.admin.refresh + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.makd.afinity.data.repository.admin.AdminRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +enum class RefreshMode { Default, Validate, Full } + +data class RefreshUiState( + val mode: RefreshMode = RefreshMode.Default, + val replaceImages: Boolean = false, + val replaceMetadata: Boolean = false, + val refreshing: Boolean = false, + val done: Boolean = false, + val error: String? = null, +) + +@HiltViewModel +class RefreshMetadataViewModel +@Inject +constructor( + private val adminRepository: AdminRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + val itemId: String = checkNotNull(savedStateHandle["itemId"]) + + private val _uiState = MutableStateFlow(RefreshUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun setMode(mode: RefreshMode) = _uiState.update { it.copy(mode = mode) } + + fun toggleReplaceImages() = _uiState.update { it.copy(replaceImages = !it.replaceImages) } + + fun toggleReplaceMetadata() = _uiState.update { it.copy(replaceMetadata = !it.replaceMetadata) } + + fun refresh() { + val state = _uiState.value + viewModelScope.launch { + _uiState.update { it.copy(refreshing = true, error = null) } + val (metaMode, imageMode, replaceMeta, replaceImages) = when (state.mode) { + RefreshMode.Default -> RefreshParams("Default", "Default", false, false) + RefreshMode.Validate -> RefreshParams("ValidationOnly", "ValidationOnly", false, state.replaceImages) + RefreshMode.Full -> RefreshParams("FullRefresh", "FullRefresh", state.replaceMetadata, state.replaceImages) + } + val result = adminRepository.refreshItem( + itemId = itemId, + metadataRefreshMode = metaMode, + imageRefreshMode = imageMode, + replaceAllMetadata = replaceMeta, + replaceAllImages = replaceImages, + ) + _uiState.update { + it.copy( + refreshing = false, + done = result.isSuccess, + error = result.exceptionOrNull()?.message, + ) + } + } + } + + private data class RefreshParams( + val metaMode: String, + val imageMode: String, + val replaceMeta: Boolean, + val replaceImages: Boolean, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/ui/favorites/FavoritesViewModel.kt b/app/src/main/java/com/makd/afinity/ui/favorites/FavoritesViewModel.kt index 68f52baf..fe76d05d 100644 --- a/app/src/main/java/com/makd/afinity/ui/favorites/FavoritesViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/favorites/FavoritesViewModel.kt @@ -2,6 +2,7 @@ package com.makd.afinity.ui.favorites import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.makd.afinity.data.manager.AdminChangeBroadcaster import com.makd.afinity.data.manager.MediaChangeManager import com.makd.afinity.data.models.download.DownloadInfo import com.makd.afinity.data.models.media.AfinityBoxSet @@ -41,6 +42,7 @@ class FavoritesViewModel constructor( private val userDataRepository: UserDataRepository, private val mediaRepository: MediaRepository, + private val adminChangeBroadcaster: AdminChangeBroadcaster, private val mediaChangeManager: MediaChangeManager, private val watchlistRepository: WatchlistRepository, private val downloadRepository: DownloadRepository, @@ -94,6 +96,10 @@ constructor( } } + viewModelScope.launch { + adminChangeBroadcaster.itemChanged.collect { loadFavorites() } + } + viewModelScope.launch { mediaChangeManager.mediaChanges.collect { event -> val currentState = _uiState.value diff --git a/app/src/main/java/com/makd/afinity/ui/home/HomeViewModel.kt b/app/src/main/java/com/makd/afinity/ui/home/HomeViewModel.kt index ebe80a2f..54c3ada1 100644 --- a/app/src/main/java/com/makd/afinity/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/home/HomeViewModel.kt @@ -11,6 +11,7 @@ import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import com.makd.afinity.R +import com.makd.afinity.data.manager.AdminChangeBroadcaster import com.makd.afinity.data.manager.MediaChangeManager import com.makd.afinity.data.manager.MediaChangeSource import com.makd.afinity.data.manager.OfflineModeManager @@ -96,6 +97,7 @@ constructor( private val authRepository: AuthRepository, private val mediaRepository: MediaRepository, private val playbackStateManager: PlaybackStateManager, + private val adminChangeBroadcaster: AdminChangeBroadcaster, private val mediaChangeManager: MediaChangeManager, private val itemDownloadDelegate: ItemDownloadDelegate, private val itemUserDataDelegate: ItemUserDataDelegate, @@ -224,6 +226,12 @@ constructor( } } + viewModelScope.launch { + adminChangeBroadcaster.itemChanged.collect { + appDataRepository.refreshLiveSections() + } + } + viewModelScope.launch { appDataRepository.separateMovieLibrarySections.collect { sections -> _uiState.update { it.copy(separateMovieLibrarySections = sections) } diff --git a/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt b/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt index 6979585e..349b88bd 100644 --- a/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt @@ -79,6 +79,7 @@ import com.makd.afinity.data.models.media.AfinityVideo import com.makd.afinity.data.models.tmdb.TmdbReview import com.makd.afinity.navigation.Destination import com.makd.afinity.navigation.LocalPlayerOffset +import com.makd.afinity.ui.admin.refresh.RefreshMetadataDialog import com.makd.afinity.ui.components.AsyncImage import com.makd.afinity.ui.components.FullScreenError import com.makd.afinity.ui.components.FullScreenLoading @@ -90,6 +91,7 @@ import com.makd.afinity.ui.item.components.SeasonDetailContent import com.makd.afinity.ui.item.components.SeriesDetailContent import com.makd.afinity.ui.item.components.VersionPickerDialog import com.makd.afinity.ui.item.components.shared.ActionButtonsRow +import com.makd.afinity.ui.item.components.shared.AdminAction import com.makd.afinity.ui.item.components.shared.HeroSection import com.makd.afinity.ui.item.components.shared.MediaSourceOption import com.makd.afinity.ui.item.components.shared.MetadataRow @@ -122,6 +124,8 @@ fun ItemDetailScreen( val selectedEpisodeDownloadInfo by viewModel.selectedEpisodeDownloadInfo.collectAsStateWithLifecycle() val canDownload by viewModel.canDownload.collectAsStateWithLifecycle() + val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle() + var showEpisodeRefreshDialog by remember { mutableStateOf(false) } val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { @@ -252,7 +256,29 @@ fun ItemDetailScreen( pendingNavigationSeriesId = episode.seriesId.toString() } } else null, + isAdmin = isAdmin, + onAdminAction = { action -> + when (action) { + AdminAction.EditMetadata -> + navController.navigate( + Destination.createEditMetadataRoute(episode.id.toString()) + ) + AdminAction.EditImages -> + navController.navigate( + Destination.createEditImagesRoute(episode.id.toString()) + ) + AdminAction.Refresh -> showEpisodeRefreshDialog = true + AdminAction.Identify -> Unit + } + }, ) + + if (showEpisodeRefreshDialog) { + RefreshMetadataDialog( + itemId = episode.id.toString(), + onDismiss = { showEpisodeRefreshDialog = false }, + ) + } } LaunchedEffect(selectedEpisode, pendingNavigationSeriesId) { @@ -434,6 +460,8 @@ private fun LandscapeItemDetailContent( ) { val preferencesRepository = rememberPreferencesRepository() val canDownload by viewModel.canDownload.collectAsStateWithLifecycle() + val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle() + var showRefreshDialog by remember { mutableStateOf(false) } val density = LocalDensity.current val statusBarHeight = WindowInsets.statusBars.getTop(density) val displayCutoutLeft = WindowInsets.displayCutout.getLeft(density, LayoutDirection.Ltr) @@ -507,7 +535,11 @@ private fun LandscapeItemDetailContent( } } - MetadataRow(item = item, boxSetItems = boxSetItems, selectedSourceId = selectedMediaSource?.id) + MetadataRow( + item = item, + boxSetItems = boxSetItems, + selectedSourceId = selectedMediaSource?.id, + ) Row( horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -555,8 +587,43 @@ private fun LandscapeItemDetailContent( onCancelDownload = { viewModel.cancelDownload() }, canDownload = canDownload, isLandscape = true, + isAdmin = isAdmin, + onAdminAction = { action -> + when (action) { + AdminAction.EditMetadata -> + navController.navigate( + Destination.createEditMetadataRoute( + item.id.toString() + ) + ) + AdminAction.Identify -> + navController.navigate( + Destination.createIdentifyItemRoute( + item.id.toString(), + when (item) { + is AfinityShow -> "Series" + else -> "Movie" + }, + ) + ) + AdminAction.EditImages -> + navController.navigate( + Destination.createEditImagesRoute( + item.id.toString() + ) + ) + AdminAction.Refresh -> showRefreshDialog = true + } + }, modifier = Modifier.weight(2f), ) + + if (showRefreshDialog) { + RefreshMetadataDialog( + itemId = item.id.toString(), + onDismiss = { showRefreshDialog = false }, + ) + } } VideoQualitySelection( @@ -626,6 +693,8 @@ private fun PortraitItemDetailContent( val preferencesRepository = rememberPreferencesRepository() val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val canDownload by viewModel.canDownload.collectAsStateWithLifecycle() + val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle() + var showRefreshDialog by remember { mutableStateOf(false) } val playerOffset = LocalPlayerOffset.current LazyColumn( @@ -654,7 +723,11 @@ private fun PortraitItemDetailContent( } } - MetadataRow(item = item, boxSetItems = boxSetItems, selectedSourceId = selectedMediaSource?.id) + MetadataRow( + item = item, + boxSetItems = boxSetItems, + selectedSourceId = selectedMediaSource?.id, + ) if (item !is AfinityBoxSet && item.canPlay) { PrimaryPlaybackButton( @@ -685,8 +758,39 @@ private fun PortraitItemDetailContent( onCancelDownload = { viewModel.cancelDownload() }, canDownload = canDownload, isLandscape = false, + isAdmin = isAdmin, + onAdminAction = { action -> + when (action) { + AdminAction.EditMetadata -> + navController.navigate( + Destination.createEditMetadataRoute(item.id.toString()) + ) + AdminAction.Identify -> + navController.navigate( + Destination.createIdentifyItemRoute( + item.id.toString(), + when (item) { + is AfinityShow -> "Series" + else -> "Movie" + }, + ) + ) + AdminAction.EditImages -> + navController.navigate( + Destination.createEditImagesRoute(item.id.toString()) + ) + AdminAction.Refresh -> showRefreshDialog = true + } + }, ) + if (showRefreshDialog) { + RefreshMetadataDialog( + itemId = item.id.toString(), + onDismiss = { showRefreshDialog = false }, + ) + } + VideoQualitySelection( mediaSourceOptions = mediaSourceOptions, selectedSource = selectedMediaSource, diff --git a/app/src/main/java/com/makd/afinity/ui/item/ItemDetailViewModel.kt b/app/src/main/java/com/makd/afinity/ui/item/ItemDetailViewModel.kt index c195f863..443cadb0 100644 --- a/app/src/main/java/com/makd/afinity/ui/item/ItemDetailViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/item/ItemDetailViewModel.kt @@ -11,6 +11,7 @@ import androidx.paging.cachedIn import androidx.paging.map import com.makd.afinity.R import com.makd.afinity.data.database.entities.ItemMetadataCacheEntity +import com.makd.afinity.data.manager.AdminChangeBroadcaster import com.makd.afinity.data.manager.MediaChangeManager import com.makd.afinity.data.manager.MediaChangeSource import com.makd.afinity.data.manager.OfflineModeManager @@ -66,6 +67,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -91,6 +93,7 @@ constructor( private val offlineModeManager: OfflineModeManager, private val authRepository: AuthRepository, private val playbackStateManager: PlaybackStateManager, + private val adminChangeBroadcaster: AdminChangeBroadcaster, private val mediaChangeManager: MediaChangeManager, private val serverRepository: ServerRepository, private val securePreferencesRepository: SecurePreferencesRepository, @@ -141,6 +144,11 @@ constructor( private val _uiState = MutableStateFlow(ItemDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() + val isAdmin: StateFlow = + sessionManager.currentSession + .map { it?.isAdmin == true } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + val canDownload: StateFlow = preferencesRepository .getDownloadWifiOnlyFlow() @@ -172,6 +180,12 @@ constructor( loadItem() observeDownloadStatus() + viewModelScope.launch { + adminChangeBroadcaster.itemChanged + .filter { it == itemId.toString() } + .collect { forceReloadFromServer() } + } + viewModelScope.launch { playbackStateManager.playbackEvents.collect { event -> if (event is PlaybackEvent.Stopped && event.itemId == itemId) { @@ -1001,6 +1015,10 @@ constructor( } } + fun forceReloadFromServer() { + loadItem() + } + fun onScreenResumed() { if (appDataRepository.lastUserDataChangedAt.value > itemLastLoadedAt) { refreshFromCacheImmediate(skipNetworkSync = false) diff --git a/app/src/main/java/com/makd/afinity/ui/item/components/EpisodeDetailOverlay.kt b/app/src/main/java/com/makd/afinity/ui/item/components/EpisodeDetailOverlay.kt index 8fb29483..9fbd4f5e 100644 --- a/app/src/main/java/com/makd/afinity/ui/item/components/EpisodeDetailOverlay.kt +++ b/app/src/main/java/com/makd/afinity/ui/item/components/EpisodeDetailOverlay.kt @@ -17,6 +17,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -25,7 +27,10 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -53,6 +58,7 @@ import com.makd.afinity.data.models.extensions.thumbBlurHash import com.makd.afinity.data.models.extensions.thumbImageUrl import com.makd.afinity.data.models.media.AfinityEpisode import com.makd.afinity.ui.components.AsyncImage +import com.makd.afinity.ui.item.components.shared.AdminAction import com.makd.afinity.ui.item.components.shared.PlaybackSelection import com.makd.afinity.ui.item.components.shared.PlaybackSelectionButton import org.jellyfin.sdk.model.api.MediaStreamType @@ -79,6 +85,8 @@ fun EpisodeDetailOverlay( onCancelDownload: () -> Unit, canDownload: Boolean = true, onGoToSeries: (() -> Unit)? = null, + isAdmin: Boolean = false, + onAdminAction: (AdminAction) -> Unit = {}, ) { val context = LocalContext.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -116,7 +124,12 @@ fun EpisodeDetailOverlay( stringResource( R.string.episode_season_episode_fmt, episode.parentIndexNumber ?: 0, - if (episode.indexNumberEnd != null && episode.indexNumberEnd != episode.indexNumber) "${episode.indexNumber ?: 0}-${episode.indexNumberEnd}" else "${episode.indexNumber ?: 0}", + if ( + episode.indexNumberEnd != null && + episode.indexNumberEnd != episode.indexNumber + ) + "${episode.indexNumber ?: 0}-${episode.indexNumberEnd}" + else "${episode.indexNumber ?: 0}", episode.name, ), style = MaterialTheme.typography.titleMedium, @@ -503,6 +516,69 @@ fun EpisodeDetailOverlay( canDownload = canDownload, ) } + + if (isAdmin) { + var menuExpanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon( + painter = + painterResource(id = R.drawable.ic_admin_panel_settings), + contentDescription = stringResource(R.string.cd_admin_manage), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp), + ) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.admin_action_edit_metadata)) }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_edit), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + }, + onClick = { + menuExpanded = false + onAdminAction(AdminAction.EditMetadata) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.admin_action_edit_images)) }, + leadingIcon = { + Icon( + painter = + painterResource(id = R.drawable.ic_auto_awesome), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + }, + onClick = { + menuExpanded = false + onAdminAction(AdminAction.EditImages) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.admin_action_refresh_metadata)) }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_refresh), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + }, + onClick = { + menuExpanded = false + onAdminAction(AdminAction.Refresh) + }, + ) + } + } + } } } } diff --git a/app/src/main/java/com/makd/afinity/ui/item/components/shared/ActionButtonsRow.kt b/app/src/main/java/com/makd/afinity/ui/item/components/shared/ActionButtonsRow.kt index b7d88041..efe21b34 100644 --- a/app/src/main/java/com/makd/afinity/ui/item/components/shared/ActionButtonsRow.kt +++ b/app/src/main/java/com/makd/afinity/ui/item/components/shared/ActionButtonsRow.kt @@ -1,13 +1,21 @@ package com.makd.afinity.ui.item.components.shared import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -39,6 +47,8 @@ fun ActionButtonsRow( onCancelDownload: () -> Unit, canDownload: Boolean = true, isLandscape: Boolean = false, + isAdmin: Boolean = false, + onAdminAction: (AdminAction) -> Unit = {}, modifier: Modifier = Modifier, ) { Row( @@ -129,5 +139,80 @@ fun ActionButtonsRow( canDownload = canDownload && hasPlayableItems, isLandscape = isLandscape, ) + + if (isAdmin) { + var menuExpanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon( + painter = painterResource(id = R.drawable.ic_options), + contentDescription = stringResource(R.string.cd_admin_manage), + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(28.dp), + ) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.admin_action_edit_metadata)) }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_edit_circle), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + }, + onClick = { + menuExpanded = false + onAdminAction(AdminAction.EditMetadata) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.admin_action_identify)) }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_search), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + }, + onClick = { + menuExpanded = false + onAdminAction(AdminAction.Identify) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.admin_action_edit_images)) }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_photo_search), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + }, + onClick = { + menuExpanded = false + onAdminAction(AdminAction.EditImages) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.admin_action_refresh_metadata)) }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_refresh), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + }, + onClick = { + menuExpanded = false + onAdminAction(AdminAction.Refresh) + }, + ) + } + } + } } } diff --git a/app/src/main/java/com/makd/afinity/ui/item/components/shared/AdminAction.kt b/app/src/main/java/com/makd/afinity/ui/item/components/shared/AdminAction.kt new file mode 100644 index 00000000..d4e266eb --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/item/components/shared/AdminAction.kt @@ -0,0 +1,8 @@ +package com.makd.afinity.ui.item.components.shared + +sealed interface AdminAction { + data object EditMetadata : AdminAction + data object Identify : AdminAction + data object EditImages : AdminAction + data object Refresh : AdminAction +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/ui/library/LibraryContentViewModel.kt b/app/src/main/java/com/makd/afinity/ui/library/LibraryContentViewModel.kt index d32ada44..a42e8ccb 100644 --- a/app/src/main/java/com/makd/afinity/ui/library/LibraryContentViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/library/LibraryContentViewModel.kt @@ -9,6 +9,7 @@ import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map import com.makd.afinity.R +import com.makd.afinity.data.manager.AdminChangeBroadcaster import com.makd.afinity.data.manager.MediaChangeManager import com.makd.afinity.data.manager.MediaChangeSource import com.makd.afinity.data.models.common.CollectionType @@ -50,6 +51,7 @@ constructor( @param:ApplicationContext private val context: Context, private val mediaRepository: MediaRepository, private val appDataRepository: AppDataRepository, + private val adminChangeBroadcaster: AdminChangeBroadcaster, private val mediaChangeManager: MediaChangeManager, private val preferencesRepository: PreferencesRepository, savedStateHandle: SavedStateHandle, @@ -137,6 +139,10 @@ constructor( } } + viewModelScope.launch { + adminChangeBroadcaster.itemChanged.collect { loadItems() } + } + viewModelScope.launch { mediaChangeManager.mediaChanges.collect { event -> event.updatedItem?.let { pendingUpdates[it.id] = it } diff --git a/app/src/main/java/com/makd/afinity/ui/person/PersonViewModel.kt b/app/src/main/java/com/makd/afinity/ui/person/PersonViewModel.kt index 95bc1220..c7776ec4 100644 --- a/app/src/main/java/com/makd/afinity/ui/person/PersonViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/person/PersonViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.makd.afinity.R +import com.makd.afinity.data.manager.AdminChangeBroadcaster import com.makd.afinity.data.manager.MediaChangeManager import com.makd.afinity.data.models.media.AfinityItem import com.makd.afinity.data.models.media.AfinityMovie @@ -36,6 +37,7 @@ constructor( private val mediaRepository: MediaRepository, private val appDataRepository: AppDataRepository, private val userDataRepository: UserDataRepository, + private val adminChangeBroadcaster: AdminChangeBroadcaster, private val mediaChangeManager: MediaChangeManager, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -57,6 +59,10 @@ constructor( private var lastLoadedAt = 0L init { + viewModelScope.launch { + adminChangeBroadcaster.itemChanged.collect { loadPersonDetails() } + } + viewModelScope.launch { appDataRepository.isInitialDataLoaded.collect { isLoaded -> if (isLoaded) { diff --git a/app/src/main/java/com/makd/afinity/ui/search/GenreResultsViewModel.kt b/app/src/main/java/com/makd/afinity/ui/search/GenreResultsViewModel.kt index b07390ae..9b775adf 100644 --- a/app/src/main/java/com/makd/afinity/ui/search/GenreResultsViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/search/GenreResultsViewModel.kt @@ -10,6 +10,7 @@ import androidx.paging.PagingSource import androidx.paging.PagingState import androidx.paging.cachedIn import androidx.paging.map +import com.makd.afinity.data.manager.AdminChangeBroadcaster import com.makd.afinity.data.manager.MediaChangeManager import com.makd.afinity.data.models.extensions.toAfinityItem import com.makd.afinity.data.models.media.AfinityEpisode @@ -42,6 +43,7 @@ constructor( @param:ApplicationContext private val context: Context, private val mediaRepository: MediaRepository, private val appDataRepository: AppDataRepository, + private val adminChangeBroadcaster: AdminChangeBroadcaster, private val mediaChangeManager: MediaChangeManager, ) : ViewModel() { @@ -84,6 +86,12 @@ constructor( } } + viewModelScope.launch { + adminChangeBroadcaster.itemChanged.collect { + currentGenre?.let { reloadGenre(it) } + } + } + viewModelScope.launch { mediaChangeManager.mediaChanges.collect { event -> var targetItem = event.updatedItem ?: event.parentItem ?: event.seasonItem diff --git a/app/src/main/java/com/makd/afinity/ui/watchlist/WatchlistViewModel.kt b/app/src/main/java/com/makd/afinity/ui/watchlist/WatchlistViewModel.kt index 59906574..ed582987 100644 --- a/app/src/main/java/com/makd/afinity/ui/watchlist/WatchlistViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/watchlist/WatchlistViewModel.kt @@ -2,6 +2,7 @@ package com.makd.afinity.ui.watchlist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.makd.afinity.data.manager.AdminChangeBroadcaster import com.makd.afinity.data.manager.MediaChangeManager import com.makd.afinity.data.models.download.DownloadInfo import com.makd.afinity.data.models.media.AfinityBoxSet @@ -43,6 +44,7 @@ constructor( private val downloadRepository: DownloadRepository, private val appDataRepository: AppDataRepository, private val mediaRepository: MediaRepository, + private val adminChangeBroadcaster: AdminChangeBroadcaster, private val mediaChangeManager: MediaChangeManager, private val itemUserDataDelegate: ItemUserDataDelegate, private val itemDownloadDelegate: ItemDownloadDelegate, @@ -75,6 +77,10 @@ constructor( _selectedEpisodeDownloadInfo.asStateFlow() init { + viewModelScope.launch { + adminChangeBroadcaster.itemChanged.collect { loadWatchlist() } + } + viewModelScope.launch { appDataRepository.watchlistData.collect { data -> _uiState.value = diff --git a/app/src/main/res/drawable/ic_broken_image.xml b/app/src/main/res/drawable/ic_broken_image.xml new file mode 100644 index 00000000..9cf3cc35 --- /dev/null +++ b/app/src/main/res/drawable/ic_broken_image.xml @@ -0,0 +1,42 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_edit_circle.xml b/app/src/main/res/drawable/ic_edit_circle.xml new file mode 100644 index 00000000..ea6b9295 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_circle.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_photo_search.xml b/app/src/main/res/drawable/ic_photo_search.xml new file mode 100644 index 00000000..d80df12f --- /dev/null +++ b/app/src/main/res/drawable/ic_photo_search.xml @@ -0,0 +1,36 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d033f9e..db254648 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1085,4 +1085,79 @@ Failed to delete: %1$s Approve failed: %1$s Decline failed: %1$s + + + Manage + Edit Metadata + Identify + Edit Images + Refresh Metadata + + + Refresh Metadata + Refresh mode + Default + Fill missing data only + Validate + Check and correct all fields + Full + Replace everything from providers + Replace existing images + Replace all metadata + Refresh + + + Edit Metadata + Save Changes + Saving… + General + People + Advanced + Title + Original Title + Overview + Year + Rating + Genres + Tags + Studios + Cast & Crew + Add Person + Close Form + Name + Type (Actor, Director, Writer…) + Role / Character + Add to List + Add item… + Community Rating + Custom Rating + Status (Continuing / Ended / Unreleased) + Display Order (AirDate / DVD / Absolute) + Lock data (prevent auto-refresh from overwriting) + Locked fields + Remove + Add + + + Identify Media + Name + Year + Provider IDs (Optional) + Replace all images + Search + Results + No results found + + + Edit Images + Delete image? + This will permanently remove the image from the server. + Upload Image + Primary + Backdrop + Logo + Include all languages + Current Images + Available from Providers + No image placeholder \ No newline at end of file