diff --git a/data/core/src/commonMain/kotlin/com/tunjid/heron/data/datastore/migrations/SavedStateVersion.kt b/data/core/src/commonMain/kotlin/com/tunjid/heron/data/datastore/migrations/SavedStateVersion.kt index 727bd6079..f0cceea46 100644 --- a/data/core/src/commonMain/kotlin/com/tunjid/heron/data/datastore/migrations/SavedStateVersion.kt +++ b/data/core/src/commonMain/kotlin/com/tunjid/heron/data/datastore/migrations/SavedStateVersion.kt @@ -18,6 +18,7 @@ package com.tunjid.heron.data.datastore.migrations import androidx.datastore.core.okio.OkioSerializer import com.tunjid.heron.data.core.models.Constants +import com.tunjid.heron.data.core.models.SessionSummary import com.tunjid.heron.data.core.types.ProfileId import com.tunjid.heron.data.repository.SavedState import kotlinx.serialization.Serializable @@ -73,6 +74,10 @@ internal data class VersionedSavedState( -> null else -> profileData[profileId] } + override val pastSessions: List + get() = profileData.values + .mapNotNull { it.sessionSummary } + .sortedByDescending { it.lastSeen } override val auth: AuthTokens? get() = activeProfileId diff --git a/data/core/src/commonMain/kotlin/com/tunjid/heron/data/repository/AuthRepository.kt b/data/core/src/commonMain/kotlin/com/tunjid/heron/data/repository/AuthRepository.kt index 1c9e0f710..919d1e815 100644 --- a/data/core/src/commonMain/kotlin/com/tunjid/heron/data/repository/AuthRepository.kt +++ b/data/core/src/commonMain/kotlin/com/tunjid/heron/data/repository/AuthRepository.kt @@ -23,6 +23,7 @@ import app.bsky.graph.GetListQueryParams import com.tunjid.heron.data.core.models.OauthUriRequest import com.tunjid.heron.data.core.models.Profile import com.tunjid.heron.data.core.models.SessionRequest +import com.tunjid.heron.data.core.models.SessionSummary import com.tunjid.heron.data.core.models.TimelinePreference import com.tunjid.heron.data.core.types.GenericUri import com.tunjid.heron.data.core.types.ProfileId @@ -61,6 +62,8 @@ interface AuthRepository { val signedInUser: Flow + val pastSessions: Flow> + fun isSignedInProfile(id: ProfileId): Flow suspend fun oauthRequestUri( @@ -69,7 +72,7 @@ interface AuthRepository { suspend fun createSession( request: SessionRequest, - ): Result + ): Outcome suspend fun signOut() @@ -112,6 +115,11 @@ internal class AuthTokenRepository( .withRefresh(::updateSignedInUser) } + override val pastSessions: Flow> = + savedStateDataSource.savedState + .map { it.pastSessions ?: emptyList() } + .distinctUntilChanged() + override fun isSignedInProfile(id: ProfileId): Flow = savedStateDataSource.singleSessionFlow { signedInProfileId -> flowOf(signedInProfileId == id) @@ -131,21 +139,32 @@ internal class AuthTokenRepository( override suspend fun createSession( request: SessionRequest, - ): Result = runCatchingUnlessCancelled { + ): Outcome = runCatchingUnlessCancelled { sessionManager.createSession(request) } .mapCatchingUnlessCancelled { authToken -> savedStateDataSource.setAuth(authToken) // Suspend till auth token has been saved and is readable savedStateDataSource.savedState.first { it.auth != null } - if (authToken is SavedState.AuthTokens.Authenticated) { - updateSignedInUser(authToken.authProfileId.id.let(::Did)) + + // Check if it is an authenticated session. Guest sessions are valid. + when (authToken) { + is SavedState.AuthTokens.Authenticated -> + savedStateDataSource.inCurrentProfileSession { signedInProfileId -> + if (authToken.authProfileId == signedInProfileId) updateSignedInUser( + did = signedInProfileId.id.let(::Did), + ) + else expiredSessionOutcome() + } + ?: expiredSessionOutcome() + else -> + Outcome.Success } - Unit - } - .onFailure { - savedStateDataSource.setAuth(null) } + .fold( + onSuccess = { it }, + onFailure = Outcome::Failure, + ) override suspend fun signOut() { runCatchingUnlessCancelled { @@ -161,14 +180,20 @@ internal class AuthTokenRepository( } override suspend fun updateSignedInUser(): Outcome = - networkService.runCatchingWithMonitoredNetworkRetry { - getSession() - }.fold( - onSuccess = { updateSignedInUser(it.did) }, - onFailure = Outcome::Failure, - ) + savedStateDataSource.inCurrentProfileSession { signedInProfileId -> + if (signedInProfileId == null) return@inCurrentProfileSession expiredSessionOutcome() + + networkService.runCatchingWithMonitoredNetworkRetry { + getSession() + }.fold( + onSuccess = { updateSignedInUser(it.did) }, + onFailure = Outcome::Failure, + ) + } ?: expiredSessionOutcome() - private suspend fun updateSignedInUser(did: Did): Outcome = supervisorScope { + private suspend fun updateSignedInUser( + did: Did, + ): Outcome = supervisorScope { val succeeded = listOf( async { networkService.runCatchingWithMonitoredNetworkRetry { @@ -176,7 +201,19 @@ internal class AuthTokenRepository( } .getOrNull() ?.profileEntity() - ?.let { profileDao.upsertProfiles(listOf(it)) } != null + ?.let { profileEntity -> + profileDao.upsertProfiles(listOf(profileEntity)) + savedStateDataSource.updateSignedInProfileData { + copy( + sessionSummary = SessionSummary( + lastSeen = Clock.System.now(), + profileId = profileEntity.did, + profileHandle = profileEntity.handle, + profileAvatar = profileEntity.avatar, + ), + ) + } + } != null }, async { networkService.runCatchingWithMonitoredNetworkRetry { diff --git a/data/core/src/commonMain/kotlin/com/tunjid/heron/data/repository/SavedStateDataSource.kt b/data/core/src/commonMain/kotlin/com/tunjid/heron/data/repository/SavedStateDataSource.kt index f54c31fdf..63ba176d1 100644 --- a/data/core/src/commonMain/kotlin/com/tunjid/heron/data/repository/SavedStateDataSource.kt +++ b/data/core/src/commonMain/kotlin/com/tunjid/heron/data/repository/SavedStateDataSource.kt @@ -23,6 +23,7 @@ import com.tunjid.heron.data.core.models.Constants import com.tunjid.heron.data.core.models.NotificationPreferences import com.tunjid.heron.data.core.models.Preferences import com.tunjid.heron.data.core.models.Server +import com.tunjid.heron.data.core.models.SessionSummary import com.tunjid.heron.data.core.types.ProfileHandle import com.tunjid.heron.data.core.types.ProfileId import com.tunjid.heron.data.core.utilities.Outcome @@ -65,6 +66,8 @@ abstract class SavedState { abstract val navigation: Navigation abstract val signedInProfileData: ProfileData? + abstract val pastSessions: List? + @Serializable sealed class AuthTokens { abstract val authProfileId: ProfileId @@ -193,6 +196,7 @@ abstract class SavedState { // Need default for migration val writes: Writes = Writes(), val auth: AuthTokens? = null, + val sessionSummary: SessionSummary? = null, ) { companion object { val defaultGuestData = ProfileData( diff --git a/data/core/src/commonMain/kotlin/com/tunjid/heron/data/repository/UserDataRepository.kt b/data/core/src/commonMain/kotlin/com/tunjid/heron/data/repository/UserDataRepository.kt index 7fe1bf73c..93087a220 100644 --- a/data/core/src/commonMain/kotlin/com/tunjid/heron/data/repository/UserDataRepository.kt +++ b/data/core/src/commonMain/kotlin/com/tunjid/heron/data/repository/UserDataRepository.kt @@ -41,6 +41,10 @@ interface UserDataRepository { suspend fun setAutoHideBottomNavigation( autoHideBottomNavigation: Boolean, ): Outcome + + suspend fun setAutoPlayTimelineVideos( + autoPlayTimelineVideos: Boolean, + ): Outcome } internal class OfflineUserDataRepository @Inject constructor( @@ -100,6 +104,11 @@ internal class OfflineUserDataRepository @Inject constructor( copy(local = local.copy(autoHideBottomNavigation = autoHideBottomNavigation)) } + override suspend fun setAutoPlayTimelineVideos( + autoPlayTimelineVideos: Boolean, + ): Outcome = updatePreferences { + copy(local = local.copy(autoPlayTimelineVideos = autoPlayTimelineVideos)) + } private suspend inline fun updatePreferences( crossinline updater: suspend Preferences.() -> Preferences, ): Outcome = diff --git a/data/models/src/commonMain/kotlin/com/tunjid/heron/data/core/models/Preferences.kt b/data/models/src/commonMain/kotlin/com/tunjid/heron/data/core/models/Preferences.kt index 6407c005d..ab1628ea1 100644 --- a/data/models/src/commonMain/kotlin/com/tunjid/heron/data/core/models/Preferences.kt +++ b/data/models/src/commonMain/kotlin/com/tunjid/heron/data/core/models/Preferences.kt @@ -67,6 +67,8 @@ data class Preferences( val useCompactNavigation: Boolean = false, @ProtoNumber(5) val autoHideBottomNavigation: Boolean = true, + @ProtoNumber(6) + val autoPlayTimelineVideos: Boolean = true, ) companion object { diff --git a/data/models/src/commonMain/kotlin/com/tunjid/heron/data/core/models/SessionSummary.kt b/data/models/src/commonMain/kotlin/com/tunjid/heron/data/core/models/SessionSummary.kt new file mode 100644 index 000000000..c10ca41e4 --- /dev/null +++ b/data/models/src/commonMain/kotlin/com/tunjid/heron/data/core/models/SessionSummary.kt @@ -0,0 +1,15 @@ +package com.tunjid.heron.data.core.models + +import com.tunjid.heron.data.core.types.ImageUri +import com.tunjid.heron.data.core.types.ProfileHandle +import com.tunjid.heron.data.core.types.ProfileId +import kotlin.time.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class SessionSummary( + val lastSeen: Instant, + val profileId: ProfileId, + val profileHandle: ProfileHandle, + val profileAvatar: ImageUri?, +) diff --git a/feature/auth/src/commonMain/kotlin/com/tunjid/heron/signin/SignInScreen.kt b/feature/auth/src/commonMain/kotlin/com/tunjid/heron/signin/SignInScreen.kt index 61a910a8a..e657a3e4f 100644 --- a/feature/auth/src/commonMain/kotlin/com/tunjid/heron/signin/SignInScreen.kt +++ b/feature/auth/src/commonMain/kotlin/com/tunjid/heron/signin/SignInScreen.kt @@ -16,6 +16,7 @@ package com.tunjid.heron.signin +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateBounds import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -23,6 +24,7 @@ import androidx.compose.animation.shrinkOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -33,24 +35,34 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.key +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import com.tunjid.heron.data.core.models.SessionSummary +import com.tunjid.heron.images.AsyncImage +import com.tunjid.heron.images.ImageArgs import com.tunjid.heron.scaffold.scaffold.PaneScaffoldState import com.tunjid.heron.signin.oauth.rememberOauthFlowState import com.tunjid.heron.signin.ui.NoAccountButton import com.tunjid.heron.signin.ui.ServerSelection import com.tunjid.heron.signin.ui.ServerSelectionSheetState.Companion.rememberUpdatedServerSelectionState +import com.tunjid.heron.ui.shapes.RoundedPolygonShape +import com.tunjid.heron.ui.text.CommonStrings import com.tunjid.heron.ui.text.FormField +import com.tunjid.heron.ui.text.LeadingIcon +import heron.ui.core.generated.resources.profile_avatar import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource @Composable internal fun SignInScreen( @@ -86,7 +98,7 @@ internal fun SignInScreen( state.fields.forEach { field -> key(field.id) { - androidx.compose.animation.AnimatedVisibility( + AnimatedVisibility( visible = state.isVisible(field), enter = EnterTransition, exit = ExitTransition, @@ -95,6 +107,12 @@ internal fun SignInScreen( modifier = Modifier .fillMaxWidth(), field = field, + leadingIcon = { + LoadingIcon( + field = field, + mostRecentSession = state.mostRecentSession, + ) + }, onValueChange = { field, newValue -> actions( Action.FieldChanged( @@ -153,6 +171,42 @@ internal fun SignInScreen( } } +@Composable +private fun LoadingIcon( + modifier: Modifier = Modifier, + field: FormField, + mostRecentSession: SessionSummary?, +) { + Box( + modifier = modifier, + ) { + // Always show the default leading icon + // in case the avatar does not load + field.LeadingIcon() + + val sessionAvatar = mostRecentSession?.profileAvatar + + val isAvatarForField = field.id == Username && + sessionAvatar != null && + mostRecentSession.profileHandle.id == field.value + + if (isAvatarForField) { + val avatarDescription = stringResource(CommonStrings.profile_avatar) + AsyncImage( + modifier = FormField.LeadingIconSizeModifier, + args = remember(sessionAvatar) { + ImageArgs( + url = sessionAvatar.uri, + contentDescription = avatarDescription, + contentScale = ContentScale.Crop, + shape = RoundedPolygonShape.Circle, + ) + }, + ) + } + } +} + private val EnterTransition = fadeIn() + slideInVertically { -it } private val ExitTransition = shrinkOut { IntSize(it.width, 0) } + slideOutVertically { -it } + fadeOut() diff --git a/feature/auth/src/commonMain/kotlin/com/tunjid/heron/signin/SignInStateHolder.kt b/feature/auth/src/commonMain/kotlin/com/tunjid/heron/signin/SignInStateHolder.kt index b0f1c86e9..56bec7497 100644 --- a/feature/auth/src/commonMain/kotlin/com/tunjid/heron/signin/SignInStateHolder.kt +++ b/feature/auth/src/commonMain/kotlin/com/tunjid/heron/signin/SignInStateHolder.kt @@ -21,6 +21,7 @@ import com.tunjid.heron.data.core.models.OauthUriRequest import com.tunjid.heron.data.core.models.Server import com.tunjid.heron.data.core.models.SessionRequest import com.tunjid.heron.data.core.types.GenericUri +import com.tunjid.heron.data.core.utilities.Outcome import com.tunjid.heron.data.repository.AuthRepository import com.tunjid.heron.feature.AssistedViewModelFactory import com.tunjid.heron.feature.FeatureWhileSubscribed @@ -38,7 +39,6 @@ import com.tunjid.mutator.coroutines.actionStateFlowMutator import com.tunjid.mutator.coroutines.mapLatestToManyMutations import com.tunjid.mutator.coroutines.mapToMutation import com.tunjid.mutator.coroutines.toMutationStream -import com.tunjid.mutator.mutationOf import com.tunjid.treenav.strings.Route import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory @@ -53,7 +53,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map internal typealias SignInStateHolder = ActionStateMutator> @@ -78,7 +77,12 @@ class ActualSignInViewModel( initialState = State(), started = SharingStarted.WhileSubscribed(FeatureWhileSubscribed), inputs = listOf( - authRepository.isSignedIn.map { mutationOf { copy(isSignedIn = it) } }, + pastSessionMutations( + authRepository = authRepository, + ), + isSignedInMutations( + authRepository = authRepository, + ), authDeeplinkMutations( route = route, authRepository = authRepository, @@ -114,6 +118,31 @@ class ActualSignInViewModel( }, ) +private fun isSignedInMutations( + authRepository: AuthRepository, +): Flow> = + authRepository.isSignedIn + .mapToMutation { + copy(isSignedIn = it) + } + +private fun pastSessionMutations( + authRepository: AuthRepository, +): Flow> = + authRepository.pastSessions + .mapToMutation { pastSessions -> + val mostRecentSession = pastSessions.firstOrNull() + copy( + pastSessions = pastSessions, + fields = + if (fields != InitialFields || mostRecentSession == null) fields + else fields.map { field -> + if (field.id != Username) field + else field.copy(value = mostRecentSession.profileHandle.id) + }, + ) + } + private fun authDeeplinkMutations( route: Route, authRepository: AuthRepository, @@ -250,12 +279,12 @@ private suspend fun FlowCollector>.createSessionMutations( navActions: (NavigationMutation) -> Unit, ) { emit { copy(isSubmitting = true) } - when (val exception = authRepository.createSession(request).exceptionOrNull()) { - null -> navActions(NavigationContext::resetAuthNavigation) - else -> emit { + when (val outcome = authRepository.createSession(request)) { + is Outcome.Success -> navActions(NavigationContext::resetAuthNavigation) + is Outcome.Failure -> emit { copy( messages = messages.plus( - exception.message + outcome.exception.message ?.let(Memo::Text) ?: Memo.Resource(Res.string.oauth_flow_failed), ) diff --git a/feature/auth/src/commonMain/kotlin/com/tunjid/heron/signin/State.kt b/feature/auth/src/commonMain/kotlin/com/tunjid/heron/signin/State.kt index 76f3b72bc..fd8703003 100644 --- a/feature/auth/src/commonMain/kotlin/com/tunjid/heron/signin/State.kt +++ b/feature/auth/src/commonMain/kotlin/com/tunjid/heron/signin/State.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import com.tunjid.heron.data.core.models.Server import com.tunjid.heron.data.core.models.SessionRequest +import com.tunjid.heron.data.core.models.SessionSummary import com.tunjid.heron.data.core.types.GenericUri import com.tunjid.heron.data.core.types.ProfileHandle import com.tunjid.heron.scaffold.navigation.NavigationAction @@ -76,56 +77,62 @@ data class State( val selectedServer: Server = Server.BlueSky, val availableServers: List = StartingServers, val showCustomServerPopup: Boolean = false, + val pastSessions: List = emptyList(), @Transient - val fields: List = listOf( - FormField( - id = Username, - value = "", - maxLines = 1, - leadingIcon = Icons.Rounded.AccountCircle, - transformation = VisualTransformation.None, - contentType = ContentType.Username, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Next, + val fields: List = InitialFields, + @Transient + val messages: List = emptyList(), +) + +val State.mostRecentSession + get() = pastSessions.firstOrNull() + +internal val InitialFields: List = listOf( + FormField( + id = Username, + value = "", + maxLines = 1, + leadingIcon = Icons.Rounded.AccountCircle, + transformation = VisualTransformation.None, + contentType = ContentType.Username, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next, + ), + contentDescription = Memo.Resource(Res.string.username), + validator = Validator( + String::isNotBlank to Memo.Resource( + Res.string.empty_form, + listOf(Res.string.username), ), - contentDescription = Memo.Resource(Res.string.username), - validator = Validator( - String::isNotBlank to Memo.Resource( - Res.string.empty_form, - listOf(Res.string.username), - ), - DomainRegex::matches to Memo.Resource( - Res.string.invalid_handle, - ), + DomainRegex::matches to Memo.Resource( + Res.string.invalid_handle, ), ), - FormField( - id = Password, - value = "", - maxLines = 1, - leadingIcon = Icons.Rounded.Lock, - transformation = PasswordVisualTransformation(), - contentType = ContentType.Password, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), - contentDescription = Memo.Resource(Res.string.password), - validator = Validator( - String::isNotBlank to Memo.Resource( - Res.string.empty_form, - listOf(Res.string.password), - ), + ), + FormField( + id = Password, + value = "", + maxLines = 1, + leadingIcon = Icons.Rounded.Lock, + transformation = PasswordVisualTransformation(), + contentType = ContentType.Password, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + contentDescription = Memo.Resource(Res.string.password), + validator = Validator( + String::isNotBlank to Memo.Resource( + Res.string.empty_form, + listOf(Res.string.password), ), ), ), - @Transient - val messages: List = emptyList(), ) val State.submitButtonEnabled: Boolean get() = !isSignedIn && !isSubmitting diff --git a/feature/feed/src/commonMain/kotlin/com/tunjid/heron/feed/FeedScreen.kt b/feature/feed/src/commonMain/kotlin/com/tunjid/heron/feed/FeedScreen.kt index ef77f98ee..fbf1ca11a 100644 --- a/feature/feed/src/commonMain/kotlin/com/tunjid/heron/feed/FeedScreen.kt +++ b/feature/feed/src/commonMain/kotlin/com/tunjid/heron/feed/FeedScreen.kt @@ -122,6 +122,7 @@ internal fun FeedScreen( signedInProfileId = state.signedInProfileId, recentConversations = state.recentConversations, mutedWordsPreferences = state.preferences.mutedWordPreferences, + autoPlayTimelineVideos = state.preferences.local.autoPlayTimelineVideos, ) } } @@ -136,6 +137,7 @@ private fun FeedTimeline( actions: (Action) -> Unit, mutedWordsPreferences: List, recentConversations: List, + autoPlayTimelineVideos: Boolean, ) { var pendingScrollOffset by rememberSaveable { mutableIntStateOf(0) } val gridState = rememberLazyScrollableState( @@ -429,7 +431,7 @@ private fun FeedTimeline( } } - if (paneScaffoldState.paneState.pane == ThreePane.Primary) { + if (paneScaffoldState.paneState.pane == ThreePane.Primary && autoPlayTimelineVideos) { val videoPlayerController = LocalVideoPlayerController.current gridState.interpolatedVisibleIndexEffect( denominator = 10, diff --git a/feature/home/src/commonMain/kotlin/com/tunjid/heron/home/HomeScreen.kt b/feature/home/src/commonMain/kotlin/com/tunjid/heron/home/HomeScreen.kt index b319c0a16..ad83c0f68 100644 --- a/feature/home/src/commonMain/kotlin/com/tunjid/heron/home/HomeScreen.kt +++ b/feature/home/src/commonMain/kotlin/com/tunjid/heron/home/HomeScreen.kt @@ -178,6 +178,7 @@ internal fun HomeScreen( paneScaffoldState = paneScaffoldState, signedInProfileId = state.signedInProfile?.did, mutedWordsPreferences = state.preferences.mutedWordPreferences, + autoPlayTimelineVideos = state.preferences.local.autoPlayTimelineVideos, recentConversations = state.recentConversations, timelineStateHolder = timelineStateHolder, tabsOffset = tabsOffsetNestedScrollConnection::offset, @@ -295,6 +296,7 @@ private fun HomeTimeline( paneScaffoldState: PaneScaffoldState, signedInProfileId: ProfileId?, mutedWordsPreferences: List, + autoPlayTimelineVideos: Boolean, recentConversations: List, timelineStateHolder: TimelineStateHolder, tabsOffset: () -> Offset, @@ -582,7 +584,7 @@ private fun HomeTimeline( } } - if (paneScaffoldState.paneState.pane == ThreePane.Primary) { + if (paneScaffoldState.paneState.pane == ThreePane.Primary && autoPlayTimelineVideos) { val videoPlayerController = LocalVideoPlayerController.current gridState.interpolatedVisibleIndexEffect( denominator = 10, diff --git a/feature/list/src/commonMain/kotlin/com/tunjid/heron/list/ListScreen.kt b/feature/list/src/commonMain/kotlin/com/tunjid/heron/list/ListScreen.kt index 2c42cd1f7..5bb4f5e40 100644 --- a/feature/list/src/commonMain/kotlin/com/tunjid/heron/list/ListScreen.kt +++ b/feature/list/src/commonMain/kotlin/com/tunjid/heron/list/ListScreen.kt @@ -250,6 +250,7 @@ internal fun ListScreen( actions = actions, recentConversations = state.recentConversations, mutedWordsPreferences = state.preferences.mutedWordPreferences, + autoPlayTimelineVideos = state.preferences.local.autoPlayTimelineVideos, ) } }, @@ -369,6 +370,7 @@ private fun ListTimeline( actions: (Action) -> Unit, recentConversations: List, mutedWordsPreferences: List, + autoPlayTimelineVideos: Boolean, ) { var pendingScrollOffset by rememberSaveable { mutableIntStateOf(0) } val gridState = rememberLazyScrollableState( @@ -643,7 +645,7 @@ private fun ListTimeline( } } - if (paneScaffoldState.paneState.pane == ThreePane.Primary) { + if (paneScaffoldState.paneState.pane == ThreePane.Primary && autoPlayTimelineVideos) { val videoPlayerController = LocalVideoPlayerController.current gridState.interpolatedVisibleIndexEffect( denominator = 10, diff --git a/feature/post-detail/src/commonMain/kotlin/com/tunjid/heron/postdetail/PostDetailScreen.kt b/feature/post-detail/src/commonMain/kotlin/com/tunjid/heron/postdetail/PostDetailScreen.kt index 82eeea96f..eeb3c98ce 100644 --- a/feature/post-detail/src/commonMain/kotlin/com/tunjid/heron/postdetail/PostDetailScreen.kt +++ b/feature/post-detail/src/commonMain/kotlin/com/tunjid/heron/postdetail/PostDetailScreen.kt @@ -352,7 +352,7 @@ internal fun PostDetailScreen( } } - if (paneScaffoldState.paneState.pane == ThreePane.Primary) { + if (paneScaffoldState.paneState.pane == ThreePane.Primary && state.preferences.local.autoPlayTimelineVideos) { val videoPlayerController = LocalVideoPlayerController.current gridState.interpolatedVisibleIndexEffect( denominator = 10, diff --git a/feature/posts/src/commonMain/kotlin/com/tunjid/heron/posts/PostsScreen.kt b/feature/posts/src/commonMain/kotlin/com/tunjid/heron/posts/PostsScreen.kt index be9213fe6..4a81839c3 100644 --- a/feature/posts/src/commonMain/kotlin/com/tunjid/heron/posts/PostsScreen.kt +++ b/feature/posts/src/commonMain/kotlin/com/tunjid/heron/posts/PostsScreen.kt @@ -379,7 +379,7 @@ internal fun PostsScreen( } // Auto-play videos for visible items - if (paneScaffoldState.paneState.pane == ThreePane.Primary) { + if (paneScaffoldState.paneState.pane == ThreePane.Primary && state.preferences.local.autoPlayTimelineVideos) { val videoPlayerController = LocalVideoPlayerController.current gridState.interpolatedVisibleIndexEffect( denominator = 10, diff --git a/feature/profile/src/commonMain/kotlin/com/tunjid/heron/profile/ProfileScreen.kt b/feature/profile/src/commonMain/kotlin/com/tunjid/heron/profile/ProfileScreen.kt index f4bd3679e..2eed78e05 100644 --- a/feature/profile/src/commonMain/kotlin/com/tunjid/heron/profile/ProfileScreen.kt +++ b/feature/profile/src/commonMain/kotlin/com/tunjid/heron/profile/ProfileScreen.kt @@ -475,6 +475,7 @@ internal fun ProfileScreen( actions = actions, recentConversations = state.recentConversations, mutedWordsPreferences = state.preferences.mutedWordPreferences, + autoPlayTimelineVideos = state.preferences.local.autoPlayTimelineVideos, ) is ProfileScreenStateHolders.LabelerSettings -> LabelerSettings( prefersCompactBottomNav = paneScaffoldState.prefersCompactBottomNav, @@ -793,7 +794,7 @@ private fun ProfileAvatar( trackColor = MaterialTheme.colorScheme.surface, amplitude = { if (showWave) 1f else 0f }, modifier = Modifier - .fillParentAxisIfFixedOrWrap(), + .fillMaxSize(), ) } paneScaffoldState.UpdatedMovableStickySharedElementOf( @@ -803,7 +804,7 @@ private fun ProfileAvatar( ) }, zIndexInOverlay = AvatarZIndex, - modifier = modifier + modifier = Modifier .fillMaxSize() .padding(headerState.avatarPadding) .clickable { onProfileAvatarClicked() }, @@ -1142,6 +1143,7 @@ private fun ProfileTimeline( actions: (Action) -> Unit, recentConversations: List, mutedWordsPreferences: List, + autoPlayTimelineVideos: Boolean, ) { var pendingScrollOffset by rememberSaveable { mutableIntStateOf(0) } val gridState = rememberLazyScrollableState( @@ -1406,7 +1408,7 @@ private fun ProfileTimeline( } } - if (paneScaffoldState.paneState.pane == ThreePane.Primary) { + if (paneScaffoldState.paneState.pane == ThreePane.Primary && autoPlayTimelineVideos) { val videoPlayerController = LocalVideoPlayerController.current gridState.interpolatedVisibleIndexEffect( denominator = 10, diff --git a/feature/search/src/commonMain/kotlin/com/tunjid/heron/search/ui/searchresults/GeneralSearchResults.kt b/feature/search/src/commonMain/kotlin/com/tunjid/heron/search/ui/searchresults/GeneralSearchResults.kt index 844814fe8..26a92c8aa 100644 --- a/feature/search/src/commonMain/kotlin/com/tunjid/heron/search/ui/searchresults/GeneralSearchResults.kt +++ b/feature/search/src/commonMain/kotlin/com/tunjid/heron/search/ui/searchresults/GeneralSearchResults.kt @@ -198,6 +198,7 @@ internal fun GeneralSearchResults( modifier = modifier, signedInProfileId = state.signedInProfile?.did, mutedWordPreferences = state.preferences.mutedWordPreferences, + autoPlayTimelineVideos = state.preferences.local.autoPlayTimelineVideos, recentConversations = state.recentConversations, videoStates = videoStates, paneScaffoldState = paneScaffoldState, diff --git a/feature/search/src/commonMain/kotlin/com/tunjid/heron/search/ui/searchresults/PostSearchResults.kt b/feature/search/src/commonMain/kotlin/com/tunjid/heron/search/ui/searchresults/PostSearchResults.kt index c20e6bbc7..5251f58e5 100644 --- a/feature/search/src/commonMain/kotlin/com/tunjid/heron/search/ui/searchresults/PostSearchResults.kt +++ b/feature/search/src/commonMain/kotlin/com/tunjid/heron/search/ui/searchresults/PostSearchResults.kt @@ -86,6 +86,7 @@ internal fun PostSearchResults( signedInProfileId: ProfileId?, recentConversations: List, mutedWordPreferences: List, + autoPlayTimelineVideos: Boolean, videoStates: ThreadedVideoPositionStates, paneScaffoldState: PaneScaffoldState, onLinkTargetClicked: (LinkTarget) -> Unit, @@ -255,7 +256,7 @@ internal fun PostSearchResults( }, ) } - if (paneScaffoldState.paneState.pane == ThreePane.Primary) { + if (paneScaffoldState.paneState.pane == ThreePane.Primary && autoPlayTimelineVideos) { val videoPlayerController = LocalVideoPlayerController.current gridState.interpolatedVisibleIndexEffect( denominator = 10, diff --git a/feature/settings/src/commonMain/composeResources/values/strings.xml b/feature/settings/src/commonMain/composeResources/values/strings.xml index bf8a270df..83f3a6a22 100644 --- a/feature/settings/src/commonMain/composeResources/values/strings.xml +++ b/feature/settings/src/commonMain/composeResources/values/strings.xml @@ -32,4 +32,5 @@ Use device theme from wallpaper color Use compact bottom navigation bar Autohide bottom navigation bar on scroll + Autoplay videos on the timeline diff --git a/feature/settings/src/commonMain/kotlin/com/tunjid/heron/settings/SettingsScreen.kt b/feature/settings/src/commonMain/kotlin/com/tunjid/heron/settings/SettingsScreen.kt index 6f98781cb..78f275d1b 100644 --- a/feature/settings/src/commonMain/kotlin/com/tunjid/heron/settings/SettingsScreen.kt +++ b/feature/settings/src/commonMain/kotlin/com/tunjid/heron/settings/SettingsScreen.kt @@ -57,6 +57,9 @@ internal fun SettingsScreen( setRefreshHomeTimelineOnLaunch = { actions(Action.SetRefreshHomeTimelinesOnLaunch(it)) }, + setAutoplayTimelineVideos = { + actions(Action.SetAutoPlayTimelineVideos(it)) + }, ) ModerationItem( modifier = Modifier diff --git a/feature/settings/src/commonMain/kotlin/com/tunjid/heron/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/com/tunjid/heron/settings/SettingsViewModel.kt index 2d1620b01..024824a08 100644 --- a/feature/settings/src/commonMain/kotlin/com/tunjid/heron/settings/SettingsViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/com/tunjid/heron/settings/SettingsViewModel.kt @@ -84,6 +84,10 @@ class ActualSettingsViewModel( userDataRepository = userDataRepository, ) + is Action.SetAutoPlayTimelineVideos -> action.flow.timelineVideoAutoPlayMutations( + userDataRepository = userDataRepository, + ) + is Action.SetDynamicThemingPreference -> action.flow.toggleDynamicTheming( userDataRepository = userDataRepository, ) @@ -132,6 +136,13 @@ private fun Flow.homeTimelineRefreshOnLa userDataRepository.setRefreshedHomeTimelineOnLaunch(refreshOnLaunch) } +private fun Flow.timelineVideoAutoPlayMutations( + userDataRepository: UserDataRepository, +): Flow> = + mapToManyMutations { (autoPlayTimelineVideos) -> + userDataRepository.setAutoPlayTimelineVideos(autoPlayTimelineVideos) + } + private fun Flow.toggleDynamicTheming( userDataRepository: UserDataRepository, ): Flow> = diff --git a/feature/settings/src/commonMain/kotlin/com/tunjid/heron/settings/State.kt b/feature/settings/src/commonMain/kotlin/com/tunjid/heron/settings/State.kt index 0204c608a..117840a66 100644 --- a/feature/settings/src/commonMain/kotlin/com/tunjid/heron/settings/State.kt +++ b/feature/settings/src/commonMain/kotlin/com/tunjid/heron/settings/State.kt @@ -37,6 +37,10 @@ sealed class Action(val key: String) { val refreshHomeTimelinesOnLaunch: Boolean, ) : Action(key = "SetRefreshHomeTimelinesOnLaunch") + data class SetAutoPlayTimelineVideos( + val autoPlayTimelineVideos: Boolean, + ) : Action(key = "SetAutoPlayTimelineVideos") + data class SetDynamicThemingPreference( val dynamicTheming: Boolean, ) : Action(key = "SetDynamicThemingPreference") diff --git a/feature/settings/src/commonMain/kotlin/com/tunjid/heron/settings/ui/ContentAndMediaItem.kt b/feature/settings/src/commonMain/kotlin/com/tunjid/heron/settings/ui/ContentAndMediaItem.kt index 75ba715b6..572e6af10 100644 --- a/feature/settings/src/commonMain/kotlin/com/tunjid/heron/settings/ui/ContentAndMediaItem.kt +++ b/feature/settings/src/commonMain/kotlin/com/tunjid/heron/settings/ui/ContentAndMediaItem.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.tunjid.heron.data.core.models.Preferences import heron.feature.settings.generated.resources.Res +import heron.feature.settings.generated.resources.auto_play_timeline_videos import heron.feature.settings.generated.resources.content_and_media import heron.feature.settings.generated.resources.refresh_timelines_on_launch import org.jetbrains.compose.resources.stringResource @@ -32,6 +33,7 @@ fun ContentAndMediaItem( modifier: Modifier = Modifier, signedInProfilePreferences: Preferences, setRefreshHomeTimelineOnLaunch: (Boolean) -> Unit, + setAutoplayTimelineVideos: (Boolean) -> Unit, ) { ExpandableSettingsItemRow( modifier = modifier @@ -47,5 +49,13 @@ fun ContentAndMediaItem( checked = signedInProfilePreferences.local.refreshHomeTimelineOnLaunch, onCheckedChange = setRefreshHomeTimelineOnLaunch, ) + SettingsToggleItem( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(Res.string.auto_play_timeline_videos), + enabled = true, + checked = signedInProfilePreferences.local.autoPlayTimelineVideos, + onCheckedChange = setAutoplayTimelineVideos, + ) } } diff --git a/scaffold/src/commonMain/kotlin/com/tunjid/heron/scaffold/scaffold/PaneNavigation.kt b/scaffold/src/commonMain/kotlin/com/tunjid/heron/scaffold/scaffold/PaneNavigation.kt index ae583346d..d7e9e3699 100644 --- a/scaffold/src/commonMain/kotlin/com/tunjid/heron/scaffold/scaffold/PaneNavigation.kt +++ b/scaffold/src/commonMain/kotlin/com/tunjid/heron/scaffold/scaffold/PaneNavigation.kt @@ -27,6 +27,7 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.size @@ -122,6 +123,7 @@ internal fun AppState.PaneNavigationBar( LookaheadScope { Surface( modifier = modifier + .fillMaxWidth() .animateBounds(lookaheadScope = this@LookaheadScope), color = BottomAppBarDefaults.containerColor, contentColor = contentColorFor(BottomAppBarDefaults.containerColor), @@ -129,11 +131,14 @@ internal fun AppState.PaneNavigationBar( Row( modifier = Modifier .navigationBarsPadding() + .fillMaxWidth() .height(UiTokens.bottomNavHeight(isCompact = prefersCompactBottomNav)), verticalAlignment = Alignment.CenterVertically, ) { navItems.forEach { item -> NavigationBarItem( + modifier = Modifier + .weight(1f), icon = { BadgedBox( badge = { diff --git a/ui/core/src/commonMain/composeResources/values/strings.xml b/ui/core/src/commonMain/composeResources/values/strings.xml index 09d55f341..bcce7925a 100644 --- a/ui/core/src/commonMain/composeResources/values/strings.xml +++ b/ui/core/src/commonMain/composeResources/values/strings.xml @@ -61,6 +61,7 @@ List Post Starter Pack + Profile avatar Follow Following Follows you diff --git a/ui/core/src/commonMain/kotlin/com/tunjid/heron/ui/text/Forms.kt b/ui/core/src/commonMain/kotlin/com/tunjid/heron/ui/text/Forms.kt index aba9f567e..279f5389e 100644 --- a/ui/core/src/commonMain/kotlin/com/tunjid/heron/ui/text/Forms.kt +++ b/ui/core/src/commonMain/kotlin/com/tunjid/heron/ui/text/Forms.kt @@ -16,6 +16,7 @@ package com.tunjid.heron.ui.text +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardActionScope import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -35,6 +36,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.contentType import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.tunjid.heron.ui.text.FormField.Companion.LeadingIconSizeModifier import kotlin.jvm.JvmInline import kotlinx.serialization.Transient @@ -63,6 +66,11 @@ data class FormField( ) { override fun toString(): String = id } + + companion object { + val LeadingIconSizeModifier = Modifier + .size(24.dp) + } } fun List.copyWithValidation( @@ -118,6 +126,9 @@ fun Validator(vararg pairs: Pair<(String) -> Boolean, Memo>) = inline fun FormField( modifier: Modifier = Modifier, field: FormField, + crossinline leadingIcon: @Composable () -> Unit = { + field.LeadingIcon() + }, crossinline onValueChange: (field: FormField, newValue: String) -> Unit, crossinline keyboardActions: KeyboardActionScope.(FormField) -> Unit, ) { @@ -147,15 +158,10 @@ inline fun FormField( Text(it.message) } }, - leadingIcon = field.leadingIcon?.let { - { - Icon( - imageVector = it, - contentDescription = field.contentDescription?.message, - ) - } + leadingIcon = { + leadingIcon() }, - supportingText = if (showError) field.errorMessage?.let { + supportingText = if (showError) field.errorMessage.let { { Text( text = it.message, @@ -166,3 +172,14 @@ inline fun FormField( else null, ) } + +@Composable +fun FormField.LeadingIcon() { + leadingIcon?.let { + Icon( + modifier = LeadingIconSizeModifier, + imageVector = it, + contentDescription = contentDescription?.message, + ) + } +} diff --git a/ui/timeline/src/commonMain/composeResources/values/strings.xml b/ui/timeline/src/commonMain/composeResources/values/strings.xml index ec351cd05..f1c61a3be 100644 --- a/ui/timeline/src/commonMain/composeResources/values/strings.xml +++ b/ui/timeline/src/commonMain/composeResources/values/strings.xml @@ -87,6 +87,7 @@ More Options Unmute video Play video + Pause video Copy link to clipboard Send via Direct Message Share in a post diff --git a/ui/timeline/src/commonMain/kotlin/com/tunjid/heron/timeline/ui/post/PostVideos.kt b/ui/timeline/src/commonMain/kotlin/com/tunjid/heron/timeline/ui/post/PostVideos.kt index cface50d7..fd376b504 100644 --- a/ui/timeline/src/commonMain/kotlin/com/tunjid/heron/timeline/ui/post/PostVideos.kt +++ b/ui/timeline/src/commonMain/kotlin/com/tunjid/heron/timeline/ui/post/PostVideos.kt @@ -36,6 +36,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.VolumeOff import androidx.compose.material.icons.automirrored.rounded.VolumeUp import androidx.compose.material.icons.rounded.Movie +import androidx.compose.material.icons.rounded.Pause import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -67,6 +68,7 @@ import com.tunjid.treenav.compose.MovableElementSharedTransitionScope import com.tunjid.treenav.compose.UpdatedMovableStickySharedElementOf import heron.ui.timeline.generated.resources.Res import heron.ui.timeline.generated.resources.mute_video +import heron.ui.timeline.generated.resources.pause_video import heron.ui.timeline.generated.resources.play_video import heron.ui.timeline.generated.resources.unmute_video import org.jetbrains.compose.resources.stringResource @@ -184,6 +186,19 @@ private fun PlayerInfo( visible = videoPlayerState.status is PlayerStatus.Play.Confirmed, ) { Row { + PlayerControlBackground( + onClicked = { + videoPlayerController.pauseActiveVideo() + }, + content = { + Icon( + modifier = Modifier + .padding(4.dp), + contentDescription = stringResource(Res.string.pause_video), + imageVector = Icons.Rounded.Pause, + ) + }, + ) PlayerControlBackground { BasicText( modifier = Modifier diff --git a/ui/timeline/src/commonMain/kotlin/com/tunjid/heron/timeline/utilities/TimelineExt.kt b/ui/timeline/src/commonMain/kotlin/com/tunjid/heron/timeline/utilities/TimelineExt.kt index 9509c1054..f881ba69f 100644 --- a/ui/timeline/src/commonMain/kotlin/com/tunjid/heron/timeline/utilities/TimelineExt.kt +++ b/ui/timeline/src/commonMain/kotlin/com/tunjid/heron/timeline/utilities/TimelineExt.kt @@ -128,7 +128,10 @@ fun TimelineTitle( Spacer(Modifier.width(12.dp)) - Box { + Box( + modifier = Modifier + .weight(1f), + ) { Column { PaneStickySharedElement( modifier = Modifier, @@ -166,7 +169,6 @@ fun TimelineTitle( modifier = Modifier.size(4.dp), ) } - Spacer(Modifier.weight(1f)) TimelinePresentationSelector( selected = timeline.presentation, available = timeline.supportedPresentations,