From 08a65a71502b71d2f0d9cfa82ed6e04966a67e65 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 4 Sep 2026 09:41:14 +0200 Subject: [PATCH 1/3] feat: add provider source link sharing --- .../android/core/ui/share/ShareUrls.kt | 100 ++++++++++++++++++ .../library/LibraryPlaylistContextTab.kt | 9 +- .../library/playlist/PlaylistScreen.kt | 14 +-- .../android/feature/menu/VideoMenuHost.kt | 9 +- .../feature/player/PlayerInteractionRow.kt | 9 +- app/src/main/res/values/strings.xml | 2 + .../android/core/ui/share/ShareUrlsTest.kt | 16 +++ 7 files changed, 126 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt b/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt index 5aef0f6c..25d473b5 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt @@ -1,6 +1,20 @@ package dev.typetype.android.core.ui.share +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView import androidx.compose.runtime.compositionLocalOf +import dev.typetype.android.R +import dev.typetype.android.domain.navigation.resolveIncomingVideoUrl import dev.typetype.android.domain.navigation.toPublicWatchParameter import java.net.URI import java.net.URLEncoder @@ -19,6 +33,92 @@ fun buildShareUrl(serverBaseUrl: String?, videoUrl: String): String { return "$origin/watch?v=$encoded" } +fun buildSourceShareUrl(videoUrl: String): String = + resolveIncomingVideoUrl(videoUrl) ?: videoUrl.trim() + +fun showShareChooser( + context: Context, + serverBaseUrl: String?, + videoUrl: String, + chooserTitle: String, +) { + val sourceUrl = buildSourceShareUrl(videoUrl) + val typeTypeUrl = buildShareUrl(serverBaseUrl, videoUrl) + val choices = buildList { + add( + ShareChoice( + context.getString(R.string.video_menu_share_typetype), + typeTypeUrl, + android.R.drawable.ic_menu_share, + ), + ) + providerChoice(context, sourceUrl)?.takeIf { it.url != typeTypeUrl }?.let(::add) + } + AlertDialog.Builder(context) + .setTitle(R.string.video_menu_share) + .setAdapter(ShareChoiceAdapter(context, choices)) { _, which -> + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, choices[which].url) + } + context.startActivity(Intent.createChooser(intent, chooserTitle)) + } + .show() +} + +private data class ShareChoice(val label: String, val url: String, val icon: Int) + +private fun providerChoice(context: Context, sourceUrl: String): ShareChoice? { + val provider = sourceUrl.lowercase() + return when { + "youtube.com" in provider || "youtu.be" in provider -> ShareChoice( + context.getString(R.string.video_menu_share_source, "YouTube"), + sourceUrl, + R.drawable.ic_service_youtube, + ) + "nicovideo.jp" in provider || "nico.ms" in provider -> ShareChoice( + context.getString(R.string.video_menu_share_source, "NicoNico"), + sourceUrl, + R.drawable.ic_service_niconico, + ) + "bilibili.com" in provider || "b23.tv" in provider -> ShareChoice( + context.getString(R.string.video_menu_share_source, "BiliBili"), + sourceUrl, + R.drawable.ic_service_bilibili, + ) + else -> null + } +} + +private class ShareChoiceAdapter( + private val context: Context, + private val choices: List, +) : BaseAdapter() { + override fun getCount(): Int = choices.size + override fun getItem(position: Int): ShareChoice = choices[position] + override fun getItemId(position: Int): Long = position.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val choice = getItem(position) + val density = context.resources.displayMetrics.density + val row = (convertView as? LinearLayout) ?: LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + background = ColorDrawable(Color.TRANSPARENT) + val icon = ImageView(context) + addView(icon, LinearLayout.LayoutParams((28 * density).toInt(), (28 * density).toInt())) + val label = TextView(context).apply { + setTextAppearance(android.R.style.TextAppearance_Material_Body1) + } + addView(label, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) + } + row.setPadding((20 * density).toInt(), (12 * density).toInt(), (20 * density).toInt(), (12 * density).toInt()) + (row.getChildAt(0) as ImageView).setImageResource(choice.icon) + (row.getChildAt(1) as TextView).text = choice.label + return row + } +} + fun buildImageUrl(serverBaseUrl: String?, imageUrl: String): String { val source = imageUrl.trim().let { value -> if (value.startsWith("httpss://")) "https://${value.removePrefix("httpss://")}" else value diff --git a/app/src/main/java/dev/typetype/android/feature/library/LibraryPlaylistContextTab.kt b/app/src/main/java/dev/typetype/android/feature/library/LibraryPlaylistContextTab.kt index cb7f09be..4ecfd202 100644 --- a/app/src/main/java/dev/typetype/android/feature/library/LibraryPlaylistContextTab.kt +++ b/app/src/main/java/dev/typetype/android/feature/library/LibraryPlaylistContextTab.kt @@ -1,6 +1,5 @@ package dev.typetype.android.feature.library -import android.content.Intent import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.grid.GridCells @@ -24,7 +23,7 @@ import dev.typetype.android.core.ui.components.LocalAppSnackbarHost import dev.typetype.android.core.ui.components.PlaylistVideoActionsSheet import dev.typetype.android.core.ui.components.PlaylistVideoCard import dev.typetype.android.core.ui.share.LocalServerBaseUrl -import dev.typetype.android.core.ui.share.buildShareUrl +import dev.typetype.android.core.ui.share.showShareChooser import dev.typetype.android.domain.library.PlaylistVideo import dev.typetype.android.feature.library.components.rememberVideoMetas import dev.typetype.android.feature.menu.VideoMenuEvent @@ -104,11 +103,7 @@ fun PlaylistContextTab( onRemoveFromList = { onRemove(video) }, onToggleWatched = { onToggleWatched(video, video.url in watchedUrls) }, onShare = { - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, buildShareUrl(serverBaseUrl, video.url)) - } - context.startActivity(Intent.createChooser(intent, shareChooserTitle)) + showShareChooser(context, serverBaseUrl, video.url, shareChooserTitle) }, onBlockVideo = { onBlockVideo(video) }, onDismiss = { pendingMenu = null }, diff --git a/app/src/main/java/dev/typetype/android/feature/library/playlist/PlaylistScreen.kt b/app/src/main/java/dev/typetype/android/feature/library/playlist/PlaylistScreen.kt index 0f5f4610..84db207a 100644 --- a/app/src/main/java/dev/typetype/android/feature/library/playlist/PlaylistScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/library/playlist/PlaylistScreen.kt @@ -1,6 +1,5 @@ package dev.typetype.android.feature.library.playlist -import android.content.Intent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -41,6 +40,7 @@ import dev.typetype.android.feature.menu.VideoMenuHandlerViewModel import dev.typetype.android.feature.menu.blockVideoUrl import dev.typetype.android.feature.menu.removeFromPlaylist import dev.typetype.android.feature.menu.toggleWatchedUrl +import dev.typetype.android.core.ui.share.showShareChooser @Composable fun PlaylistRoute( @@ -253,17 +253,7 @@ internal fun PlaylistScreen( ) }, onShare = { - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - dev.typetype.android.core.ui.share.buildShareUrl( - serverBaseUrl, - video.url, - ), - ) - } - context.startActivity(Intent.createChooser(intent, shareChooserTitle)) + showShareChooser(context, serverBaseUrl, video.url, shareChooserTitle) }, onBlockVideo = { menuVm.blockVideoUrl(video.url) }, onDismiss = { pendingMenu = null }, diff --git a/app/src/main/java/dev/typetype/android/feature/menu/VideoMenuHost.kt b/app/src/main/java/dev/typetype/android/feature/menu/VideoMenuHost.kt index 9c1d5b0b..ede80d50 100644 --- a/app/src/main/java/dev/typetype/android/feature/menu/VideoMenuHost.kt +++ b/app/src/main/java/dev/typetype/android/feature/menu/VideoMenuHost.kt @@ -1,6 +1,5 @@ package dev.typetype.android.feature.menu -import android.content.Intent import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable @@ -19,7 +18,7 @@ import dev.typetype.android.core.ui.components.LocalAppSnackbarHost import dev.typetype.android.core.ui.components.VideoMenuAction import dev.typetype.android.core.ui.components.VideoMenuItemState import dev.typetype.android.core.ui.share.LocalServerBaseUrl -import dev.typetype.android.core.ui.share.buildShareUrl +import dev.typetype.android.core.ui.share.showShareChooser import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.navigation.canonicalVideoIdentity import dev.typetype.android.domain.actions.titleMatchesBlockedKeyword @@ -126,11 +125,7 @@ fun rememberVideoMenuScope( ) VideoMenuAction.Download -> downloadVideo = video VideoMenuAction.Share -> { - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, buildShareUrl(serverBaseUrl, video.url)) - } - context.startActivity(Intent.createChooser(intent, shareChooserTitle)) + showShareChooser(context, serverBaseUrl, video.url, shareChooserTitle) } VideoMenuAction.OpenChannel -> onOpenChannel(video.uploaderUrl) VideoMenuAction.BlockVideo -> viewModel.blockVideo(video) diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerInteractionRow.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerInteractionRow.kt index 062573e6..cbcc5bac 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerInteractionRow.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerInteractionRow.kt @@ -1,6 +1,5 @@ package dev.typetype.android.feature.player -import android.content.Intent import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -30,7 +29,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import dev.typetype.android.R import dev.typetype.android.core.ui.share.LocalServerBaseUrl -import dev.typetype.android.core.ui.share.buildShareUrl +import dev.typetype.android.core.ui.share.showShareChooser @Composable fun PlayerInteractionRow( @@ -104,11 +103,7 @@ fun PlayerInteractionRow( icon = Icons.Filled.Share, contentDescription = stringResource(R.string.video_menu_share), onClick = { - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, buildShareUrl(serverBaseUrl, shareUrl)) - } - context.startActivity(Intent.createChooser(intent, shareChooserTitle)) + showShareChooser(context, serverBaseUrl, shareUrl, shareChooserTitle) }, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c05d5139..301ad0a3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -921,6 +921,8 @@ Block video Block channel Share via + Share TypeType link + Share %1$s link Video actions Open %1$s channel Open %1$s diff --git a/app/src/test/java/dev/typetype/android/core/ui/share/ShareUrlsTest.kt b/app/src/test/java/dev/typetype/android/core/ui/share/ShareUrlsTest.kt index 773bfa67..04885088 100644 --- a/app/src/test/java/dev/typetype/android/core/ui/share/ShareUrlsTest.kt +++ b/app/src/test/java/dev/typetype/android/core/ui/share/ShareUrlsTest.kt @@ -5,6 +5,22 @@ import org.junit.Test class ShareUrlsTest { + @Test + fun sharesCanonicalProviderSourceLinks() { + assertEquals( + "https://www.youtube.com/watch?v=AbCdEfGhI_1", + buildSourceShareUrl("AbCdEfGhI_1"), + ) + assertEquals( + "https://www.bilibili.com/video/BV1UbX3B2EZQ?p=3", + buildSourceShareUrl("BV1UbX3B2EZQ?p=3"), + ) + assertEquals( + "https://www.nicovideo.jp/watch/sm46525483", + buildSourceShareUrl("sm46525483"), + ) + } + @Test fun sharesCompactPublicWatchRoutes() { assertEquals( From 4259125b0484b5ee668d0616fa2fd97c958d55d8 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 4 Sep 2026 10:32:39 +0200 Subject: [PATCH 2/3] fix: improve mobile player seekbar behavior --- .../android/feature/player/LoadedPlayer.kt | 33 ------ .../player/components/PlayerControls.kt | 110 ++++++++++-------- .../player/components/PlayerSurfaceBox.kt | 33 +++++- .../player/components/PlayerTimeBar.kt | 63 +++++++++- 4 files changed, 149 insertions(+), 90 deletions(-) diff --git a/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt b/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt index c0b698f5..9106a6fc 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.derivedStateOf @@ -189,8 +188,6 @@ fun LoadedPlayer( onSaveProgress = { onAction(PlayerAction.OnSaveProgress(it)) }, ) - val expandedTopPadding = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() - val expandedTopPaddingPx = with(LocalDensity.current) { expandedTopPadding.toPx() } val autoplayVisible by remember(hostTransitionProgress) { derivedStateOf { hostTransitionProgress() < 0.01f } } @@ -198,16 +195,6 @@ fun LoadedPlayer( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) - .then( - if (isFullscreen) { - Modifier - } else { - Modifier.playerTopProgressPadding( - maxTopPx = expandedTopPaddingPx, - progress = hostTransitionProgress, - ) - } - ), ) { PlayerContentLayout( isFullscreen = isFullscreen, @@ -322,23 +309,3 @@ fun LoadedPlayer( onAction = onAction, ) } - -private fun Modifier.playerTopProgressPadding( - maxTopPx: Float, - progress: () -> Float, -): Modifier = layout { measurable, constraints -> - val topPx = (maxTopPx * (1f - progress().coerceIn(0f, 1f))).roundToInt() - val maxHeight = if (constraints.hasBoundedHeight) { - (constraints.maxHeight - topPx).coerceAtLeast(0) - } else { - constraints.maxHeight - } - val childConstraints = constraints.copy( - minHeight = constraints.minHeight.coerceAtMost(maxHeight), - maxHeight = maxHeight, - ) - val placeable = measurable.measure(childConstraints) - layout(placeable.width, placeable.height + topPx) { - placeable.placeRelative(0, topPx) - } -} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt index f65401e4..5bd834ca 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt @@ -47,53 +47,59 @@ fun PlayerControls( chaptersAvailable: Boolean = false, sponsorBlockSegments: List = emptyList(), seekPreviewPositionMs: Long? = null, + timelineScrubbing: Boolean = false, onTimelineScrubbingChange: (Boolean) -> Unit = {}, ) { BoxWithConstraints(modifier = modifier) { val compactControls = !isFullscreen && maxHeight < COMPACT_CONTROLS_HEIGHT - TopScrim( - compact = compactControls, - modifier = Modifier.align(Alignment.TopCenter), - ) + if (!timelineScrubbing) { + TopScrim( + compact = compactControls, + modifier = Modifier.align(Alignment.TopCenter), + ) + } BottomScrim( compact = compactControls, isFullscreen = isFullscreen, modifier = Modifier.align(Alignment.BottomCenter), ) - PlayerTopBar( - title = title, - onNavigateBack = onNavigateBack, - onOpenChapters = onOpenChapters, - onOpenOptions = onOpenOptions, - onEnterPip = onEnterPip, - onCycleResizeMode = onCycleResizeMode, - resizeMode = resizeMode, - isFullscreen = isFullscreen, - isPipAvailable = isPipAvailable, - chaptersAvailable = chaptersAvailable, - compact = compactControls, - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .testTag(PLAYER_TOP_CONTROLS_TAG) - .then( - if (isFullscreen) { - Modifier.windowInsetsPadding(WindowInsets.statusBars) - } else { - Modifier - }, - ), - ) - PlayerCenterControls( - player = player, - isFullscreen = isFullscreen, - compact = compactControls, - modifier = Modifier.align(Alignment.Center).testTag(PLAYER_CENTER_CONTROLS_TAG), - ) + if (!timelineScrubbing) { + PlayerTopBar( + title = title, + onNavigateBack = onNavigateBack, + onOpenChapters = onOpenChapters, + onOpenOptions = onOpenOptions, + onEnterPip = onEnterPip, + onCycleResizeMode = onCycleResizeMode, + resizeMode = resizeMode, + isFullscreen = isFullscreen, + isPipAvailable = isPipAvailable, + chaptersAvailable = chaptersAvailable, + compact = compactControls, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .testTag(PLAYER_TOP_CONTROLS_TAG) + .then( + if (isFullscreen) { + Modifier.windowInsetsPadding(WindowInsets.statusBars) + } else { + Modifier + }, + ), + ) + PlayerCenterControls( + player = player, + isFullscreen = isFullscreen, + compact = compactControls, + modifier = Modifier.align(Alignment.Center).testTag(PLAYER_CENTER_CONTROLS_TAG), + ) + } BottomBar( player = player, sponsorBlockSegments = sponsorBlockSegments, seekPreviewPositionMs = seekPreviewPositionMs, + timelineScrubbing = timelineScrubbing, onTimelineScrubbingChange = onTimelineScrubbingChange, isFullscreen = isFullscreen, compact = compactControls, @@ -161,12 +167,14 @@ private fun BottomBar( player: Player, sponsorBlockSegments: List, seekPreviewPositionMs: Long?, + timelineScrubbing: Boolean, onTimelineScrubbingChange: (Boolean) -> Unit, isFullscreen: Boolean, compact: Boolean, onToggleFullscreen: () -> Unit, modifier: Modifier = Modifier, ) { + // I SPEND 2 HOURS FIGURING OUT HOW TO ALIGN THIS FREAKING SEEKBAR WITH THE BUTTON FULLSCREEN IM SUCH A FAILURE Row( modifier = modifier .height( @@ -187,23 +195,25 @@ private fun BottomBar( onScrubbingChange = onTimelineScrubbingChange, modifier = Modifier.weight(1f), ) - IconButton( - onClick = onToggleFullscreen, - modifier = Modifier.size( - when { - isFullscreen -> 48.dp - compact -> 36.dp - else -> 40.dp - }, - ), - ) { - Icon( - painter = painterResource( - if (isFullscreen) R.drawable.ic_fullscreen_exit else R.drawable.ic_fullscreen, + if (!timelineScrubbing) { + IconButton( + onClick = onToggleFullscreen, + modifier = Modifier.size( + when { + isFullscreen -> 48.dp + compact -> 36.dp + else -> 40.dp + }, ), - contentDescription = stringResource(R.string.player_fullscreen), - tint = Color.White, - ) + ) { + Icon( + painter = painterResource( + if (isFullscreen) R.drawable.ic_fullscreen_exit else R.drawable.ic_fullscreen, + ), + contentDescription = stringResource(R.string.player_fullscreen), + tint = Color.White, + ) + } } } } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt index d5d1eef0..f79a05fe 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt @@ -9,6 +9,11 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -24,6 +29,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.unit.dp import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.ui.compose.state.rememberPresentationState @@ -262,7 +268,7 @@ internal fun PlayerSurfaceBox( fraction }, onGestureFeedback = { - controlsVisible = gestureState.dragMode.value == DragMode.Seek + controlsVisible = false hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) }, isFullscreen = isFullscreen, @@ -278,6 +284,31 @@ internal fun PlayerSurfaceBox( ) } + AnimatedVisibility( + visible = gestureState.seekDragOverlayActive.value, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + PlayerSeekScrubOverlay( + player = player, + positionMs = gestureState.seekDragTargetMs.longValue, + segments = sponsorBlockPolicy.visibleSegments, + isFullscreen = isFullscreen, + modifier = Modifier + .fillMaxWidth() + .then( + if (isFullscreen) { + Modifier.padding(start = 12.dp, end = 8.dp, bottom = 6.dp) + } else { + Modifier + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(start = 4.dp, end = 4.dp) + }, + ), + ) + } + AnimatedVisibility( visible = controlsAllowedByProgress && (controlsVisible || accessibleControls) && diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt index 249ab29d..028e0bda 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.width @@ -54,6 +55,8 @@ private val THUMB_WIDTH = 14.dp private val THUMB_HEIGHT = 14.dp private val COMPACT_THUMB_WIDTH = 10.dp private val COMPACT_THUMB_HEIGHT = 10.dp +private val SCRUBBING_TRACK_HEIGHT = 6.dp +private val SCRUBBING_THUMB_SIZE = 22.dp @OptIn(markerClass = [UnstableApi::class]) @Composable @@ -72,6 +75,7 @@ fun PlayerTimeBar( val displayedPosMs = scrubPositionMs ?: previewPositionMs?.takeIf { durationMs > 0L } ?: progressState.currentPositionMs.coerceIn(0L, durationMs) + val emphasized = scrubPositionMs != null || previewPositionMs != null val positionLabel = formatPlayerTime(displayedPosMs) val durationLabel = formatPlayerTime(durationMs) @@ -96,6 +100,7 @@ fun PlayerTimeBar( durationMs = durationMs, segments = segments, compact = compact, + emphasized = emphasized, onScrub = { onScrubbingChange(true) scrubPositionMs = it @@ -139,6 +144,8 @@ internal fun TimelineTrack( durationMs: Long, segments: List, compact: Boolean, + emphasized: Boolean = false, + interactive: Boolean = true, onScrub: (Long) -> Unit, onScrubFinished: (Long) -> Unit, onScrubCancelled: () -> Unit, @@ -148,8 +155,8 @@ internal fun TimelineTrack( ) { val activeColor = MaterialTheme.colorScheme.primary val inactiveColor = Color.Black.copy(alpha = 0.38f) - Box( - modifier = modifier + val timelineModifier = if (interactive) { + modifier .semantics { contentDescription = accessibilityLabel stateDescription = accessibilityStateDescription @@ -185,12 +192,27 @@ internal fun TimelineTrack( onDragEnd = { onScrubFinished(lastTargetMs) }, onDragCancel = onScrubCancelled, ) - }, + } + } else { + modifier + } + Box( + modifier = timelineModifier, ) { Canvas(modifier = Modifier.fillMaxSize()) { - val trackHeight = if (compact) COMPACT_TRACK_HEIGHT.toPx() else TRACK_HEIGHT.toPx() - val thumbWidth = if (compact) COMPACT_THUMB_WIDTH.toPx() else THUMB_WIDTH.toPx() - val thumbHeight = if (compact) COMPACT_THUMB_HEIGHT.toPx() else THUMB_HEIGHT.toPx() + val targetTrackHeight = when { + emphasized -> SCRUBBING_TRACK_HEIGHT + compact -> COMPACT_TRACK_HEIGHT + else -> TRACK_HEIGHT + } + val targetThumbSize = when { + emphasized -> SCRUBBING_THUMB_SIZE + compact -> COMPACT_THUMB_WIDTH + else -> THUMB_WIDTH + } + val trackHeight = targetTrackHeight.toPx() + val thumbWidth = targetThumbSize.toPx() + val thumbHeight = targetThumbSize.toPx() val trackTop = (size.height - trackHeight) / 2f val trackRadius = trackHeight / 2f val progress = if (durationMs > 0) positionMs.toFloat() / durationMs.toFloat() else 0f @@ -245,6 +267,35 @@ internal fun playerTimeBarThumbStartX( return (progressX - thumbWidth / 2f).coerceIn(0f, maximumStartX) } +@Composable +internal fun PlayerSeekScrubOverlay( + player: Player, + positionMs: Long, + segments: List, + isFullscreen: Boolean, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.height(if (isFullscreen) 52.dp else 40.dp)) { + TimelineTrack( + positionMs = positionMs, + durationMs = player.duration.coerceAtLeast(0L), + segments = segments, + compact = false, + emphasized = true, + interactive = false, + onScrub = {}, + onScrubFinished = {}, + onScrubCancelled = {}, + accessibilityLabel = "", + accessibilityStateDescription = "", + modifier = Modifier + .align(Alignment.Center) + .fillMaxSize() + .padding(horizontal = 4.dp), + ) + } +} + private fun Float.toPositionMs(width: Float, durationMs: Long): Long { if (durationMs <= 0 || width <= 0f) return 0L return ((this / width).coerceIn(0f, 1f) * durationMs).toLong() From 1bf18474f25b1874ada8e2aa30712e2e58486ae9 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 4 Sep 2026 12:11:09 +0200 Subject: [PATCH 3/3] fix: restore portrait player top inset --- .../android/feature/player/LoadedPlayer.kt | 14 ++++++++++- .../player/PlayerTopProgressPadding.kt | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/dev/typetype/android/feature/player/PlayerTopProgressPadding.kt diff --git a/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt b/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt index 9106a6fc..79c48faa 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt @@ -5,6 +5,7 @@ import androidx.activity.compose.LocalActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.padding @@ -25,7 +26,6 @@ import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.unit.Constraints import androidx.compose.ui.platform.LocalContext import androidx.paging.PagingData import dev.typetype.android.domain.comments.Comment @@ -188,6 +188,8 @@ fun LoadedPlayer( onSaveProgress = { onAction(PlayerAction.OnSaveProgress(it)) }, ) + val expandedTopPadding = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() + val expandedTopPaddingPx = with(LocalDensity.current) { expandedTopPadding.toPx() } val autoplayVisible by remember(hostTransitionProgress) { derivedStateOf { hostTransitionProgress() < 0.01f } } @@ -195,6 +197,16 @@ fun LoadedPlayer( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) + .then( + if (isFullscreen) { + Modifier + } else { + Modifier.playerTopProgressPadding( + maxTopPx = expandedTopPaddingPx, + progress = hostTransitionProgress, + ) + }, + ), ) { PlayerContentLayout( isFullscreen = isFullscreen, diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerTopProgressPadding.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerTopProgressPadding.kt new file mode 100644 index 00000000..03b78187 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerTopProgressPadding.kt @@ -0,0 +1,25 @@ +package dev.typetype.android.feature.player + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import kotlin.math.roundToInt + +internal fun Modifier.playerTopProgressPadding( + maxTopPx: Float, + progress: () -> Float, +): Modifier = layout { measurable, constraints -> + val topPx = (maxTopPx * (1f - progress().coerceIn(0f, 1f))).roundToInt() + val maxHeight = if (constraints.hasBoundedHeight) { + (constraints.maxHeight - topPx).coerceAtLeast(0) + } else { + constraints.maxHeight + } + val childConstraints = constraints.copy( + minHeight = constraints.minHeight.coerceAtMost(maxHeight), + maxHeight = maxHeight, + ) + val placeable = measurable.measure(childConstraints) + layout(placeable.width, placeable.height + topPx) { + placeable.placeRelative(0, topPx) + } +}