v0.1.0-AA (vc=15): inline player on VideoDetail + fullscreen pill

Tap the 16:9 thumbnail box on VideoDetail and the video plays right
there in the card — like YouTube. Uses its own ExoPlayer (released on
nav-back via DisposableEffect) with PlayerView's built-in controls
(play/pause/seek/duration bar).

Top-right ⛶ pill on the inline player jumps to the existing fullscreen
PlayerScreen which still has the full toolset (speed picker, audio-only,
share, PiP, background, SponsorBlock chip). Restarts from 0 on entry —
seek-position handoff between inline + fullscreen is a future refinement.

Inline player state (playing/not-playing) is keyed on streamUrl so
navigating to a different video resets it back to the thumbnail-with-
play-overlay default.
This commit is contained in:
Kayos 2026-05-24 11:17:36 -07:00
parent 75329867e9
commit 5b36de8888
2 changed files with 165 additions and 25 deletions

View file

@ -15,6 +15,6 @@ const val NEWPIPE_APPLICATION_ID_OLD = "org.schabi.newpipe"
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// Sulkta fork — Straw
const val STRAW_VERSION_CODE = 14
const val STRAW_VERSION_NAME = "0.1.0-Z"
const val STRAW_VERSION_CODE = 15
const val STRAW_VERSION_NAME = "0.1.0-AA"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -33,13 +33,17 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import android.content.Intent
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.material3.AlertDialog
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import com.sulkta.straw.feature.download.DownloadKind
import com.sulkta.straw.feature.download.Downloader
import com.sulkta.straw.feature.player.PlayerViewModel
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
@ -53,7 +57,19 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.dash.DashMediaSource
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView
import coil3.compose.AsyncImage
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.util.formatCount
import com.sulkta.straw.util.formatViews
import com.sulkta.straw.util.stripHtml
@ -70,6 +86,9 @@ fun VideoDetailScreen(
val state by vm.ui.collectAsStateWithLifecycle()
val context = LocalContext.current
var showDownloadDialog by remember { mutableStateOf(false) }
// Inline-play state. Resets when the user navigates to a different
// video (keyed on streamUrl).
var inlinePlaying by remember(streamUrl) { mutableStateOf(false) }
LaunchedEffect(streamUrl) { vm.load(streamUrl) }
Column(
@ -92,35 +111,47 @@ fun VideoDetailScreen(
else -> {
val d = state.detail ?: return@Column
// AUD-feedback: tap the thumbnail to play. Was hidden under
// a "Play" button below. Now the thumbnail is the obvious
// affordance with a play-icon overlay.
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onPlay),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = d.thumbnail,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
// Tap the thumbnail to play inline. Fullscreen button (top-right
// overlay on the inline player) jumps to the fullscreen Player
// screen which has the full toolset.
if (inlinePlaying) {
InlinePlayer(
streamUrl = streamUrl,
onFullscreen = onPlay,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(8.dp))
.background(Color.Black),
)
} else {
Box(
modifier = Modifier
.size(64.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(Color(0xCC000000)),
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(8.dp))
.clickable { inlinePlaying = true },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = Color.White,
modifier = Modifier.size(40.dp),
AsyncImage(
model = d.thumbnail,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
Box(
modifier = Modifier
.size(64.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(Color(0xCC000000)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = Color.White,
modifier = Modifier.size(40.dp),
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
@ -346,3 +377,112 @@ private fun RelatedRow(
}
}
/**
* Inline player embedded in the 16:9 thumbnail box on VideoDetailScreen.
* Uses its own ExoPlayer + PlayerView (with the built-in controller for
* play/pause/seek). A small fullscreen pill in the top-right hops the user
* to the fullscreen PlayerScreen for the full toolset (speed picker, audio-
* only, share, PiP, background). Player is released when the composable
* leaves composition (navigate back or away from VideoDetail).
*/
@OptIn(UnstableApi::class)
@Composable
private fun InlinePlayer(
streamUrl: String,
onFullscreen: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val playerVm: PlayerViewModel = viewModel()
val state by playerVm.ui.collectAsStateWithLifecycle()
LaunchedEffect(streamUrl) { playerVm.resolve(streamUrl) }
val exoPlayer = remember {
ExoPlayer.Builder(context)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build(),
/* handleAudioFocus = */ true,
)
.build()
}
DisposableEffect(Unit) {
onDispose { exoPlayer.release() }
}
val resolved = state.resolved
LaunchedEffect(resolved) {
val r = resolved ?: return@LaunchedEffect
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setUserAgent(NewPipeDownloader.USER_AGENT)
.setAllowCrossProtocolRedirects(true)
val source = when {
r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.dashMpdUrl))
r.hlsUrl != null -> HlsMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.hlsUrl))
r.combinedUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.combinedUrl))
r.videoUrl != null && r.audioUrl != null -> {
val v = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.videoUrl))
val a = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.audioUrl))
MergingMediaSource(v, a)
}
r.videoUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.videoUrl))
else -> null
}
if (source != null) {
exoPlayer.setMediaSource(source)
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
}
Box(modifier = modifier, contentAlignment = Alignment.Center) {
when {
state.loading -> CircularProgressIndicator(color = Color.White)
state.error != null -> Text(
"playback error: ${state.error}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
resolved?.isPlayable != true -> Text(
"no playable stream",
color = Color.White,
modifier = Modifier.padding(16.dp),
)
else -> {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
useController = true
}
},
modifier = Modifier.fillMaxSize(),
)
// Top-right fullscreen pill — hops to the fullscreen
// PlayerScreen which has speed/audio-only/share/PiP/background.
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color(0xCC222222))
.clickable(onClick = onFullscreen),
contentAlignment = Alignment.Center,
) {
Text("", color = Color.White)
}
}
}
}
}