Skip to content

Commit 0be09d9

Browse files
committed
Add video player
1 parent 9ce3bce commit 0be09d9

10 files changed

Lines changed: 202 additions & 107 deletions

File tree

app/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ dependencies {
122122
implementation(libs.accompanist.permissions)
123123
implementation(libs.androidx.lifecycle.runtime.compose)
124124
implementation(libs.zoomable)
125+
implementation(libs.media3.exoplayer)
126+
implementation(libs.media3.ui)
125127
implementation(libs.androidx.runtime.livedata)
126128
implementation(libs.bcrypt)
127129
implementation(libs.androidx.work.runtime.ktx)

app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryContent.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import com.darkrockstudios.app.securecamera.camera.MediaType
3333
import com.darkrockstudios.app.securecamera.camera.PhotoDef
3434
import com.darkrockstudios.app.securecamera.camera.SecureImageRepository
3535
import com.darkrockstudios.app.securecamera.navigation.NavController
36-
import com.darkrockstudios.app.securecamera.navigation.ViewPhoto
36+
import com.darkrockstudios.app.securecamera.navigation.ViewMedia
3737
import com.darkrockstudios.app.securecamera.ui.HandleUiEvents
3838
import kotlinx.coroutines.CoroutineDispatcher
3939
import kotlinx.coroutines.CoroutineScope
@@ -111,9 +111,7 @@ fun GalleryContent(
111111
if (uiState.isSelectionMode) {
112112
viewModel.toggleMediaSelection(mediaName)
113113
} else {
114-
// For now, only navigate to photo viewer for photos
115-
// TODO: Add video player navigation
116-
navController.navigate(ViewPhoto(mediaName))
114+
navController.navigate(ViewMedia(mediaName))
117115
}
118116
},
119117
)

app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppDestinations.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ object Camera : DestinationKey
1616
object Gallery : DestinationKey
1717

1818
@Serializable
19-
data class ViewPhoto(val photoName: String) : DestinationKey
19+
data class ViewMedia(val mediaName: String) : DestinationKey
2020

2121
@Serializable
2222
data class ObfuscatePhoto(val photoName: String) : DestinationKey

app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/AppNavigation.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,19 +81,19 @@ fun AppNavHost(
8181
}
8282
}
8383
}
84-
entry<ViewPhoto> { key ->
84+
entry<ViewMedia> { key ->
8585
if (authManager.checkSessionValidity()) {
86-
val photo = imageManager.getPhotoByName(key.photoName)
87-
if (photo != null) {
86+
val mediaItem = imageManager.getMediaItemByName(key.mediaName)
87+
if (mediaItem != null) {
8888
ViewPhotoContent(
89-
initialPhoto = photo,
89+
initialMedia = mediaItem,
9090
navController = navController,
9191
modifier = Modifier.fillMaxSize(),
9292
paddingValues = paddingValues,
9393
snackbarHostState = snackbarHostState,
9494
)
9595
} else {
96-
Text(text = stringResource(R.string.photo_content_none_selected))
96+
Text(text = stringResource(R.string.media_content_none_selected))
9797
}
9898
} else {
9999
Box(modifier = Modifier.fillMaxSize()) {
@@ -176,7 +176,7 @@ fun enforceAuth(
176176
currentKey !is Introduction
177177
) {
178178
val returnKey = when (currentKey) {
179-
is ViewPhoto -> ViewPhoto(currentKey.photoName)
179+
is ViewMedia -> ViewMedia(currentKey.mediaName)
180180
is ObfuscatePhoto -> ObfuscatePhoto(currentKey.photoName)
181181
is Gallery -> Gallery
182182
is Settings -> Settings

app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoViewModel.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import com.darkrockstudios.app.securecamera.BaseViewModel
1111
import com.darkrockstudios.app.securecamera.R
1212
import com.darkrockstudios.app.securecamera.camera.PhotoDef
1313
import com.darkrockstudios.app.securecamera.camera.SecureImageRepository
14-
import com.darkrockstudios.app.securecamera.navigation.ViewPhoto
14+
import com.darkrockstudios.app.securecamera.navigation.ViewMedia
1515
import kotlinx.coroutines.Dispatchers
1616
import kotlinx.coroutines.flow.update
1717
import kotlinx.coroutines.launch
@@ -181,7 +181,7 @@ class ObfuscatePhotoViewModel(
181181

182182
Timber.i("Saved copy of image: ${newPhotoDef.photoName}")
183183
showCopySuccessMessage()
184-
onNavigate(ViewPhoto(newPhotoDef.photoName))
184+
onNavigate(ViewMedia(newPhotoDef.photoName))
185185
} catch (e: Exception) {
186186
Timber.e(e, "Failed to save copy of image")
187187
showSaveErrorMessage()

app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoContent.kt

Lines changed: 91 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,17 @@ import androidx.compose.ui.layout.ContentScale
2424
import androidx.compose.ui.platform.LocalContext
2525
import androidx.compose.ui.res.stringResource
2626
import androidx.compose.ui.unit.dp
27+
import androidx.compose.ui.viewinterop.AndroidView
2728
import androidx.lifecycle.compose.collectAsStateWithLifecycle
29+
import androidx.media3.common.Player
30+
import androidx.media3.exoplayer.ExoPlayer
31+
import androidx.media3.ui.PlayerView
2832
import com.darkrockstudios.app.securecamera.ConfirmDeletePhotoDialog
2933
import com.darkrockstudios.app.securecamera.R
34+
import com.darkrockstudios.app.securecamera.camera.MediaItem
35+
import com.darkrockstudios.app.securecamera.camera.MediaType
3036
import com.darkrockstudios.app.securecamera.camera.PhotoDef
37+
import com.darkrockstudios.app.securecamera.camera.VideoDef
3138
import com.darkrockstudios.app.securecamera.navigation.NavController
3239
import com.darkrockstudios.app.securecamera.navigation.ObfuscatePhoto
3340
import com.darkrockstudios.app.securecamera.ui.HandleUiEvents
@@ -36,25 +43,26 @@ import net.engawapg.lib.zoomable.rememberZoomState
3643
import net.engawapg.lib.zoomable.zoomableWithScroll
3744
import org.koin.androidx.compose.koinViewModel
3845
import org.koin.core.parameter.parametersOf
46+
import androidx.media3.common.MediaItem as ExoMediaItem
3947

4048
@SuppressLint("UnusedBoxWithConstraintsScope")
4149
@OptIn(ExperimentalZoomableApi::class)
4250
@Composable
4351
fun ViewPhotoContent(
44-
initialPhoto: PhotoDef,
52+
initialMedia: MediaItem,
4553
navController: NavController,
4654
modifier: Modifier = Modifier,
4755
snackbarHostState: SnackbarHostState,
4856
paddingValues: PaddingValues
4957
) {
5058
val viewModel: ViewPhotoViewModel =
51-
koinViewModel(key = initialPhoto.photoName) { parametersOf(initialPhoto.photoName) }
59+
koinViewModel(key = initialMedia.mediaName) { parametersOf(initialMedia.mediaName) }
5260
val context = LocalContext.current
5361

5462
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
5563

56-
LaunchedEffect(uiState.photoDeleted) {
57-
if (uiState.photoDeleted) {
64+
LaunchedEffect(uiState.mediaDeleted) {
65+
if (uiState.mediaDeleted) {
5866
navController.navigateUp()
5967
}
6068
}
@@ -66,6 +74,7 @@ fun ViewPhotoContent(
6674
) {
6775
ViewPhotoTopBar(
6876
navController = navController,
77+
mediaType = uiState.currentMediaType,
6978
onDeleteClick = {
7079
viewModel.showDeleteConfirmation()
7180
},
@@ -81,7 +90,7 @@ fun ViewPhotoContent(
8190
onShareClick = {
8291
viewModel.sharePhoto(context)
8392
},
84-
showDecoyButton = uiState.hasPoisonPill,
93+
showDecoyButton = uiState.hasPoisonPill && uiState.currentMediaType == MediaType.PHOTO,
8594
isDecoy = uiState.isDecoy,
8695
isDecoyLoading = uiState.isDecoyLoading,
8796
onDecoyClick = {
@@ -93,7 +102,7 @@ fun ViewPhotoContent(
93102
ConfirmDeletePhotoDialog(
94103
selectedCount = 1,
95104
onConfirm = {
96-
viewModel.deleteCurrentPhoto()
105+
viewModel.deleteCurrentMedia()
97106
viewModel.hideDeleteConfirmation()
98107
},
99108
onDismiss = {
@@ -102,7 +111,7 @@ fun ViewPhotoContent(
102111
)
103112
}
104113

105-
if (uiState.photos.isNotEmpty()) {
114+
if (uiState.mediaItems.isNotEmpty()) {
106115
val listState = remember { LazyListState(firstVisibleItemIndex = uiState.currentIndex) }
107116

108117
LaunchedEffect(listState) {
@@ -111,7 +120,7 @@ fun ViewPhotoContent(
111120
listState.firstVisibleItemScrollOffset
112121
}.collect { (idx, off) ->
113122
if (listState.firstVisibleItemIndex != uiState.currentIndex) {
114-
viewModel.setCurrentPhotoIndex(listState.firstVisibleItemIndex)
123+
viewModel.setCurrentMediaIndex(listState.firstVisibleItemIndex)
115124
}
116125
}
117126
}
@@ -123,16 +132,26 @@ fun ViewPhotoContent(
123132
verticalAlignment = Alignment.CenterVertically,
124133
horizontalArrangement = Arrangement.spacedBy(32.dp),
125134
) {
126-
items(count = uiState.photos.size, key = { uiState.photos[it].photoName }) { index ->
127-
val photo = uiState.photos[index]
128-
129-
ViewPhoto(
130-
modifier = Modifier
131-
.fillParentMaxSize()
132-
.padding(bottom = paddingValues.calculateBottomPadding()),
133-
photo = photo,
134-
viewModel = viewModel,
135-
)
135+
items(count = uiState.mediaItems.size, key = { uiState.mediaItems[it].mediaName }) { index ->
136+
val mediaItem = uiState.mediaItems[index]
137+
138+
when (mediaItem) {
139+
is PhotoDef -> ViewPhoto(
140+
modifier = Modifier
141+
.fillParentMaxSize()
142+
.padding(bottom = paddingValues.calculateBottomPadding()),
143+
photo = mediaItem,
144+
viewModel = viewModel,
145+
)
146+
147+
is VideoDef -> ViewVideo(
148+
modifier = Modifier
149+
.fillParentMaxSize()
150+
.padding(bottom = paddingValues.calculateBottomPadding()),
151+
video = mediaItem,
152+
isCurrentItem = index == uiState.currentIndex,
153+
)
154+
}
136155
}
137156
}
138157
}
@@ -210,3 +229,57 @@ private fun ViewPhoto(
210229
}
211230
}
212231
}
232+
233+
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
234+
@Composable
235+
private fun ViewVideo(
236+
modifier: Modifier,
237+
video: VideoDef,
238+
isCurrentItem: Boolean,
239+
) {
240+
val context = LocalContext.current
241+
242+
val exoPlayer = remember(video.videoName) {
243+
ExoPlayer.Builder(context).build().apply {
244+
val mediaItem = ExoMediaItem.fromUri(video.videoFile.toURI().toString())
245+
setMediaItem(mediaItem)
246+
prepare()
247+
repeatMode = Player.REPEAT_MODE_OFF
248+
}
249+
}
250+
251+
// Pause when not the current item
252+
LaunchedEffect(isCurrentItem) {
253+
if (!isCurrentItem) {
254+
exoPlayer.pause()
255+
}
256+
}
257+
258+
DisposableEffect(video.videoName) {
259+
onDispose {
260+
exoPlayer.release()
261+
}
262+
}
263+
264+
Box(
265+
modifier = modifier.clipToBounds(),
266+
contentAlignment = Alignment.Center
267+
) {
268+
if (video.videoFile.exists()) {
269+
AndroidView(
270+
factory = { ctx ->
271+
PlayerView(ctx).apply {
272+
player = exoPlayer
273+
useController = true
274+
}
275+
},
276+
modifier = Modifier.fillMaxSize()
277+
)
278+
} else {
279+
Text(
280+
modifier = Modifier.align(alignment = Alignment.Center),
281+
text = stringResource(id = R.string.video_not_found),
282+
)
283+
}
284+
}
285+
}

0 commit comments

Comments
 (0)