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