diff --git a/app/src/main/kotlin/com/wisp/app/MainActivity.kt b/app/src/main/kotlin/com/wisp/app/MainActivity.kt index ae347a10..4af99ff0 100644 --- a/app/src/main/kotlin/com/wisp/app/MainActivity.kt +++ b/app/src/main/kotlin/com/wisp/app/MainActivity.kt @@ -52,6 +52,7 @@ class MainActivity : FragmentActivity() { mutableStateOf(MediaSettings( autoLoadMedia = interfacePrefs.isAutoLoadMedia(), videoAutoPlay = interfacePrefs.isVideoAutoPlay(), + videoLoop = interfacePrefs.isVideoLoop(), mediaLayoutStyle = interfacePrefs.getMediaLayoutStyle() )) } @@ -97,6 +98,7 @@ class MainActivity : FragmentActivity() { mediaSettings = MediaSettings( autoLoadMedia = interfacePrefs.isAutoLoadMedia(), videoAutoPlay = interfacePrefs.isVideoAutoPlay(), + videoLoop = interfacePrefs.isVideoLoop(), mediaLayoutStyle = interfacePrefs.getMediaLayoutStyle() ) } diff --git a/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt b/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt index 8bd40085..b6da6e72 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/InterfacePreferences.kt @@ -40,6 +40,11 @@ class InterfacePreferences(context: Context) { fun isVideoAutoPlay(): Boolean = prefs.getBoolean("video_auto_play", true) fun setVideoAutoPlay(enabled: Boolean) = prefs.edit().putBoolean("video_auto_play", enabled).apply() + // Loop videos in the timeline and full-screen gallery. Not yet + // round-tripped over NIP-78 to match iOS, which carries the same TODO. + fun isVideoLoop(): Boolean = prefs.getBoolean("video_loop", true) + fun setVideoLoop(enabled: Boolean) = prefs.edit().putBoolean("video_loop", enabled).apply() + fun getMediaLayoutStyle(): MediaLayoutStyle = MediaLayoutStyle.fromKey(prefs.getString("media_layout_style", null)) fun setMediaLayoutStyle(style: MediaLayoutStyle) = diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/FullScreenVideoPlayer.kt b/app/src/main/kotlin/com/wisp/app/ui/component/FullScreenVideoPlayer.kt index 2946ef6b..0037db09 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/FullScreenVideoPlayer.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/FullScreenVideoPlayer.kt @@ -22,11 +22,13 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.media3.common.MediaItem +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView import com.wisp.app.R import com.wisp.app.relay.HttpClientFactory +import com.wisp.app.repo.InterfacePreferences import com.wisp.app.util.MediaDownloader import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -64,12 +66,15 @@ fun FullScreenVideoPlayer( DisposableEffect(videoUrl) { val activity = context.findActivity() ?: return@DisposableEffect onDispose {} + val loopEnabled = InterfacePreferences(context).isVideoLoop() val ownsPlayer = existingPlayer == null - val exoPlayer = existingPlayer ?: HttpClientFactory.createExoPlayer(context).apply { + val exoPlayer = (existingPlayer ?: HttpClientFactory.createExoPlayer(context).apply { setMediaItem(MediaItem.fromUri(Uri.parse(videoUrl))) prepare() seekTo(startPositionMs) playWhenReady = true + }).apply { + repeatMode = if (loopEnabled) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF } var minimizedToPip = false diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt b/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt index 6cb7127c..c5555672 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/RichContent.kt @@ -133,6 +133,7 @@ private const val INLINE_CONTENT_TAG = "androidx.compose.foundation.text.inlineC data class MediaSettings( val autoLoadMedia: Boolean = true, val videoAutoPlay: Boolean = true, + val videoLoop: Boolean = true, val mediaLayoutStyle: com.wisp.app.repo.InterfacePreferences.MediaLayoutStyle = com.wisp.app.repo.InterfacePreferences.MediaLayoutStyle.GALLERY ) @@ -2310,7 +2311,7 @@ internal fun InlineVideoPlayerWithFullscreen(meta: MediaMeta, onFullScreen: (pos volume = if (globalMuted.value) 0f else 1f playWhenReady = false }).apply { - repeatMode = Player.REPEAT_MODE_ONE + repeatMode = if (mediaSettings.videoLoop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF } } @@ -2319,6 +2320,11 @@ internal fun InlineVideoPlayerWithFullscreen(meta: MediaMeta, onFullScreen: (pos exoPlayer.volume = if (isMuted) 0f else 1f } + LaunchedEffect(mediaSettings.videoLoop) { + exoPlayer.repeatMode = + if (mediaSettings.videoLoop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF + } + DisposableEffect(url) { val listener = object : Player.Listener { override fun onVideoSizeChanged(videoSize: VideoSize) { @@ -2589,7 +2595,7 @@ private fun InlineVideoPlayer(url: String, modifier: Modifier = Modifier) { volume = if (globalMuted.value) 0f else 1f playWhenReady = false }).apply { - repeatMode = Player.REPEAT_MODE_ONE + repeatMode = if (mediaSettings.videoLoop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF } } @@ -2597,6 +2603,11 @@ private fun InlineVideoPlayer(url: String, modifier: Modifier = Modifier) { exoPlayer.volume = if (isMuted) 0f else 1f } + LaunchedEffect(mediaSettings.videoLoop) { + exoPlayer.repeatMode = + if (mediaSettings.videoLoop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF + } + DisposableEffect(url) { val listener = object : Player.Listener { override fun onVideoSizeChanged(videoSize: VideoSize) { diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt index 110da1cb..1066e284 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt @@ -99,6 +99,7 @@ fun InterfaceScreen( var clientTagEnabled by remember { mutableStateOf(interfacePrefs.isClientTagEnabled()) } var autoLoadMedia by remember { mutableStateOf(interfacePrefs.isAutoLoadMedia()) } var videoAutoPlay by remember { mutableStateOf(interfacePrefs.isVideoAutoPlay()) } + var videoLoop by remember { mutableStateOf(interfacePrefs.isVideoLoop()) } var mediaLayout by remember { mutableStateOf(interfacePrefs.getMediaLayoutStyle()) } var liveStreamsHidden by remember { mutableStateOf(interfacePrefs.isLiveStreamsHidden()) } var autoTranslate by remember { mutableStateOf(interfacePrefs.isAutoTranslate()) } @@ -463,6 +464,29 @@ fun InterfaceScreen( ) } Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.settings_video_loop), style = MaterialTheme.typography.bodyMedium) + Text( + stringResource(R.string.settings_video_loop_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = videoLoop, + onCheckedChange = { + videoLoop = it + interfacePrefs.setVideoLoop(it) + onChanged() + }, + colors = wispSwitchColors() + ) + } + Spacer(Modifier.height(12.dp)) Column(modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.settings_media_layout), style = MaterialTheme.typography.bodyMedium) Text( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 565d7414..e123c18c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -533,6 +533,8 @@ Automatically download images and videos in notes. When off, tap to load. Video autoplay Automatically play videos when they scroll into view + Loop videos + Replays timeline and gallery videos from the beginning when they finish. Multi-image layout Gallery: horizontal swipe through every photo and video. Stack: each item full-width below the next. Gallery