Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5cefe8b
docs(catchup): define Guide service-owned playback intent slice
MrFr3di Sep 2, 2026
64e0f49
docs(catchup): plan Guide service-owned playback intent
MrFr3di Sep 2, 2026
19ad04d
test(playback): define semantic start request contract
MrFr3di Sep 2, 2026
4791004
feat(playback): carry provider-neutral start intent
MrFr3di Sep 2, 2026
e15a37e
test(playback): define semantic setup command codec
MrFr3di Sep 2, 2026
715baf1
feat(playback): encode semantic playback intent
MrFr3di Sep 2, 2026
6bc15ae
fix(playback): use block body for intent parser
MrFr3di Sep 2, 2026
8100379
test(catchup): require intent-aware candidate resolution
MrFr3di Sep 2, 2026
4516be5
test(catchup): require exact archive candidate resolution
MrFr3di Sep 2, 2026
08e145c
feat(catchup): add intent-aware candidate resolver contract
MrFr3di Sep 2, 2026
c5adcb4
feat(catchup): resolve archive intent for exact candidate
MrFr3di Sep 2, 2026
4d7f00d
test(catchup): require recovery to preserve semantic intent
MrFr3di Sep 2, 2026
72733da
feat(catchup): delegate recovery candidate with semantic intent
MrFr3di Sep 2, 2026
ad79ce5
feat(catchup): keep semantic intent in service recovery
MrFr3di Sep 2, 2026
f514fe3
test(catchup): keep JUnit intent tests void-returning
MrFr3di Sep 2, 2026
9d0e8d4
test(guide): require original programme bounds
MrFr3di Sep 2, 2026
bd1d0cc
test(guide): require semantic playback selection
MrFr3di Sep 2, 2026
64a4ff8
feat(guide): preserve original programme bounds
MrFr3di Sep 2, 2026
4b5e612
feat(guide): derive semantic playback selection
MrFr3di Sep 2, 2026
2ac4413
test(navigation): require semantic player destination
MrFr3di Sep 2, 2026
e035820
test(player): require semantic playback start request
MrFr3di Sep 2, 2026
513e296
test(catalog): make intent resolver JUnit contract explicit
MrFr3di Sep 2, 2026
f7d9a3d
feat(catchup): wire Guide playback intent to Player
MrFr3di Sep 2, 2026
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
24 changes: 24 additions & 0 deletions app/tv/src/main/kotlin/app/muxtv/navigation/AppDestination.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,38 @@ sealed interface AppDestination : NavKey {
@Serializable
data class Player(
val channelId: String,
val programmeId: String? = null,
val programmeStartEpochMillis: Long? = null,
val programmeEndEpochMillis: Long? = null,
) : AppDestination {
init {
require(channelId.isNotBlank())
val programmeTuple = listOf(
programmeId,
programmeStartEpochMillis,
programmeEndEpochMillis,
)
require(programmeTuple.all { it == null } || programmeTuple.all { it != null })
if (programmeId != null &&
programmeStartEpochMillis != null &&
programmeEndEpochMillis != null
) {
require(programmeId.isNotBlank())
require(programmeId.length <= MAX_PROGRAMME_ID_LENGTH)
require(programmeStartEpochMillis >= 0L)
require(programmeEndEpochMillis > programmeStartEpochMillis)
}
}

override fun toString(): String =
"AppDestination.Player(channelId=<redacted>, " +
"programmePresent=${programmeId != null})"
}

companion object {
val initial: AppDestination = Home
val topLevel: List<AppDestination> = listOf(Home, Channels, Guide, Search, Settings)
}
}

private const val MAX_PROGRAMME_ID_LENGTH = 80
47 changes: 27 additions & 20 deletions app/tv/src/main/kotlin/app/muxtv/navigation/AppNavigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ fun AppNavigation(
open(AppDestination.Player(channelId))
},
railFocusRequester = railFocusRequester,
onPlaybackSelected = { selection ->
open(selection.toPlayerDestination())
},
)

AppDestination.Search -> SearchRoute(
Expand Down Expand Up @@ -199,26 +202,30 @@ fun AppNavigation(
openLocalNetworkPermissionSettings = openLocalNetworkPermissionSettings,
)

is AppDestination.Player -> PlayerFavoriteRoute(
playbackCatalog = playbackCatalog,
channelPreferencesRepository = channelPreferencesRepository,
playbackSessionGateway = playbackSessionGateway,
playbackSurface = { session, surfaceModifier ->
Media3PlaybackSurface(
session = session,
modifier = surfaceModifier,
)
},
profileId = DatabaseDefaults.PRIMARY_PROFILE_ID,
channelId = destination.channelId,
onBack = ::goBack,
onOpenDoctor = ::openDoctorFromPlayer,
playbackStartGateway = playbackStartGateway,
requestLocalNetworkPermission = {
requestLocalNetworkPermission().toPlayerOutcome()
},
openLocalNetworkPermissionSettings = openLocalNetworkPermissionSettings,
)
is AppDestination.Player -> {
val playbackIntent = destination.toPlaybackIntent()
PlayerFavoriteRoute(
playbackCatalog = playbackCatalog,
channelPreferencesRepository = channelPreferencesRepository,
playbackSessionGateway = playbackSessionGateway,
playbackSurface = { session, surfaceModifier ->
Media3PlaybackSurface(
session = session,
modifier = surfaceModifier,
)
},
profileId = DatabaseDefaults.PRIMARY_PROFILE_ID,
channelId = playbackIntent.channelId,
onBack = ::goBack,
onOpenDoctor = ::openDoctorFromPlayer,
playbackStartGateway = playbackStartGateway,
requestLocalNetworkPermission = {
requestLocalNetworkPermission().toPlayerOutcome()
},
openLocalNetworkPermissionSettings = openLocalNetworkPermissionSettings,
playbackIntent = playbackIntent,
)
}
}
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package app.muxtv.navigation

import app.muxtv.feature.guide.GuidePlaybackSelection
import app.muxtv.player.PlaybackIntent

internal fun AppDestination.Player.toPlaybackIntent(): PlaybackIntent {
val resolvedProgrammeId = programmeId
val resolvedStart = programmeStartEpochMillis
val resolvedEnd = programmeEndEpochMillis
return if (resolvedProgrammeId == null || resolvedStart == null || resolvedEnd == null) {
PlaybackIntent.Live(channelId)
} else {
PlaybackIntent.CatchupProgram(
channelId = channelId,
programmeId = resolvedProgrammeId,
startEpochMillis = resolvedStart,
endEpochMillis = resolvedEnd,
)
}
}

internal fun GuidePlaybackSelection.toPlayerDestination(): AppDestination.Player = when (this) {
is GuidePlaybackSelection.Live -> AppDestination.Player(channelId)
is GuidePlaybackSelection.CatchupProgram -> AppDestination.Player(
channelId = channelId,
programmeId = programmeId,
programmeStartEpochMillis = startEpochMillis,
programmeEndEpochMillis = endEpochMillis,
)
}
19 changes: 12 additions & 7 deletions app/tv/src/main/kotlin/app/muxtv/navigation/PlayerFavoriteRoute.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import app.muxtv.feature.player.PlaybackSurfaceRenderer
import app.muxtv.feature.player.PlayerFavoriteAction
import app.muxtv.feature.player.PlayerLocalNetworkPermissionOutcome
import app.muxtv.feature.player.PlayerRoute
import app.muxtv.player.PlaybackIntent
import app.muxtv.player.PlaybackSessionGateway
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
Expand All @@ -36,19 +37,23 @@ internal fun PlayerFavoriteRoute(
},
openLocalNetworkPermissionSettings: suspend () -> Boolean = { false },
modifier: Modifier = Modifier,
playbackIntent: PlaybackIntent = PlaybackIntent.Live(channelId),
) {
val playbackChannelId = playbackIntent.channelId
require(channelId == playbackChannelId)

val scope = rememberCoroutineScope()
var favoriteOverride by remember(profileId, channelId) { mutableStateOf<Boolean?>(null) }
var mutationInProgress by remember(profileId, channelId) { mutableStateOf(false) }
var mutationFailed by remember(profileId, channelId) { mutableStateOf(false) }
var favoriteOverride by remember(profileId, playbackChannelId) { mutableStateOf<Boolean?>(null) }
var mutationInProgress by remember(profileId, playbackChannelId) { mutableStateOf(false) }
var mutationFailed by remember(profileId, playbackChannelId) { mutableStateOf(false) }
val catalogFavorite by produceState<Boolean?>(
initialValue = null,
playbackCatalog,
profileId,
channelId,
playbackChannelId,
) {
value = try {
playbackCatalog.getChannel(profileId = profileId, channelId = channelId)
playbackCatalog.getChannel(profileId = profileId, channelId = playbackChannelId)
?.summary
?.isFavorite
} catch (cancelled: CancellationException) {
Expand All @@ -64,7 +69,7 @@ internal fun PlayerFavoriteRoute(
playbackSessionGateway = playbackSessionGateway,
playbackSurface = playbackSurface,
profileId = profileId,
channelId = channelId,
playbackIntent = playbackIntent,
onBack = onBack,
onOpenDoctor = onOpenDoctor,
playbackStartGateway = playbackStartGateway,
Expand All @@ -86,7 +91,7 @@ internal fun PlayerFavoriteRoute(
when (
channelPreferencesRepository.setFavorite(
profileId = profileId,
channelId = channelId,
channelId = playbackChannelId,
isFavorite = requestedFavorite,
)
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package app.muxtv.navigation

import app.muxtv.feature.guide.GuidePlaybackSelection
import app.muxtv.player.PlaybackIntent
import com.google.common.truth.Truth.assertThat
import org.junit.Test

class PlayerCatchupDestinationTest {
@Test
fun `legacy player destination remains live`() {
val destination = AppDestination.Player(CHANNEL_ID)

assertThat(destination.toPlaybackIntent())
.isEqualTo(PlaybackIntent.Live(CHANNEL_ID))
}

@Test
fun `catchup destination reconstructs provider neutral programme intent`() {
val destination = AppDestination.Player(
channelId = CHANNEL_ID,
programmeId = PROGRAMME_ID,
programmeStartEpochMillis = PROGRAMME_START,
programmeEndEpochMillis = PROGRAMME_END,
)

assertThat(destination.toPlaybackIntent()).isEqualTo(
PlaybackIntent.CatchupProgram(
channelId = CHANNEL_ID,
programmeId = PROGRAMME_ID,
startEpochMillis = PROGRAMME_START,
endEpochMillis = PROGRAMME_END,
),
)
}

@Test
fun `guide catchup selection maps to bounded player destination`() {
val destination = GuidePlaybackSelection.CatchupProgram(
channelId = CHANNEL_ID,
programmeId = PROGRAMME_ID,
startEpochMillis = PROGRAMME_START,
endEpochMillis = PROGRAMME_END,
).toPlayerDestination()

assertThat(destination).isEqualTo(
AppDestination.Player(
channelId = CHANNEL_ID,
programmeId = PROGRAMME_ID,
programmeStartEpochMillis = PROGRAMME_START,
programmeEndEpochMillis = PROGRAMME_END,
),
)
}

@Test
fun `guide live selection maps to legacy player destination`() {
assertThat(GuidePlaybackSelection.Live(CHANNEL_ID).toPlayerDestination())
.isEqualTo(AppDestination.Player(CHANNEL_ID))
}

@Test(expected = IllegalArgumentException::class)
fun `partial catchup tuple is rejected`() {
AppDestination.Player(
channelId = CHANNEL_ID,
programmeId = PROGRAMME_ID,
programmeStartEpochMillis = PROGRAMME_START,
programmeEndEpochMillis = null,
)
}

private companion object {
const val CHANNEL_ID = "channel-news"
const val PROGRAMME_ID = "gp1_00112233445566778899aabbccddeeff"
const val PROGRAMME_START = 1_800_000_000_000L
const val PROGRAMME_END = PROGRAMME_START + 3_600_000L
}
}
17 changes: 17 additions & 0 deletions catalog/api/src/main/kotlin/app/muxtv/catalog/PlaybackCatalog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,23 @@ interface PlaybackCandidateResolver {
profileId: String,
candidate: PlaybackCandidateIdentity,
): PlaybackVariantResolution?

suspend fun resolveIntentCandidate(
profileId: String,
intent: PlaybackIntent,
candidate: PlaybackCandidateIdentity,
): PlaybackVariantResolution? = when (intent) {
is PlaybackIntent.Live -> resolveCandidate(
profileId = profileId,
candidate = candidate,
)

is PlaybackIntent.CatchupProgram,
is PlaybackIntent.CatchupPosition,
-> PlaybackVariantResolution.AccessUnavailable(
PlaybackAccessUnavailableReason.ArchiveUnsupported,
)
}
}

interface PlaybackCatalog {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package app.muxtv.catalog

import app.muxtv.player.PlaybackIntent
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.runBlocking
import org.junit.Test

class PlaybackCandidateResolverIntentTest {
@Test
fun liveIntentDefaultsToExistingExactCandidateResolution(): Unit = runBlocking {
val candidate = PlaybackCandidateIdentity(
channelId = CHANNEL_ID,
variantId = VARIANT_ID,
)
val expected = PlaybackVariantResolution.AccessUnavailable(
PlaybackAccessUnavailableReason.CredentialUnavailable,
)
val resolvedCandidates = mutableListOf<PlaybackCandidateIdentity>()
val resolver = recordingResolver(resolvedCandidates, expected)

val result = resolver.resolveIntentCandidate(
profileId = PROFILE_ID,
intent = PlaybackIntent.Live(CHANNEL_ID),
candidate = candidate,
)

assertThat(result).isEqualTo(expected)
assertThat(resolvedCandidates).containsExactly(candidate)
Unit
}

@Test
fun archiveIntentDefaultsToUnsupportedWithoutFallingBackToLiveCandidateResolution(): Unit =
runBlocking {
val candidate = PlaybackCandidateIdentity(
channelId = CHANNEL_ID,
variantId = VARIANT_ID,
)
val resolvedCandidates = mutableListOf<PlaybackCandidateIdentity>()
val resolver = recordingResolver(
resolvedCandidates = resolvedCandidates,
resolution = PlaybackVariantResolution.AccessUnavailable(
PlaybackAccessUnavailableReason.CredentialUnavailable,
),
)

val result = resolver.resolveIntentCandidate(
profileId = PROFILE_ID,
intent = PlaybackIntent.CatchupPosition(
channelId = CHANNEL_ID,
positionEpochMillis = 1_800_000_000_000L,
),
candidate = candidate,
)

assertThat(result).isEqualTo(
PlaybackVariantResolution.AccessUnavailable(
PlaybackAccessUnavailableReason.ArchiveUnsupported,
),
)
assertThat(resolvedCandidates).isEmpty()
Unit
}

private fun recordingResolver(
resolvedCandidates: MutableList<PlaybackCandidateIdentity>,
resolution: PlaybackVariantResolution,
): PlaybackCandidateResolver = object : PlaybackCandidateResolver {
override suspend fun getCandidates(
profileId: String,
channelId: String,
preferredVariantId: String?,
limit: Int,
): List<PlaybackCandidateIdentity> = emptyList()

override suspend fun resolveCandidate(
profileId: String,
candidate: PlaybackCandidateIdentity,
): PlaybackVariantResolution {
resolvedCandidates += candidate
return resolution
}
}

private companion object {
const val PROFILE_ID = "profile-main"
const val CHANNEL_ID = "channel-news"
const val VARIANT_ID = "variant-primary"
}
}
Loading