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. */
|
/** Put back what the last undoable change replaced. */
|
||||||
fun undo() {
|
fun undo() {
|
||||||
val snapshots = mutableState.value.undo ?: return
|
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.VideoLibrary
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material.icons.filled.Download
|
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.Settings
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Close
|
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) {
|
private enum class Destination(val label: String, val icon: ImageVector) {
|
||||||
Library("Library", Icons.Default.VideoLibrary),
|
Library("Library", Icons.Default.VideoLibrary),
|
||||||
|
History("History", Icons.Default.History),
|
||||||
Shares("Shares", Icons.Default.Share),
|
Shares("Shares", Icons.Default.Share),
|
||||||
Downloads("Downloads", Icons.Default.Download),
|
Downloads("Downloads", Icons.Default.Download),
|
||||||
Settings("Settings", Icons.Default.Settings),
|
Settings("Settings", Icons.Default.Settings),
|
||||||
@@ -239,6 +241,17 @@ fun ProtonStreamApp(
|
|||||||
onShareLinkTaken,
|
onShareLinkTaken,
|
||||||
body,
|
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(
|
Destination.Downloads -> DownloadsScreen(
|
||||||
state,
|
state,
|
||||||
model::removeOffline,
|
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.RobolectricDeviceQualifiers
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneOffset
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -98,6 +100,13 @@ class ScreenshotTest {
|
|||||||
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues())
|
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
|
@Test
|
||||||
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
|
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
|
||||||
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
|
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
|
||||||
@@ -129,7 +138,12 @@ class ScreenshotTest {
|
|||||||
providerOverview = null,
|
providerOverview = null,
|
||||||
stillUrl = null,
|
stillUrl = null,
|
||||||
airDate = 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(
|
fun title(key: String, name: String, seasons: List<SeasonRecord>, overview: String? = null) = TitleRecord(
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
+1
-1
@@ -213,7 +213,7 @@ across fails here rather than being noticed in a screenshot.
|
|||||||
|
|
||||||
**Screenshot tests** (`ScreenshotTest`, Roborazzi) draw each screen from fixed
|
**Screenshot tests** (`ScreenshotTest`, Roborazzi) draw each screen from fixed
|
||||||
records — Library on a phone, a tablet and at font scale 1.5, an empty library,
|
records — Library on a phone, a tablet and at font scale 1.5, an empty library,
|
||||||
a title, Shares, Settings and Downloads — and `check` compares them with the
|
a title, History, Shares, Settings and Downloads — and `check` compares them with the
|
||||||
images in `app/src/test/screenshots`. A layout regression compiles and passes
|
images in `app/src/test/screenshots`. A layout regression compiles and passes
|
||||||
everything else; here it fails, and the review shows the changed image. After a
|
everything else; here it fails, and the review shows the changed image. After a
|
||||||
deliberate change, `gradlew recordRoborazziDebug` rewrites the images and they
|
deliberate change, `gradlew recordRoborazziDebug` rewrites the images and they
|
||||||
|
|||||||
Reference in new issue
Block a user