From f4c2d25c7008fea563abfd1444f470911835e72c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 15:56:56 +0000 Subject: [PATCH 1/2] feat(livetv): honor transport and add Guide/Channels/Recordings Decode session transport, play HLS via ExoPlayer remux MIME or MPEG-TS direct, and redesign phone/TV Live TV around Guide, Channels, and My recordings with cancel/play hooks. Co-authored-by: Jonah May --- .../android/ui/navigation/AppNavigation.kt | 3 + .../ui/screens/livetv/LiveTvPlayerScreen.kt | 24 +- .../android/ui/screens/livetv/LiveTvScreen.kt | 246 ++++++++++++++++-- .../ui/screens/livetv/TvLiveTvPlayerScreen.kt | 24 +- .../tv/ui/screens/livetv/TvLiveTvScreen.kt | 156 ++++++++++- .../prairie/model/livetv/LiveTvModels.kt | 31 ++- .../prairie/viewmodel/LiveTvViewModels.kt | 148 +++++++++-- .../prairie/network/api/LiveTvApiTest.kt | 22 +- .../prairie/viewmodel/LiveTvViewModelTest.kt | 6 +- 9 files changed, 595 insertions(+), 65 deletions(-) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt index af4853f97..0eb444197 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt @@ -534,6 +534,9 @@ fun AppNavigation( Route.LiveTvPlayer(channel.id, channel.displayName).route, ) }, + onPlayLibraryItem = { contentId -> + navController.navigate(Route.ItemDetail(contentId).route) + }, ) } composable( diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/livetv/LiveTvPlayerScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/livetv/LiveTvPlayerScreen.kt index 4ab8e2949..28873d48f 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/livetv/LiveTvPlayerScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/livetv/LiveTvPlayerScreen.kt @@ -84,17 +84,27 @@ fun LiveTvPlayerScreen( onDispose { player.release() } } - LaunchedEffect(state.session?.sessionId, state.session?.playableUrl) { + LaunchedEffect(state.session?.sessionId, state.session?.playableUrl, state.session?.transport) { val session = state.session ?: return@LaunchedEffect val url = session.playableUrl if (url.isBlank()) return@LaunchedEffect val serverUrl = tokenManager.getServerUrl() - val mediaItem = playerFactory.buildMediaItem( - streamUrl = url, - playMethod = PlayMethod.TRANSCODE, - serverUrl = serverUrl, - title = channelName.ifBlank { "Live TV" }, - ) + val mediaItem = if (session.isHls) { + playerFactory.buildMediaItem( + streamUrl = url, + playMethod = PlayMethod.TRANSCODE, + serverUrl = serverUrl, + title = channelName.ifBlank { "Live TV" }, + ) + } else { + playerFactory.buildMediaItem( + streamUrl = url, + playMethod = PlayMethod.DIRECT, + container = "mpegts", + serverUrl = serverUrl, + title = channelName.ifBlank { "Live TV" }, + ) + } player.setMediaItem(mediaItem) player.prepare() player.play() diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/livetv/LiveTvScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/livetv/LiveTvScreen.kt index b13aa769d..a7d1412af 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/livetv/LiveTvScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/livetv/LiveTvScreen.kt @@ -16,11 +16,15 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.outlined.FiberManualRecord import androidx.compose.material.icons.outlined.LiveTv +import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -46,15 +50,17 @@ import org.prairieserver.prairie.android.ui.components.LoadingIndicator import org.prairieserver.prairie.android.ui.components.PrairieTopBar import org.prairieserver.prairie.model.livetv.LiveTvChannel import org.prairieserver.prairie.model.livetv.LiveTvProgram +import org.prairieserver.prairie.model.livetv.LiveTvRecording import org.prairieserver.prairie.viewmodel.LiveTvChannelRow +import org.prairieserver.prairie.viewmodel.LiveTvTab import org.prairieserver.prairie.viewmodel.LiveTvViewModel -import androidx.compose.material.icons.filled.Refresh @OptIn(ExperimentalMaterial3Api::class) @Composable fun LiveTvScreen( onBackClick: () -> Unit, onChannelClick: (LiveTvChannel) -> Unit, + onPlayLibraryItem: (String) -> Unit = {}, viewModel: LiveTvViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() @@ -110,16 +116,6 @@ fun LiveTvScreen( } } } - state.channels.isEmpty() -> { - EmptyStateView( - icon = Icons.Outlined.LiveTv, - title = "No channels", - subtitle = "No Live TV channels are available on this server.", - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) - } else -> { PullToRefreshBox( isRefreshing = state.isRefreshing, @@ -128,17 +124,69 @@ fun LiveTvScreen( .fillMaxSize() .padding(padding), ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(state.channels, key = { it.channel.id }) { row -> - LiveTvChannelListItem( - row = row, - onClick = { onChannelClick(row.channel) }, - onRecord = { program -> viewModel.scheduleRecording(program) }, - ) + Column(modifier = Modifier.fillMaxSize()) { + LiveTvTabRow( + selected = state.selectedTab, + onSelect = viewModel::selectTab, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + when (state.selectedTab) { + LiveTvTab.Guide, LiveTvTab.Channels -> { + if (state.selectedTab == LiveTvTab.Channels) { + OutlinedTextField( + value = state.channelQuery, + onValueChange = viewModel::setChannelQuery, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + singleLine = true, + label = { Text("Search channels") }, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + val rows = if (state.selectedTab == LiveTvTab.Channels) { + state.filteredChannels + } else { + state.channels + } + if (rows.isEmpty()) { + EmptyStateView( + icon = Icons.Outlined.LiveTv, + title = if (state.selectedTab == LiveTvTab.Channels) { + "No matching channels" + } else { + "No channels" + }, + subtitle = "No Live TV channels are available on this server.", + modifier = Modifier.fillMaxSize(), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(rows, key = { it.channel.id }) { row -> + LiveTvChannelListItem( + row = row, + showNext = state.selectedTab == LiveTvTab.Guide, + onClick = { onChannelClick(row.channel) }, + onRecord = { program -> + viewModel.scheduleRecording(program) + }, + ) + } + } + } + } + LiveTvTab.Recordings -> { + LiveTvRecordingsPane( + active = state.activeRecordings, + history = state.historyRecordings, + onCancel = viewModel::cancelRecording, + onPlayLibraryItem = onPlayLibraryItem, + ) + } } } } @@ -147,9 +195,149 @@ fun LiveTvScreen( } } +@Composable +private fun LiveTvTabRow( + selected: LiveTvTab, + onSelect: (LiveTvTab) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + LiveTvTab.entries.forEach { tab -> + FilterChip( + selected = selected == tab, + onClick = { onSelect(tab) }, + label = { + Text( + when (tab) { + LiveTvTab.Guide -> "Guide" + LiveTvTab.Channels -> "Channels" + LiveTvTab.Recordings -> "My recordings" + }, + ) + }, + ) + } + } +} + +@Composable +private fun LiveTvRecordingsPane( + active: List, + history: List, + onCancel: (LiveTvRecording) -> Unit, + onPlayLibraryItem: (String) -> Unit, +) { + if (active.isEmpty() && history.isEmpty()) { + EmptyStateView( + icon = Icons.Outlined.FiberManualRecord, + title = "No recordings", + subtitle = "Scheduled and completed Live TV recordings will appear here.", + modifier = Modifier.fillMaxSize(), + ) + return + } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (active.isNotEmpty()) { + item { + Text( + text = "Scheduled & in progress", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + } + items(active, key = { it.id }) { recording -> + LiveTvRecordingRow( + recording = recording, + onCancel = { onCancel(recording) }, + onPlay = null, + ) + } + } + if (history.isNotEmpty()) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "History", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + } + items(history, key = { it.id }) { recording -> + val libraryId = recording.libraryItemId?.takeIf { it.isNotBlank() } + LiveTvRecordingRow( + recording = recording, + onCancel = null, + onPlay = libraryId?.let { { onPlayLibraryItem(it) } }, + ) + } + } + } +} + +@Composable +private fun LiveTvRecordingRow( + recording: LiveTvRecording, + onCancel: (() -> Unit)?, + onPlay: (() -> Unit)?, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .padding(horizontal = 4.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = recording.title.ifBlank { "Recording" }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = recording.status.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (recording.lastError.isNotBlank()) { + Text( + text = recording.lastError, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (onCancel != null) { + TextButton(onClick = onCancel) { Text("Cancel") } + } + if (onPlay != null) { + TextButton(onClick = onPlay) { + Icon( + imageVector = Icons.Outlined.PlayArrow, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Play") + } + } + } +} + @Composable private fun LiveTvChannelListItem( row: LiveTvChannelRow, + showNext: Boolean, onClick: () -> Unit, onRecord: (LiveTvProgram) -> Unit, ) { @@ -210,13 +398,25 @@ private fun LiveTvChannelListItem( val nowTitle = row.nowPlaying?.title?.takeIf { it.isNotBlank() } if (nowTitle != null) { Text( - text = nowTitle, + text = "Now · $nowTitle", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } + if (showNext) { + val nextTitle = row.upNext?.title?.takeIf { it.isNotBlank() } + if (nextTitle != null) { + Text( + text = "Next · $nextTitle", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } val nowProgram = row.nowPlaying if (nowProgram != null && nowProgram.id.isNotBlank()) { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/livetv/TvLiveTvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/livetv/TvLiveTvPlayerScreen.kt index e31e2cee8..7aa015d48 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/livetv/TvLiveTvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/livetv/TvLiveTvPlayerScreen.kt @@ -69,17 +69,27 @@ fun TvLiveTvPlayerScreen( onDispose { player.release() } } - LaunchedEffect(state.session?.sessionId, state.session?.playableUrl) { + LaunchedEffect(state.session?.sessionId, state.session?.playableUrl, state.session?.transport) { val session = state.session ?: return@LaunchedEffect val url = session.playableUrl if (url.isBlank()) return@LaunchedEffect val serverUrl = tokenManager.getServerUrl() - val mediaItem = playerFactory.buildMediaItem( - streamUrl = url, - playMethod = PlayMethod.TRANSCODE, - serverUrl = serverUrl, - title = channelName.ifBlank { "Live TV" }, - ) + val mediaItem = if (session.isHls) { + playerFactory.buildMediaItem( + streamUrl = url, + playMethod = PlayMethod.TRANSCODE, + serverUrl = serverUrl, + title = channelName.ifBlank { "Live TV" }, + ) + } else { + playerFactory.buildMediaItem( + streamUrl = url, + playMethod = PlayMethod.DIRECT, + container = "mpegts", + serverUrl = serverUrl, + title = channelName.ifBlank { "Live TV" }, + ) + } player.setMediaItem(mediaItem) player.prepare() player.play() diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/livetv/TvLiveTvScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/livetv/TvLiveTvScreen.kt index bc0f7b45e..dd16f159e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/livetv/TvLiveTvScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/livetv/TvLiveTvScreen.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -43,9 +45,11 @@ import androidx.tv.material3.Text import coil3.compose.AsyncImage import org.koin.compose.viewmodel.koinViewModel import org.prairieserver.prairie.model.livetv.LiveTvChannel +import org.prairieserver.prairie.model.livetv.LiveTvRecording import org.prairieserver.prairie.tv.ui.shell.TvTopMenuLayout import org.prairieserver.prairie.tv.ui.theme.Spacing import org.prairieserver.prairie.viewmodel.LiveTvChannelRow +import org.prairieserver.prairie.viewmodel.LiveTvTab import org.prairieserver.prairie.viewmodel.LiveTvViewModel @OptIn(ExperimentalTvMaterial3Api::class) @@ -58,7 +62,7 @@ fun TvLiveTvScreen( val state by viewModel.uiState.collectAsState() val firstFocus = remember { FocusRequester() } - LaunchedEffect(state.channels.firstOrNull()?.channel?.id, state.isLoading) { + LaunchedEffect(state.channels.firstOrNull()?.channel?.id, state.isLoading, state.selectedTab) { if (!state.isLoading && state.channels.isNotEmpty()) { runCatching { firstFocus.requestFocus() } onInitialContentFocus() @@ -75,6 +79,44 @@ fun TvLiveTvScreen( onRefresh = viewModel::refresh, ) + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.safeArea), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(LiveTvTab.entries) { tab -> + val selected = state.selectedTab == tab + Surface( + onClick = { viewModel.selectTab(tab) }, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + colors = ClickableSurfaceDefaults.colors( + containerColor = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.35f) + } else { + Color.White.copy(alpha = 0.08f) + }, + contentColor = Color.White, + focusedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + focusedContentColor = Color.White, + ), + ) { + Text( + text = when (tab) { + LiveTvTab.Guide -> "Guide" + LiveTvTab.Channels -> "Channels" + LiveTvTab.Recordings -> "My recordings" + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + when { state.isLoading && state.channels.isEmpty() -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -89,6 +131,13 @@ fun TvLiveTvScreen( ) } } + state.selectedTab == LiveTvTab.Recordings -> { + TvRecordingsList( + active = state.activeRecordings, + history = state.historyRecordings, + onCancel = viewModel::cancelRecording, + ) + } state.channels.isEmpty() -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text( @@ -110,6 +159,7 @@ fun TvLiveTvScreen( itemsIndexed(state.channels, key = { _, row -> row.channel.id }) { index, row -> TvLiveTvChannelRow( row = row, + showNext = state.selectedTab == LiveTvTab.Guide, focusRequester = firstFocus.takeIf { index == 0 }, onClick = { onChannelClick(row.channel) }, ) @@ -120,6 +170,95 @@ fun TvLiveTvScreen( } } +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun TvRecordingsList( + active: List, + history: List, + onCancel: (LiveTvRecording) -> Unit, +) { + if (active.isEmpty() && history.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "No recordings yet.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = Spacing.safeArea, + end = Spacing.safeArea, + bottom = 56.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (active.isNotEmpty()) { + item { + Text( + text = "Scheduled & in progress", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + items(active, key = { it.id }) { recording -> + TvRecordingRow(recording = recording, onCancel = { onCancel(recording) }) + } + } + if (history.isNotEmpty()) { + item { + Text( + text = "History", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + items(history, key = { it.id }) { recording -> + TvRecordingRow(recording = recording, onCancel = null) + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun TvRecordingRow( + recording: LiveTvRecording, + onCancel: (() -> Unit)?, +) { + Surface( + onClick = { onCancel?.invoke() }, + enabled = onCancel != null, + modifier = Modifier.fillMaxWidth(), + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.015f), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.White.copy(alpha = 0.06f), + contentColor = Color.White, + focusedContainerColor = Color.White.copy(alpha = 0.16f), + focusedContentColor = Color.White, + ), + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) { + Text( + text = recording.title.ifBlank { "Recording" }, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = recording.status.replaceFirstChar { it.uppercase() } + + if (onCancel != null) " · Select to cancel" else "", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun LiveTvHeader( @@ -166,6 +305,7 @@ private fun LiveTvHeader( @Composable private fun TvLiveTvChannelRow( row: LiveTvChannelRow, + showNext: Boolean, onClick: () -> Unit, focusRequester: FocusRequester? = null, ) { @@ -231,13 +371,25 @@ private fun TvLiveTvChannelRow( val nowTitle = row.nowPlaying?.title?.takeIf { it.isNotBlank() } if (nowTitle != null) { Text( - text = nowTitle, + text = "Now · $nowTitle", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } + if (showNext) { + val nextTitle = row.upNext?.title?.takeIf { it.isNotBlank() } + if (nextTitle != null) { + Text( + text = "Next · $nextTitle", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/livetv/LiveTvModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/livetv/LiveTvModels.kt index ad8e178b4..ebfe8552a 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/livetv/LiveTvModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/livetv/LiveTvModels.kt @@ -62,8 +62,9 @@ data class LiveTvGuideResponse( /** * Response from `POST /api/v1/livetv/channels/{id}/session`. * - * Clients play [hlsUrl] (preferred) or fall back to [streamUrl], then - * `DELETE /api/v1/livetv/sessions/{sessionId}` when playback stops. + * [transport] is `"hls"` when the server HLS remux bridge is active, otherwise + * `"mpegts"` for the authenticated session MPEG-TS proxy. Clients play + * [playableUrl], then `DELETE /api/v1/livetv/sessions/{sessionId}` when stopped. */ @Serializable data class LiveTvSessionStartResponse( @@ -71,10 +72,24 @@ data class LiveTvSessionStartResponse( @SerialName("playback_ticket") val playbackTicket: String = "", @SerialName("hls_url") val hlsUrl: String = "", @SerialName("stream_url") val streamUrl: String = "", + val transport: String = "mpegts", val note: String = "", ) { + /** True when the server (or URL shape) indicates HLS remux. */ + val isHls: Boolean + get() { + val explicit = transport.trim().lowercase() + if (explicit == "hls") return true + if (explicit == "mpegts") return false + val url = (hlsUrl.ifBlank { streamUrl }).lowercase() + return url.contains(".m3u8") || url.contains("/live-hls/") + } + val playableUrl: String - get() = hlsUrl.takeIf { it.isNotBlank() } ?: streamUrl + get() = when { + isHls -> hlsUrl.takeIf { it.isNotBlank() } ?: streamUrl + else -> streamUrl.takeIf { it.isNotBlank() } ?: hlsUrl + } } @Serializable @@ -103,7 +118,15 @@ data class LiveTvRecording( val start: String = "", val stop: String = "", val title: String = "", -) + @SerialName("last_error") val lastError: String = "", +) { + val isActive: Boolean + get() = status.equals("scheduled", ignoreCase = true) || + status.equals("recording", ignoreCase = true) + + val isCompleted: Boolean + get() = status.equals("completed", ignoreCase = true) +} @Serializable data class LiveTvRecordingsResponse( diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModels.kt index 43dca5786..9f5532aed 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModels.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.prairieserver.prairie.model.livetv.LiveTvChannel import org.prairieserver.prairie.model.livetv.LiveTvProgram +import org.prairieserver.prairie.model.livetv.LiveTvRecording import org.prairieserver.prairie.model.livetv.LiveTvScheduleRecordingRequest import org.prairieserver.prairie.model.livetv.LiveTvSessionStartResponse import org.prairieserver.prairie.network.ApiResult @@ -16,18 +17,47 @@ import org.prairieserver.prairie.network.errorMessage import org.prairieserver.prairie.repository.LiveTvRepository import org.prairieserver.prairie.util.parseRfc3339ToEpochMillis +enum class LiveTvTab { + Guide, + Channels, + Recordings, +} + data class LiveTvChannelRow( val channel: LiveTvChannel, val nowPlaying: LiveTvProgram? = null, + val upNext: LiveTvProgram? = null, ) data class LiveTvUiState( val isLoading: Boolean = true, val isRefreshing: Boolean = false, + val selectedTab: LiveTvTab = LiveTvTab.Guide, val channels: List = emptyList(), + val recordings: List = emptyList(), + val channelQuery: String = "", val recordingMessage: String? = null, val error: String? = null, -) +) { + val filteredChannels: List + get() { + val q = channelQuery.trim().lowercase() + if (q.isEmpty()) return channels + return channels.filter { row -> + val ch = row.channel + ch.displayName.lowercase().contains(q) || + ch.displayNumber.lowercase().contains(q) || + ch.callsign.lowercase().contains(q) || + (row.nowPlaying?.title?.lowercase()?.contains(q) == true) + } + } + + val activeRecordings: List + get() = recordings.filter { it.isActive } + + val historyRecordings: List + get() = recordings.filter { !it.isActive } +} class LiveTvViewModel( private val repository: LiveTvRepository, @@ -44,19 +74,31 @@ class LiveTvViewModel( fun load() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } - fetchChannels() + fetchAll() } } fun refresh() { viewModelScope.launch { _uiState.update { it.copy(isRefreshing = true, error = null, recordingMessage = null) } - fetchChannels() + fetchAll() _uiState.update { it.copy(isRefreshing = false) } } } + fun selectTab(tab: LiveTvTab) { + _uiState.update { it.copy(selectedTab = tab) } + if (tab == LiveTvTab.Recordings) { + viewModelScope.launch { fetchRecordings() } + } + } + + fun setChannelQuery(query: String) { + _uiState.update { it.copy(channelQuery = query) } + } + private val schedulingProgramIds = mutableSetOf() + private val cancellingRecordingIds = mutableSetOf() fun scheduleRecording(program: LiveTvProgram) { val programId = program.id.trim() @@ -70,6 +112,7 @@ class LiveTvViewModel( ) { is ApiResult.Success -> { _uiState.update { it.copy(recordingMessage = "Recording scheduled") } + fetchRecordings() } is ApiResult.Error -> { val message = when (result.code) { @@ -92,24 +135,52 @@ class LiveTvViewModel( } } + fun cancelRecording(recording: LiveTvRecording) { + val id = recording.id.trim() + if (id.isEmpty() || !cancellingRecordingIds.add(id)) return + viewModelScope.launch { + try { + when (val result = repository.cancelRecording(id)) { + is ApiResult.Success -> { + _uiState.update { it.copy(recordingMessage = "Recording cancelled") } + fetchRecordings() + } + is ApiResult.Error, is ApiResult.NetworkError -> { + _uiState.update { + it.copy(recordingMessage = "Could not cancel recording") + } + } + } + } finally { + cancellingRecordingIds.remove(id) + } + } + } + fun clearRecordingMessage() { _uiState.update { it.copy(recordingMessage = null) } } - private suspend fun fetchChannels() { + private suspend fun fetchAll() { when (val result = repository.channels()) { is ApiResult.Success -> { val channels = result.data.channels.filter { it.enabled } - val nowByChannel = loadNowPlaying(channels.map { it.id }) + val slots = loadNowNext(channels.map { it.id }) _uiState.update { it.copy( isLoading = false, channels = channels.map { channel -> - LiveTvChannelRow(channel, nowByChannel[channel.id]) + val slot = slots[channel.id] + LiveTvChannelRow( + channel = channel, + nowPlaying = slot?.first, + upNext = slot?.second, + ) }, error = null, ) } + fetchRecordings() } is ApiResult.Error, is ApiResult.NetworkError -> { _uiState.update { @@ -122,20 +193,61 @@ class LiveTvViewModel( } } - private suspend fun loadNowPlaying(channelIds: List): Map { + private suspend fun fetchRecordings() { + when (val result = repository.recordings()) { + is ApiResult.Success -> { + _uiState.update { + it.copy( + recordings = result.data.recordings.sortedByDescending { rec -> rec.start }, + ) + } + } + is ApiResult.Error, is ApiResult.NetworkError -> { + // Soft-fail: older servers / empty DVR should not blank the channel UI. + } + } + } + + private suspend fun loadNowNext( + channelIds: List, + ): Map> { if (channelIds.isEmpty()) return emptyMap() return when (val guide = repository.guide(channelIds = channelIds)) { is ApiResult.Success -> { val now = nowMillisProvider() - guide.data.programs - .filter { program -> - if (now <= 0L) return@filter true - val start = parseRfc3339ToEpochMillis(program.start) ?: return@filter false - val stop = parseRfc3339ToEpochMillis(program.stop) ?: return@filter false - now in start until stop + channelIds.associateWith { channelId -> + val sorted = guide.data.programs + .filter { it.channelId == channelId } + .sortedBy { it.start } + var current: LiveTvProgram? = null + var upcoming: LiveTvProgram? = null + for (program in sorted) { + val start = parseRfc3339ToEpochMillis(program.start) ?: continue + val stop = parseRfc3339ToEpochMillis(program.stop) ?: continue + if (now > 0L) { + if (now in start until stop) { + current = program + continue + } + if (start > now) { + upcoming = program + break + } + } else if (current == null) { + current = program + } else if (upcoming == null) { + upcoming = program + break + } } - .groupBy { it.channelId } - .mapValues { (_, programs) -> programs.maxBy { it.start } } + if (upcoming == null && current != null) { + val idx = sorted.indexOfFirst { it.id == current.id } + if (idx >= 0 && idx + 1 < sorted.size) { + upcoming = sorted[idx + 1] + } + } + current to upcoming + } } is ApiResult.Error, is ApiResult.NetworkError -> emptyMap() } @@ -151,9 +263,9 @@ data class LiveTvPlayerUiState( ) /** - * Owns Live TV playback session lifecycle: POST session to obtain an HLS URL, - * then DELETE the session when the player stops. ExoPlayer wiring lives in the - * Android UI layer via [org.prairieserver.prairie.common.player.PrairiePlayerFactory]. + * Owns Live TV playback session lifecycle: POST session to obtain a playable + * URL (HLS remux or MPEG-TS proxy), then DELETE when the player stops. + * ExoPlayer wiring lives in the Android UI layer via PrairiePlayerFactory. */ class LiveTvPlayerViewModel( private val repository: LiveTvRepository, diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/LiveTvApiTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/LiveTvApiTest.kt index 002b8fae4..e8f940d80 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/LiveTvApiTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/LiveTvApiTest.kt @@ -86,7 +86,7 @@ class LiveTvApiTest { val captured = Captured() val result = api( captured, - body = """{"session_id":"s1","playback_ticket":"t1","hls_url":"/hls/s1.m3u8"}""", + body = """{"session_id":"s1","playback_ticket":"t1","hls_url":"/api/v1/livetv/live-hls/t1/index.m3u8","transport":"hls"}""", status = HttpStatusCode.Created, ).startSession("ch-9") @@ -95,7 +95,25 @@ class LiveTvApiTest { val success = assertIs>(result) val session = success.data as org.prairieserver.prairie.model.livetv.LiveTvSessionStartResponse assertEquals("s1", session.sessionId) - assertEquals("/hls/s1.m3u8", session.playableUrl) + assertEquals("hls", session.transport) + assertTrue(session.isHls) + assertEquals("/api/v1/livetv/live-hls/t1/index.m3u8", session.playableUrl) + } + + @Test + fun startSessionMpegtsPrefersStreamUrl() = runTest { + val captured = Captured() + val result = api( + captured, + body = """{"session_id":"s2","playback_ticket":"s2","stream_url":"/api/v1/livetv/sessions/s2/stream","transport":"mpegts"}""", + status = HttpStatusCode.Created, + ).startSession("ch-1") + + val success = assertIs>(result) + val session = success.data as org.prairieserver.prairie.model.livetv.LiveTvSessionStartResponse + assertEquals("mpegts", session.transport) + assertTrue(!session.isHls) + assertEquals("/api/v1/livetv/sessions/s2/stream", session.playableUrl) } @Test diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModelTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModelTest.kt index 76e42ca38..5d9d2dc1f 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModelTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModelTest.kt @@ -110,7 +110,8 @@ class LiveTvViewModelTest { startResult = ApiResult.Success( LiveTvSessionStartResponse( sessionId = "s1", - hlsUrl = "/api/v1/livetv/hls/s1.m3u8", + hlsUrl = "/api/v1/livetv/live-hls/s1/index.m3u8", + transport = "hls", ), ), releaseResult = ApiResult.Success(LiveTvSession(id = "s1", status = "released")), @@ -121,7 +122,8 @@ class LiveTvViewModelTest { advanceUntilIdle() assertEquals("s1", viewModel.uiState.value.session?.sessionId) - assertEquals("/api/v1/livetv/hls/s1.m3u8", viewModel.uiState.value.session?.playableUrl) + assertEquals("/api/v1/livetv/live-hls/s1/index.m3u8", viewModel.uiState.value.session?.playableUrl) + assertTrue(viewModel.uiState.value.session?.isHls == true) assertNull(viewModel.uiState.value.error) viewModel.stop() From c33eeb8baee2aeebf7ca2042115334cb2bd59f98 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 16:31:45 +0000 Subject: [PATCH 2/2] Refuse Live TV Record for programmes that already ended UI already shows now/next only; guard scheduleRecording the same way as web/Apple when stop is in the past. Co-authored-by: Jonah May --- .../prairieserver/prairie/viewmodel/LiveTvViewModels.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModels.kt index 9f5532aed..457e04f0b 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModels.kt @@ -103,6 +103,13 @@ class LiveTvViewModel( fun scheduleRecording(program: LiveTvProgram) { val programId = program.id.trim() if (programId.isEmpty() || !schedulingProgramIds.add(programId)) return + val stopMs = parseRfc3339ToEpochMillis(program.stop) + val nowMs = nowMillisProvider() + if (stopMs != null && nowMs > 0L && stopMs <= nowMs) { + schedulingProgramIds.remove(programId) + _uiState.update { it.copy(recordingMessage = "Program already ended") } + return + } viewModelScope.launch { try { when (