Skip to content

Commit

Permalink
Recordings library and dynamic loading sub-menus (#1013)
Browse files Browse the repository at this point in the history
  • Loading branch information
aleksandar-apostolov authored Feb 15, 2024
1 parent d3b76d3 commit 42e6664
Show file tree
Hide file tree
Showing 8 changed files with 227 additions and 31 deletions.
2 changes: 2 additions & 0 deletions demo-app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<!-- For Android < 10 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission
android:name="com.google.android.gms.permission.AD_ID"
tools:node="remove" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,17 @@ import androidx.compose.material.icons.filled.SettingsVoice
import androidx.compose.material.icons.filled.SpeakerPhone
import androidx.compose.material.icons.filled.SwitchLeft
import androidx.compose.material.icons.filled.VideoFile
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material.icons.filled.VideoSettings
import io.getstream.video.android.core.audio.StreamAudioDevice
import io.getstream.video.android.ui.menu.base.ActionMenuItem
import io.getstream.video.android.ui.menu.base.DynamicSubMenuItem
import io.getstream.video.android.ui.menu.base.MenuItem
import io.getstream.video.android.ui.menu.base.SubMenuItem

/**
* Defines the default Stream menu for the demo app.
*/
fun defaultStreamMenu(
showDebugOptions: Boolean = false,
codecList: List<MediaCodecInfo>,
Expand All @@ -57,6 +62,7 @@ fun defaultStreamMenu(
onSwitchSfuClick: () -> Unit,
onDeviceSelected: (StreamAudioDevice) -> Unit,
availableDevices: List<StreamAudioDevice>,
loadRecordings: suspend () -> List<MenuItem>,
) = buildList<MenuItem> {
add(
ActionMenuItem(
Expand Down Expand Up @@ -98,6 +104,13 @@ fun defaultStreamMenu(
},
),
)
add(
DynamicSubMenuItem(
title = "Recordings",
icon = Icons.Default.VideoLibrary,
itemsLoader = loadRecordings,
),
)
if (showDebugOptions) {
add(
SubMenuItem(
Expand All @@ -117,6 +130,9 @@ fun defaultStreamMenu(
}
}

/**
* Lists the available codecs for this device as list of [MenuItem]
*/
fun codecMenu(codecList: List<MediaCodecInfo>, onCodecSelected: (MediaCodecInfo) -> Unit) =
codecList.map {
val isHw = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Expand All @@ -132,6 +148,9 @@ fun codecMenu(codecList: List<MediaCodecInfo>, onCodecSelected: (MediaCodecInfo)
)
}

/**
* Optionally defines the debug sub-menu of the demo app.
*/
fun debugSubmenu(
codecList: List<MediaCodecInfo>,
onCodecSelected: (MediaCodecInfo) -> Unit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,24 @@

package io.getstream.video.android.ui.menu

import android.Manifest
import android.app.Activity
import android.app.DownloadManager
import android.content.Context
import android.content.Context.DOWNLOAD_SERVICE
import android.graphics.Bitmap
import android.media.MediaCodecList
import android.media.projection.MediaProjectionManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VideoFile
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
Expand All @@ -34,21 +44,27 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.core.content.ContextCompat.getSystemService
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus
import com.google.accompanist.permissions.rememberPermissionState
import io.getstream.video.android.compose.theme.base.VideoTheme
import io.getstream.video.android.core.Call
import io.getstream.video.android.core.call.audio.AudioFilter
import io.getstream.video.android.core.call.video.BitmapVideoFilter
import io.getstream.video.android.core.mapper.ReactionMapper
import io.getstream.video.android.tooling.extensions.toPx
import io.getstream.video.android.ui.call.ReactionsMenu
import io.getstream.video.android.ui.menu.base.ActionMenuItem
import io.getstream.video.android.ui.menu.base.DynamicMenu
import io.getstream.video.android.ui.menu.base.MenuItem
import io.getstream.video.android.util.BlurredBackgroundVideoFilter
import io.getstream.video.android.util.SampleAudioFilter
import kotlinx.coroutines.launch
import java.nio.ByteBuffer

@OptIn(ExperimentalComposeUiApi::class)
@OptIn(ExperimentalComposeUiApi::class, ExperimentalPermissionsApi::class)
@Composable
internal fun SettingsMenu(
call: Call,
Expand Down Expand Up @@ -155,6 +171,29 @@ internal fun SettingsMenu(
} != null
}
}

val onLoadRecordings: suspend () -> List<MenuItem> = storagePermissionAndroidBellow10 {
when (it) {
is PermissionStatus.Granted -> {
{
call.listRecordings().getOrNull()?.recordings?.map {
ActionMenuItem(
title = it.filename,
icon = Icons.Default.VideoFile,
action = {
context.downloadFile(it.url, it.filename)
onDismissed()
},
)
} ?: emptyList()
}
}
is PermissionStatus.Denied -> {
{ emptyList() }
}
}
}

Popup(
offset = IntOffset(
0,
Expand Down Expand Up @@ -196,11 +235,46 @@ internal fun SettingsMenu(
onShowCallStats = onShowCallStats,
isBackgroundBlurEnabled = isBackgroundBlurEnabled,
isScreenShareEnabled = isScreenSharing,
loadRecordings = onLoadRecordings,
),
)
}
}

@OptIn(ExperimentalPermissionsApi::class)
@Composable
private fun storagePermissionAndroidBellow10(
permission: (PermissionStatus) -> suspend () -> List<MenuItem>,
): suspend () -> List<MenuItem> {
// Check if the device's API level is below Android 10 (API level 29)
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
val writeStoragePermissionState =
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE)
LaunchedEffect(key1 = true) {
// Request permission
writeStoragePermissionState.launchPermissionRequest()
}
permission(writeStoragePermissionState.status)
} else {
permission(PermissionStatus.Granted)
}
}

private fun Context.downloadFile(url: String, title: String) {
val request = DownloadManager.Request(Uri.parse(url))
.setTitle(title) // Title of the Download Notification
.setDescription("Downloading") // Description of the Download Notification
.setNotificationVisibility(
DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED,
) // Visibility of the download Notification
.setAllowedOverMetered(true) // Set if download is allowed on Mobile network
.setAllowedOverRoaming(true) // Set if download is allowed on Roaming network
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, title)

val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
downloadManager.enqueue(request) // enqueue puts the download request in the queue.
}

@Preview
@Composable
private fun SettingsMenuPreview() {
Expand All @@ -223,6 +297,7 @@ private fun SettingsMenuPreview() {
availableDevices = emptyList(),
onDeviceSelected = {
},
loadRecordings = { emptyList() },
),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,17 @@ import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
Expand All @@ -44,10 +49,21 @@ import io.getstream.video.android.compose.ui.components.base.styling.StyleSize
import io.getstream.video.android.ui.menu.debugSubmenu
import io.getstream.video.android.ui.menu.defaultStreamMenu

/**
* A composable capable of loading a menu based on a list structure of menu items and sub menus.
* There are three types of items:
* - [ActionMenuItem] - shown normally as an item that can be clicked.
* - [SubMenuItem] - that contains another list of [ActionMenuItem] o [SubMenuItem] which will be shown when clicked.
* - [DynamicSubMenuItem] - that shows a spinner and calls a loading function before behaving as [SubMenuItem]
*
* The transition and history between the items is automatic.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DynamicMenu(header: (@Composable LazyItemScope.() -> Unit)? = null, items: List<MenuItem>) {
val history = remember { mutableStateListOf<Pair<String, List<MenuItem>>>() }
val history = remember { mutableStateListOf<Pair<String, SubMenuItem>>() }
val dynamicItems = remember { mutableStateListOf<MenuItem>() }
var loadedItems by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxWidth()
Expand All @@ -70,7 +86,7 @@ fun DynamicMenu(header: (@Composable LazyItemScope.() -> Unit)? = null, items: L
item(content = header)
}
menuItems(items) {
history.add(Pair(it.title, it.items))
history.add(Pair(it.title, it))
}
} else {
val lastContent = history.last()
Expand All @@ -96,26 +112,73 @@ fun DynamicMenu(header: (@Composable LazyItemScope.() -> Unit)? = null, items: L
}
}

if (lastContent.second.isEmpty()) {
item {
Text(
textAlign = TextAlign.Center,
text = "No items",
style = VideoTheme.typography.subtitleS,
color = VideoTheme.colors.basePrimary,
)
val subMenu = lastContent.second
val dynamicMenu = subMenu as? DynamicSubMenuItem

if (dynamicMenu != null) {
if (!loadedItems) {
dynamicItems.clear()
loadingItems(dynamicMenu) {
loadedItems = true
dynamicItems.addAll(it)
}
}
if (dynamicItems.isNotEmpty()) {
menuItems(dynamicItems) {
history.add(Pair(it.title, it))
}
} else if (loadedItems) {
noItems()
}
} else {
menuItems(lastContent.second) {
history.add(Pair(it.title, it.items))
if (subMenu.items.isEmpty()) {
noItems()
} else {
menuItems(subMenu.items) {
history.add(Pair(it.title, it))
}
}
}
}
}
}
}

private fun LazyListScope.menuItems(items: List<MenuItem>, onNewSubmenu: (SubMenuItem) -> Unit) {
private fun LazyListScope.loadingItems(
dynamicMenu: DynamicSubMenuItem,
onLoaded: (List<MenuItem>) -> Unit,
) {
item {
LaunchedEffect(key1 = dynamicMenu) {
onLoaded(dynamicMenu.itemsLoader.invoke())
}
LinearProgressIndicator(
modifier = Modifier
.padding(33.dp)
.fillMaxWidth(),
color = VideoTheme.colors.basePrimary,
)
}
}

private fun LazyListScope.noItems() {
item {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
textAlign = TextAlign.Center,
text = "No items",
style = VideoTheme.typography.subtitleS,
color = VideoTheme.colors.basePrimary,
)
}
}

private fun LazyListScope.menuItems(
items: List<MenuItem>,
onNewSubmenu: (SubMenuItem) -> Unit,
) {
items(items.size) { index ->
val item = items[index]
val highlight = item.highlight
Expand Down Expand Up @@ -147,8 +210,7 @@ private fun DynamicMenuPreview() {
DynamicMenu(
items = defaultStreamMenu(
codecList = emptyList(),
onCodecSelected = {
},
onCodecSelected = {},
isScreenShareEnabled = false,
isBackgroundBlurEnabled = true,
onToggleScreenShare = { },
Expand All @@ -160,8 +222,8 @@ private fun DynamicMenuPreview() {
onKillSfuWsClick = { },
onSwitchSfuClick = { },
availableDevices = emptyList(),
onDeviceSelected = {
},
onDeviceSelected = {},
loadRecordings = { emptyList() },
),
)
}
Expand All @@ -175,8 +237,7 @@ private fun DynamicMenuDebugOptionPreview() {
items = defaultStreamMenu(
showDebugOptions = true,
codecList = emptyList(),
onCodecSelected = {
},
onCodecSelected = {},
isScreenShareEnabled = true,
isBackgroundBlurEnabled = true,
onToggleScreenShare = { },
Expand All @@ -188,8 +249,8 @@ private fun DynamicMenuDebugOptionPreview() {
onKillSfuWsClick = { },
onSwitchSfuClick = { },
availableDevices = emptyList(),
onDeviceSelected = {
},
onDeviceSelected = {},
loadRecordings = { emptyList() },
),
)
}
Expand All @@ -202,8 +263,7 @@ private fun DynamicMenuDebugPreview() {
DynamicMenu(
items = debugSubmenu(
codecList = emptyList(),
onCodecSelected = {
},
onCodecSelected = {},
onKillSfuWsClick = { },
onRestartPublisherIceClick = { },
onRestartSubscriberIceClick = { },
Expand Down
Loading

0 comments on commit 42e6664

Please sign in to comment.