Straw phase M-1: tap-thumbnail-to-play + hi-res thumbs + Picture-in-Picture

Cobb's real-use feedback: "playing a video is a button under a very
pixelated thumbnail, no playing in window mode, ... where are all the
features." This is the visible-UX pass.

Thumbnails:
- New util/Thumbnails.kt#bestThumbnail picks the highest-res image from
  NewPipeExtractor's List<Image> by w*h pixel area. Was firstOrNull()
  which is the smallest in the sorted list. Applied across Search,
  Channel video rows, Channel avatar + banner, VideoDetail.

Tap-to-play:
- VideoDetail thumbnail is now wrapped in a clickable Box that fires
  onPlay(). Centered semi-transparent black circle with a white play
  triangle overlay so the affordance is obvious. The standalone "Play"
  button below stays for accessibility (tap target consistency).

Picture-in-Picture:
- Manifest: android:supportsPictureInPicture="true" on StrawActivity +
  added screenLayout|smallestScreenSize|screenSize to configChanges so
  rotation into PiP doesn't recreate.
- PlayerScreen: top-right floating button enters PiP with 16:9 aspect.
- Lifecycle ON_STOP observer now checks isInPictureInPictureMode and
  skips pausing when we're in PiP (was killing playback on PiP entry).

Phase M-2 next: MediaSession + background audio + foreground service
(the "play with screen off / lock screen controls" feature).
This commit is contained in:
Kayos 2026-05-23 20:40:53 -07:00
parent 2fd439cac8
commit 1578de5dbb
7 changed files with 108 additions and 13 deletions

View file

@ -16,7 +16,8 @@
android:name=".StrawActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|keyboardHidden">
android:supportsPictureInPicture="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View file

@ -8,6 +8,7 @@ package com.sulkta.straw.feature.channel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.bestThumbnail
import kotlinx.coroutines.Dispatchers
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
@ -61,7 +62,7 @@ class ChannelViewModel : ViewModel() {
title = it.name ?: "(no title)",
uploader = it.uploaderName ?: info.name ?: "",
uploaderUrl = it.uploaderUrl ?: channelUrl,
thumbnail = it.thumbnails?.firstOrNull()?.url,
thumbnail = bestThumbnail(it.thumbnails),
durationSeconds = it.duration,
viewCount = it.viewCount,
)
@ -74,8 +75,8 @@ class ChannelViewModel : ViewModel() {
loading = false,
name = info.name ?: "",
subscriberCount = info.subscriberCount,
banner = info.banners?.firstOrNull()?.url,
avatar = info.avatars?.firstOrNull()?.url,
banner = bestThumbnail(info.banners),
avatar = bestThumbnail(info.avatars),
videos = videos,
)
} catch (t: Throwable) {

View file

@ -5,6 +5,7 @@
package com.sulkta.straw.feature.detail
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -16,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@ -24,8 +26,11 @@ import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -72,14 +77,37 @@ fun VideoDetailScreen(
else -> {
val d = state.detail ?: return@Column
AsyncImage(
model = d.thumbnail,
contentDescription = null,
// 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)),
)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onPlay),
contentAlignment = Alignment.Center,
) {
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))
Text(
text = d.title,

View file

@ -13,6 +13,7 @@ import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.net.RydClient
import com.sulkta.straw.net.RydVotes
import com.sulkta.straw.net.SponsorBlockClient
import com.sulkta.straw.util.bestThumbnail
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -57,7 +58,7 @@ class VideoDetailViewModel : ViewModel() {
try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
val videoId = info.id
val thumb = info.thumbnails?.firstOrNull()?.url
val thumb = bestThumbnail(info.thumbnails)
val title = info.name ?: "(no title)"
val uploader = info.uploaderName ?: ""

View file

@ -8,12 +8,18 @@
package com.sulkta.straw.feature.player
import android.app.Activity
import android.app.PictureInPictureParams
import android.os.Build
import android.util.Rational
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
@ -71,11 +77,17 @@ fun PlayerScreen(
// AUD-MED: pause playback when app goes to background. Without this,
// ExoPlayer keeps playing audio with no MediaSession — user can't pause
// from the notification shade.
// from the notification shade. EXCEPTION: don't pause when entering
// Picture-in-Picture mode (that's the whole point of PiP).
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) exoPlayer.pause()
if (event == Lifecycle.Event.ON_STOP) {
val activity = context as? Activity
if (activity?.isInPictureInPictureMode != true) {
exoPlayer.pause()
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
@ -200,6 +212,33 @@ fun PlayerScreen(
)
}
}
// PiP button — top-right. Tapping it puts the player into
// floating-window mode so the user can use other apps while
// the video keeps playing.
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(12.dp)
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color(0xCC222222))
.clickable {
val activity = (context as? Activity) ?: return@clickable
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9))
.build()
runCatching { activity.enterPictureInPictureMode(params) }
}
},
contentAlignment = Alignment.Center,
) {
Text(
text = "",
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
}
}
}
}

View file

@ -8,6 +8,7 @@ package com.sulkta.straw.feature.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.History
import com.sulkta.straw.util.bestThumbnail
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -74,7 +75,7 @@ class SearchViewModel : ViewModel() {
title = it.name ?: "(no title)",
uploader = it.uploaderName ?: "",
uploaderUrl = it.uploaderUrl,
thumbnail = it.thumbnails?.firstOrNull()?.url,
thumbnail = bestThumbnail(it.thumbnails),
durationSeconds = it.duration,
viewCount = it.viewCount,
)

View file

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* NewPipeExtractor returns thumbnails as a List<Image> with width/height
* fields. Calling .firstOrNull() picks the smallest (the list is sorted
* ascending) which gave us pixelated thumbnails. This helper picks the
* largest by pixel area instead.
*/
package com.sulkta.straw.util
import org.schabi.newpipe.extractor.Image
fun bestThumbnail(images: List<Image>?): String? {
if (images.isNullOrEmpty()) return null
return images
.maxByOrNull {
val w = it.width.takeIf { v -> v > 0 } ?: 0
val h = it.height.takeIf { v -> v > 0 } ?: 0
w.toLong() * h.toLong()
}
?.url
}