diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt index c72475c..204b4c4 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt @@ -110,12 +110,14 @@ data class EmbyItem( } } +@Immutable data class EmbyUserData( @SerializedName("PlaybackPositionTicks") val playbackPositionTicks: Long = 0L, @SerializedName("Played") val played: Boolean = false, @SerializedName("LastPlayedDate") val lastPlayedDate: String? = null, ) +@Immutable data class EmbyPerson( @SerializedName("Id") val id: String = "", @SerializedName("Name") val name: String = "", @@ -124,12 +126,14 @@ data class EmbyPerson( @SerializedName("PrimaryImageTag") val primaryImageTag: String? = null, ) +@Immutable data class EmbyNamedReference( @SerializedName("Name") val name: String = "", ) /* ---------- Detail ---------- */ +@Immutable data class EmbyMediaStream( @SerializedName("DisplayTitle") val displayTitle: String? = null, @SerializedName("DisplayLanguage") val displayLanguage: String? = null, @@ -161,12 +165,14 @@ data class EmbyMediaStream( @SerializedName("IsForced") val isForced: Boolean = false, ) +@Immutable data class EmbyItemCounts( @SerializedName("MovieCount") val movieCount: Int = 0, @SerializedName("SeriesCount") val seriesCount: Int = 0, @SerializedName("EpisodeCount") val episodeCount: Int = 0, ) +@Immutable data class EmbyResourceStats( val withVersions: EmbyItemCounts = EmbyItemCounts(), val withoutVersions: EmbyItemCounts = EmbyItemCounts(), @@ -181,6 +187,7 @@ data class EmbyResourceStats( ) } +@Immutable data class EmbyChapter( @SerializedName("StartPositionTicks") val startPositionTicks: Long = 0L, @SerializedName("Name") val name: String? = null, @@ -196,6 +203,7 @@ data class EmbyPlaybackInfoResponse( @SerializedName("PlaySessionId") val playSessionId: String? = null, ) +@Immutable data class EmbyMediaSource( @SerializedName("Id") val id: String, @SerializedName("PlaySessionId") val playSessionId: String? = null, diff --git a/app/src/main/kotlin/com/example/myapplication/screens/FavoritesAllScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/FavoritesAllScreen.kt index 63f3994..5d66167 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/FavoritesAllScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/FavoritesAllScreen.kt @@ -33,14 +33,14 @@ 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.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage -import coil.request.ImageRequest +import com.example.myapplication.ui.design.rememberImageRequest import com.example.myapplication.R as Res import com.example.myapplication.data.EmbyServer import com.example.myapplication.data.FavoriteEntity @@ -108,10 +108,7 @@ fun FavoritesAllScreen( ) { if (server != null && server.iconUrl.isNotBlank()) { AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(server.iconUrl) - .crossfade(true) - .build(), + model = rememberImageRequest(server.iconUrl), contentDescription = server.name, contentScale = ContentScale.Crop, modifier = Modifier @@ -227,10 +224,7 @@ private fun FavoriteGridCard( ) { if (imageUrl != null) { AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) - .crossfade(true) - .build(), + model = rememberImageRequest(imageUrl), contentDescription = title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeLibraryAllScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeLibraryAllScreen.kt index 62bfa14..882a28e 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeLibraryAllScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeLibraryAllScreen.kt @@ -29,11 +29,11 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot -import androidx.compose.ui.platform.LocalContext + import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil.request.ImageRequest +import com.example.myapplication.ui.design.rememberImageRequest import com.example.myapplication.R as Res import com.example.myapplication.data.EmbyItem import com.example.myapplication.data.EmbyRepository @@ -286,7 +286,7 @@ private fun HomeLibraryMediaCard( ) { LumaMediaCard( model = EmbyRepository.buildImageUrl(serverUrl, item)?.let { - ImageRequest.Builder(LocalContext.current).data(it).crossfade(true).build() + rememberImageRequest(it) }, title = item.name, modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt index 0124e63..1c1869e 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -69,6 +69,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.TextUnit import coil.request.ImageRequest +import com.example.myapplication.ui.design.rememberImageRequest import coil.imageLoader import com.example.myapplication.R as Res import com.example.myapplication.data.EmbyItem @@ -343,7 +344,7 @@ private fun FeaturedItemBanner( Box(Modifier.fillMaxWidth().height(72.dp), contentAlignment = Alignment.Center) { if (logoUrl != null) { AsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(logoUrl).crossfade(true).build(), + model = rememberImageRequest(logoUrl), contentDescription = null, contentScale = ContentScale.Fit, placeholder = ColorPainter(Color.Transparent), diff --git a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt index 45f3a94..af6aeab 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt @@ -110,6 +110,7 @@ import com.example.myapplication.ui.design.LumaGlassLevel import com.example.myapplication.ui.design.LumaShapes import com.example.myapplication.ui.design.lumaGlass import coil.request.ImageRequest +import com.example.myapplication.ui.design.rememberImageRequest import com.kyant.backdrop.Backdrop import com.kyant.backdrop.backdrops.layerBackdrop import kotlinx.coroutines.delay @@ -381,7 +382,7 @@ fun LibraryScreen( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { // Servers section - items(servers.size) { index -> + items(servers.size, key = { servers[it].id }) { index -> val server = servers[index] val tint = serverTints[index % serverTints.size] if (isGrid) { @@ -872,7 +873,7 @@ private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) { contentAlignment = Alignment.Center, ) { LumaAsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(), + model = rememberImageRequest(icon.url), contentDescription = icon.name, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(6.dp)), @@ -1014,7 +1015,7 @@ private fun ManageIconLibraryDialog( contentAlignment = Alignment.Center, ) { LumaAsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(), + model = rememberImageRequest(icon.url), contentDescription = icon.name, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(10.dp)), @@ -1213,7 +1214,7 @@ private fun AddIconLibraryDialog( contentAlignment = Alignment.Center, ) { LumaAsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(), + model = rememberImageRequest(icon.url), contentDescription = icon.name, modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(10.dp)), contentScale = ContentScale.Crop, @@ -1429,7 +1430,7 @@ private fun IconPickerDialog( contentAlignment = Alignment.Center, ) { LumaAsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(), + model = rememberImageRequest(icon.url), contentDescription = icon.name, modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(10.dp)), contentScale = ContentScale.Crop, diff --git a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt index 80e9016..dc35725 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt @@ -583,7 +583,7 @@ fun MediaDetailScreen( if (detail.genres.isNotEmpty()) { Spacer(Modifier.height(18.dp)) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(detail.genres.size) { index -> + items(detail.genres.size, key = { detail.genres[it] }) { index -> val genre = detail.genres[index] Box( Modifier @@ -648,7 +648,7 @@ fun MediaDetailScreen( val episodeSpacing = 9.dp val episodeWidth = (maxWidth - episodeSpacing) / 2 LazyRow(horizontalArrangement = Arrangement.spacedBy(episodeSpacing)) { - items(episodes.size) { index -> + items(episodes.size, key = { episodes[it].id }) { index -> val episode = episodes[index] Box(Modifier.width(episodeWidth)) { EpisodeCard( @@ -1186,7 +1186,7 @@ private fun ResourceSection( if (qualities.isNotEmpty()) { Spacer(Modifier.height(12.dp)) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(qualities.size) { index -> + items(qualities.size, key = { qualities[it] }) { index -> Box( Modifier .height(36.dp) @@ -1562,7 +1562,7 @@ private fun MediaInformationSection(item: EmbyItem, loading: Boolean) { fontSize = 12.sp, ) else -> LazyRow(horizontalArrangement = Arrangement.spacedBy(9.dp)) { - items(streams.size) { index -> + items(streams.size, key = { "${streams[it].type}_${streams[it].codec}_${streams[it].language}" }) { index -> val stream = streams[index] val typeIndex = streams.take(index + 1).count { it.type.equals(stream.type, ignoreCase = true) } val typeCount = streams.count { it.type.equals(stream.type, ignoreCase = true) } @@ -1774,7 +1774,7 @@ private fun EpisodeListDialog( modifier = Modifier.fillMaxWidth().heightIn(max = 440.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(episodes.size) { index -> + items(episodes.size, key = { episodes[it].id }) { index -> val episode = episodes[index] CompactEpisodeRow( item = episode, diff --git a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt index cdab733..27dad12 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt @@ -41,7 +41,7 @@ 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.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -49,7 +49,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage -import coil.request.ImageRequest +import com.example.myapplication.ui.design.rememberImageRequest import com.example.myapplication.R as Res import com.example.myapplication.data.EmbyItem import com.example.myapplication.data.EmbyRepository @@ -254,13 +254,7 @@ private fun ServerHeader(server: EmbyServer) { contentAlignment = Alignment.Center, ) { if (server.iconUrl.isNotBlank()) { - val ctx = LocalContext.current - val iconRequest = remember(server.iconUrl) { - ImageRequest.Builder(ctx) - .data(server.iconUrl) - .crossfade(true) - .build() - } + val iconRequest = rememberImageRequest(server.iconUrl) AsyncImage( model = iconRequest, contentDescription = server.name, @@ -396,10 +390,7 @@ private fun FavoritePosterCard( ) { if (imageUrl != null) { AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) - .crossfade(true) - .build(), + model = rememberImageRequest(imageUrl), contentDescription = title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), @@ -792,10 +783,7 @@ private fun CalendarEpisodeCard(episode: TmdbEpisodeCalendar, onClick: () -> Uni ) { if (posterUrl != null) { AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(posterUrl) - .crossfade(true) - .build(), + model = rememberImageRequest(posterUrl), contentDescription = episode.seriesName, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), @@ -834,10 +822,7 @@ private fun CalendarEpisodeCard(episode: TmdbEpisodeCalendar, onClick: () -> Uni if (episode.logoPath != null) { Spacer(Modifier.width(6.dp)) AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(episode.logoPath) - .crossfade(true) - .build(), + model = rememberImageRequest(episode.logoPath), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier @@ -870,10 +855,7 @@ private fun CalendarEpisodeCard(episode: TmdbEpisodeCalendar, onClick: () -> Uni Spacer(Modifier.width(4.dp)) if (episode.networkLogoPath != null) { AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(episode.networkLogoPath) - .crossfade(true) - .build(), + model = rememberImageRequest(episode.networkLogoPath), contentDescription = episode.networkName, contentScale = ContentScale.Fit, modifier = Modifier diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SearchAllScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SearchAllScreen.kt index 93ee690..572686a 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SearchAllScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchAllScreen.kt @@ -32,14 +32,14 @@ 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.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage -import coil.request.ImageRequest +import com.example.myapplication.ui.design.rememberImageRequest import com.example.myapplication.R as Res import com.example.myapplication.data.EmbyItem import com.example.myapplication.data.EmbyRepository @@ -119,10 +119,7 @@ fun SearchAllScreen( ) { if (server != null && server.iconUrl.isNotBlank()) { AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(server.iconUrl) - .crossfade(true) - .build(), + model = rememberImageRequest(server.iconUrl), contentDescription = server.name, contentScale = ContentScale.Crop, modifier = Modifier @@ -252,10 +249,7 @@ private fun SearchAllMediaCard( ) { if (imageUrl != null) { AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) - .crossfade(true) - .build(), + model = rememberImageRequest(imageUrl), contentDescription = title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt index 557c511..fa0f04a 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt @@ -50,7 +50,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage -import coil.request.ImageRequest +import com.example.myapplication.ui.design.rememberImageRequest import com.example.myapplication.R as Res import com.example.myapplication.data.EmbyItem import com.example.myapplication.data.EmbyRepository @@ -551,10 +551,7 @@ private fun ServerSearchSection( ) { if (server.iconUrl.isNotBlank()) { AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(server.iconUrl) - .crossfade(true) - .build(), + model = rememberImageRequest(server.iconUrl), contentDescription = server.name, contentScale = ContentScale.Crop, modifier = Modifier @@ -759,10 +756,7 @@ private fun SearchMediaPoster( ) { if (imageUrl != null) { AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) - .crossfade(true) - .build(), + model = rememberImageRequest(imageUrl), contentDescription = title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt index b95f359..7ab8b40 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.ContentScale @@ -70,6 +71,15 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import top.yukonga.miuix.kmp.basic.Text +// P2: Reusable helper — creates a remembered ImageRequest to avoid allocation on every recomposition +@Composable +fun rememberImageRequest(modelData: Any?): ImageRequest? { + val context = LocalContext.current + return remember(modelData) { + modelData?.let { ImageRequest.Builder(context).data(it).crossfade(true).build() } + } +} + private enum class LumaImageState { Loading, Success,