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 @@ -11,7 +11,7 @@ import org.siloserver.silo.common.pairing.PairingDeviceId
/**
* Advertises the SiloCast receiver. TXT keys mirror silo-apple's
* TVControlReceiver Bonjour record (`v`, `name`, `id`, `server`,
* `serverName`, `playing`) so an iPhone browsing `_silocast._tcp` can filter
* `serverName`, `serverIdentity`, `playing`) so an iPhone browsing `_silocast._tcp` can filter
* an Android TV by server the same way it filters an Apple TV — and the
* Android browser can read Apple receivers. Android's NsdManager can't
* mutate a live TXT record, so [updatePlaying] re-registers.
Expand Down Expand Up @@ -46,8 +46,14 @@ class SiloCastNsdAdvertiser(
}

@Synchronized
fun start(port: Int, serverId: String?, serverName: String?, playing: Boolean = false) {
register(Registration(port, serverId, serverName, playing))
fun start(
port: Int,
serverId: String?,
serverName: String?,
playing: Boolean = false,
serverIdentity: String? = null,
) {
register(Registration(port, serverId, serverName, playing, serverIdentity))
}

/** Re-advertise with an updated `playing` flag (no-op when not started). */
Expand Down Expand Up @@ -81,6 +87,8 @@ class SiloCastNsdAdvertiser(
put("id", deviceIdProvider())
registration.serverId?.let { put("server", it) }
registration.serverName?.let { put("serverName", it) }
// Lets a phone signed in at another address of the same deployment list this TV.
registration.serverIdentity?.let { put("serverIdentity", it) }
put("playing", if (registration.playing) "1" else "0")
}
val serviceInfo = NsdServiceInfo().apply {
Expand Down Expand Up @@ -124,6 +132,7 @@ class SiloCastNsdAdvertiser(
val serverId: String?,
val serverName: String?,
val playing: Boolean,
val serverIdentity: String?,
)

private companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -896,7 +896,6 @@ private data class MenuEntry(
val label: String,
val selected: Boolean,
val enabled: Boolean = true,
val isSectionHeader: Boolean = false,
val onClick: () -> Unit = {},
)

Expand Down Expand Up @@ -941,10 +940,9 @@ private fun RemoteSecondaryControls(
)
}

val hasSubtitleControls = playback.subtitleTracks.isNotEmpty() ||
playback.supportsSubtitleDelay == true ||
playback.supportsSubtitlePosition == true
if (hasSubtitleControls) {
// Subtitle delay and position stay on the TV's own player menu;
// from the couch the remote only switches the language.
if (playback.subtitleTracks.isNotEmpty()) {
RemoteChipMenu(
icon = Icons.Outlined.ClosedCaption,
caption = "Subtitles",
Expand Down Expand Up @@ -972,52 +970,21 @@ private fun subtitleMenuEntries(
playback: SiloCastPlaybackState,
controller: SiloCastController,
): List<MenuEntry> = buildList {
if (playback.subtitleTracks.isNotEmpty()) {
add(MenuEntry(label = "Track", selected = false, isSectionHeader = true))
add(
MenuEntry(
label = "Off",
selected = playback.selectedSubtitleTrackId == null,
onClick = { controller.selectSubtitleTrack(null) },
),
)
playback.subtitleTracks.forEach { track ->
add(
MenuEntry(
label = "Off",
selected = playback.selectedSubtitleTrackId == null,
onClick = { controller.selectSubtitleTrack(null) },
label = track.title,
selected = playback.selectedSubtitleTrackId == track.trackId,
onClick = { controller.selectSubtitleTrack(track.trackId) },
),
)
playback.subtitleTracks.forEach { track ->
add(
MenuEntry(
label = track.title,
selected = playback.selectedSubtitleTrackId == track.trackId,
onClick = { controller.selectSubtitleTrack(track.trackId) },
),
)
}
}
if (playback.supportsSubtitleDelay == true) {
add(MenuEntry(label = "Delay", selected = false, isSectionHeader = true))
val presets = listOf(-2_000, -1_500, -1_000, -500, -250, 0, 250, 500, 1_000, 1_500, 2_000)
val current = playback.subtitleSyncMs ?: 0
val options = if (presets.contains(current)) presets else (presets + current).sorted()
options.forEach { ms ->
add(
MenuEntry(
label = subtitleDelayLabel(ms),
selected = current == ms,
onClick = { controller.setSubtitleSyncMs(ms) },
),
)
}
}
if (playback.supportsSubtitlePosition == true) {
add(MenuEntry(label = "Position", selected = false, isSectionHeader = true))
// tvOS SubtitlePositionPreset raw values; it rejects anything else.
listOf("bottom" to "Bottom", "lower-third" to "Lower Third", "top" to "Top").forEach { (id, label) ->
add(
MenuEntry(
label = label,
selected = playback.subtitlePosition == id,
onClick = { controller.setSubtitlePosition(id) },
),
)
}
}
}

Expand Down Expand Up @@ -1054,40 +1021,26 @@ private fun RemoteChipMenu(
}
SiloDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
entries.forEach { entry ->
if (entry.isSectionHeader) {
Text(
entry.label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
)
} else {
SiloDropdownMenuItem(
text = { Text(entry.label) },
enabled = entry.enabled,
leadingIcon = {
if (entry.selected) {
Icon(Icons.Filled.Check, contentDescription = "Selected", modifier = Modifier.size(18.dp))
} else {
Spacer(modifier = Modifier.size(18.dp))
}
},
onClick = {
expanded = false
entry.onClick()
},
)
}
SiloDropdownMenuItem(
text = { Text(entry.label) },
enabled = entry.enabled,
leadingIcon = {
if (entry.selected) {
Icon(Icons.Filled.Check, contentDescription = "Selected", modifier = Modifier.size(18.dp))
} else {
Spacer(modifier = Modifier.size(18.dp))
}
},
onClick = {
expanded = false
entry.onClick()
},
)
}
}
}
}

private fun subtitleDelayLabel(milliseconds: Int): String {
if (milliseconds == 0) return "0.0s"
return String.format(java.util.Locale.US, "%+.1fs", milliseconds / 1000.0)
}

private fun formatHMS(seconds: Double): String {
val total = seconds.toInt().coerceAtLeast(0)
val h = total / 3600
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class RemotePlaybackIdentityManager(
val serverId: String,
val serverUrl: String,
val serverName: String?,
/** The deployment identity the phone verified for [serverId], when it sent one. */
val serverIdentity: String? = null,
val profileId: String,
val profileName: String?,
val controllerDeviceId: String,
Expand Down Expand Up @@ -61,13 +63,17 @@ class RemotePlaybackIdentityManager(
* the outgoing identity (the TV's own or an earlier phone's) still owns the
* credentials, so whatever is playing can close its server session under the
* identity it started with. An offer that fails or is denied leaves the
* current identity and playback alone.
* current identity and playback alone. [allowReuse] gates answering a
* matching offer from the active identity without a new approval; the
* receiver decides it ([TvSiloCastReceiverPolicy.mayReuseIdentity]),
* because the device ID in the match is self-reported.
*/
suspend fun prepare(
offer: SiloCastHandoffOffer,
controllerDeviceId: String,
controllerDeviceName: String?,
receiverRun: Long,
allowReuse: Boolean,
/** Called once the offer needs the full server exchange rather than a reuse. */
onFullHandoff: () -> Unit = {},
beforeActivation: suspend () -> Unit = {},
Expand All @@ -78,7 +84,7 @@ class RemotePlaybackIdentityManager(
// An end in flight has already unpublished its identity (see
// [endLocked]) and holds this lock, so an identity being ended is
// never reused: this waits for it and takes the full path.
activeIdentity?.takeIf { matches(offer, controllerDeviceId) }?.let { active ->
activeIdentity?.takeIf { allowReuse && matches(offer, controllerDeviceId) }?.let { active ->
val reclaimed = active.copy(receiverRun = receiverRun)
activeIdentity = reclaimed
return@withLock reclaimed.toReady(offer.requestId, reused = true)
Expand Down Expand Up @@ -156,6 +162,7 @@ class RemotePlaybackIdentityManager(
serverId = offer.serverId,
serverUrl = normalizedUrl(offer.serverURL),
serverName = offer.serverName,
serverIdentity = offer.serverIdentity?.trim()?.takeIf { it.isNotEmpty() },
profileId = profileId,
profileName = offer.profileName,
controllerDeviceId = controllerDeviceId,
Expand Down
Loading
Loading