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:
parent
2fd439cac8
commit
1578de5dbb
7 changed files with 108 additions and 13 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ?: ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
24
strawApp/src/main/kotlin/com/sulkta/straw/util/Thumbnails.kt
Normal file
24
strawApp/src/main/kotlin/com/sulkta/straw/util/Thumbnails.kt
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue