Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,9 @@ fun AppNavigation(
Route.LiveTvPlayer(channel.id, channel.displayName).route,
)
},
onPlayLibraryItem = { contentId ->
navController.navigate(Route.ItemDetail(contentId).route)
},
)
}
composable(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
}
}
}
}
Expand All @@ -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<LiveTvRecording>,
history: List<LiveTvRecording>,
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,
) {
Expand Down Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading