Add LIVE pill with at-edge indicator and tap-to-jump

LIVE pill rendered next to the time display (regular + fullscreen
layouts):

  - red filled with a white dot when at the live edge
  - gray bordered with a muted dot when behind
  - tap to jump to the live edge

While live, the duration text + divider, loop button, and chapter view
are hidden -- they're meaningless on a live stream and were just visual
noise. Position text stays visible since for HLS DVR streams it shows
offset within the available seek window.

Pill state updates in the existing PlayerControlView progress tick;
applyLiveUI() switches the surrounding UI on the onLiveChanged event.
At construction the current isLive value is applied once so that
attaching to an already-live media item starts in the right state.

The chapter view's left-constraint now chains through live_pill_container,
which collapses to width 0 when the pill is GONE on VOD -- so the chapter
view sits in the same place it always did when live is off.
This commit is contained in:
Simon Gardling
2026-05-01 00:31:37 -04:00
parent ed6270552b
commit 985bd433a2
8 changed files with 201 additions and 2 deletions
@@ -18,6 +18,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ImageButton
import android.widget.TextView
import androidx.annotation.OptIn
@@ -119,6 +120,15 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
private val _control_duration_fullscreen: TextView;
private val _control_pause_fullscreen: ImageButton;
// LIVE pill: shown only when current media item is live; dot color reflects live-edge proximity.
private val _live_pill: LinearLayout
private val _live_pill_dot: View
private val _live_pill_fullscreen: LinearLayout
private val _live_pill_dot_fullscreen: View
private val _text_divider: TextView
private val _text_divider_fullscreen: TextView
private var _wasAtLiveEdge: Boolean = true
private val _title_fullscreen: TextView;
private val _author_fullscreen: TextView;
private var _shouldRestartHideJobOnPlaybackStateChange: Boolean = false;
@@ -189,6 +199,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_buttonPrevious = videoControls.findViewById(R.id.button_previous);
_control_time = videoControls.findViewById(R.id.text_position);
_control_duration = videoControls.findViewById(R.id.text_duration);
_live_pill = videoControls.findViewById(R.id.live_pill_container)
_live_pill_dot = videoControls.findViewById(R.id.live_pill_dot)
_text_divider = videoControls.findViewById(R.id.text_divider)
_videoControls_fullscreen = findViewById(R.id.video_player_controller_fullscreen);
_control_autoplay_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_autoplay);
@@ -206,6 +219,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_control_time_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_position);
_control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration);
_control_pause_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_pause);
_live_pill_fullscreen = _videoControls_fullscreen.findViewById(R.id.live_pill_container)
_live_pill_dot_fullscreen = _videoControls_fullscreen.findViewById(R.id.live_pill_dot)
_text_divider_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_divider)
_loaderGame = findViewById(R.id.loader_overlay)
_loaderGame.visibility = View.GONE
@@ -475,6 +491,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_time_bar_fullscreen.setBufferedPosition(bufferedPosition);
_time_bar.setBufferedPosition(bufferedPosition);
// While live, refresh the LIVE pill's edge state so the dot reflects whether the user
// is at the live edge or seeked behind. Cheap and only updates when state actually changes.
if (isLive) {
updateLiveEdgeState()
}
onTimeBarChanged.emit(position, bufferedPosition);
if(!_currentChapterLoopActive)
@@ -501,6 +523,23 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
}
}
// Toggle LIVE pill / time UI when the underlying media item changes liveness.
// The base class emits this on Timeline / MediaItem transitions.
onLiveChanged.subscribe { live ->
CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) {
applyLiveUI(live)
}
}
val jumpToLiveListener = View.OnClickListener {
seekToLiveEdge()
}
_live_pill.setOnClickListener(jumpToLiveListener)
_live_pill_fullscreen.setOnClickListener(jumpToLiveListener)
// Apply once at construction in case we attach to an already-live media item.
applyLiveUI(isLive)
updateLoopVideoUI();
if(!isInEditMode) {
@@ -897,6 +936,58 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
}
}
/**
* Applies (or reverts) live-stream-specific control affordances:
* - shows/hides the LIVE pill
* - hides the duration text + divider when live (duration shown by the pill)
* - hides the loop button (looping a live stream is meaningless)
* - hides the chapter text (live streams from the source plugins do not provide chapters)
*
* Position text is kept visible because for HLS DVR streams it shows offset within the
* available seek window, which is useful information.
*/
private fun applyLiveUI(live: Boolean) {
val pillVis = if (live) View.VISIBLE else View.GONE
val timeVis = if (live) View.GONE else View.VISIBLE
_live_pill.visibility = pillVis
_live_pill_fullscreen.visibility = pillVis
_text_divider.visibility = timeVis
_text_divider_fullscreen.visibility = timeVis
_control_duration.visibility = timeVis
_control_duration_fullscreen.visibility = timeVis
if (live) {
// Loop / chapter UI is meaningless on live; hide and reset.
_control_loop.visibility = View.GONE
_control_loop_fullscreen.visibility = View.GONE
_control_chapter.visibility = View.GONE
_control_chapter_fullscreen.visibility = View.GONE
updateLiveEdgeState()
} else {
_control_loop.visibility = View.VISIBLE
_control_loop_fullscreen.visibility = View.VISIBLE
_control_chapter.visibility = View.VISIBLE
_control_chapter_fullscreen.visibility = View.VISIBLE
}
}
/**
* Updates the LIVE pill's dot + background to reflect whether playback is at the live edge.
* Idempotent: only mutates when state changes to avoid invalidations on every progress tick.
*/
private fun updateLiveEdgeState() {
val atEdge = isAtLiveEdge
if (atEdge == _wasAtLiveEdge) return
_wasAtLiveEdge = atEdge
Logger.i(TAG, "LIVE pill -> ${if (atEdge) "AT EDGE" else "BEHIND"} (offset=${liveOffsetMs}ms target=${targetLiveOffsetMs}ms)")
val bg = if (atEdge) R.drawable.background_live_pill else R.drawable.background_live_pill_behind
val dot = if (atEdge) R.drawable.dot_live_edge else R.drawable.dot_live_behind
_live_pill.setBackgroundResource(bg)
_live_pill_fullscreen.setBackgroundResource(bg)
_live_pill_dot.setBackgroundResource(dot)
_live_pill_dot_fullscreen.setBackgroundResource(dot)
}
fun setGestureSoundFactor(soundFactor: Float) {
gestureControl.setSoundFactor(soundFactor);
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- LIVE pill background: solid red when at the live edge. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#DDFF0000" />
<corners android:radius="3dp" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- LIVE pill background while behind the live edge: muted gray, signaling tap-to-jump. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#88555555" />
<corners android:radius="3dp" />
<stroke android:width="1dp" android:color="#FFAAAAAA" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Hollow / muted dot rendered while seeked behind the live edge. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FFAAAAAA" />
<size android:width="6dp" android:height="6dp" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- White dot rendered on top of the red LIVE pill while at the edge. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FFFFFFFF" />
<size android:width="6dp" android:height="6dp" />
</shape>
+42 -1
View File
@@ -195,6 +195,47 @@
app:layout_constraintTop_toTopOf="@id/text_position"
app:layout_constraintBottom_toBottomOf="@id/text_position"/>
<!-- LIVE pill: hidden by default; shown by FutoVideoPlayer when isLive=true.
Constrained next to the duration text so it occupies the same row but does not
reflow when toggled (alpha/visibility only). Tap to jump to live edge. -->
<LinearLayout
android:id="@+id/live_pill_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/background_live_pill"
android:paddingStart="6dp"
android:paddingEnd="6dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:layout_marginStart="6dp"
android:visibility="gone"
android:clickable="true"
android:focusable="true"
android:contentDescription="@string/cd_button_jump_to_live"
app:layout_constraintLeft_toRightOf="@id/text_duration"
app:layout_constraintTop_toTopOf="@id/text_position"
app:layout_constraintBottom_toBottomOf="@id/text_position">
<View
android:id="@+id/live_pill_dot"
android:layout_width="6dp"
android:layout_height="6dp"
android:layout_marginEnd="4dp"
android:background="@drawable/dot_live_edge" />
<TextView
android:id="@+id/live_pill_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:text="@string/live_capitalized"
android:textColor="#FFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:id="@+id/text_chapter_current"
android:layout_width="0dp"
@@ -204,7 +245,7 @@
android:paddingRight="10dp"
android:textSize="11sp"
android:gravity="left"
app:layout_constraintLeft_toRightOf="@id/text_duration"
app:layout_constraintLeft_toRightOf="@id/live_pill_container"
app:layout_constraintTop_toTopOf="@id/text_duration"
app:layout_constraintBottom_toBottomOf="@id/text_duration"
app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
@@ -225,6 +225,45 @@
app:layout_constraintBottom_toBottomOf="@id/text_position"/>
<!-- LIVE pill: hidden by default; shown by FutoVideoPlayer when isLive=true. -->
<LinearLayout
android:id="@+id/live_pill_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/background_live_pill"
android:paddingStart="6dp"
android:paddingEnd="6dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:layout_marginStart="6dp"
android:visibility="gone"
android:clickable="true"
android:focusable="true"
android:contentDescription="@string/cd_button_jump_to_live"
app:layout_constraintLeft_toRightOf="@id/text_duration"
app:layout_constraintTop_toTopOf="@id/text_position"
app:layout_constraintBottom_toBottomOf="@id/text_position">
<View
android:id="@+id/live_pill_dot"
android:layout_width="6dp"
android:layout_height="6dp"
android:layout_marginEnd="4dp"
android:background="@drawable/dot_live_edge" />
<TextView
android:id="@+id/live_pill_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:text="@string/live_capitalized"
android:textColor="#FFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:id="@+id/text_chapter_current"
android:layout_width="0dp"
@@ -234,7 +273,7 @@
android:layout_marginTop="-2dp"
android:textSize="11sp"
android:gravity="left"
app:layout_constraintLeft_toRightOf="@id/text_duration"
app:layout_constraintLeft_toRightOf="@id/live_pill_container"
app:layout_constraintTop_toTopOf="@id/text_duration"
app:layout_constraintBottom_toBottomOf="@id/text_duration"
app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
+1
View File
@@ -952,6 +952,7 @@
<string name="cd_button_stop">Stop</string>
<string name="cd_button_scan_qr">Scan QR code</string>
<string name="cd_button_help">Help</string>
<string name="cd_button_jump_to_live">Jump to live edge</string>
<string name="cd_image_polycentric">Change Polycentric profile picture</string>
<string-array name="home_screen_array">
<item>Recommendations</item>