mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(android): a history page
History sits second in the navigation, as on the desktop: every played episode, newest first, under Today, Yesterday, a weekday or a date. The still plays the episode, the text opens its title, and the cross takes it off the page — forgetting its position and watched mark — with Undo. The list is the desktop's Library::history rule over the records the library already holds, so no bridge call is added; HistoryTest pins the rule and the day headings.
This commit is contained in:
1 parent
8d40b211ed
commit
1c61aadb22
7 files changed
+275
-2
No files matched your search
@@ -231,6 +231,19 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Off the history page: the position and the watched mark both go, which
|
||||
* is "never played", with an Undo.
|
||||
*/
|
||||
fun removeFromHistory(title: TitleRecord, episode: EpisodeRecord) {
|
||||
changeWatch(
|
||||
listOf(episode),
|
||||
"${episode.label} of ${title.canonicalName ?: title.name} removed from history",
|
||||
) { engine, target, before ->
|
||||
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Put back what the last undoable change replaced. */
|
||||
fun undo() {
|
||||
val snapshots = mutableState.value.undo ?: return
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.narl.protonstream.ui.theme.AccentProgress
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
|
||||
/** One played episode, with the title it belongs to and its place in that title's playlist. */
|
||||
internal data class Played(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
|
||||
|
||||
/**
|
||||
* Every episode that has been started or finished, newest first — the
|
||||
* desktop's `Library::history`, over the records the library already holds.
|
||||
*
|
||||
* A row marked unwatched again sits at position zero, which is "never played"
|
||||
* for every purpose here, so it is left out. Progress stands in for the
|
||||
* position: it is non-zero exactly when the position is.
|
||||
*/
|
||||
internal fun history(titles: List<TitleRecord>): List<Played> =
|
||||
titles.flatMap { title ->
|
||||
title.seasons.flatMap(SeasonRecord::episodes).withIndex()
|
||||
.filter { (_, episode) -> episode.watched || (episode.progress ?: 0.0) > 0.0 }
|
||||
.map { (index, episode) -> Played(title, episode, index) }
|
||||
}.sortedByDescending { it.episode.lastPlayed }
|
||||
|
||||
/**
|
||||
* "Today", "Yesterday", a weekday within the last week, then a date — how the
|
||||
* history page heads each day.
|
||||
*/
|
||||
internal fun dayHeading(day: LocalDate, today: LocalDate, locale: Locale = Locale.getDefault()): String = when {
|
||||
day == today -> "Today"
|
||||
day == today.minusDays(1) -> "Yesterday"
|
||||
day.isAfter(today.minusDays(7)) -> day.dayOfWeek.getDisplayName(TextStyle.FULL, locale)
|
||||
day.year == today.year -> day.format(DateTimeFormatter.ofPattern("d MMMM", locale))
|
||||
else -> day.format(DateTimeFormatter.ofPattern("d MMMM yyyy", locale))
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HistoryScreen(
|
||||
state: AppUiState,
|
||||
onPlay: (TitleRecord, Int) -> Unit,
|
||||
onTitle: (TitleRecord) -> Unit,
|
||||
onRemove: (TitleRecord, EpisodeRecord) -> Unit,
|
||||
padding: PaddingValues,
|
||||
zone: ZoneId = remember { ZoneId.systemDefault() },
|
||||
today: LocalDate = LocalDate.now(zone),
|
||||
) {
|
||||
val days = remember(state.titles) {
|
||||
history(state.titles).groupBy { Instant.ofEpochSecond(it.episode.lastPlayed).atZone(zone).toLocalDate() }
|
||||
}
|
||||
if (days.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
|
||||
}
|
||||
return
|
||||
}
|
||||
LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||
days.forEach { (day, played) ->
|
||||
item(key = "day/$day") {
|
||||
Text(
|
||||
dayHeading(day, today),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
items(played, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
|
||||
HistoryRow(
|
||||
entry,
|
||||
onPlay = { onPlay(entry.title, entry.index) },
|
||||
onTitle = { onTitle(entry.title) },
|
||||
onRemove = { onRemove(entry.title, entry.episode) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The still plays the episode, the text opens its title, and the cross takes
|
||||
* it off the page — the same three targets the desktop row has.
|
||||
*/
|
||||
@Composable
|
||||
private fun HistoryRow(entry: Played, onPlay: () -> Unit, onTitle: () -> Unit, onRemove: () -> Unit) {
|
||||
val episode = entry.episode
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(128.dp)
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.clickable(onClick = onPlay),
|
||||
) {
|
||||
RemoteArtwork(
|
||||
episode.stillUrl ?: entry.title.backdropUrl,
|
||||
episode.label,
|
||||
Modifier.fillMaxSize(),
|
||||
fallback = episode.thumbnailSource,
|
||||
)
|
||||
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
||||
AccentProgress(
|
||||
progress = { progress.toFloat().coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).clickable(onClick = onTitle).padding(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
Text(
|
||||
entry.title.canonicalName ?: entry.title.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
episode.providerName?.let { "${episode.label} · $it" } ?: episode.label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
if (episode.watched) "Finished" else episode.stoppedAt(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Remove from history")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Stopped at 12:04 of 23:40", or as much of it as is known. */
|
||||
private fun EpisodeRecord.stoppedAt(): String {
|
||||
val at = resumeAt ?: return "Started"
|
||||
val duration = progress?.takeIf { it > 0.0 }?.let { at / it }
|
||||
return "Stopped at ${clock(at)}" + (duration?.let { " of ${clock(it)}" } ?: "")
|
||||
}
|
||||
|
||||
private fun clock(seconds: Double): String {
|
||||
val total = seconds.toInt().coerceAtLeast(0)
|
||||
val hours = total / 3_600
|
||||
return if (hours > 0) "%d:%02d:%02d".format(hours, total % 3_600 / 60, total % 60)
|
||||
else "%d:%02d".format(total / 60, total % 60)
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VideoLibrary
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
@@ -79,6 +80,7 @@ private data class PlayRequest(
|
||||
|
||||
private enum class Destination(val label: String, val icon: ImageVector) {
|
||||
Library("Library", Icons.Default.VideoLibrary),
|
||||
History("History", Icons.Default.History),
|
||||
Shares("Shares", Icons.Default.Share),
|
||||
Downloads("Downloads", Icons.Default.Download),
|
||||
Settings("Settings", Icons.Default.Settings),
|
||||
@@ -239,6 +241,17 @@ fun ProtonStreamApp(
|
||||
onShareLinkTaken,
|
||||
body,
|
||||
)
|
||||
Destination.History -> HistoryScreen(
|
||||
state,
|
||||
{ title, index ->
|
||||
playingTitleKey = title.key
|
||||
playingIndex = index
|
||||
playerMinimized = false
|
||||
},
|
||||
{ destination = Destination.Library; selectedTitleKey = it.key },
|
||||
model::removeFromHistory,
|
||||
body,
|
||||
)
|
||||
Destination.Downloads -> DownloadsScreen(
|
||||
state,
|
||||
model::removeOffline,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.util.Locale
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.TitleType
|
||||
|
||||
class HistoryTest {
|
||||
private fun episode(link: String, watched: Boolean, progress: Double?, lastPlayed: Long) = EpisodeRecord(
|
||||
shareId = "s", volumeId = "v", linkId = link, name = "$link.mkv", label = link, detail = "$link.mkv",
|
||||
season = 1u, number = null, size = null, progress = progress, resumeAt = progress?.let { it * 1000 },
|
||||
watched = watched, offline = false, providerName = null, providerOverview = null, stillUrl = null,
|
||||
airDate = null, lastPlayed = lastPlayed,
|
||||
)
|
||||
|
||||
private fun title(key: String, vararg episodes: EpisodeRecord) = TitleRecord(
|
||||
key = key, name = key, year = null, kind = TitleType.SERIES, watchedCount = 0uL,
|
||||
episodeCount = episodes.size.toULong(), canonicalName = null, originalName = null, overview = null,
|
||||
metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null,
|
||||
backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null,
|
||||
manualMatch = false, seasons = listOf(SeasonRecord(1u, "Season 1", episodes.toList())),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `history is every started or finished episode, newest first, across titles`() {
|
||||
val titles = listOf(
|
||||
title(
|
||||
"a",
|
||||
episode("a1", watched = true, progress = 1.0, lastPlayed = 30),
|
||||
// Marked unwatched again: a row at zero, which is not a play.
|
||||
episode("a2", watched = false, progress = 0.0, lastPlayed = 40),
|
||||
episode("a3", watched = false, progress = null, lastPlayed = 0),
|
||||
),
|
||||
title("b", episode("b1", watched = false, progress = 0.4, lastPlayed = 50)),
|
||||
)
|
||||
val played = history(titles)
|
||||
assertEquals(listOf("b1", "a1"), played.map { it.episode.linkId })
|
||||
assertEquals(0, played.last().index)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `days are headed relative to today, then by date`() {
|
||||
val today = LocalDate.of(2026, 10, 3) // a Saturday
|
||||
val english = Locale.ENGLISH
|
||||
assertEquals("Today", dayHeading(today, today, english))
|
||||
assertEquals("Yesterday", dayHeading(today.minusDays(1), today, english))
|
||||
assertEquals("Monday", dayHeading(LocalDate.of(2026, 9, 28), today, english))
|
||||
assertEquals("26 September", dayHeading(LocalDate.of(2026, 9, 26), today, english))
|
||||
assertEquals("3 October 2025", dayHeading(LocalDate.of(2025, 10, 3), today, english))
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -98,6 +100,13 @@ class ScreenshotTest {
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the history page`() = capture("history-phone") {
|
||||
// Fixed day and zone: "Today" and "Yesterday" are relative, and an
|
||||
// image that changes with the date is no baseline.
|
||||
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
|
||||
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
|
||||
@@ -129,7 +138,12 @@ class ScreenshotTest {
|
||||
providerOverview = null,
|
||||
stillUrl = null,
|
||||
airDate = null,
|
||||
lastPlayed = if (progress != null) 1_790_000_000L else 0L,
|
||||
// 2026-09-21 for the part-watched one, the day before for the rest.
|
||||
lastPlayed = when {
|
||||
progress != null -> 1_790_000_000L
|
||||
watched -> 1_789_900_000L - number.toLong() * 600
|
||||
else -> 0L
|
||||
},
|
||||
)
|
||||
|
||||
fun title(key: String, name: String, seasons: List<SeasonRecord>, overview: String? = null) = TitleRecord(
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Reference in new issue
Block a user