mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
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:
@@ -18,6 +18,7 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
@@ -119,6 +120,15 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
private val _control_duration_fullscreen: TextView;
|
private val _control_duration_fullscreen: TextView;
|
||||||
private val _control_pause_fullscreen: ImageButton;
|
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 _title_fullscreen: TextView;
|
||||||
private val _author_fullscreen: TextView;
|
private val _author_fullscreen: TextView;
|
||||||
private var _shouldRestartHideJobOnPlaybackStateChange: Boolean = false;
|
private var _shouldRestartHideJobOnPlaybackStateChange: Boolean = false;
|
||||||
@@ -189,6 +199,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
_buttonPrevious = videoControls.findViewById(R.id.button_previous);
|
_buttonPrevious = videoControls.findViewById(R.id.button_previous);
|
||||||
_control_time = videoControls.findViewById(R.id.text_position);
|
_control_time = videoControls.findViewById(R.id.text_position);
|
||||||
_control_duration = videoControls.findViewById(R.id.text_duration);
|
_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);
|
_videoControls_fullscreen = findViewById(R.id.video_player_controller_fullscreen);
|
||||||
_control_autoplay_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_autoplay);
|
_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_time_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_position);
|
||||||
_control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration);
|
_control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration);
|
||||||
_control_pause_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_pause);
|
_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 = findViewById(R.id.loader_overlay)
|
||||||
_loaderGame.visibility = View.GONE
|
_loaderGame.visibility = View.GONE
|
||||||
@@ -475,6 +491,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||||||
_time_bar_fullscreen.setBufferedPosition(bufferedPosition);
|
_time_bar_fullscreen.setBufferedPosition(bufferedPosition);
|
||||||
_time_bar.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);
|
onTimeBarChanged.emit(position, bufferedPosition);
|
||||||
|
|
||||||
if(!_currentChapterLoopActive)
|
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();
|
updateLoopVideoUI();
|
||||||
|
|
||||||
if(!isInEditMode) {
|
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) {
|
fun setGestureSoundFactor(soundFactor: Float) {
|
||||||
gestureControl.setSoundFactor(soundFactor);
|
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>
|
||||||
@@ -195,6 +195,47 @@
|
|||||||
app:layout_constraintTop_toTopOf="@id/text_position"
|
app:layout_constraintTop_toTopOf="@id/text_position"
|
||||||
app:layout_constraintBottom_toBottomOf="@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
|
<TextView
|
||||||
android:id="@+id/text_chapter_current"
|
android:id="@+id/text_chapter_current"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
@@ -204,7 +245,7 @@
|
|||||||
android:paddingRight="10dp"
|
android:paddingRight="10dp"
|
||||||
android:textSize="11sp"
|
android:textSize="11sp"
|
||||||
android:gravity="left"
|
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_constraintTop_toTopOf="@id/text_duration"
|
||||||
app:layout_constraintBottom_toBottomOf="@id/text_duration"
|
app:layout_constraintBottom_toBottomOf="@id/text_duration"
|
||||||
app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
|
app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
|
||||||
|
|||||||
@@ -225,6 +225,45 @@
|
|||||||
app:layout_constraintBottom_toBottomOf="@id/text_position"/>
|
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
|
<TextView
|
||||||
android:id="@+id/text_chapter_current"
|
android:id="@+id/text_chapter_current"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
@@ -234,7 +273,7 @@
|
|||||||
android:layout_marginTop="-2dp"
|
android:layout_marginTop="-2dp"
|
||||||
android:textSize="11sp"
|
android:textSize="11sp"
|
||||||
android:gravity="left"
|
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_constraintTop_toTopOf="@id/text_duration"
|
||||||
app:layout_constraintBottom_toBottomOf="@id/text_duration"
|
app:layout_constraintBottom_toBottomOf="@id/text_duration"
|
||||||
app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
|
app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
|
||||||
|
|||||||
@@ -952,6 +952,7 @@
|
|||||||
<string name="cd_button_stop">Stop</string>
|
<string name="cd_button_stop">Stop</string>
|
||||||
<string name="cd_button_scan_qr">Scan QR code</string>
|
<string name="cd_button_scan_qr">Scan QR code</string>
|
||||||
<string name="cd_button_help">Help</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 name="cd_image_polycentric">Change Polycentric profile picture</string>
|
||||||
<string-array name="home_screen_array">
|
<string-array name="home_screen_array">
|
||||||
<item>Recommendations</item>
|
<item>Recommendations</item>
|
||||||
|
|||||||
Reference in New Issue
Block a user