diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index 38302e6..eb9bc80 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -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 diff --git a/android/app/src/main/java/io/narl/protonstream/ui/HistoryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/HistoryScreen.kt new file mode 100644 index 0000000..584f022 --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/ui/HistoryScreen.kt @@ -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): List = + 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) +} diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index 006cef1..05b89a4 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -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, diff --git a/android/app/src/test/java/io/narl/protonstream/ui/HistoryTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/HistoryTest.kt new file mode 100644 index 0000000..5ee790f --- /dev/null +++ b/android/app/src/test/java/io/narl/protonstream/ui/HistoryTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt index 8e8675c..0928476 100644 --- a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt +++ b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt @@ -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, overview: String? = null) = TitleRecord( diff --git a/android/app/src/test/screenshots/history-phone.png b/android/app/src/test/screenshots/history-phone.png new file mode 100644 index 0000000..85d324e Binary files /dev/null and b/android/app/src/test/screenshots/history-phone.png differ diff --git a/docs/TESTING.md b/docs/TESTING.md index fbf563c..a9720a3 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -213,7 +213,7 @@ across fails here rather than being noticed in a screenshot. **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, -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 everything else; here it fails, and the review shows the changed image. After a deliberate change, `gradlew recordRoborazziDebug` rewrites the images and they