diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 12eec46..e503149 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ key(item.id) { val active = selected == item.id - val contentColor = if (active) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } + val interactionSource = remember { MutableInteractionSource() } + val contentColor by animateColorAsState( + targetValue = if (active) MaterialTheme.colorScheme.onSecondaryContainer + else MaterialTheme.colorScheme.onSurfaceVariant, + animationSpec = tween(150), + label = "${item.label} navigation color", + ) + val iconScale by animateFloatAsState( + targetValue = if (active) 1.1f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "${item.label} navigation scale", + ) Column( modifier = Modifier .weight(1f) .fillMaxHeight() .selectable( selected = active, - onClick = { if (!active) onSelected(item.id) }, + onClick = { onSelected(item.id) }, role = Role.Tab, + interactionSource = interactionSource, + indication = null, ), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Surface( - color = if (active) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, - contentColor = contentColor, - shape = CircleShape, + Box( + modifier = Modifier.width(64.dp).height(32.dp), + contentAlignment = Alignment.Center, ) { + androidx.compose.animation.AnimatedVisibility( + visible = active, + enter = fadeIn(tween(100)) + scaleIn( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + ), + exit = fadeOut(tween(100)) + scaleOut(tween(100)), + ) { + Box( + Modifier + .fillMaxSize() + .padding(horizontal = 4.dp) + .background( + MaterialTheme.colorScheme.secondaryContainer, + RoundedCornerShape(16.dp), + ), + ) + } Box( - modifier = Modifier.width(64.dp).height(32.dp), + modifier = Modifier + .width(48.dp) + .height(24.dp) + .graphicsLayer { + scaleX = iconScale + scaleY = iconScale + }, contentAlignment = Alignment.Center, ) { - StraeNavIcon(item) + StraeNavIcon(item, contentColor) } } Text( @@ -180,7 +237,7 @@ fun StraeShortNavigation( } @Composable -private fun StraeNavIcon(item: ExpressiveNavItem) { +private fun StraeNavIcon(item: ExpressiveNavItem, contentColor: Color) { if (item.avatarUrl != null || item.avatarFallback != null) { Box( modifier = Modifier @@ -201,11 +258,12 @@ private fun StraeNavIcon(item: ExpressiveNavItem) { text = item.avatarFallback?.take(1)?.uppercase() ?: "?", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, + color = contentColor, ) } } } else { - Icon(painterResource(item.icon), contentDescription = item.label) + Icon(painterResource(item.icon), contentDescription = item.label, tint = contentColor) } } @@ -225,24 +283,90 @@ fun StraeFilterSortBar( sortLabel: String, sortOptions: List, selectedSort: Int, + sortDescending: Boolean, availableTags: List, selectedTags: Set, + randomEnabled: Boolean, + onRandom: () -> Unit, onSortSelected: (Int) -> Unit, + onSortDirectionToggle: () -> Unit, onTagsSelected: (Set) -> Unit, ) { StraeExpressiveTheme { var showSort by remember { mutableStateOf(false) } var showFilters by remember { mutableStateOf(false) } + var randomTurns by remember { mutableIntStateOf(0) } + val randomRotation by animateFloatAsState( + targetValue = randomTurns * 360f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + label = "Random icon rotation", + ) - ButtonGroup( + Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), - overflowIndicator = { menuState -> ButtonGroupDefaults.OverflowIndicator(menuState) }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - clickableItem(onClick = { showSort = true }, label = sortLabel) - clickableItem( - onClick = { showFilters = true }, - label = if (selectedTags.isEmpty()) "Filters" else "Filters · ${selectedTags.size}", - ) + FilledTonalButton( + onClick = { + randomTurns += 1 + onRandom() + }, + enabled = randomEnabled, + modifier = Modifier.height(42.dp), + shape = RoundedCornerShape(26.dp), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ), + contentPadding = ButtonDefaults.ContentPadding, + ) { + Icon( + painter = painterResource(R.drawable.ic_shuffle), + contentDescription = null, + modifier = Modifier.size(20.dp).graphicsLayer { rotationZ = randomRotation }, + ) + Spacer(Modifier.width(8.dp)) + Text("Random", fontWeight = FontWeight.Medium) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + FilledTonalIconButton( + onClick = { showFilters = true }, + modifier = Modifier.size(42.dp), + shape = RoundedCornerShape( + topStart = 26.dp, + bottomStart = 26.dp, + topEnd = 8.dp, + bottomEnd = 8.dp, + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_filter), + contentDescription = if (selectedTags.isEmpty()) "Filter live streams" + else "Filter live streams, ${selectedTags.size} selected", + ) + } + Spacer(Modifier.width(4.dp)) + FilledTonalIconButton( + onClick = { showSort = true }, + modifier = Modifier.size(42.dp), + shape = RoundedCornerShape( + topStart = 8.dp, + bottomStart = 8.dp, + topEnd = 26.dp, + bottomEnd = 26.dp, + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_sort), + contentDescription = "Sort live streams: $sortLabel", + ) + } + } } if (showSort) { @@ -250,11 +374,10 @@ fun StraeFilterSortBar( title = "Sort live streams", options = sortOptions, selected = selectedSort, + descending = sortDescending, onDismiss = { showSort = false }, - onSelected = { - onSortSelected(it) - showSort = false - }, + onSelected = onSortSelected, + onDirectionToggle = onSortDirectionToggle, ) } @@ -278,8 +401,10 @@ private fun ChoiceSheet( title: String, options: List, selected: Int, + descending: Boolean, onDismiss: () -> Unit, onSelected: (Int) -> Unit, + onDirectionToggle: () -> Unit, ) { ModalBottomSheet( onDismissRequest = onDismiss, @@ -300,28 +425,42 @@ private fun ChoiceSheet( fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 12.dp), ) - options.forEachIndexed { index, label -> - val isSelected = selected == index - Surface( - color = if (isSelected) MaterialTheme.colorScheme.secondaryContainer - else MaterialTheme.colorScheme.surfaceContainerLow, - contentColor = if (isSelected) MaterialTheme.colorScheme.onSecondaryContainer - else MaterialTheme.colorScheme.onSurface, - shape = RoundedCornerShape(18.dp), - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = isSelected, - onClick = { onSelected(index) }, - role = Role.RadioButton, - ), - ) { - Row( - modifier = Modifier.padding(start = 20.dp, end = 12.dp, top = 12.dp, bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically, + SortDirectionCard( + descending = descending, + onClick = onDirectionToggle, + modifier = Modifier.padding(bottom = 12.dp), + ) + Column( + modifier = Modifier.clip(RoundedCornerShape(20.dp)), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + options.forEachIndexed { index, label -> + val isSelected = selected == index + val containerColor by animateColorAsState( + targetValue = if (isSelected) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.surfaceContainerLow, + label = "$label selection color", + ) + Surface( + color = containerColor, + contentColor = if (isSelected) MaterialTheme.colorScheme.onSecondaryContainer + else MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .selectable( + selected = isSelected, + onClick = { onSelected(index) }, + role = Role.RadioButton, + ), ) { - Text(label, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) - RadioButton(selected = isSelected, onClick = null) + Row( + modifier = Modifier.padding(start = 20.dp, end = 12.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + RadioButton(selected = isSelected, onClick = null) + } } } } @@ -329,6 +468,88 @@ private fun ChoiceSheet( } } +@Composable +private fun SortDirectionCard( + descending: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val transition = updateTransition(descending, label = "Sort direction") + val containerColor by transition.animateColor( + transitionSpec = { + spring(dampingRatio = 0.82f, stiffness = Spring.StiffnessMediumLow) + }, + label = "Sort direction container", + ) { isDescending -> + if (isDescending) MaterialTheme.colorScheme.tertiaryContainer + else MaterialTheme.colorScheme.primaryContainer + } + val contentColor by transition.animateColor( + transitionSpec = { + spring(dampingRatio = 0.86f, stiffness = Spring.StiffnessMediumLow) + }, + label = "Sort direction content", + ) { isDescending -> + if (isDescending) MaterialTheme.colorScheme.onTertiaryContainer + else MaterialTheme.colorScheme.onPrimaryContainer + } + val arrowRotation by transition.animateFloat( + transitionSpec = { + spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessLow) + }, + label = "Sort direction arrow", + ) { isDescending -> if (isDescending) 0f else 180f } + val arrowScale by transition.animateFloat( + transitionSpec = { + spring(dampingRatio = 0.62f, stiffness = Spring.StiffnessMediumLow) + }, + label = "Sort direction arrow scale", + ) { isDescending -> if (isDescending) 1f else 1.08f } + val iconContainerColor by animateColorAsState( + targetValue = contentColor.copy(alpha = 0.16f), + label = "Sort direction icon container", + ) + + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(containerColor) + .clickable(onClick = onClick), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Box( + modifier = Modifier.size(44.dp).clip(CircleShape).background(iconContainerColor), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_down), + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(24.dp).rotate(arrowRotation).scale(arrowScale), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Order", + style = MaterialTheme.typography.labelLarge, + color = contentColor.copy(alpha = 0.82f), + ) + Text( + text = if (descending) "Descending" else "Ascending", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = contentColor, + ) + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun FilterSheet( @@ -361,8 +582,12 @@ private fun FilterSheet( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp, bottom = 12.dp), ) - Column( - modifier = Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), + FlowRow( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { if (availableTags.isEmpty()) { @@ -378,7 +603,6 @@ private fun FilterSheet( pendingTags = if (tag in pendingTags) pendingTags - tag else pendingTags + tag }, label = { Text(tag) }, - modifier = Modifier.fillMaxWidth(), ) } } @@ -400,16 +624,21 @@ fun StraeYouScreen( handle: String?, did: String?, avatarUrl: String?, + scrollToTopRequest: Int, onFindPeople: () -> Unit, onSignIn: (String) -> Unit, onSignOut: () -> Unit, ) { StraeExpressiveTheme { var handleInput by remember { mutableStateOf("") } + val scrollState = rememberScrollState() + LaunchedEffect(scrollToTopRequest) { + if (scrollToTopRequest > 0) scrollState.animateScrollTo(0) + } Column( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) .padding(horizontal = 24.dp, vertical = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/app/src/main/java/place/strae/MainActivity.kt b/app/src/main/java/place/strae/MainActivity.kt index dc23239..26a5180 100644 --- a/app/src/main/java/place/strae/MainActivity.kt +++ b/app/src/main/java/place/strae/MainActivity.kt @@ -1,5 +1,7 @@ package you.jolly.strae +import android.animation.Animator +import android.animation.AnimatorListenerAdapter import android.content.ComponentName import android.content.Intent import android.content.res.ColorStateList @@ -12,6 +14,7 @@ import android.text.format.DateUtils import android.view.Gravity import android.view.View import android.view.ViewGroup +import android.view.animation.PathInterpolator import android.widget.Button import android.widget.EditText import android.widget.FrameLayout @@ -21,6 +24,8 @@ import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.annotation.OptIn +import androidx.activity.BackEventCompat +import androidx.activity.OnBackPressedCallback import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -66,17 +71,27 @@ class MainActivity : AppCompatActivity() { private lateinit var followStore: FollowStore private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private lateinit var content: FrameLayout - private lateinit var toolbar: MaterialToolbar private lateinit var overlayRoot: FrameLayout + private var pageTitle: CharSequence = "Strae" + private var pageSubtitle: CharSequence? = "Live, on the open social web" private var miniPlayer: View? = null private var miniControllerFuture: ListenableFuture? = null private var miniPlayerExtras: Bundle? = null private var loadJob: Job? = null - private var liveSort by mutableStateOf(LiveSort.VIEWERS_HIGH) + private var liveSort by mutableStateOf(LiveSort.VIEWS) + private var liveSortDescending by mutableStateOf(true) private var liveTags by mutableStateOf(emptySet()) private var followingSource = FollowingSource.LOCAL private var selectedNav by mutableIntStateOf(NAV_LIVE) private var accountActor by mutableStateOf(null) + private var pendingTabDirection = 0 + private var liveScreenView: View? = null + private var predictiveBackView: View? = null + private var predictiveBackPreview: View? = null + private var showingTabRoot = false + private var scrollCurrentTabToTop: (() -> Unit)? = null + private var scrollLiveToTop: (() -> Unit)? = null + private var accountScrollToTopRequest by mutableIntStateOf(0) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -84,6 +99,28 @@ class MainActivity : AppCompatActivity() { oauth = OAuthManager(this) followStore = FollowStore(this) buildShell() + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackStarted(backEvent: BackEventCompat) { + if (selectedNav != NAV_LIVE) beginTabPredictiveBack(backEvent) + } + + override fun handleOnBackProgressed(backEvent: BackEventCompat) { + if (selectedNav != NAV_LIVE) updateTabPredictiveBack(backEvent) + } + + override fun handleOnBackCancelled() { + cancelTabPredictiveBack() + } + + override fun handleOnBackPressed() { + if (selectedNav != NAV_LIVE) { + if (!commitTabPredictiveBack()) navigateTo(NAV_LIVE) + } else { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } + } + }) oauth.did?.let { did -> scope.launch { accountActor = runCatching { api.profiles(listOf(did))[did] }.getOrNull() @@ -111,13 +148,8 @@ class MainActivity : AppCompatActivity() { orientation = LinearLayout.VERTICAL setBackgroundColor(MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface)) } - toolbar = MaterialToolbar(this).apply { - title = "Strae" - subtitle = "Live, on the open social web" - } content = FrameLayout(this).apply { id = View.generateViewId() } val navigation = compactNavigation() - root.addView(toolbar, matchWrap()) root.addView(content, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) root.addView(navigation, matchWrap()) overlayRoot = FrameLayout(this).apply { addView(root, matchMatch()) } @@ -155,7 +187,22 @@ class MainActivity : AppCompatActivity() { } private fun navigateTo(destination: Int) { + if (destination == selectedNav && content.childCount > 0) { + if (showingTabRoot) { + scrollCurrentTabToTop?.invoke() + } else { + showTabRoot(destination) + } + return + } + val previous = selectedNav + pendingTabDirection = if (content.childCount > 0) destination.compareTo(previous) else 0 selectedNav = destination + showTabRoot(destination) + pendingTabDirection = 0 + } + + private fun showTabRoot(destination: Int) { when (destination) { NAV_LIVE -> showLive() NAV_FOLLOWING -> showFollowing() @@ -165,13 +212,17 @@ class MainActivity : AppCompatActivity() { } private fun showLive() { - toolbar.title = "Live on Streamplace" - toolbar.subtitle = null + showingTabRoot = true + scrollCurrentTabToTop = null + setPageHeading("Live on Streamplace") val screen = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } var availableTags by mutableStateOf(emptyList()) var loadedStreams = emptyList() + var randomCandidates = emptyList() + var randomEnabled by mutableStateOf(false) var rerender: () -> Unit = {} + lateinit var recycler: RecyclerView val controls = ComposeView(this).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool) setContent { @@ -179,12 +230,21 @@ class MainActivity : AppCompatActivity() { sortLabel = liveSort.label, sortOptions = LiveSort.entries.map { it.label }, selectedSort = liveSort.ordinal, + sortDescending = liveSortDescending, availableTags = availableTags, selectedTags = liveTags, + randomEnabled = randomEnabled, + onRandom = { + randomCandidates.randomOrNull()?.let(::openLive) + }, onSortSelected = { index -> liveSort = LiveSort.entries[index] rerender() }, + onSortDirectionToggle = { + liveSortDescending = !liveSortDescending + rerender() + }, onTagsSelected = { tags -> liveTags = tags rerender() @@ -193,11 +253,13 @@ class MainActivity : AppCompatActivity() { } } - val recycler = RecyclerView(this).apply { + recycler = RecyclerView(this).apply { layoutManager = GridLayoutManager(this@MainActivity, if (resources.configuration.screenWidthDp >= 700) 2 else 1) clipToPadding = false setPadding(dp(10), dp(3), dp(10), dp(20)) } + scrollLiveToTop = { recycler.smoothScrollToPosition(0) } + scrollCurrentTabToTop = scrollLiveToTop val adapter = LiveStreamAdapter( click = ::openLive, selectTag = { selected -> @@ -211,46 +273,38 @@ class MainActivity : AppCompatActivity() { setColorSchemeResources(R.color.strae_primary, R.color.strae_secondary) } val emptyState = messageView("").apply { visibility = View.GONE } - val scrollTop = MaterialButton(this).apply { - text = "↑ Top" - isAllCaps = false - cornerRadius = dp(22) - visibility = View.GONE - setOnClickListener { recycler.smoothScrollToPosition(0) } - } val body = FrameLayout(this).apply { addView(refresh, matchMatch()) addView(emptyState, centered()) - addView(scrollTop, FrameLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - Gravity.BOTTOM or Gravity.END, - ).apply { setMargins(0, 0, dp(16), dp(16)) }) } - recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - scrollTop.visibility = if (recyclerView.canScrollVertically(-1)) View.VISIBLE else View.GONE - } - }) screen.addView(controls, matchWrap()) screen.addView(body, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) - replace(screen) + liveScreenView = replace(screen) fun render(streams: List) { loadedStreams = streams availableTags = streams.flatMap { it.tags }.distinct().sortedWith( compareByDescending { it.startsWith("lang:") }.thenBy { it.lowercase() }, ) - val filtered = streams - .filter { liveTags.isEmpty() || liveTags.all { selected -> it.tags.any { tag -> tag.equals(selected, true) } } } - .let { items -> - when (liveSort) { - LiveSort.VIEWERS_HIGH -> items.sortedByDescending { it.viewers } - LiveSort.VIEWERS_LOW -> items.sortedBy { it.viewers } - LiveSort.RECENT -> items.sortedByDescending { it.startedAt } - LiveSort.CHANNEL -> items.sortedBy { it.author.handle.lowercase() } - } + val matching = streams.filter { + liveTags.isEmpty() || liveTags.all { selected -> + it.tags.any { tag -> tag.equals(selected, true) } + } + } + val comparator = when (liveSort) { + LiveSort.TIME -> compareBy { it.startedAt } + LiveSort.VIEWS -> compareBy { it.viewers } + LiveSort.TITLE -> compareBy { it.title.lowercase() } + LiveSort.CREATOR -> compareBy { it.author.handle.lowercase() } } + val directedComparator = if (liveSortDescending) { + Comparator { first, second -> comparator.compare(second, first) } + } else { + comparator + } + val filtered = matching.sortedWith(directedComparator) + randomCandidates = matching + randomEnabled = matching.isNotEmpty() adapter.submit(filtered) emptyState.text = if (liveTags.isEmpty()) "Nobody is live right now." else "No live streams match these filters." emptyState.visibility = if (filtered.isEmpty()) View.VISIBLE else View.GONE @@ -279,23 +333,35 @@ class MainActivity : AppCompatActivity() { } private fun showVideos(repo: String? = null) { - toolbar.title = if (repo == null) "Recent videos" else "Videos by @$repo" - toolbar.subtitle = null + showingTabRoot = repo == null + scrollCurrentTabToTop = null + setPageHeading(if (repo == null) "Recent videos" else "Videos by @$repo") loading() loadJob?.cancel() loadJob = scope.launch { runCatching { api.videos(repo) } .onSuccess { videos -> if (videos.isEmpty()) empty("No videos found.") - else list(VideoAdapter(videos) { video -> openVideo(video) }) + else { + val videoRecycler = recycler(VideoAdapter(videos) { video -> openVideo(video) }) + replace(videoRecycler, matchMatch()) + if (repo == null) { + scrollCurrentTabToTop = { videoRecycler.smoothScrollToPosition(0) } + } + } } .onFailure { error(it) } } } private fun showFollowing() { - toolbar.title = "Following" - toolbar.subtitle = if (followingSource == FollowingSource.LOCAL) "Saved on this device" else "@${oauth.handle ?: oauth.did}" + showingTabRoot = true + scrollCurrentTabToTop = null + setPageHeading( + title = "Following", + subtitle = if (followingSource == FollowingSource.LOCAL) "Saved on this device" + else "@${oauth.handle ?: oauth.did}", + ) val screen = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } val sources = LinearLayout(this).apply { @@ -321,6 +387,7 @@ class MainActivity : AppCompatActivity() { clipToPadding = false setPadding(dp(10), dp(4), dp(10), dp(18)) } + scrollCurrentTabToTop = { recycler.smoothScrollToPosition(0) } val adapter = FollowingAdapter( open = { entry -> entry.live?.let(::openLive) ?: showActor(entry.actor) }, removeLocal = { actor -> @@ -385,8 +452,9 @@ class MainActivity : AppCompatActivity() { } private fun showSearch() { - toolbar.title = "Find people" - toolbar.subtitle = null + showingTabRoot = false + scrollCurrentTabToTop = null + setPageHeading("Find people") val root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL setPadding(dp(16), dp(16), dp(16), dp(16)) @@ -428,8 +496,9 @@ class MainActivity : AppCompatActivity() { } private fun showActor(actor: Actor) { - toolbar.title = actor.displayName - toolbar.subtitle = null + showingTabRoot = false + scrollCurrentTabToTop = null + setPageHeading(actor.displayName) val layout = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER @@ -501,8 +570,9 @@ class MainActivity : AppCompatActivity() { } private fun showAccount() { - toolbar.title = "You" - toolbar.subtitle = null + showingTabRoot = true + scrollCurrentTabToTop = { accountScrollToTopRequest += 1 } + setPageHeading("You") replace(ComposeView(this).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool) setContent { @@ -511,6 +581,7 @@ class MainActivity : AppCompatActivity() { handle = accountActor?.handle ?: oauth.handle, did = oauth.did, avatarUrl = accountActor?.avatarUrl, + scrollToTopRequest = accountScrollToTopRequest, onFindPeople = ::showSearch, onSignIn = { handle -> scope.launch { @@ -677,7 +748,6 @@ class MainActivity : AppCompatActivity() { replace(messageView(error.message ?: "Something went wrong"), centered()) Toast.makeText(this, error.message ?: "Something went wrong", Toast.LENGTH_LONG).show() } - private fun list(adapter: RecyclerView.Adapter<*>) = replace(recycler(adapter), matchMatch()) private fun recycler(adapter: RecyclerView.Adapter<*>) = RecyclerView(this).apply { layoutManager = LinearLayoutManager(this@MainActivity) this.adapter = adapter @@ -694,10 +764,152 @@ class MainActivity : AppCompatActivity() { gravity = Gravity.CENTER setPadding(dp(24), dp(24), dp(24), dp(24)) } - private fun replace(view: View, params: ViewGroup.LayoutParams = matchMatch()) { - content.removeAllViews() - content.addView(view, params) + private fun setPageHeading(title: CharSequence, subtitle: CharSequence? = null) { + pageTitle = title + pageSubtitle = subtitle + } + + private fun replace(view: View, params: ViewGroup.LayoutParams = matchMatch()): View { + val page = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(MaterialToolbar(this@MainActivity).apply { + title = pageTitle + subtitle = pageSubtitle + }, matchWrap()) + addView(FrameLayout(this@MainActivity).apply { + addView(view, params) + }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + } + val direction = pendingTabDirection + val outgoing = content.getChildAt(content.childCount - 1) + val distance = content.width * 0.5f + if (direction == 0 || outgoing == null || distance == 0f) { + content.removeAllViews() + content.addView(page, matchMatch()) + return page + } + + while (content.childCount > 1) content.removeViewAt(0) + outgoing.animate().cancel() + page.translationX = direction * distance + page.alpha = 0f + content.addView(page, matchMatch()) + + val easing = PathInterpolator(0.2f, 0f, 0f, 1f) + outgoing.animate() + .translationX(-direction * distance) + .alpha(0f) + .setDuration(TAB_TRANSITION_DURATION_MS) + .setInterpolator(easing) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + if (outgoing.parent === content) content.removeView(outgoing) + } + }) + .start() + page.animate() + .translationX(0f) + .alpha(1f) + .setDuration(TAB_TRANSITION_DURATION_MS) + .setInterpolator(easing) + .start() + return page + } + + private fun beginTabPredictiveBack(backEvent: BackEventCompat) { + val outgoing = content.getChildAt(content.childCount - 1) ?: return + outgoing.animate().cancel() + predictiveBackView = outgoing + + val preview = liveScreenView?.takeIf { it !== outgoing && it.parent == null } + if (preview != null) { + preview.animate().cancel() + preview.translationX = 0f + preview.translationY = 0f + preview.scaleX = 1f + preview.scaleY = 1f + preview.alpha = 1f + content.addView(preview, 0, matchMatch()) + predictiveBackPreview = preview + } + updateTabPredictiveBack(backEvent) + } + + private fun updateTabPredictiveBack(backEvent: BackEventCompat) { + val outgoing = predictiveBackView ?: return + val progress = backEvent.progress.coerceIn(0f, 1f) + val width = content.width.toFloat() + + outgoing.translationX = width * 0.5f * progress + outgoing.scaleX = 1f - 0.08f * progress + outgoing.scaleY = 1f - 0.08f * progress + outgoing.alpha = 1f - progress + + predictiveBackPreview?.let { preview -> + preview.translationX = -width * 0.25f * (1f - progress) + preview.scaleX = 0.95f + 0.05f * progress + preview.scaleY = 0.95f + 0.05f * progress + preview.alpha = progress + } } + + private fun cancelTabPredictiveBack() { + val outgoing = predictiveBackView + val preview = predictiveBackPreview + predictiveBackView = null + predictiveBackPreview = null + val easing = PathInterpolator(0.2f, 0f, 0f, 1f) + outgoing?.animate() + ?.translationX(0f) + ?.scaleX(1f) + ?.scaleY(1f) + ?.alpha(1f) + ?.setDuration(PREDICTIVE_BACK_CANCEL_DURATION_MS) + ?.setInterpolator(easing) + ?.start() + preview?.animate() + ?.translationX(-content.width * 0.25f) + ?.scaleX(0.95f) + ?.scaleY(0.95f) + ?.alpha(0f) + ?.setDuration(PREDICTIVE_BACK_CANCEL_DURATION_MS) + ?.setInterpolator(easing) + ?.setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + preview.animate().setListener(null) + if (preview.parent === content) content.removeView(preview) + } + }) + ?.start() + } + + private fun commitTabPredictiveBack(): Boolean { + val outgoing = predictiveBackView + val preview = predictiveBackPreview + predictiveBackView = null + predictiveBackPreview = null + if (outgoing == null || preview == null || preview.parent !== content) return false + + loadJob?.cancel() + pendingTabDirection = 0 + outgoing.animate().cancel() + preview.animate().cancel() + preview.translationX = 0f + preview.translationY = 0f + preview.scaleX = 1f + preview.scaleY = 1f + preview.alpha = 1f + for (index in content.childCount - 1 downTo 0) { + if (content.getChildAt(index) !== preview) content.removeViewAt(index) + } + liveScreenView = preview + selectedNav = NAV_LIVE + showingTabRoot = true + scrollCurrentTabToTop = scrollLiveToTop + setPageHeading("Live on Streamplace") + return true + } + private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() private fun matchMatch() = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) private fun matchWrap() = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) @@ -1140,13 +1352,15 @@ class MainActivity : AppCompatActivity() { private const val NAV_FOLLOWING = 2 private const val NAV_VIDEOS = 3 private const val NAV_YOU = 4 + private const val TAB_TRANSITION_DURATION_MS = 380L + private const val PREDICTIVE_BACK_CANCEL_DURATION_MS = 220L } private enum class LiveSort(val label: String) { - VIEWERS_HIGH("Viewers: high to low"), - VIEWERS_LOW("Viewers: low to high"), - RECENT("Recently started"), - CHANNEL("Channel name"), + TIME("Time"), + VIEWS("Views"), + TITLE("Title"), + CREATOR("Creator"), } private enum class FollowingSource { LOCAL, ACCOUNT } diff --git a/app/src/main/java/place/strae/PlayerActivity.kt b/app/src/main/java/place/strae/PlayerActivity.kt index 12b90eb..7912313 100644 --- a/app/src/main/java/place/strae/PlayerActivity.kt +++ b/app/src/main/java/place/strae/PlayerActivity.kt @@ -15,6 +15,7 @@ import android.content.res.Configuration import android.graphics.Color import android.graphics.Rect import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable import android.graphics.drawable.GradientDrawable import android.graphics.drawable.Icon import android.net.Uri @@ -39,6 +40,7 @@ import android.widget.TextView import android.widget.Toast import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity +import androidx.activity.BackEventCompat import androidx.activity.OnBackPressedCallback import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat @@ -155,7 +157,39 @@ class PlayerActivity : AppCompatActivity() { startPlayback() if (type == TYPE_LIVE) connectChat() onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() = stopPlaybackAndFinish() + override fun handleOnBackStarted(backEvent: BackEventCompat) { + root.animate().cancel() + window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + root.background = GradientDrawable().apply { + setColor(getColor(R.color.strae_ink)) + cornerRadius = dp(32).toFloat() + } + root.clipToOutline = true + updatePlayerPredictiveBack(backEvent) + } + + override fun handleOnBackProgressed(backEvent: BackEventCompat) { + updatePlayerPredictiveBack(backEvent) + } + + override fun handleOnBackCancelled() { + root.animate() + .translationX(0f) + .translationY(0f) + .scaleX(1f) + .scaleY(1f) + .alpha(1f) + .setDuration(PREDICTIVE_BACK_CANCEL_DURATION_MS) + .withEndAction { + root.clipToOutline = false + root.setBackgroundColor(getColor(R.color.strae_ink)) + } + .start() + } + + override fun handleOnBackPressed() { + stopPlaybackAndFinish() + } }) } @@ -944,6 +978,15 @@ class PlayerActivity : AppCompatActivity() { finish() } + private fun updatePlayerPredictiveBack(backEvent: BackEventCompat) { + val progress = backEvent.progress.coerceIn(0f, 1f) + root.translationX = 0f + root.translationY = root.height * 0.08f * progress + root.scaleX = 1f - 0.08f * progress + root.scaleY = 1f - 0.08f * progress + root.alpha = 1f + } + private fun parseTime(value: String): Long? = listOf("yyyy-MM-dd'T'HH:mm:ss.SSSX", "yyyy-MM-dd'T'HH:mm:ssX") .firstNotNullOfOrNull { pattern -> runCatching { SimpleDateFormat(pattern, Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.parse(value)?.time @@ -990,5 +1033,6 @@ class PlayerActivity : AppCompatActivity() { private const val MENU_PIP = 4 private const val ACTION_TOGGLE_PLAYBACK = "you.jolly.strae.action.TOGGLE_PLAYBACK" private const val ACTION_BACKGROUND_AUDIO = "you.jolly.strae.action.BACKGROUND_AUDIO" + private const val PREDICTIVE_BACK_CANCEL_DURATION_MS = 220L } } diff --git a/app/src/main/res/drawable/ic_arrow_down.xml b/app/src/main/res/drawable/ic_arrow_down.xml new file mode 100644 index 0000000..4d909f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_filter.xml b/app/src/main/res/drawable/ic_filter.xml new file mode 100644 index 0000000..3609f04 --- /dev/null +++ b/app/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_shuffle.xml b/app/src/main/res/drawable/ic_shuffle.xml new file mode 100644 index 0000000..e6019f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_shuffle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sort.xml b/app/src/main/res/drawable/ic_sort.xml new file mode 100644 index 0000000..9eb5d08 --- /dev/null +++ b/app/src/main/res/drawable/ic_sort.xml @@ -0,0 +1,9 @@ + + +