功能补全: 错误重试、确认对话框、TMDB错误状态

- AppNavHost: 详情页加载失败显示LumaErrorState+重试按钮
- SearchScreen: 搜索历史清除添加GlassConfirmDialog确认对话框
- SearchAllScreen: 修复重试机制(添加retryToken触发重新搜索)
- VideoPlayerScreen: 播放错误添加重试按钮(re-prepare+play)
- RecordsScreen: 日历TMDB API失败显示错误状态而非静默空白

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
beyondandClaude Opus 5 committed 2026-09-06 17:06:37 +08:00
1 parent 6e5c9fce95
commit 3a01b42aeb
5 files changed
+71 -14

No files matched your search

@@ -10,6 +10,7 @@ import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
@@ -48,6 +49,7 @@ import com.example.myapplication.screens.SearchTopBar
import com.example.myapplication.screens.SettingsScreen
import com.example.myapplication.screens.SettingsTopBar
import com.example.myapplication.screens.VideoPlayerScreen
import com.example.myapplication.ui.design.LumaErrorState
import com.example.myapplication.ui.design.LumaLoading
import com.example.myapplication.ui.design.LumaColors
import com.example.myapplication.ui.design.rememberLumaLayerBackdrop
@@ -370,9 +372,12 @@ private fun MediaDetailDestination(
) {
var item by remember(server?.id, itemId) { mutableStateOf<EmbyItem?>(null) }
var loading by remember(server?.id, itemId) { mutableStateOf(true) }
var loadError by remember(server?.id, itemId) { mutableStateOf(false) }
var retryToken by remember(server?.id, itemId) { mutableIntStateOf(0) }
LaunchedEffect(server?.id, itemId) {
LaunchedEffect(server?.id, itemId, retryToken) {
loading = true
loadError = false
item = try {
if (server == null || server.apiKey.isBlank() || itemId.isBlank()) {
null
@@ -382,6 +387,7 @@ private fun MediaDetailDestination(
} catch (error: CancellationException) {
throw error
} catch (_: Exception) {
loadError = true
null
}
loading = false
@@ -390,6 +396,12 @@ private fun MediaDetailDestination(
when {
item != null -> MediaDetailScreen(item = item!!, server = server, servers = servers, onClose = onClose, onPlay = onPlay)
loading -> DestinationLoading()
loadError -> LumaErrorState(
title = stringResource(Res.string.detail_load_failed),
description = stringResource(Res.string.error_content_sub),
retryLabel = stringResource(Res.string.retry),
onRetry = { retryToken++ },
)
else -> DestinationMessage(stringResource(Res.string.detail_load_failed), background = Color.Transparent)
}
}
@@ -444,6 +444,7 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> })
var loading by remember { mutableStateOf(true) }
var selectedDate by remember { mutableStateOf<java.time.LocalDate?>(null) }
var weekOffset by remember { mutableIntStateOf(0) }
var calendarError by remember { mutableStateOf(false) }
// P1: Key on watchlist size so calendar re-fetches when watchlist changes
var watchlistVersion by remember { mutableIntStateOf(0) }
@@ -455,9 +456,13 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> })
loading = false
return@LaunchedEffect
}
calendarError = false
calendarEpisodes = try {
TmdbRepository.getCalendarEpisodes(watchlist)
} catch (_: Exception) { emptyList() }
} catch (_: Exception) {
calendarError = true
emptyList()
}
loading = false
}
@@ -495,8 +500,14 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> })
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
LumaLoading(label = stringResource(Res.string.loading_search))
}
} else if (calendarError) {
LumaEmptyStateContainer(
icon = LumaIcon.Calendar,
title = stringResource(Res.string.error_content_title),
description = stringResource(Res.string.empty_calendar_sub),
verticalOffset = (-60).dp,
)
} else {
// P2: Remember filtered + grouped results to avoid recomputation on unrelated state changes
val filteredEpisodes = remember(calendarEpisodes, selectedDate, today) {
filterCalendarEpisodes(calendarEpisodes, selectedDate, today)
}
@@ -24,6 +24,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -68,8 +69,9 @@ fun SearchAllScreen(
var results by remember(serverId, initialQuery) { mutableStateOf<List<EmbyItem>>(emptyList()) }
var loading by remember(serverId, initialQuery) { mutableStateOf(true) }
var error by remember(serverId, initialQuery) { mutableStateOf(false) }
var retryToken by remember(serverId, initialQuery) { mutableIntStateOf(0) }
androidx.compose.runtime.LaunchedEffect(serverId, initialQuery) {
androidx.compose.runtime.LaunchedEffect(serverId, initialQuery, retryToken) {
val term = initialQuery.trim()
if (server == null || server.apiKey.isBlank() || term.isBlank()) {
results = emptyList()
@@ -184,7 +186,7 @@ fun SearchAllScreen(
title = stringResource(Res.string.error_content_title),
description = stringResource(Res.string.error_content_sub),
retryLabel = stringResource(Res.string.retry),
onRetry = { loading = true },
onRetry = { retryToken++ },
)
}
}
@@ -58,6 +58,7 @@ import com.example.myapplication.data.EmbyServer
import com.example.myapplication.ui.GlassSearchField
import com.example.myapplication.ui.GlassTokens
import com.example.myapplication.ui.LiquidGlassTopBar
import com.example.myapplication.ui.GlassConfirmDialog
import com.example.myapplication.ui.LumaEmptyStateContainer
import com.example.myapplication.ui.bottomBarContentPadding
import com.example.myapplication.ui.design.LumaColors
@@ -193,6 +194,7 @@ fun SearchScreen(
val completedIds = remember { mutableSetOf<String>() }
var searching by remember { mutableStateOf(false) }
var historyVersion by remember { mutableIntStateOf(0) }
var showClearHistoryConfirm by remember { mutableStateOf(false) }
var lastSearchedQuery by remember { mutableStateOf("") }
// Stabilize LaunchedEffect key — use only server IDs, not list reference
@@ -348,10 +350,7 @@ fun SearchScreen(
query.isBlank() && history.isNotEmpty() -> SearchHistoryList(
history = history,
onSelect = { onQueryChange(it) },
onClear = {
clearSearchHistory(context)
historyVersion++
},
onClear = { showClearHistoryConfirm = true },
)
// ★ KEY FIX: searching = true always shows loading, never error/empty
// This prevents the flash of error/empty state during transitions
@@ -387,6 +386,22 @@ fun SearchScreen(
}
}
}
// Clear history confirmation dialog
if (showClearHistoryConfirm) {
GlassConfirmDialog(
title = stringResource(Res.string.search_history_clear),
message = stringResource(Res.string.search_history_clear),
confirmText = stringResource(Res.string.confirm_delete),
dismissText = stringResource(Res.string.confirm_cancel),
onConfirm = {
clearSearchHistory(context)
historyVersion++
showClearHistoryConfirm = false
},
onDismissRequest = { showClearHistoryConfirm = false },
)
}
}
// ─── Centered Loading ───────────────────────────────────────────────────────
@@ -587,11 +587,28 @@ fun VideoPlayerScreen(
if (playerError != null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = playerError ?: stringResource(Res.string.player_failed),
color = Color.White,
modifier = Modifier.padding(24.dp),
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = playerError ?: stringResource(Res.string.player_failed),
color = Color.White,
modifier = Modifier.padding(24.dp),
)
androidx.compose.material3.TextButton(
onClick = {
playerError = null
playbackUrl?.let { url ->
player.setMediaItem(androidx.media3.common.MediaItem.fromUri(url))
player.prepare()
player.play()
}
},
) {
Text(
text = stringResource(Res.string.retry),
color = LumaColors.accent,
)
}
}
}
}
}