From 7f24697ece4f0df9caa97f9105f1af1e883b1712 Mon Sep 17 00:00:00 2001 From: beyond Date: Mon, 31 Aug 2026 03:58:55 +0800 Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E5=AE=8C=E6=88=90=E6=94=B6=E8=97=8F?= =?UTF-8?q?=E5=92=8C=E8=BF=BD=E5=89=A7=E6=97=A5=E5=8E=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../myapplication/data/AppDatabase.java | 47 +- .../myapplication/data/FavoriteDao.java | 26 + .../myapplication/data/FavoriteEntity.java | 44 + .../myapplication/data/WatchlistDao.java | 26 + .../myapplication/data/WatchlistEntity.java | 44 + .../com/example/myapplication/MainActivity.kt | 4 + .../com/example/myapplication/MainApp.kt | 10 +- .../myapplication/data/FavoriteRepository.kt | 142 +++ .../myapplication/data/TmdbRepository.kt | 155 ++++ .../myapplication/data/WatchlistRepository.kt | 81 ++ .../myapplication/navigation/AppNavHost.kt | 44 +- .../myapplication/navigation/AppRoute.kt | 4 + .../screens/FavoritesAllScreen.kt | 260 ++++++ .../myapplication/screens/LibraryScreen.kt | 30 +- .../screens/MediaDetailScreen.kt | 96 +- .../myapplication/screens/RecordsScreen.kt | 825 ++++++++++++++++-- .../example/myapplication/ui/LiquidGlass.kt | 199 +++-- app/src/main/res/values-en/strings.xml | 11 + app/src/main/res/values/strings.xml | 11 + 19 files changed, 1865 insertions(+), 194 deletions(-) create mode 100644 app/src/main/java/com/example/myapplication/data/FavoriteDao.java create mode 100644 app/src/main/java/com/example/myapplication/data/FavoriteEntity.java create mode 100644 app/src/main/java/com/example/myapplication/data/WatchlistDao.java create mode 100644 app/src/main/java/com/example/myapplication/data/WatchlistEntity.java create mode 100644 app/src/main/kotlin/com/example/myapplication/data/FavoriteRepository.kt create mode 100644 app/src/main/kotlin/com/example/myapplication/data/WatchlistRepository.kt create mode 100644 app/src/main/kotlin/com/example/myapplication/screens/FavoritesAllScreen.kt diff --git a/app/src/main/java/com/example/myapplication/data/AppDatabase.java b/app/src/main/java/com/example/myapplication/data/AppDatabase.java index 1f09df0..668dc4e 100644 --- a/app/src/main/java/com/example/myapplication/data/AppDatabase.java +++ b/app/src/main/java/com/example/myapplication/data/AppDatabase.java @@ -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(); } } } diff --git a/app/src/main/java/com/example/myapplication/data/FavoriteDao.java b/app/src/main/java/com/example/myapplication/data/FavoriteDao.java new file mode 100644 index 0000000..bd41cd8 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/data/FavoriteDao.java @@ -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 getFavoritesByServer(String serverId); + + @Query("SELECT * FROM favorites ORDER BY createdAt DESC") + List 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); +} diff --git a/app/src/main/java/com/example/myapplication/data/FavoriteEntity.java b/app/src/main/java/com/example/myapplication/data/FavoriteEntity.java new file mode 100644 index 0000000..4d4f0e0 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/data/FavoriteEntity.java @@ -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 + 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; + } +} diff --git a/app/src/main/java/com/example/myapplication/data/WatchlistDao.java b/app/src/main/java/com/example/myapplication/data/WatchlistDao.java new file mode 100644 index 0000000..2c66828 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/data/WatchlistDao.java @@ -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 getWatchlistByServer(String serverId); + + @Query("SELECT * FROM watchlist ORDER BY createdAt DESC") + List 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); +} diff --git a/app/src/main/java/com/example/myapplication/data/WatchlistEntity.java b/app/src/main/java/com/example/myapplication/data/WatchlistEntity.java new file mode 100644 index 0000000..06976a5 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/data/WatchlistEntity.java @@ -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; + } +} diff --git a/app/src/main/kotlin/com/example/myapplication/MainActivity.kt b/app/src/main/kotlin/com/example/myapplication/MainActivity.kt index f38eb86..033b80a 100644 --- a/app/src/main/kotlin/com/example/myapplication/MainActivity.kt +++ b/app/src/main/kotlin/com/example/myapplication/MainActivity.kt @@ -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 { diff --git a/app/src/main/kotlin/com/example/myapplication/MainApp.kt b/app/src/main/kotlin/com/example/myapplication/MainApp.kt index d1262b4..5c763e7 100644 --- a/app/src/main/kotlin/com/example/myapplication/MainApp.kt +++ b/app/src/main/kotlin/com/example/myapplication/MainApp.kt @@ -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) diff --git a/app/src/main/kotlin/com/example/myapplication/data/FavoriteRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/FavoriteRepository.kt new file mode 100644 index 0000000..02af4a4 --- /dev/null +++ b/app/src/main/kotlin/com/example/myapplication/data/FavoriteRepository.kt @@ -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>(emptyList()) + val favorites: StateFlow> = _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 { + 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 { + 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 { + 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() } + + 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() + } + } +} diff --git a/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt index af0f8bc..d8f3ff1 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt @@ -37,6 +37,24 @@ data class TmdbSeriesExtras( val recommendations: List = 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 = emptyList(), ) @@ -67,6 +85,55 @@ private data class TmdbRecommendationsResponse( @SerializedName("results") val results: List = 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 = emptyList(), + @SerializedName("genres") val genres: List = emptyList(), + @SerializedName("first_air_date") val firstAirDate: String = "", + @SerializedName("networks") val networks: List = 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 = 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): List = withContext(Dispatchers.IO) { + if (!isConfigured || watchlist.isEmpty()) return@withContext emptyList() + val today = java.time.LocalDate.now().toString() // "2026-08-31" + val results = mutableListOf() + 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 get(url: String, type: Class): T? { return try { val request = Request.Builder() diff --git a/app/src/main/kotlin/com/example/myapplication/data/WatchlistRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/WatchlistRepository.kt new file mode 100644 index 0000000..ffd01d7 --- /dev/null +++ b/app/src/main/kotlin/com/example/myapplication/data/WatchlistRepository.kt @@ -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 { + 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 { + return try { + database?.watchlistDao()?.getWatchlistByServer(serverId).orEmpty() + } catch (e: Exception) { + Log.e("WatchlistRepo", "getWatchlistFromDb failed", e) + emptyList() + } + } +} diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt index c89c709..e58a541 100644 --- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt +++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt @@ -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() }, ) } diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt index 5a6b93a..b83f437 100644 --- a/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt +++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt @@ -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) diff --git a/app/src/main/kotlin/com/example/myapplication/screens/FavoritesAllScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/FavoritesAllScreen.kt new file mode 100644 index 0000000..c5655b3 --- /dev/null +++ b/app/src/main/kotlin/com/example/myapplication/screens/FavoritesAllScreen.kt @@ -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, + onOpenDetail: (String, String) -> Unit = { _, _ -> }, + onBack: () -> Unit = {}, +) { + val server = remember(serverId) { servers.firstOrNull { it.id == serverId } } + var favorites by remember { mutableStateOf>(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() } + 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, + ) + } + } +} 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 e0a787f..b87f9e5 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt @@ -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)) } } } 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 f6afad8..f695174 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt @@ -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 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 3f0925b..e9774b7 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt @@ -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 = emptyList(), activeServer: EmbyServer? = null, - onOpenDetail: (String) -> Unit = {}, + onOpenDetail: (String, String) -> Unit = { _, _ -> }, + onViewAll: (String, String) -> Unit = { _, _ -> }, ) { - var favorites by remember { mutableStateOf>(emptyList()) } - var loading by remember { mutableStateOf(false) } - var loadError by remember { mutableStateOf(false) } - var reloadToken by remember { mutableStateOf(0) } + var allFavorites by remember { mutableStateOf>(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, + 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() } + 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>(emptyList()) } + var calendarEpisodes by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var selectedDate by remember { mutableStateOf(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>, + 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, + ) + } + } + } } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt index 2c12562..9a38a7c 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt @@ -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, @@ -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, + ) + } } } } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 0278b52..0594c91 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -127,6 +127,10 @@ No synopsis All episodes View all + Favorite + Favorited + Watch + Watching Close Failed to load details Episodes are still loading @@ -255,6 +259,13 @@ The current server has no content available No favorites yet Browse media and add to favorites + No shows tracked + Add a show from the series detail page + More episodes will appear here + %1$d/%2$d + Today + Tomorrow + Day after No servers yet Tap the add button to get started No calendar data diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01868f4..7ae519f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -127,6 +127,10 @@ 暂无简介 全部剧集 查看全部 + 收藏 + 已收藏 + 追剧 + 已追剧 关闭 详情加载失败 剧集尚未加载完成 @@ -255,6 +259,13 @@ 当前服务器暂无可用内容 暂无收藏内容 浏览媒体并添加收藏 + 暂无追剧 + 在剧集详情页添加追剧 + 未来新集将持续刷新 + %1$d年%2$d月 + 今天 + 明天 + 后天 暂无服务器 点击右上角添加服务器 暂无日历数据