diff --git a/strawApp/src/main/AndroidManifest.xml b/strawApp/src/main/AndroidManifest.xml index f7e491cdb..165683b38 100644 --- a/strawApp/src/main/AndroidManifest.xml +++ b/strawApp/src/main/AndroidManifest.xml @@ -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"> diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt index 837913df0..e3cb6b2e4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt @@ -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) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 176b62bfa..53438ce80 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -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, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 90e560a93..d1ce7fa43 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -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 ?: "" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 4a2413171..e65006436 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -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, + ) + } } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index 2cabb80eb..5ef859be4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -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, ) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/Thumbnails.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/Thumbnails.kt new file mode 100644 index 000000000..6d47e386b --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/Thumbnails.kt @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * NewPipeExtractor returns thumbnails as a List 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?): 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 +}