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