1、完成收藏和追剧日历

This commit is contained in:
beyond committed 2026-08-31 03:58:55 +08:00
1 parent f1b5a96c2f
commit 7f24697ece
19 files changed
+1865 -194

No files matched your search

@@ -8,13 +8,15 @@ import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase;
@Database(
entities = {IconLibraryEntity.class, ServerEntity.class},
version = 9,
entities = {IconLibraryEntity.class, ServerEntity.class, FavoriteEntity.class, WatchlistEntity.class},
version = 11,
exportSchema = false
)
public abstract class AppDatabase extends RoomDatabase {
public abstract IconLibraryDao iconLibraryDao();
public abstract ServerDao serverDao();
public abstract FavoriteDao favoriteDao();
public abstract WatchlistDao watchlistDao();
private static volatile AppDatabase instance;
@@ -104,6 +106,45 @@ public abstract class AppDatabase extends RoomDatabase {
}
};
private static final Migration MIGRATION_9_10 = new Migration(9, 10) {
@Override
public void migrate(SupportSQLiteDatabase db) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS favorites (" +
"`serverId` TEXT NOT NULL, " +
"`itemId` TEXT NOT NULL, " +
"`name` TEXT, " +
"`type` TEXT, " +
"`productionYear` INTEGER, " +
"`imageTagsJson` TEXT, " +
"`seriesName` TEXT, " +
"`createdAt` INTEGER NOT NULL DEFAULT 0, " +
"PRIMARY KEY(`serverId`, `itemId`))"
);
db.execSQL("CREATE INDEX IF NOT EXISTS index_favorites_serverId ON favorites (serverId)");
}
};
private static final Migration MIGRATION_10_11 = new Migration(10, 11) {
@Override
public void migrate(SupportSQLiteDatabase db) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS watchlist (" +
"`serverId` TEXT NOT NULL, " +
"`itemId` TEXT NOT NULL, " +
"`name` TEXT, " +
"`tmdbId` TEXT, " +
"`currentSeason` INTEGER, " +
"`currentEpisode` INTEGER, " +
"`posterUrl` TEXT, " +
"`createdAt` INTEGER NOT NULL DEFAULT 0, " +
"PRIMARY KEY(`serverId`, `itemId`))"
);
db.execSQL("CREATE INDEX IF NOT EXISTS index_watchlist_serverId ON watchlist (serverId)");
db.execSQL("CREATE INDEX IF NOT EXISTS index_watchlist_tmdbId ON watchlist (tmdbId)");
}
};
public static AppDatabase getInstance(Context context) {
if (instance == null) {
synchronized (AppDatabase.class) {
@@ -112,7 +153,7 @@ public static AppDatabase getInstance(Context context) {
context.getApplicationContext(),
AppDatabase.class,
"emby_player.db"
).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9).build();
).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11).build();
}
}
}
@@ -0,0 +1,26 @@
package com.example.myapplication.data;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import java.util.List;
@Dao
public interface FavoriteDao {
@Query("SELECT * FROM favorites WHERE serverId = :serverId ORDER BY createdAt DESC")
List<FavoriteEntity> getFavoritesByServer(String serverId);
@Query("SELECT * FROM favorites ORDER BY createdAt DESC")
List<FavoriteEntity> getAllFavorites();
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertFavorite(FavoriteEntity entity);
@Query("DELETE FROM favorites WHERE serverId = :serverId AND itemId = :itemId")
void removeFavorite(String serverId, String itemId);
@Query("SELECT EXISTS(SELECT 1 FROM favorites WHERE serverId = :serverId AND itemId = :itemId)")
boolean isFavorited(String serverId, String itemId);
}
@@ -0,0 +1,44 @@
package com.example.myapplication.data;
import androidx.annotation.NonNull;
import androidx.room.Entity;
import androidx.room.Index;
import androidx.room.PrimaryKey;
@Entity(
tableName = "favorites",
primaryKeys = {"serverId", "itemId"},
indices = {@Index("serverId")}
)
public class FavoriteEntity {
@NonNull
public String serverId;
@NonNull
public String itemId;
public String name;
public String type; // "Movie" / "Series"
public Integer productionYear;
public String imageTagsJson; // JSON string of Map<String,String>
public String seriesName; // for episodes: the series name
public long createdAt; // when favorited
public FavoriteEntity(
@NonNull String serverId,
@NonNull String itemId,
String name,
String type,
Integer productionYear,
String imageTagsJson,
String seriesName,
long createdAt
) {
this.serverId = serverId;
this.itemId = itemId;
this.name = name;
this.type = type;
this.productionYear = productionYear;
this.imageTagsJson = imageTagsJson;
this.seriesName = seriesName;
this.createdAt = createdAt;
}
}
@@ -0,0 +1,26 @@
package com.example.myapplication.data;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import java.util.List;
@Dao
public interface WatchlistDao {
@Query("SELECT * FROM watchlist WHERE serverId = :serverId ORDER BY createdAt DESC")
List<WatchlistEntity> getWatchlistByServer(String serverId);
@Query("SELECT * FROM watchlist ORDER BY createdAt DESC")
List<WatchlistEntity> getAllWatchlist();
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertWatchlist(WatchlistEntity entity);
@Query("DELETE FROM watchlist WHERE serverId = :serverId AND itemId = :itemId")
void removeWatchlist(String serverId, String itemId);
@Query("SELECT EXISTS(SELECT 1 FROM watchlist WHERE serverId = :serverId AND itemId = :itemId)")
boolean isWatching(String serverId, String itemId);
}
@@ -0,0 +1,44 @@
package com.example.myapplication.data;
import androidx.annotation.NonNull;
import androidx.room.Entity;
import androidx.room.Index;
import androidx.room.PrimaryKey;
@Entity(
tableName = "watchlist",
primaryKeys = {"serverId", "itemId"},
indices = {@Index("serverId"), @Index("tmdbId")}
)
public class WatchlistEntity {
@NonNull
public String serverId;
@NonNull
public String itemId;
public String name;
public String tmdbId; // TMDB series ID
public Integer currentSeason;
public Integer currentEpisode;
public String posterUrl; // Emby image URL for poster
public long createdAt;
public WatchlistEntity(
@NonNull String serverId,
@NonNull String itemId,
String name,
String tmdbId,
Integer currentSeason,
Integer currentEpisode,
String posterUrl,
long createdAt
) {
this.serverId = serverId;
this.itemId = itemId;
this.name = name;
this.tmdbId = tmdbId;
this.currentSeason = currentSeason;
this.currentEpisode = currentEpisode;
this.posterUrl = posterUrl;
this.createdAt = createdAt;
}
}
@@ -5,8 +5,10 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.example.myapplication.data.FavoriteRepository
import com.example.myapplication.data.IconLibraryRepository
import com.example.myapplication.data.ServerRepository
import com.example.myapplication.data.WatchlistRepository
import com.example.myapplication.ui.AppLanguage
import com.example.myapplication.ui.AppTheme
@@ -24,6 +26,8 @@ class MainActivity : ComponentActivity() {
AppTheme.init(this)
IconLibraryRepository.init(this)
ServerRepository.init(this)
FavoriteRepository.init(this)
WatchlistRepository.init(this)
dataReady = true
setContent {
@@ -73,6 +73,11 @@ fun MainApp() {
LaunchedEffect(servers.size) {
if (servers.isNotEmpty()) ServerRepository.checkAllServersOnline()
}
// Also refresh on first composition (app restart) when servers are already loaded
LaunchedEffect(Unit) {
delay(500) // wait for database load
if (servers.isNotEmpty()) ServerRepository.checkAllServersOnline()
}
LaunchedEffect(servers) {
if (servers.isNotEmpty()) {
selectedServerId = when {
@@ -162,7 +167,10 @@ fun MainApp() {
try {
val authResult = EmbyRepository.authenticate(server)
if (authResult.success) {
ServerRepository.updateServer(server.copy(apiKey = authResult.accessToken, userId = authResult.userId))
val updatedServer = server.copy(apiKey = authResult.accessToken, userId = authResult.userId)
ServerRepository.updateServer(updatedServer)
// Re-check online status after edit
ServerRepository.checkServerOnline(updatedServer.id)
onResult(null)
} else {
onResult(authResult.errorMessage ?: loginFailedMessage)
@@ -0,0 +1,142 @@
package com.example.myapplication.data
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
object FavoriteRepository {
private var database: AppDatabase? = null
private var initialized = false
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _favorites = MutableStateFlow<List<FavoriteEntity>>(emptyList())
val favorites: StateFlow<List<FavoriteEntity>> = _favorites.asStateFlow()
fun init(context: Context) {
if (initialized) return
initialized = true
database = AppDatabase.getInstance(context)
}
/** Check if an item is favorited. Must be called from a background thread. */
fun isFavorited(serverId: String, itemId: String): Boolean {
return try {
database?.favoriteDao()?.isFavorited(serverId, itemId) ?: false
} catch (e: Exception) {
Log.e("FavoriteRepo", "isFavorited failed", e)
false
}
}
/** Toggle favorite status. Returns true if now favorited, false if unfavorited. */
suspend fun toggleFavorite(serverId: String, item: EmbyItem): Boolean = withContext(Dispatchers.IO) {
try {
val dao = database?.favoriteDao() ?: return@withContext false
val currentlyFavorited = dao.isFavorited(serverId, item.id)
if (currentlyFavorited) {
dao.removeFavorite(serverId, item.id)
Log.d("FavoriteRepo", "Removed favorite: ${item.name}")
reloadFavorites(serverId)
false
} else {
val imageTagsJson = try {
val obj = JSONObject()
item.imageTags?.forEach { (k, v) -> obj.put(k, v) }
obj.toString()
} catch (_: Exception) { "{}" }
dao.insertFavorite(
FavoriteEntity(
serverId,
item.id,
item.name,
item.type ?: "",
item.productionYear,
imageTagsJson,
item.seriesName,
System.currentTimeMillis(),
)
)
Log.d("FavoriteRepo", "Added favorite: ${item.name}")
reloadFavorites(serverId)
true
}
} catch (e: Exception) {
Log.e("FavoriteRepo", "toggleFavorite failed", e)
false
}
}
/** Reload favorites for a specific server. */
suspend fun reloadFavorites(serverId: String) = withContext(Dispatchers.IO) {
try {
val list = database?.favoriteDao()?.getFavoritesByServer(serverId).orEmpty()
_favorites.value = list
} catch (e: Exception) {
Log.e("FavoriteRepo", "reloadFavorites failed", e)
}
}
/** Reload all favorites. */
suspend fun reloadAll() = withContext(Dispatchers.IO) {
try {
val list = database?.favoriteDao()?.getAllFavorites().orEmpty()
_favorites.value = list
} catch (e: Exception) {
Log.e("FavoriteRepo", "reloadAll failed", e)
}
}
/** Get all favorites from all servers (blocking, call from IO thread). */
fun getAllFavoritesFromDb(): List<FavoriteEntity> {
return try {
database?.favoriteDao()?.getAllFavorites().orEmpty()
} catch (e: Exception) {
Log.e("FavoriteRepo", "getAllFavoritesFromDb failed", e)
emptyList()
}
}
/** Get favorites for a server directly from Room (blocking, call from IO thread). */
fun getFavoritesFromDb(serverId: String): List<FavoriteEntity> {
return try {
database?.favoriteDao()?.getFavoritesByServer(serverId).orEmpty()
} catch (e: Exception) {
Log.e("FavoriteRepo", "getFavoritesFromDb failed", e)
emptyList()
}
}
/** Get favorites for a server, converting to EmbyItem for display. */
fun getFavoritesAsItems(serverId: String): List<EmbyItem> {
return try {
val entities = database?.favoriteDao()?.getFavoritesByServer(serverId).orEmpty()
entities.map { entity ->
val imageTags = try {
val obj = JSONObject(entity.imageTagsJson ?: "{}")
obj.keys().asSequence().associateWith { obj.getString(it) }
} catch (_: Exception) { emptyMap<String, String>() }
EmbyItem(
id = entity.itemId,
name = entity.name ?: "",
type = entity.type,
productionYear = entity.productionYear,
imageTags = imageTags,
seriesName = entity.seriesName,
)
}
} catch (e: Exception) {
Log.e("FavoriteRepo", "getFavoritesAsItems failed", e)
emptyList()
}
}
}
@@ -37,6 +37,24 @@ data class TmdbSeriesExtras(
val recommendations: List<TmdbRecommendation> = emptyList(),
)
data class TmdbEpisodeCalendar(
val tmdbId: Int,
val seriesName: String,
val seasonNumber: Int,
val episodeNumber: Int,
val episodeName: String,
val airDate: String,
val overview: String,
val stillPath: String?,
val posterUrl: String?,
val totalEpisodes: Int = 0,
val genres: String = "", // e.g. "动作, 爱情"
val firstAirYear: String = "", // e.g. "2024"
val networkName: String = "", // e.g. "Netflix"
val networkLogoPath: String? = null, // network logo from TMDB
val logoPath: String? = null, // series logo from TMDB
)
private data class TmdbSearchResponse(
@SerializedName("results") val results: List<TmdbSearchItem> = emptyList(),
)
@@ -67,6 +85,55 @@ private data class TmdbRecommendationsResponse(
@SerializedName("results") val results: List<TmdbRecommendationItem> = emptyList(),
)
private data class TmdbTvDetailResponse(
@SerializedName("id") val id: Int = 0,
@SerializedName("name") val name: String = "",
@SerializedName("poster_path") val posterPath: String? = null,
@SerializedName("next_episode_to_air") val nextEpisodeToAir: TmdbNextEpisode? = null,
@SerializedName("seasons") val seasons: List<TmdbSeasonInfo> = emptyList(),
@SerializedName("genres") val genres: List<TmdbGenre> = emptyList(),
@SerializedName("first_air_date") val firstAirDate: String = "",
@SerializedName("networks") val networks: List<TmdbNetwork> = emptyList(),
@SerializedName("logo_path") val logoPath: String? = null,
)
private data class TmdbGenre(
@SerializedName("name") val name: String = "",
)
private data class TmdbNetwork(
@SerializedName("name") val name: String = "",
@SerializedName("logo_path") val logoPath: String? = null,
)
private data class TmdbNextEpisode(
@SerializedName("season_number") val seasonNumber: Int = 0,
@SerializedName("episode_number") val episodeNumber: Int = 0,
@SerializedName("name") val name: String = "",
@SerializedName("air_date") val airDate: String = "",
@SerializedName("overview") val overview: String = "",
@SerializedName("still_path") val stillPath: String? = null,
)
private data class TmdbSeasonInfo(
@SerializedName("season_number") val seasonNumber: Int = 0,
@SerializedName("episode_count") val episodeCount: Int = 0,
@SerializedName("air_date") val airDate: String? = null,
)
private data class TmdbSeasonDetailResponse(
@SerializedName("episodes") val episodes: List<TmdbEpisodeDetail> = emptyList(),
)
private data class TmdbEpisodeDetail(
@SerializedName("episode_number") val episodeNumber: Int = 0,
@SerializedName("season_number") val seasonNumber: Int = 0,
@SerializedName("name") val name: String = "",
@SerializedName("air_date") val airDate: String = "",
@SerializedName("overview") val overview: String = "",
@SerializedName("still_path") val stillPath: String? = null,
)
private data class TmdbRecommendationItem(
@SerializedName("id") val id: Int = 0,
@SerializedName("name") val name: String = "",
@@ -164,6 +231,94 @@ object TmdbRepository {
}
}
/** Get calendar episodes for all watchlist items. */
suspend fun getCalendarEpisodes(watchlist: List<WatchlistEntity>): List<TmdbEpisodeCalendar> = withContext(Dispatchers.IO) {
if (!isConfigured || watchlist.isEmpty()) return@withContext emptyList()
val today = java.time.LocalDate.now().toString() // "2026-08-31"
val results = mutableListOf<TmdbEpisodeCalendar>()
watchlist.forEach { entity ->
val tmdbId = entity.tmdbId?.toIntOrNull() ?: return@forEach
try {
val seriesDetail = get("$API_BASE/tv/$tmdbId?language=${language()}", TmdbTvDetailResponse::class.java)
?: return@forEach
val seriesName = seriesDetail.name.ifBlank { entity.name }
val posterUrl = seriesDetail.posterPath?.let { IMAGE_BASE + it } ?: entity.posterUrl
val genres = seriesDetail.genres.joinToString(", ") { it.name }
val firstAirYear = seriesDetail.firstAirDate.take(4)
val networkName = seriesDetail.networks.firstOrNull()?.name ?: ""
val networkLogo = seriesDetail.networks.firstOrNull()?.logoPath?.let { IMAGE_BASE.replace("w342", "w154") + it }
val logoUrl = seriesDetail.logoPath?.let { IMAGE_BASE.replace("w342", "w300") + it }
// Get next episode if available
seriesDetail.nextEpisodeToAir?.let { next ->
if (next.airDate >= today) {
val nextSeasonEpisodes = seriesDetail.seasons
.firstOrNull { it.seasonNumber == next.seasonNumber }
?.episodeCount ?: 0
results.add(TmdbEpisodeCalendar(
tmdbId = tmdbId,
seriesName = seriesName,
seasonNumber = next.seasonNumber,
episodeNumber = next.episodeNumber,
episodeName = next.name,
airDate = next.airDate,
overview = next.overview,
stillPath = next.stillPath,
posterUrl = posterUrl,
totalEpisodes = nextSeasonEpisodes,
genres = genres,
firstAirYear = firstAirYear,
networkName = networkName,
networkLogoPath = networkLogo,
logoPath = logoUrl,
))
}
}
// Also fetch upcoming episodes from current season
val currentSeason = seriesDetail.nextEpisodeToAir?.seasonNumber
?: seriesDetail.seasons.lastOrNull { it.seasonNumber > 0 && !it.airDate.isNullOrBlank() }?.seasonNumber
if (currentSeason != null) {
val seasonDetail = get(
"$API_BASE/tv/$tmdbId/season/$currentSeason?language=${language()}",
TmdbSeasonDetailResponse::class.java,
)
val totalInSeason = seasonDetail?.episodes?.size ?: 0
seasonDetail?.episodes?.forEach { ep ->
if (ep.airDate >= today && ep.airDate.isNotBlank()) {
// Avoid duplicates with next_episode_to_air
val isDuplicate = seriesDetail.nextEpisodeToAir?.let { next ->
next.seasonNumber == ep.seasonNumber && next.episodeNumber == ep.episodeNumber
} ?: false
if (!isDuplicate) {
results.add(TmdbEpisodeCalendar(
tmdbId = tmdbId,
seriesName = seriesName,
seasonNumber = ep.seasonNumber,
episodeNumber = ep.episodeNumber,
episodeName = ep.name,
airDate = ep.airDate,
overview = ep.overview,
stillPath = ep.stillPath,
posterUrl = posterUrl,
totalEpisodes = totalInSeason,
genres = genres,
firstAirYear = firstAirYear,
networkName = networkName,
networkLogoPath = networkLogo,
logoPath = logoUrl,
))
}
}
}
}
} catch (e: Exception) {
Log.w("TmdbRepository", "Failed to get calendar for ${entity.name}", e)
}
}
results.sortedBy { it.airDate }
}
private fun <T> get(url: String, type: Class<T>): T? {
return try {
val request = Request.Builder()
@@ -0,0 +1,81 @@
package com.example.myapplication.data
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object WatchlistRepository {
private var database: AppDatabase? = null
private var initialized = false
fun init(context: Context) {
if (initialized) return
initialized = true
database = AppDatabase.getInstance(context)
}
/** Check if an item is being watched. Call from IO thread. */
fun isWatching(serverId: String, itemId: String): Boolean {
return try {
database?.watchlistDao()?.isWatching(serverId, itemId) ?: false
} catch (e: Exception) {
Log.e("WatchlistRepo", "isWatching failed", e)
false
}
}
/** Toggle watchlist status. Returns true if now watching. */
suspend fun toggleWatchlist(serverId: String, item: EmbyItem): Boolean = withContext(Dispatchers.IO) {
try {
val dao = database?.watchlistDao() ?: return@withContext false
val currentlyWatching = dao.isWatching(serverId, item.id)
if (currentlyWatching) {
dao.removeWatchlist(serverId, item.id)
Log.d("WatchlistRepo", "Removed from watchlist: ${item.name}")
false
} else {
val tmdbId = item.providerIds["Tmdb"] ?: item.providerIds["Imdb"]
val posterUrl = item.posterUrlPath?.let { "https://placeholder" } // stored for reference
dao.insertWatchlist(
WatchlistEntity(
serverId,
item.id,
item.name,
tmdbId,
null,
null,
posterUrl,
System.currentTimeMillis(),
)
)
Log.d("WatchlistRepo", "Added to watchlist: ${item.name} (tmdbId=$tmdbId)")
true
}
} catch (e: Exception) {
Log.e("WatchlistRepo", "toggleWatchlist failed", e)
false
}
}
/** Get all watchlist items from Room (blocking, call from IO thread). */
fun getAllWatchlistFromDb(): List<WatchlistEntity> {
return try {
database?.watchlistDao()?.getAllWatchlist().orEmpty()
} catch (e: Exception) {
Log.e("WatchlistRepo", "getAllWatchlistFromDb failed", e)
emptyList()
}
}
/** Get watchlist items for a server (blocking, call from IO thread). */
fun getWatchlistFromDb(serverId: String): List<WatchlistEntity> {
return try {
database?.watchlistDao()?.getWatchlistByServer(serverId).orEmpty()
} catch (e: Exception) {
Log.e("WatchlistRepo", "getWatchlistFromDb failed", e)
emptyList()
}
}
}
@@ -37,6 +37,7 @@ import com.example.myapplication.screens.LibraryScreen
import com.example.myapplication.screens.MediaDetailScreen
import com.example.myapplication.screens.RecordsScreen
import com.example.myapplication.screens.RecordsTopBar
import com.example.myapplication.screens.FavoritesAllScreen
import com.example.myapplication.screens.SearchAllScreen
import com.example.myapplication.screens.SearchScreen
import com.example.myapplication.screens.SearchTopBar
@@ -73,14 +74,15 @@ fun AppNavHost(
navController.navigate(route) { launchSingleTop = true }
}
val openDetail: (String) -> Unit = { itemId ->
selectedServer?.id?.let { serverId ->
val openDetail: (String, String) -> Unit = { serverId, itemId ->
if (serverId.isNotBlank() && itemId.isNotBlank()) {
val currentEntry = navController.currentBackStackEntry
if (currentEntry?.destination?.route == AppRoute.MediaDetail.pattern &&
val isAlreadyOnThisDetail = currentEntry?.destination?.route == AppRoute.MediaDetail.pattern &&
currentEntry.arguments?.getString("itemId") == itemId
) return@let
navController.navigate(AppRoute.MediaDetail.create(serverId, itemId)) {
launchSingleTop = true
if (!isAlreadyOnThisDetail) {
navController.navigate(AppRoute.MediaDetail.create(serverId, itemId)) {
launchSingleTop = true
}
}
}
}
@@ -99,7 +101,7 @@ fun AppNavHost(
servers = servers,
selected = selectedServer,
onSelectServer = onSelectServer,
onOpenDetail = openDetail,
onOpenDetail = { openDetail(selectedServer?.id.orEmpty(), it) },
)
}
@@ -110,8 +112,14 @@ fun AppNavHost(
Box(Modifier.weight(1f)) {
RecordsScreen(
segment = segment,
servers = servers,
activeServer = selectedServer,
onOpenDetail = openDetail,
onOpenDetail = { serverId, itemId -> openDetail(serverId, itemId) },
onViewAll = { serverId, type ->
navController.navigate(AppRoute.FavoritesAll.create(serverId, type)) {
launchSingleTop = true
}
},
)
}
}
@@ -163,7 +171,7 @@ fun AppNavHost(
query = query,
servers = servers,
activeServer = selectedServer,
onOpenDetail = openDetail,
onOpenDetail = { openDetail(selectedServer?.id.orEmpty(), it) },
onViewAll = { serverId ->
navController.navigate(AppRoute.SearchAll.create(serverId, query)) {
launchSingleTop = true
@@ -186,7 +194,23 @@ fun AppNavHost(
serverId = entry.arguments?.getString("serverId").orEmpty(),
initialQuery = entry.arguments?.getString("query").orEmpty(),
servers = servers,
onOpenDetail = openDetail,
onOpenDetail = { openDetail(entry.arguments?.getString("serverId").orEmpty(), it) },
onBack = { navController.popBackStack() },
)
}
composable(
route = AppRoute.FavoritesAll.pattern,
arguments = listOf(
navArgument("serverId") { type = NavType.StringType },
navArgument("type") { type = NavType.StringType },
),
) { entry ->
FavoritesAllScreen(
serverId = entry.arguments?.getString("serverId").orEmpty(),
type = entry.arguments?.getString("type").orEmpty(),
servers = servers,
onOpenDetail = { serverId, itemId -> openDetail(serverId, itemId) },
onBack = { navController.popBackStack() },
)
}
@@ -30,6 +30,10 @@ sealed interface AppRoute {
override val pattern = "search/all/{serverId}/{query}"
fun create(serverId: String, query: String) = "search/all/${serverId.encode()}/${query.encode()}"
}
data object FavoritesAll : AppRoute {
override val pattern = "favorites/all/{serverId}/{type}"
fun create(serverId: String, type: String) = "favorites/all/${serverId.encode()}/${type.encode()}"
}
}
private fun String.encode(): String = Uri.encode(this)
@@ -0,0 +1,260 @@
package com.example.myapplication.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
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.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.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.R as Res
import com.example.myapplication.data.EmbyServer
import com.example.myapplication.data.FavoriteEntity
import com.example.myapplication.data.FavoriteRepository
import com.example.myapplication.ui.GlassTokens
import com.example.myapplication.ui.design.LumaColors
import com.example.myapplication.ui.design.LumaEmptyState
import com.example.myapplication.ui.design.LumaIcon
import com.example.myapplication.ui.design.LumaIconButton
import com.example.myapplication.ui.design.LumaIconView
import com.example.myapplication.ui.design.LumaGlassLevel
import com.example.myapplication.ui.design.LumaShapes
import top.yukonga.miuix.kmp.basic.Text
@Composable
fun FavoritesAllScreen(
serverId: String,
type: String,
servers: List<EmbyServer>,
onOpenDetail: (String, String) -> Unit = { _, _ -> },
onBack: () -> Unit = {},
) {
val server = remember(serverId) { servers.firstOrNull { it.id == serverId } }
var favorites by remember { mutableStateOf<List<FavoriteEntity>>(emptyList()) }
LaunchedEffect(serverId, type) {
favorites = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
FavoriteRepository.getAllFavoritesFromDb()
}.filter { it.serverId == serverId && it.type == type }
}
val title = when (type) {
"Movie" -> stringResource(Res.string.detail_type_movie)
"Series" -> stringResource(Res.string.detail_type_series)
else -> type
}
Column(Modifier.fillMaxSize()) {
// Server header — same as SearchAllScreen
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 18.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LumaIconButton(
icon = LumaIcon.Back,
contentDescription = stringResource(Res.string.back),
onClick = onBack,
selected = false,
level = LumaGlassLevel.Floating,
iconSize = 20.dp,
radius = 24.dp,
)
Spacer(Modifier.size(12.dp))
// Server icon
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(LumaColors.backgroundElevated),
contentAlignment = Alignment.Center,
) {
if (server != null && server.iconUrl.isNotBlank()) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(server.iconUrl)
.crossfade(true)
.build(),
contentDescription = server.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(32.dp)
.clip(CircleShape),
)
} else {
LumaIconView(
icon = LumaIcon.Server,
contentDescription = null,
tint = GlassTokens.subtext,
modifier = Modifier.size(18.dp),
)
}
}
Spacer(Modifier.width(10.dp))
// Server name
Column(Modifier.weight(1f)) {
Text(
text = server?.name?.ifBlank { server.host }.orEmpty(),
color = LumaColors.text,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
// Favorites type + count
if (favorites.isNotEmpty()) {
Text(
text = "$title · ${favorites.size}项",
color = GlassTokens.subtext,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 2.dp),
)
}
// Grid of favorites
Box(
Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 12.dp),
) {
if (favorites.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
LumaEmptyState(
icon = LumaIcon.Favorite,
title = stringResource(Res.string.empty_favorites),
description = stringResource(Res.string.empty_favorites_sub),
showIconBackground = false,
)
}
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(115.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
items(items = favorites, key = { it.itemId }) { entity ->
FavoriteGridCard(
entity = entity,
serverUrl = server?.fullUrl.orEmpty(),
onClick = { onOpenDetail(entity.serverId, entity.itemId) },
)
}
}
}
}
}
}
@Composable
private fun FavoriteGridCard(
entity: FavoriteEntity,
serverUrl: String,
onClick: () -> Unit,
) {
val imageUrl = remember(entity.itemId, serverUrl) {
val imageTags = try {
val obj = org.json.JSONObject(entity.imageTagsJson ?: "{}")
obj.keys().asSequence().associateWith { obj.getString(it) }
} catch (_: Exception) { emptyMap<String, String>() }
val primaryTag = imageTags["Primary"]
if (!primaryTag.isNullOrBlank()) {
"$serverUrl/emby/Items/${entity.itemId}/Images/Primary?tag=$primaryTag&maxWidth=400&quality=80"
} else null
}
val title = entity.seriesName?.takeIf { entity.type == "Series" && it.isNotBlank() } ?: entity.name.orEmpty()
val yearText = entity.productionYear?.toString() ?: ""
Column(
modifier = Modifier
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = onClick,
),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.68f)
.clip(RoundedCornerShape(LumaShapes.small))
.background(LumaColors.backgroundElevated),
) {
if (imageUrl != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUrl)
.crossfade(true)
.build(),
contentDescription = title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
Text(
text = title,
color = GlassTokens.text,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (yearText.isNotEmpty()) {
Text(
text = yearText,
color = GlassTokens.subtext,
fontSize = 11.sp,
fontWeight = FontWeight.Normal,
maxLines = 1,
)
}
}
}
@@ -232,10 +232,13 @@ private fun ServerCard(server: EmbyServer, tint: Color, nowMillis: Long, onClick
}
}
ServerStatusLine(server = server, nowMillis = nowMillis, compact = false, modifier = Modifier.padding(top = 8.dp))
if (!server.isOnline && server.authError.isNotBlank()) {
Text(text = server.authError, color = offlineRed, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp))
// Fixed-height error line to keep card heights consistent
Box(modifier = Modifier.height(14.dp)) {
if (!server.isOnline && server.authError.isNotBlank()) {
Text(text = server.authError, color = offlineRed, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
ServerMediaCounts(server = server, modifier = Modifier.padding(top = 8.dp))
ServerMediaCounts(server = server, modifier = Modifier.padding(top = 4.dp))
}
}
}
@@ -309,20 +312,15 @@ private fun ServerGridCard(server: EmbyServer, tint: Color, nowMillis: Long, onC
}
}
ServerStatusLine(server = server, nowMillis = nowMillis, compact = true, modifier = Modifier.padding(top = 8.dp))
if (!server.isOnline && server.authError.isNotBlank()) {
Text(text = server.authError, color = offlineRed, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp))
// Fixed-height area for error + note to keep card heights consistent
Box(modifier = Modifier.height(14.dp)) {
if (!server.isOnline && server.authError.isNotBlank()) {
Text(text = server.authError, color = offlineRed, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
} else if (server.note.isNotBlank()) {
Text(text = server.note, color = GlassTokens.subtext, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
if (server.note.isNotBlank()) {
Text(
text = server.note,
color = GlassTokens.subtext,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 3.dp),
)
}
ServerMediaCounts(server = server, modifier = Modifier.padding(top = 8.dp))
ServerMediaCounts(server = server, modifier = Modifier.padding(top = 4.dp))
}
}
}
@@ -75,7 +75,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.myapplication.data.EmbyItem
import com.example.myapplication.data.EmbyRepository
import com.example.myapplication.data.WatchlistRepository
import com.example.myapplication.data.EmbyServer
import com.example.myapplication.data.FavoriteRepository
import com.example.myapplication.data.TmdbRecommendation
import com.example.myapplication.data.TmdbRepository
import com.example.myapplication.data.TmdbSeriesExtras
@@ -85,8 +87,10 @@ import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import top.yukonga.miuix.kmp.basic.Text
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import java.util.Locale
@@ -140,6 +144,8 @@ fun MediaDetailScreen(
var loadingTmdb by remember(item.id) {
mutableStateOf(item.type == "Series" && TmdbRepository.isConfigured)
}
var isFavorited by remember(item.id, server?.id) { mutableStateOf(false) }
var isWatching by remember(item.id, server?.id) { mutableStateOf(false) }
val episodes = selectedSeasonId?.let(episodesBySeason::get).orEmpty()
LaunchedEffect(item.id, server?.id, server?.apiKey) {
@@ -161,6 +167,19 @@ fun MediaDetailScreen(
}
}
// Load favorite status
LaunchedEffect(item.id, server?.id) {
val sId = server?.id
if (sId != null) {
isFavorited = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
FavoriteRepository.isFavorited(sId, item.id)
}
isWatching = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
WatchlistRepository.isWatching(sId, item.id)
}
}
}
LaunchedEffect(episodes) {
if (selectedEpisodeId == null || episodes.none { it.id == selectedEpisodeId }) {
selectedEpisodeId = episodes.firstOrNull()?.id
@@ -414,6 +433,7 @@ fun MediaDetailScreen(
DetailHeadline(detail = detail, serverUrl = server?.fullUrl.orEmpty())
Spacer(Modifier.height(18.dp))
// Play button (top)
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
DetailPlayButton(
resumePositionMs = buttonPlayTarget?.playbackPositionMs ?: detail.playbackPositionMs,
@@ -430,6 +450,43 @@ fun MediaDetailScreen(
}
}
}
Spacer(Modifier.height(10.dp))
// Favorite + Watchlist buttons (bottom, smaller)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
DetailSmallButton(
icon = LumaIcon.Favorite,
label = if (isFavorited) stringResource(Res.string.detail_unfavorite) else stringResource(Res.string.detail_favorite),
tint = if (isFavorited) Color(0xFFFF3B30) else GlassTokens.text,
onClick = {
val sId = server?.id
if (sId != null) {
MainScope().launch {
isFavorited = FavoriteRepository.toggleFavorite(sId, detail)
}
}
},
)
if (detail.type == "Series") {
Spacer(Modifier.width(12.dp))
DetailSmallButton(
icon = LumaIcon.Calendar,
label = if (isWatching) stringResource(Res.string.detail_unwatch) else stringResource(Res.string.detail_watch),
tint = if (isWatching) Color(0xFF34C759) else GlassTokens.text,
onClick = {
val sId = server?.id
if (sId != null) {
MainScope().launch {
isWatching = WatchlistRepository.toggleWatchlist(sId, detail)
}
}
},
)
}
}
if (detail.overview.isNullOrBlank().not()) {
Spacer(Modifier.height(20.dp))
@@ -736,6 +793,12 @@ private fun DetailHero(
private fun DetailHeadline(detail: EmbyItem, serverUrl: String) {
val logoUrl = detail.getLogoUrl(serverUrl, maxWidth = 720)
var logoLoaded by remember(logoUrl) { mutableStateOf(false) }
// For episodes, show the series name instead of the episode name
val displayTitle = if (detail.type == "Episode" && !detail.seriesName.isNullOrBlank()) {
detail.seriesName
} else {
detail.name
}
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -746,7 +809,7 @@ private fun DetailHeadline(detail: EmbyItem, serverUrl: String) {
) {
if (!logoLoaded) {
Text(
text = detail.name,
text = displayTitle,
color = LocalDetailText.current,
fontSize = 23.sp,
fontWeight = FontWeight.Bold,
@@ -759,7 +822,7 @@ private fun DetailHeadline(detail: EmbyItem, serverUrl: String) {
if (logoUrl != null) {
AsyncImage(
model = logoUrl,
contentDescription = detail.name,
contentDescription = displayTitle,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth(0.76f).heightIn(max = 96.dp),
onSuccess = { logoLoaded = true },
@@ -848,6 +911,35 @@ private fun DetailPlayButton(resumePositionMs: Long, loading: Boolean, onClick:
}
}
@Composable
private fun DetailSmallButton(
icon: LumaIcon,
label: String,
tint: Color,
onClick: () -> Unit,
) {
LiquidGlassButton(
onClick = onClick,
modifier = Modifier.height(36.dp).widthIn(min = 80.dp),
cornerRadius = 999.dp,
fill = GlassTokens.glassFillStrong,
) {
LumaIconView(
icon = icon,
contentDescription = label,
tint = tint,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(5.dp))
Text(
text = label,
color = GlassTokens.text,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
private fun formatPlaybackTime(positionMs: Long): String {
val totalSeconds = positionMs.coerceAtLeast(0L) / 1000L
val hours = totalSeconds / 3600L
@@ -8,8 +8,11 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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
@@ -19,44 +22,53 @@ 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.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
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.graphics.vector.ImageVector
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
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.R as Res
import com.example.myapplication.data.EmbyItem
import com.example.myapplication.data.EmbyRepository
import com.example.myapplication.data.EmbyServer
import com.example.myapplication.data.FavoriteEntity
import com.example.myapplication.data.FavoriteRepository
import com.example.myapplication.data.TmdbEpisodeCalendar
import com.example.myapplication.data.TmdbRepository
import com.example.myapplication.data.WatchlistEntity
import com.example.myapplication.data.WatchlistRepository
import com.example.myapplication.ui.GlassTokens
import com.example.myapplication.ui.LiquidGlassTopBar
import com.example.myapplication.ui.SegmentedControl
import com.example.myapplication.ui.bottomBarContentPadding
import com.example.myapplication.ui.design.LumaColors
import com.example.myapplication.ui.design.LumaEmptyState
import com.example.myapplication.ui.design.LumaErrorState
import com.example.myapplication.ui.design.LumaIcon
import com.example.myapplication.ui.design.LumaMediaCard
import com.example.myapplication.ui.design.LumaSkeleton
import com.example.myapplication.ui.design.LumaIconView
import com.example.myapplication.ui.design.LumaLoading
import com.example.myapplication.ui.design.LumaShapes
import top.yukonga.miuix.kmp.basic.Text
@@ -87,30 +99,31 @@ fun RecordsTopBar(
@Composable
fun RecordsScreen(
segment: Int,
servers: List<EmbyServer> = emptyList(),
activeServer: EmbyServer? = null,
onOpenDetail: (String) -> Unit = {},
onOpenDetail: (String, String) -> Unit = { _, _ -> },
onViewAll: (String, String) -> Unit = { _, _ -> },
) {
var favorites by remember { mutableStateOf<List<EmbyItem>>(emptyList()) }
var loading by remember { mutableStateOf(false) }
var loadError by remember { mutableStateOf(false) }
var reloadToken by remember { mutableStateOf(0) }
var allFavorites by remember { mutableStateOf<List<FavoriteEntity>>(emptyList()) }
LaunchedEffect(activeServer?.id, reloadToken) {
if (activeServer != null && activeServer.apiKey.isNotBlank()) {
loading = true
try {
favorites = EmbyRepository.getFavoriteItems(activeServer)
loadError = false
} catch (_: Exception) {
favorites = emptyList()
loadError = true
}
loading = false
} else {
loadError = false
// Load ALL favorites from Room — show data from every server
LaunchedEffect(servers.map { it.id }.joinToString(",")) {
allFavorites = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
FavoriteRepository.getAllFavoritesFromDb()
}
}
// Group by server
val favoritesByServer = remember(allFavorites, servers) {
val serverMap = servers.associateBy { it.id }
allFavorites
.groupBy { it.serverId }
.mapNotNull { (serverId, favorites) ->
val server = serverMap[serverId] ?: return@mapNotNull null
server to favorites
}
}
Box(Modifier.fillMaxSize()) {
AnimatedContent(
targetState = segment,
@@ -119,57 +132,264 @@ fun RecordsScreen(
modifier = Modifier.fillMaxSize(),
) { selectedSegment ->
if (selectedSegment == 1) {
CalendarTab(onEpisodeClick = onOpenDetail)
} else if (servers.isEmpty()) {
RecordsEmptyState(
icon = LumaIcon.Calendar,
title = stringResource(Res.string.empty_calendar),
subtitle = stringResource(Res.string.empty_calendar_sub),
icon = LumaIcon.Search,
title = stringResource(Res.string.search_no_server),
subtitle = stringResource(Res.string.search_no_server_sub),
)
} else if (loading) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp),
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
contentPadding = PaddingValues(top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(6) {
LumaSkeleton(Modifier.fillMaxWidth().aspectRatio(0.68f), RoundedCornerShape(LumaShapes.small))
}
}
} else if (loadError) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
LumaErrorState(
title = stringResource(Res.string.error_content_title),
description = stringResource(Res.string.error_content_sub),
retryLabel = stringResource(Res.string.retry),
onRetry = { reloadToken++ },
)
}
} else if (favorites.isEmpty()) {
} else if (favoritesByServer.isEmpty()) {
RecordsEmptyState(
icon = LumaIcon.Favorite,
title = stringResource(Res.string.empty_favorites),
subtitle = stringResource(Res.string.empty_favorites_sub),
)
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp),
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
contentPadding = PaddingValues(top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
// Server header + grouped favorites for each server
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = 8.dp,
bottom = bottomBarContentPadding(86.dp).calculateBottomPadding(),
),
) {
items(favorites, key = { it.id }) { item ->
FavoriteMediaCard(
item = item,
serverUrl = activeServer?.fullUrl.orEmpty(),
onClick = { onOpenDetail(item.id) },
)
favoritesByServer.forEach { (server, serverFavs) ->
val movies = serverFavs.filter { it.type == "Movie" }
val series = serverFavs.filter { it.type == "Series" }
// Server header
item(key = "header_${server.id}") {
ServerHeader(server = server)
}
// Movies section
if (movies.isNotEmpty()) {
item(key = "movies_${server.id}") {
FavoriteSectionHeader(
icon = LumaIcon.FileVideo,
title = stringResource(Res.string.detail_type_movie),
count = movies.size,
onClick = { onViewAll(server.id, "Movie") },
)
}
item(key = "movies_list_${server.id}") {
FavoriteHorizontalList(
favorites = movies,
serverUrl = server.fullUrl,
onOpenDetail = onOpenDetail,
)
}
}
// Series section
if (series.isNotEmpty()) {
item(key = "series_${server.id}") {
FavoriteSectionHeader(
icon = LumaIcon.Watch,
title = stringResource(Res.string.detail_type_series),
count = series.size,
onClick = { onViewAll(server.id, "Series") },
)
}
item(key = "series_list_${server.id}") {
FavoriteHorizontalList(
favorites = series,
serverUrl = server.fullUrl,
onOpenDetail = onOpenDetail,
)
}
}
}
}
}
}
}
}
@Composable
private fun ServerHeader(server: EmbyServer) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(GlassTokens.glassFillSoft),
contentAlignment = Alignment.Center,
) {
if (server.iconUrl.isNotBlank()) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(server.iconUrl)
.crossfade(true)
.build(),
contentDescription = server.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(36.dp)
.clip(CircleShape),
)
} else {
LumaIconView(
icon = LumaIcon.Server,
contentDescription = server.name,
tint = GlassTokens.subtext,
modifier = Modifier.size(20.dp),
)
}
}
Spacer(Modifier.width(12.dp))
Text(
text = server.name.ifBlank { server.host },
color = GlassTokens.text,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun FavoriteSectionHeader(
icon: LumaIcon,
title: String,
count: Int,
onClick: () -> Unit = {},
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = onClick,
)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LumaIconView(
icon = icon,
contentDescription = null,
tint = GlassTokens.text,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = title,
color = GlassTokens.text,
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.weight(1f))
Text(
text = "$count/$count",
color = GlassTokens.subtext,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
)
Spacer(Modifier.width(4.dp))
LumaIconView(
icon = LumaIcon.ChevronRight,
contentDescription = null,
tint = GlassTokens.subtext,
modifier = Modifier.size(16.dp),
)
}
}
@Composable
private fun FavoriteHorizontalList(
favorites: List<FavoriteEntity>,
serverUrl: String,
onOpenDetail: (String, String) -> Unit,
) {
LazyRow(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
items(items = favorites, key = { it.itemId }) { entity ->
FavoritePosterCard(
entity = entity,
serverUrl = serverUrl,
onClick = { onOpenDetail(entity.serverId, entity.itemId) },
)
}
}
}
@Composable
private fun FavoritePosterCard(
entity: FavoriteEntity,
serverUrl: String,
onClick: () -> Unit,
) {
val imageUrl = remember(entity.itemId, serverUrl) {
val imageTags = try {
val obj = org.json.JSONObject(entity.imageTagsJson ?: "{}")
obj.keys().asSequence().associateWith { obj.getString(it) }
} catch (_: Exception) { emptyMap<String, String>() }
val primaryTag = imageTags["Primary"]
if (!primaryTag.isNullOrBlank()) {
"$serverUrl/emby/Items/${entity.itemId}/Images/Primary?tag=$primaryTag&maxWidth=400&quality=80"
} else null
}
val title = entity.seriesName?.takeIf { entity.type == "Series" && it.isNotBlank() } ?: entity.name.orEmpty()
val yearText = entity.productionYear?.toString() ?: ""
Column(
modifier = Modifier
.width(115.dp)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = onClick,
),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.68f)
.clip(RoundedCornerShape(LumaShapes.small))
.background(LumaColors.backgroundElevated),
) {
if (imageUrl != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUrl)
.crossfade(true)
.build(),
contentDescription = title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
Text(
text = title,
color = GlassTokens.text,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (yearText.isNotEmpty()) {
Text(
text = yearText,
color = GlassTokens.subtext,
fontSize = 11.sp,
fontWeight = FontWeight.Normal,
maxLines = 1,
)
}
}
}
@@ -189,17 +409,472 @@ private fun RecordsEmptyState(
}
}
// ─── Calendar Tab ───────────────────────────────────────────────────────────
@Composable
private fun FavoriteMediaCard(item: EmbyItem, serverUrl: String, onClick: () -> Unit) {
val imageUrl = EmbyRepository.buildImageUrl(serverUrl, item, maxWidth = 400)
LumaMediaCard(
model = imageUrl?.let {
ImageRequest.Builder(LocalContext.current).data(it).crossfade(true).build()
},
title = item.name,
titleColor = GlassTokens.text,
titleSize = 14.sp,
titleSpacing = 6.dp,
onClick = onClick,
)
private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> }) {
var watchlist by remember { mutableStateOf<List<WatchlistEntity>>(emptyList()) }
var calendarEpisodes by remember { mutableStateOf<List<TmdbEpisodeCalendar>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var selectedDate by remember { mutableStateOf<java.time.LocalDate?>(null) }
var weekOffset by remember { mutableIntStateOf(0) }
// Load watchlist and fetch calendar from TMDB
LaunchedEffect(Unit) {
watchlist = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
WatchlistRepository.getAllWatchlistFromDb()
}
if (watchlist.isEmpty()) {
loading = false
return@LaunchedEffect
}
calendarEpisodes = try {
TmdbRepository.getCalendarEpisodes(watchlist)
} catch (_: Exception) { emptyList() }
loading = false
}
val today = java.time.LocalDate.now()
val weekStart = remember(today, weekOffset) { today.with(java.time.DayOfWeek.MONDAY).plusWeeks(weekOffset.toLong()) }
if (watchlist.isEmpty() && !loading) {
RecordsEmptyState(
icon = LumaIcon.Calendar,
title = stringResource(Res.string.empty_watchlist),
subtitle = stringResource(Res.string.empty_watchlist_sub),
)
return
}
Column(Modifier.fillMaxSize()) {
// Week calendar header
WeekCalendarHeader(
today = today,
weekStart = weekStart,
episodesByDate = calendarEpisodes.groupBy { it.airDate },
selectedDate = selectedDate,
onSelectDate = { selectedDate = it },
onPrevWeek = { weekOffset-- },
onNextWeek = { weekOffset++ },
)
// Episode list
Box(
Modifier
.weight(1f)
.fillMaxWidth()
.padding(bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()),
) {
if (loading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
LumaLoading(label = stringResource(Res.string.loading_search))
}
} else {
val filteredEpisodes = if (selectedDate != null) {
calendarEpisodes.filter { it.airDate == selectedDate.toString() }
} else {
calendarEpisodes
}
if (filteredEpisodes.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
LumaEmptyState(
icon = LumaIcon.Calendar,
title = stringResource(Res.string.empty_calendar),
description = stringResource(Res.string.empty_calendar_sub),
showIconBackground = false,
)
}
} else {
val groupedByDate = filteredEpisodes.groupBy { it.airDate }.toSortedMap()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp),
) {
groupedByDate.forEach { (dateStr, episodes) ->
val date = try { java.time.LocalDate.parse(dateStr) } catch (_: Exception) { null }
val relativeLabel = date?.let { getRelativeDateLabel(it, today) }
?: dateStr
item(key = "date_$dateStr") {
CalendarDateSection(
label = relativeLabel,
fullDate = date,
)
}
items(items = episodes, key = { "${it.tmdbId}_${it.seasonNumber}_${it.episodeNumber}" }) { episode ->
val matchedWatch = watchlist.find { it.tmdbId?.toIntOrNull() == episode.tmdbId }
CalendarEpisodeCard(
episode = episode,
onClick = {
if (matchedWatch != null) {
onEpisodeClick(matchedWatch.serverId, matchedWatch.itemId)
}
},
)
}
}
item(key = "footer") {
Text(
text = stringResource(Res.string.calendar_upcoming_refresh),
color = GlassTokens.subtext,
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp),
)
}
}
}
}
}
}
}
private fun getRelativeDateLabel(date: java.time.LocalDate, today: java.time.LocalDate): String {
val dayOfWeek = when (date.dayOfWeek) {
java.time.DayOfWeek.MONDAY -> "周一"
java.time.DayOfWeek.TUESDAY -> "周二"
java.time.DayOfWeek.WEDNESDAY -> "周三"
java.time.DayOfWeek.THURSDAY -> "周四"
java.time.DayOfWeek.FRIDAY -> "周五"
java.time.DayOfWeek.SATURDAY -> "周六"
java.time.DayOfWeek.SUNDAY -> "周日"
}
val dateStr = when {
date == today -> "今天"
date == today.plusDays(1) -> "明天"
date == today.plusDays(2) -> "后天"
date.year == today.year -> "${date.monthValue}月${date.dayOfMonth}日"
else -> "${date.year}年${date.monthValue}月${date.dayOfMonth}日"
}
return "$dateStr $dayOfWeek"
}
@Composable
private fun WeekCalendarHeader(
today: java.time.LocalDate,
weekStart: java.time.LocalDate,
episodesByDate: Map<String, List<TmdbEpisodeCalendar>>,
selectedDate: java.time.LocalDate?,
onSelectDate: (java.time.LocalDate?) -> Unit,
onPrevWeek: () -> Unit = {},
onNextWeek: () -> Unit = {},
) {
val weekDays = remember(weekStart) { (0L..6L).map { weekStart.plusDays(it) } }
val monthTitle = remember(weekStart) {
val midWeek = weekStart.plusDays(3)
"${midWeek.year}年${midWeek.monthValue}月"
}
val weekdays = listOf("一", "二", "三", "四", "五", "六", "日")
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
// Month title with arrows
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "←",
color = GlassTokens.subtext,
fontSize = 18.sp,
modifier = Modifier
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = onPrevWeek,
)
.padding(4.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = monthTitle,
color = GlassTokens.text,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.weight(1f))
Text(
text = "→",
color = GlassTokens.subtext,
fontSize = 18.sp,
modifier = Modifier
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = onNextWeek,
)
.padding(4.dp),
)
}
Spacer(Modifier.height(12.dp))
// Weekday headers
Row(modifier = Modifier.fillMaxWidth()) {
weekdays.forEach { day ->
Text(
text = day,
color = GlassTokens.subtext,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f),
)
}
}
Spacer(Modifier.height(8.dp))
// Date cells
Row(modifier = Modifier.fillMaxWidth()) {
weekDays.forEach { date ->
val isToday = date == today
val isSelected = date == selectedDate
val hasEpisode = episodesByDate.containsKey(date.toString())
val isCurrentMonth = date.monthValue == today.monthValue
Column(
modifier = Modifier
.weight(1f)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = {
if (isSelected) onSelectDate(null) else onSelectDate(date)
},
)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.then(
if (isSelected) {
Modifier.background(GlassTokens.accent)
} else if (isToday) {
Modifier.background(GlassTokens.glassFillStrong)
} else {
Modifier
}
),
contentAlignment = Alignment.Center,
) {
Text(
text = "${date.dayOfMonth}",
color = when {
isSelected -> Color.White
isToday -> GlassTokens.text
!isCurrentMonth -> GlassTokens.subtext.copy(alpha = 0.4f)
else -> GlassTokens.text
},
fontSize = 13.sp,
fontWeight = if (isToday || isSelected) FontWeight.Bold else FontWeight.Normal,
)
}
// Episode indicator dot
Spacer(Modifier.height(2.dp))
Box(
modifier = Modifier
.size(5.dp)
.clip(CircleShape)
.background(
if (hasEpisode) GlassTokens.accent else Color.Transparent
),
)
}
}
}
}
}
@Composable
private fun CalendarDateSection(label: String, fullDate: java.time.LocalDate?) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
color = GlassTokens.text,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
)
}
}
@Composable
private fun CalendarEpisodeCard(episode: TmdbEpisodeCalendar, onClick: () -> Unit = {}) {
val posterUrl = episode.posterUrl
val today = java.time.LocalDate.now()
val airDate = try { java.time.LocalDate.parse(episode.airDate) } catch (_: Exception) { null }
val isAired = airDate != null && !airDate.isAfter(today)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 5.dp)
.clip(RoundedCornerShape(12.dp))
.background(GlassTokens.glassFillStrong.copy(alpha = 0.35f))
.border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(12.dp))
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = onClick,
)
.padding(10.dp),
verticalAlignment = Alignment.Top,
) {
// Poster
Box(
modifier = Modifier
.size(width = 72.dp, height = 100.dp)
.clip(RoundedCornerShape(8.dp))
.background(LumaColors.backgroundElevated),
contentAlignment = Alignment.Center,
) {
if (posterUrl != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(posterUrl)
.crossfade(true)
.build(),
contentDescription = episode.seriesName,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
LumaIconView(
icon = LumaIcon.Watch,
contentDescription = null,
tint = GlassTokens.subtext,
modifier = Modifier.size(24.dp),
)
}
}
Spacer(Modifier.width(10.dp))
// Info — 4 lines evenly distributed across 100dp
Column(
modifier = Modifier
.weight(1f)
.height(100.dp),
verticalArrangement = Arrangement.SpaceEvenly,
) {
// Line 1: Series name + logo
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = episode.seriesName,
color = GlassTokens.text,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (episode.logoPath != null) {
Spacer(Modifier.width(6.dp))
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(episode.logoPath)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.height(16.dp)
.widthIn(max = 80.dp),
)
}
}
// Line 2: Type tag + genre + year
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = "剧集", color = GlassTokens.accent, fontSize = 11.sp, fontWeight = FontWeight.Medium)
if (episode.genres.isNotBlank()) {
Text(text = episode.genres, color = GlassTokens.subtext, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (episode.firstAirYear.isNotBlank()) {
Text(text = episode.firstAirYear, color = GlassTokens.subtext, fontSize = 11.sp)
}
}
// Line 3: Network/platform with label
if (episode.networkName.isNotBlank()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "播出平台:",
color = GlassTokens.subtext,
fontSize = 11.sp,
)
Spacer(Modifier.width(4.dp))
if (episode.networkLogoPath != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(episode.networkLogoPath)
.crossfade(true)
.build(),
contentDescription = episode.networkName,
contentScale = ContentScale.Fit,
modifier = Modifier
.height(20.dp)
.widthIn(max = 60.dp)
.clip(RoundedCornerShape(3.dp)),
)
} else {
Text(
text = episode.networkName,
color = GlassTokens.accent,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
} else {
Spacer(Modifier.height(1.dp))
}
// Line 4: Progress bar
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (isAired && episode.episodeNumber > 0) {
Text(text = "已更新至第${episode.episodeNumber}集", color = GlassTokens.subtext, fontSize = 10.sp)
} else {
Text(text = "未播出 第${episode.episodeNumber}集", color = GlassTokens.subtext, fontSize = 10.sp)
}
Box(
modifier = Modifier
.weight(1f)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(GlassTokens.glassFillStrong),
) {
val fraction = if (episode.totalEpisodes > 0 && episode.episodeNumber > 0) {
(episode.episodeNumber.toFloat() / episode.totalEpisodes.toFloat()).coerceIn(0f, 1f)
} else 0f
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.fillMaxSize()
.clip(RoundedCornerShape(2.dp))
.background(GlassTokens.accent),
)
}
Text(
text = "${episode.totalEpisodes}集",
color = GlassTokens.subtext,
fontSize = 10.sp,
)
}
}
}
}
@@ -5,6 +5,7 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.drawBackdrop
@@ -65,6 +66,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.semantics.Role
@@ -83,6 +85,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogWindowProvider
@@ -94,6 +97,7 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.asPaddingValues
import top.yukonga.miuix.kmp.basic.Text
import com.example.myapplication.ui.liquidglass.DampedDragAnimation
import com.example.myapplication.ui.design.LumaColors
import com.example.myapplication.ui.design.LumaIcon
import com.example.myapplication.ui.design.LumaIconView
@@ -208,6 +212,7 @@ fun LiquidGlassTopBar(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SegmentedControl(
items: List<String>,
@@ -217,94 +222,114 @@ fun SegmentedControl(
) {
val backdrop = LocalGlassBackdrop.current
val containerShape = RoundedCornerShape(20.dp)
Row(
modifier
.clip(containerShape)
.then(
if (backdrop != null && LumaGlassRuntime.useNativeBackdrop) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { RoundedRectangle(20.dp) },
effects = {
vibrancy()
blur(8.dp.toPx())
},
onDrawSurface = { drawRect(GlassTokens.glassFillSoft) },
)
} else {
Modifier.lumaFallbackGlass(containerShape, border = false)
}
)
.border(0.5.dp, GlassTokens.glassBorder, containerShape)
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items.forEachIndexed { index, label ->
val sel = selected == index
val selectedFill by animateColorAsState(
targetValue = if (sel) GlassTokens.glassFillStrong else Color.Transparent,
animationSpec = tween(LumaMotion.fast),
label = "segmentFill$index",
)
val selectedText by animateColorAsState(
targetValue = if (sel) GlassTokens.text else GlassTokens.subtext,
animationSpec = tween(LumaMotion.fast),
label = "segmentText$index",
)
val selectedProgress by animateFloatAsState(
targetValue = if (sel) 1f else 0f,
animationSpec = tween(LumaMotion.fast),
label = "segmentProgress$index",
)
Box(
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp)
.clip(RoundedCornerShape(16.dp))
.then(
if (sel && backdrop != null && LumaGlassRuntime.useNativeBackdrop) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { RoundedRectangle(16.dp) },
effects = {
vibrancy()
blur(6.dp.toPx())
},
onDrawSurface = { drawRect(selectedFill) },
)
} else if (sel) {
Modifier.shadow(
elevation = 4.dp,
shape = RoundedCornerShape(16.dp),
ambientColor = GlassTokens.glassShadow,
spotColor = GlassTokens.glassShadow,
)
} else {
Modifier
}
)
.then(
if (sel && (backdrop == null || !LumaGlassRuntime.useNativeBackdrop)) {
Modifier.lumaFallbackGlass(RoundedCornerShape(16.dp), selected = true, border = false)
} else {
Modifier
}
)
.border(
0.5.dp,
GlassTokens.glassBorder.copy(alpha = selectedProgress * 0.9f),
RoundedCornerShape(16.dp),
)
.clickable(role = Role.Button) { onSelect(index) }
.padding(horizontal = 10.dp, vertical = 9.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = selectedText,
fontSize = 14.sp,
fontWeight = if (sel) FontWeight.SemiBold else FontWeight.Normal,
val animationScope = rememberCoroutineScope()
val itemCount = items.size
val dampedDrag = remember(animationScope, itemCount) {
DampedDragAnimation(
animationScope = animationScope,
initialValue = selected.coerceIn(0, itemCount - 1).toFloat(),
valueRange = 0f..(itemCount - 1).toFloat(),
visibilityThreshold = 0.001f,
initialScale = 1f,
pressedScale = 1.08f,
onDragStarted = {},
onDragStopped = {
val targetIndex = targetValue.roundToInt().coerceIn(0, itemCount - 1)
animateToValue(targetIndex.toFloat())
onSelect(targetIndex)
},
onDrag = { _, dragAmount ->
val width = 220f // approximate width in dp
updateValue(
(targetValue + dragAmount.x / width)
.coerceIn(0f, (itemCount - 1).toFloat())
)
},
)
}
// Sync external selected index
LaunchedEffect(selected) {
val target = selected.coerceIn(0, itemCount - 1).toFloat()
if (dampedDrag.value != target) {
dampedDrag.animateToValue(target)
}
}
val selectedFill by animateColorAsState(
targetValue = GlassTokens.glassFillStrong,
animationSpec = tween(LumaMotion.fast),
label = "segmentSelectedFill",
)
BoxWithConstraints(modifier) {
val totalWidth = maxWidth
val pillWidth = totalWidth / itemCount
Row(
Modifier
.clip(containerShape)
.then(
if (backdrop != null && LumaGlassRuntime.useNativeBackdrop) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { RoundedRectangle(20.dp) },
effects = { vibrancy(); blur(8.dp.toPx()) },
onDrawSurface = { drawRect(GlassTokens.glassFillSoft) },
)
} else {
Modifier.background(GlassTokens.glassFillSoft, containerShape)
}
)
.border(0.5.dp, GlassTokens.glassBorder, containerShape)
.then(dampedDrag.modifier)
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items.forEachIndexed { index, label ->
val sel = selected == index
val selectedText by animateColorAsState(
targetValue = if (sel) GlassTokens.text else GlassTokens.subtext,
animationSpec = tween(LumaMotion.fast),
label = "segmentText$index",
)
Box(
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp)
.clip(RoundedCornerShape(16.dp))
.then(
if (sel && backdrop != null && LumaGlassRuntime.useNativeBackdrop) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { RoundedRectangle(16.dp) },
effects = { vibrancy(); blur(6.dp.toPx()) },
onDrawSurface = { drawRect(selectedFill) },
)
} else if (sel) {
Modifier.background(selectedFill, RoundedCornerShape(16.dp))
} else {
Modifier
}
)
.border(
0.5.dp,
GlassTokens.glassBorder.copy(alpha = if (sel) 0.9f else 0f),
RoundedCornerShape(16.dp),
)
.clickable(role = Role.Button) { onSelect(index) }
.padding(horizontal = 10.dp, vertical = 9.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = selectedText,
fontSize = 14.sp,
fontWeight = if (sel) FontWeight.SemiBold else FontWeight.Normal,
)
}
}
}
}
+11
View File
@@ -127,6 +127,10 @@
<string name="detail_no_overview">No synopsis</string>
<string name="detail_all_episodes">All episodes</string>
<string name="detail_view_all">View all</string>
<string name="detail_favorite">Favorite</string>
<string name="detail_unfavorite">Favorited</string>
<string name="detail_watch">Watch</string>
<string name="detail_unwatch">Watching</string>
<string name="detail_close">Close</string>
<string name="detail_load_failed">Failed to load details</string>
<string name="detail_episode_not_ready">Episodes are still loading</string>
@@ -255,6 +259,13 @@
<string name="empty_home_no_data_sub">The current server has no content available</string>
<string name="empty_favorites">No favorites yet</string>
<string name="empty_favorites_sub">Browse media and add to favorites</string>
<string name="empty_watchlist">No shows tracked</string>
<string name="empty_watchlist_sub">Add a show from the series detail page</string>
<string name="calendar_upcoming_refresh">More episodes will appear here</string>
<string name="calendar_month_format">%1$d/%2$d</string>
<string name="calendar_day_relative_today">Today</string>
<string name="calendar_day_relative_tomorrow">Tomorrow</string>
<string name="calendar_day_relative_day_after">Day after</string>
<string name="empty_servers">No servers yet</string>
<string name="empty_servers_sub">Tap the add button to get started</string>
<string name="empty_calendar">No calendar data</string>
+11
View File
@@ -127,6 +127,10 @@
<string name="detail_no_overview">暂无简介</string>
<string name="detail_all_episodes">全部剧集</string>
<string name="detail_view_all">查看全部</string>
<string name="detail_favorite">收藏</string>
<string name="detail_unfavorite">已收藏</string>
<string name="detail_watch">追剧</string>
<string name="detail_unwatch">已追剧</string>
<string name="detail_close">关闭</string>
<string name="detail_load_failed">详情加载失败</string>
<string name="detail_episode_not_ready">剧集尚未加载完成</string>
@@ -255,6 +259,13 @@
<string name="empty_home_no_data_sub">当前服务器暂无可用内容</string>
<string name="empty_favorites">暂无收藏内容</string>
<string name="empty_favorites_sub">浏览媒体并添加收藏</string>
<string name="empty_watchlist">暂无追剧</string>
<string name="empty_watchlist_sub">在剧集详情页添加追剧</string>
<string name="calendar_upcoming_refresh">未来新集将持续刷新</string>
<string name="calendar_month_format">%1$d年%2$d月</string>
<string name="calendar_day_relative_today">今天</string>
<string name="calendar_day_relative_tomorrow">明天</string>
<string name="calendar_day_relative_day_after">后天</string>
<string name="empty_servers">暂无服务器</string>
<string name="empty_servers_sub">点击右上角添加服务器</string>
<string name="empty_calendar">暂无日历数据</string>