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月
+ 今天
+ 明天
+ 后天
暂无服务器
点击右上角添加服务器
暂无日历数据