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:
parent
75329867e9
commit
5b36de8888
2 changed files with 165 additions and 25 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue