Compare commits

25 Commits
Author SHA1 Message Date
nvrl a475ad3575 docs: 2.0 screenshots of the library, the whole library and a title page 2026-10-31 14:38:47 +01:00
nvrl da13f0547e chore(release): bump version to 2.0.1 2026-10-31 14:14:45 +01:00
nvrl 4ca57d9449 fix(android): a chooser in the player no longer closes on its own
The episode, track, speed, chapter and sleep choosers and the More menu
live inside the player's controls, and the controls hide four seconds
after they come up while the video plays. Hiding them took the open
chooser with them, so a sheet being picked from closed at what looked
like random. The controls now hold still while a chooser is open.
2026-10-31 14:12:28 +01:00
nvrl c9925b4b09 chore(release): bump version to 2.0.0 2026-10-31 03:42:32 +01:00
nvrl fe69d2fae4 fix(android): clear the two lint errors that failed the Android build
The banner height came from Configuration.screenHeightDp, which lint now
rejects in favour of LocalWindowInfo's container size; the four phone
library screenshots move by a pixel with it. The mood colour scaled its
picture with Bitmap.createScaledBitmap where lint asks for the KTX
Bitmap.scale.
2026-10-31 03:42:32 +01:00
nvrl 3fe4900337 fix(android,ios): the track tick moves when a track is chosen, and no file inherits another's languages
Choosing an audio or subtitle track read the track list back before mpv
had made the switch, so the tick stayed on the old track until the next
poll. The tick now moves on the choice, and the list is read again
300 ms later to confirm it.

On Android, `load` set `alang` and `slang` only when they were not
empty. mpv keeps both across `loadfile`, so a file with no language
preference took the previous file's. Both are now set on every load.
This closes the last open item of B41.
2026-10-31 03:26:39 +01:00
nvrl 973a8f2749 feat(app,android,ios): swipe gestures, touch lock and sleep timer
On Android and iOS a swipe up or down on the left half of the picture
sets the brightness and on the right half the volume; a sideways swipe
seeks, showing where it will land and seeking only when the finger
lifts. Swipes that start at the top or bottom edge are left to the
system. Android turns the device's media volume; iOS lets no app do
that, so there the swipe turns the player's own, as its slider does.
Brightness set by a swipe is handed back when the player goes.

Lock touch, in the player's menu, makes the picture ignore touches and
the back gesture; a tap brings up Unlock for a few seconds.

The sleep timer pauses after 30 or 60 minutes, or stops at the end of
the episode playing instead of going on to the next, with no up-next
card. On the desktop it is a panel like the others, behind a moon
button that shows what is left; on Android and iOS it lives with the
player host, so it keeps running with the screen off.
2026-10-31 03:19:04 +01:00
nvrl cf8ee58f6b feat(meta,core,app,android,ios): AniSkip where files have no chapters
Most files that are not fansub releases carry no chapters, so they had no
skip button. AniSkip knows where an episode's opening and ending are, by
MyAnimeList id and episode number. ani.zip sends each entry's MyAnimeList
id and enrichment now keeps it on every episode row; schema v14 adds the
column and marks AniList titles un-enriched, deleting nothing, as v13 did.

When a file has loaded with no chapter naming an opening or ending, and
its length is known, each client asks once, with enrichment on only. The
answer lives as long as the file is open. with_skip_ranges cuts it into
the chapters as chapters named Opening and Ending, so the skip button,
auto-skip, the up-next countdown and the seek bar's marks all work
through the code a chaptered file uses. A file's own opening or ending
always wins over AniSkip's.

PRIVACY.md lists what is sent: the MyAnimeList id, the episode number and
the file's length.
2026-10-31 03:13:41 +01:00
nvrl 466630a9ba feat(app): player side panel, seek HUD and pause screen
The audio, subtitle, speed and chapter pickers were stock popup menus
hanging off the transport. They are now panels down the right of the
picture, in the place and style of the episode list: one at a time, a
whole row per choice, the chosen one filled and ticked, closed by its
button again or by Esc. The library's mini player keeps its menus.

What a key did is shown in the middle of the picture with an icon, and
the volume with a bar. Seeks the same way in quick succession add up, so
three presses of the right arrow read "+ 90 s".

After five seconds paused with the mouse still, the picture dims and the
controls give way to what is playing: the logo or the name, the episode,
and its synopsis, or the show's when the episode has none. Moving the
mouse brings the controls back.
2026-10-31 02:52:55 +01:00
nvrl a3bc9023c7 fix(android): the banner no longer stops half way through a turn
The turn was a LaunchedEffect keyed on the pager's current page, and the
current page flips half way through a scroll. The effect restarted there,
cancelling its own animation and leaving two titles side by side until
the next swipe. It is keyed on the settled page now.
2026-10-31 02:41:22 +01:00
nvrl 7bfebc5fd5 feat(meta,core,app,android,ios): cast and trailer
AniList's fragment now asks for the first ten characters, main roles
first, with their Japanese voice actor, and for the trailer's site and
id. Both are stored in the existing details JSON as TitleDetails.cast and
.trailer with serde defaults, so no migration is needed and details
stored before them still read. The details REVISION goes to 2, so a
stored match is refetched by its id on the next metadata run and the
cast fills in without anyone matching the title again.

The title page shows a Cast row of round portraits with the character's
name over the actor's. Clicking a tile opens the Person browse page for
that actor, which now also admits voice actors, and search counts them
under People and studios. A Trailer button opens the YouTube or
Dailymotion page in the browser; the app itself makes no request to
either, and an unknown site or an id with odd characters gives no link.
2026-10-31 01:56:41 +01:00
nvrl 87ccaf26e1 feat(app,android,ios): search across episodes and people
A search now also finds, with posters and descriptions on, the directors
and studios and the genres whose names hold it, as chips that open their
page, and the episodes whose provider names hold it, which play from the
result (pstr_core::discover::find). The titles it found follow under
their own heading.

The last eight searches are kept in recent-searches.json on this device
only (pstr_core::recent), written only when a search led somewhere, and
offered under the empty search box with a way to clear them. The bridge
exposes search_extras, recent_searches, remember_search and
forget_searches.

On Android and iOS the library's titles are now the whole library
whatever is searched for; the grid narrows through the arrangement, as
it already did. A title opened from an episode result can then always be
shown, and History and Downloads no longer shrink to the search.
2026-10-30 23:07:51 +01:00
nvrl fafe0c3087 feat(core,app,android,ios): more like this and browse by genre, studio, person
pstr_core::discover scores every title against another by shared
director, studio, genres, leading tags and format, leaves out its own
franchise and keeps one title per other franchise. It drives a "More
like this" row under a title's episodes and, seeded by the title most
recently watched to the end, a "Because you watched" row on the library
of titles not yet started.

Genre chips on the title page now open a page of every title with that
genre, and the studios and director move from the line of facts onto
chips of their own that do the same. The desktop has a Browse page whose
back link returns to the title; Android and iOS show it over the library
pane. The bridge exposes more_like, browse and facet_kind.

Also moves a doc comment the My List commit left on the wrong function.
2026-10-30 22:46:08 +01:00
nvrl f66faf6b75 feat(core,app,android,ios): My List, synced, and New badges with new-episode rows
My List is a list of titles to watch later, kept in my-list.json by
title key (pstr-core::mylist) and synced as one list/<key> register per
title, so an add or a removal on one device reaches every other. A list
file that will not parse is never written over: changing the list is
refused instead. It is a filter, a row on the library, a button on the
banner and the title page, and an entry in each tile's menu.

pstr-core::news reads node_first_seen against each share's first crawl,
so a newly added share does not badge its whole library. A title that
arrived in the last two weeks and was never played is New; a series
being watched counts the unwatched episodes that arrived after it was
last played ("3 new"). They fill the tile badge and the "New episodes"
and "Recently added" rows. shelves::home puts every row in one order
for all three apps, and a shelf tile of an airing show shows a
countdown ("Ep 8 · in 2 d").

The bridge gains TitleRecord.on_list and .badge, set_on_list, the
MyList filter, airing_countdown and SyncRecord.list_changed.
2026-10-30 22:15:51 +01:00
nvrl cd9e8eea81 feat(app,android,ios): status, season dropdown, watched ticks
A title page now says where the show stands as a chip beside the
quality marks: Airing with the day the next episode comes out, Finished,
Upcoming, On hiatus or Cancelled. The words come from
TitleDetails::status_label, which the bridge hands the phones as
TitleRecord.status; the airing note leaves the line of facts.

A show with more than five seasons picks its season from a drop-down
rather than a row of pills that ran off the page, and a watched
episode's still carries a tick in its corner as well as the mark at the
end of its row.
2026-10-30 21:34:47 +01:00
nvrl c026f81283 feat(android,ios): parallax title page with a sticky bar
On a phone the backdrop at the top of a title page now moves at half
the speed of the page scrolling over it, and the back button sits in a
bar that fills in with the title's name once the backdrop has gone, so
a long episode list still says whose it is. The fade into the page is
tinted with the backdrop's colour, as on the library's banner.

iOS keeps its slide in from the edge: the zoom transition needs a
NavigationStack, and the title page is shown over the library by hand.
2026-10-30 21:22:56 +01:00
nvrl c6d303f269 feat(app,android,ios): full-bleed hero carousel with ambient colour
The library's banner now runs edge to edge and turns through up to five
titles: the one watched last, then titles with a wide picture, starting
from a different one each day. It moves on every eight seconds, holds
still under the pointer, and has dots to pick a title. A part-watched
title shows how far in the viewer is under its buttons.

The veils over the banner and over a title page's band are tinted with
the picture's own colour. The weighting and the veil live in
pstr_core::appearance (mood, mood_veil), so the desktop and both phones,
which reach them through the bridge, land on the same colour.

On Android the banner is a HorizontalPager under a clear bar that fills
in as it scrolls away; iOS uses a paged TabView the same way.
2026-10-30 21:19:37 +01:00
nvrl 255e463fc8 feat(app,android,ios): display type, artwork fade-in, shimmer, lifting tiles and paged shelves
A first pass at making the three clients look like a streaming app rather
than a file browser, with no new data behind it.

- Type: a Hero rung (34) set in Inter Display Bold, the optical size Inter
  draws for large text, for the banner's name and a title page's name where
  no logo stands in. Desktop `Role::Hero` and `theme::display`; Android and
  iOS `displayLarge`, at a tighter 1.1 leading. The font has its private use
  mappings stripped like the other two cuts.
- Artwork fades in over its skeleton on Android and iOS, as it already did
  on desktop cards; art already decoded draws at once. Desktop episode
  stills and logos fade in too.
- Skeletons shimmer: a soft diagonal highlight instead of a pulse.
- Desktop tiles lift towards the viewer under the pointer, capped so a
  lifted tile never reaches its neighbour, and keyboard focus gets a ring
  of its own.
- Desktop shelves share one `ui::shelf`: edges fade into the page and, under
  the pointer, round buttons page the row along.
- Genres are chips on Android and iOS; quality (4K, HDR, 10-bit, Dual audio)
  moves out of the facts line into small outlined marks on all three.
- Desktop title page and hero buttons use a tonal style beside the accent
  one, and the player's top bar a dark glass style, instead of egui's stock
  buttons. Episode numbers leave monospace for the type ramp.
- The phone players' top and bottom bars are gradients rather than flat
  bands, and a double-tap seek is felt as well as seen.
2026-10-30 20:36:09 +01:00
nvrl 3ebfd1a57b feat(app,android,ios): a blurred poster where a title has no wide art
A title with no fanart, banner or backdrop drew its poster cropped to the
band's shape across the title page and the library's banner: a blown-up
slice of the middle of it, which read as broken. Now the poster is
squeezed to 64x36 and blurred once when it is fetched, the cache keeps
that wash, and it is stretched across the band. The library's banner
stands the sharp poster at its right over it. Android blurs the poster
it already had (Android 12 and later; older versions decode it tiny),
and iOS likewise.

METADATA.md also records the quality badges of the commit before.
2026-10-29 16:59:46 +01:00
nvrl f4c08d03c7 feat(core,app,android,ios): quality badges from file names
Release names say what their files are — 2160p, HDR, 10-bit, Dual-Audio —
and nothing showed it. naming::quality reads those words, whatever they
are separated by, and Title::quality keeps what more than half a title's
files state, so one odd special does not mark a show down. The facts line
of a title's page ends with them: 4K, 1080p, HDR, 10-bit, Dual audio.
Release groups' names are never read.
2026-10-29 16:56:19 +01:00
nvrl a7b87cdc79 feat(meta,app,android,ios,cli): film backdrops and logos from TMDB by id
TVDB has no films, so ani.zip has no fanart or logo for one, and most of
the titles in a real library with nothing wide to draw were films. ani.zip
does name the TMDB entry of every AniList match. With a TMDB key stored
and AniList chosen, an entry left without a backdrop or a logo is asked
for /{movie,tv}/{id}/images: one request, by id, so no title name goes to
TMDB. A bare backdrop and a PNG logo in the viewer's language are
preferred, and a failure there only costs the art.

The TMDB key field is now offered with either provider, labelled optional
with AniList. TMDB as the provider fetches its logo the same way, and
backdrops at w1280 rather than w780. A forced run re-enriches hand-picked
titles too, so saving a key and matching everything again reaches them.
2026-10-29 16:50:01 +01:00
nvrl 70952fa7ca feat(core): episode titles from dotted release names
Scene releases name the episode in dotted words before their tags:
Game.of.Thrones.S01E01.Winter.Is.Coming.2160p... Read the words up to the
first quality, source or codec tag; a dotted run that no tag ends is not taken.
2026-10-29 16:33:16 +01:00
nvrl 3ca431feca feat(core,meta,app,android,ios,cli): TVmaze for the shows AniList does not have
With AniList chosen, a title AniList has no match for is now searched on
TVmaze, keyless, before it is given up on. One provider for the whole library
left live-action shows in a share of anime unmatched, and switching to TMDB
would have dropped every AniList match, chain and shelf.

The service holds a chain of providers. A match is stored under the provider
that gave it, and enrichment and refreshes go to that provider; a miss is
stored under the last provider asked, and is_fresh only trusts a miss from the
end of the chain, so AniList misses stored before this are asked again at once.
A provider failure still ends the lookup instead of falling through.

The hand matcher searches every provider in the chain and labels each result
with its source. TVmaze is never offered in the settings and is not asked
about titles known to be films.
2026-10-29 16:33:16 +01:00
nvrl bb5a6638c4 feat(core,meta,app,android,ios): title logos and runtimes
ani.zip sent each series' TVDB clear logo and every episode's running
time from the start, and neither was kept. Enrichment now stores the logo
in title_metadata.logo_url (kept across a refresh of the same entry, like
the fanart) and episode runtimes in episode_metadata.runtime, TVDB's
first and AniDB's length after it. AniList's duration lands in
TitleDetails::runtime; TMDB episode runtimes are kept too.

Schema v13 adds the two columns and marks AniList titles un-enriched
without deleting anything. TitleDetails::revision lets details written
before runtime existed refetch by id while the old ones stay on screen,
instead of blanking them in the migration as v10 did.

Clear logos are padded to a square, so each client crops them to their
visible pixels before drawing. The logo replaces the name on the
library's banner and the title page (not on a light theme's title page,
where lettering made for dark art can vanish). Episode rows show how long
they run, in place of the file size on Android and iOS; a film's runtime
joins its line of facts.
2026-10-29 16:11:24 +01:00
nvrl b5ba59b41d fix(android,ios): the player names the episode, not its file
The header under the episode's numbering showed `Episode::detail`, which
falls back to the file name — `[Judas] Chainsaw Man - S01E01.mkv` on a
phone, where the desktop shows `Dog & Chainsaw`. It now shows the
provider's name for the episode, else the one the file name gave it, and
nothing rather than the file name: the release group's tag is no caption,
and it is what a screenshot of the player would carry away.
2026-10-29 15:44:55 +01:00
97 changed files with 11423 additions and 1423 deletions

No files matched your search

Generated
+7 -7
View File
@@ -5279,7 +5279,7 @@ dependencies = [
[[package]]
name = "pstr-android"
version = "1.7.0"
version = "2.0.1"
dependencies = [
"android_logger",
"futures",
@@ -5299,7 +5299,7 @@ dependencies = [
[[package]]
name = "pstr-app"
version = "1.7.0"
version = "2.0.1"
dependencies = [
"anyhow",
"chrono",
@@ -5333,7 +5333,7 @@ dependencies = [
[[package]]
name = "pstr-cli"
version = "1.7.0"
version = "2.0.1"
dependencies = [
"anyhow",
"clap",
@@ -5352,7 +5352,7 @@ dependencies = [
[[package]]
name = "pstr-core"
version = "1.7.0"
version = "2.0.1"
dependencies = [
"directories",
"futures",
@@ -5372,7 +5372,7 @@ dependencies = [
[[package]]
name = "pstr-meta"
version = "1.7.0"
version = "2.0.1"
dependencies = [
"pstr-core",
"reqwest",
@@ -5385,7 +5385,7 @@ dependencies = [
[[package]]
name = "pstr-player"
version = "1.7.0"
version = "2.0.1"
dependencies = [
"async-trait",
"libloading",
@@ -5400,7 +5400,7 @@ dependencies = [
[[package]]
name = "pstr-stream"
version = "1.7.0"
version = "2.0.1"
dependencies = [
"async-trait",
"futures",
+1 -1
View File
@@ -11,7 +11,7 @@ members = [
]
[workspace.package]
version = "1.7.0"
version = "2.0.1"
edition = "2024"
license = "MIT"
repository = "https://github.com/narrrl/proton-stream"
@@ -102,7 +102,7 @@ class UniFfiBoundaryTest {
*/
@Test
fun chapterPlanningCrossesTheBoundaryIntact() {
val plan = chapterPlan(emptyList(), 1560.0)
val plan = chapterPlan(emptyList(), 1560.0, emptyList())
assertNotNull(plan)
assertNull(skipOffer(plan, 12.0))
}
+5 -2
View File
@@ -323,8 +323,11 @@ class Player {
// needs no window, and a load that cannot get one plays audio anyway —
// waiting for a surface here is what made background playback fail after
// a five-second stall.
if (!audio.empty()) set_string("alang", audio);
if (!subtitle.empty()) set_string("slang", subtitle);
//
// Set even when empty: mpv keeps both across `loadfile`, so a file
// with no language preference used to inherit the last one's.
set_string("alang", audio);
set_string("slang", subtitle);
set_string("sid", subtitles ? "auto" : "no");
pending_start_.store(std::max(0.0, start));
// Retire the outgoing file's position, duration and end flag *before*
@@ -1,5 +1,6 @@
package io.narl.protonstream.playback
import android.os.SystemClock
import android.view.Surface
import io.narl.protonstream.ui.ThumbnailSource
import kotlinx.coroutines.CoroutineScope
@@ -19,6 +20,7 @@ import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import uniffi.pstr_android.ChapterPlan
import uniffi.pstr_android.ChapterRecord
import uniffi.pstr_android.SkipRangeRecord
import uniffi.pstr_android.SubtitleFilesRecord
import uniffi.pstr_android.SubtitleTrackRecord
import uniffi.pstr_android.chapterPlan
@@ -104,6 +106,15 @@ data class NowPlaying(
val hasNext: Boolean,
)
/** A sleep timer that is running. */
sealed interface SleepTimer {
/** Stop when [media] ends, rather than going on to the next episode. */
data class EndOf(val media: String) : SleepTimer
/** Pause at [at], on the [SystemClock.elapsedRealtime] clock. */
data class At(val at: Long) : SleepTimer
}
data class MpvTrack(
val id: Long,
val type: String,
@@ -130,11 +141,15 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null)
private val mutableAdvancing = MutableStateFlow(false)
private val mutableProblem = MutableStateFlow<String?>(null)
private val mutableSleep = MutableStateFlow<SleepTimer?>(null)
private var sleepJob: Job? = null
private var poller: Job? = null
/** The episode key and load generation the native player currently holds. */
@Volatile private var currentMedia: String? = null
@Volatile private var currentGeneration: Double = -1.0
@Volatile private var plannedFor: Pair<Double, Long>? = null
/** What AniSkip said about the open episode, keyed by that episode. */
@Volatile private var skipRanges: Pair<String, List<SkipRangeRecord>>? = null
/** The load generation whose subtitle pick has been looked at. */
@Volatile private var subtitlesCheckedFor: Double = -1.0
var onPlaybackStarted: (() -> Unit)? = null
@@ -175,6 +190,25 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
*/
val problem: StateFlow<String?> = mutableProblem.asStateFlow()
/**
* The sleep timer. Kept here rather than on the screen, because the
* episode it is about keeps playing with the screen off.
*/
val sleep: StateFlow<SleepTimer?> = mutableSleep.asStateFlow()
/** Start a sleep timer, replacing any running one, or turn it off. */
fun setSleepTimer(timer: SleepTimer?) {
sleepJob?.cancel()
mutableSleep.value = timer
if (timer is SleepTimer.At) {
sleepJob = scope.launch {
delay((timer.at - SystemClock.elapsedRealtime()).coerceAtLeast(0))
mutableSleep.value = null
setPaused(true)
}
}
}
/** Dismiss the current [problem]. */
fun clearProblem() { mutableProblem.value = null }
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
@@ -226,6 +260,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
mutableTracks.value = emptyList()
mutableChapters.value = ChapterPlan(emptyList(), null)
plannedFor = null
skipRanges = null
}
}
} ?: false
@@ -268,6 +303,17 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
}
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
/**
* Take AniSkip's answer for [media], and plan the chapters again with it.
* Dropped when another episode has opened since it was asked for.
*/
fun setSkipRanges(media: String, ranges: List<SkipRangeRecord>) {
if (media != currentMedia) return
skipRanges = media to ranges
plannedFor = null
refreshChapters(mutableState.value)
}
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
@@ -276,7 +322,17 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
fun selectTrack(track: MpvTrack?) {
val audio = track?.type == "audio"
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
refreshTracks()
// mpv makes the switch on its own thread and lists it a moment later,
// so a track list read back now still ticks the old track. The tick
// moves here, and a read shortly after confirms it.
val type = if (audio) "audio" else "sub"
mutableTracks.value = mutableTracks.value.map {
if (it.type == type) it.copy(selected = it.id == track?.id) else it
}
scope.launch(Dispatchers.Default) {
delay(TRACK_SETTLE_MS)
refreshTracks()
}
}
/**
@@ -394,7 +450,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
}
}.onSuccess { chapters ->
plannedFor = wanted
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 })
val skips = skipRanges?.takeIf { it.first == currentMedia }?.second.orEmpty()
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 }, skips)
}
}
@@ -511,6 +568,9 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
private const val CACHED_UNTIL = 13
private const val LOADED = 14
/** How long after a track switch mpv's track list is read again. */
private const val TRACK_SETTLE_MS = 300L
init {
System.loadLibrary("pstr_android")
System.loadLibrary("pstr_mpv")
@@ -0,0 +1,220 @@
package io.narl.protonstream.playback
import android.app.Activity
import android.content.Context
import android.media.AudioManager
import android.view.WindowManager
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.BrightnessMedium
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.FastRewind
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.roundToInt
/** What a swipe on the picture is doing, for the bubble that says so. */
sealed interface Swipe {
/** The window's brightness, from 0 to 1. */
data class Brightness(val level: Float) : Swipe
/** The device's media volume, from 0 to 1. */
data class Volume(val level: Float) : Swipe
/** Where the playhead will land when the finger lifts, and where it was. */
data class Seek(val from: Double, val to: Double) : Swipe
}
/** How much of the file one swipe across the whole width is worth, in seconds. */
private const val SEEK_SPAN_SECONDS = 90.0
/**
* A finger that comes down this close to the top or bottom of the screen is
* the system's: pulling down the shade, swiping home.
*/
private val SYSTEM_EDGE = 48.dp
/** Which way a swipe went, settled by its first movement past the touch slop. */
private enum class Axis { Brightness, Volume, Seek, Ignored }
/**
* The swipes every phone video player has taught: up and down on the left for
* brightness, on the right for volume, and sideways to seek — shown while the
* finger is down and only seeking when it lifts, because a seek per movement
* would throw away every block in flight.
*/
fun Modifier.playerSwipes(
enabled: Boolean,
activity: Activity?,
position: () -> Double,
duration: () -> Double,
onSwipe: (Swipe?) -> Unit,
onSeek: (Double) -> Unit,
): Modifier = pointerInput(enabled, activity) {
if (!enabled) return@pointerInput
val audio = activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
val edge = SYSTEM_EDGE.toPx()
var axis = Axis.Ignored
var start = Offset.Zero
var travelled = Offset.Zero
var level = 0f
var seekFrom = 0.0
var seekTo = 0.0
detectDragGestures(
onDragStart = { offset ->
start = offset
travelled = Offset.Zero
axis = if (offset.y < edge || offset.y > size.height - edge) Axis.Ignored else Axis.Seek
},
onDragEnd = {
if (axis == Axis.Seek && seekTo != seekFrom) onSeek(seekTo)
onSwipe(null)
},
onDragCancel = { onSwipe(null) },
) { change, amount ->
if (axis == Axis.Ignored) return@detectDragGestures
change.consume()
val first = travelled == Offset.Zero
travelled += amount
if (first) {
// The first movement past the slop decides what the swipe is for.
axis = when {
abs(amount.x) > abs(amount.y) -> Axis.Seek
start.x < size.width / 2 -> Axis.Brightness
else -> Axis.Volume
}
level = when (axis) {
Axis.Brightness -> activity?.brightness() ?: 0.5f
Axis.Volume -> audio?.mediaLevel() ?: 0f
else -> 0f
}
seekFrom = position()
seekTo = seekFrom
}
when (axis) {
// A swipe the whole height of the screen goes from none to all.
Axis.Brightness -> {
level = (level - amount.y / size.height).coerceIn(0f, 1f)
activity?.setBrightness(level)
onSwipe(Swipe.Brightness(level))
}
Axis.Volume -> {
level = (level - amount.y / size.height).coerceIn(0f, 1f)
audio?.setMediaLevel(level)
onSwipe(Swipe.Volume(level))
}
Axis.Seek -> {
val length = duration()
if (length <= 0.0) return@detectDragGestures
seekTo = (seekFrom + travelled.x / size.width * SEEK_SPAN_SECONDS).coerceIn(0.0, length)
onSwipe(Swipe.Seek(seekFrom, seekTo))
}
Axis.Ignored -> {}
}
}
}
/** The window's own brightness, or the screen's when the window has none. */
private fun Activity.brightness(): Float {
val own = window.attributes.screenBrightness
if (own >= 0f) return own
return runCatching {
android.provider.Settings.System.getInt(contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS) / 255f
}.getOrDefault(0.5f)
}
private fun Activity.setBrightness(level: Float) {
window.attributes = window.attributes.apply {
// Never quite off: a black screen under a playing film looks broken.
screenBrightness = level.coerceAtLeast(0.01f)
}
}
/** Hand brightness back to the system, as the player is left. */
fun Activity.releaseBrightness() {
window.attributes = window.attributes.apply {
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
}
private fun AudioManager.mediaLevel(): Float {
val max = getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1)
return getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / max
}
private fun AudioManager.setMediaLevel(level: Float) {
val max = getStreamMaxVolume(AudioManager.STREAM_MUSIC)
// No flags: the bubble is the feedback, and the system's own volume panel
// over the picture would be a second one.
setStreamVolume(AudioManager.STREAM_MUSIC, (level * max).roundToInt(), 0)
}
/** The bubble in the middle of the picture while a swipe is under way. */
@Composable
fun SwipeBubble(swipe: Swipe, format: (Double) -> String, modifier: Modifier = Modifier) {
Column(
modifier
.clip(RoundedCornerShape(20.dp))
.background(Color.Black.copy(alpha = 0.6f))
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
when (swipe) {
is Swipe.Brightness -> Level(Icons.Default.BrightnessMedium, "Brightness", swipe.level)
is Swipe.Volume -> Level(Icons.AutoMirrored.Filled.VolumeUp, "Volume", swipe.level)
is Swipe.Seek -> {
val delta = swipe.to - swipe.from
Icon(
if (delta < 0) Icons.Default.FastRewind else Icons.Default.FastForward,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(32.dp),
)
Text(
(if (delta < 0) "−" else "+") + format(abs(delta)),
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
Text(format(swipe.to), color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.labelMedium)
}
}
}
}
@Composable
private fun Level(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, level: Float) {
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(32.dp))
Text("$label ${(level * 100).roundToInt()}%", color = Color.White, style = MaterialTheme.typography.labelLarge)
Box(Modifier.width(120.dp)) {
LinearProgressIndicator(
progress = { level },
modifier = Modifier.fillMaxWidth().height(4.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = Color.White.copy(alpha = 0.25f),
drawStopIndicator = {},
)
}
}
@@ -2,6 +2,7 @@ package io.narl.protonstream.playback
import android.app.Activity
import android.content.pm.ActivityInfo
import android.os.SystemClock
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.WindowManager
@@ -35,7 +36,10 @@ import androidx.compose.material.icons.automirrored.filled.Toc
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Forward30
import androidx.compose.material.icons.filled.Fullscreen
@@ -84,6 +88,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -97,6 +104,7 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.shownName
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.AccentTrack
import io.narl.protonstream.ui.theme.EdgedButton
@@ -121,6 +129,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uniffi.pstr_android.ChapterEntry
import uniffi.pstr_android.ChapterKind
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TrackPreferencesRecord
@@ -140,6 +149,9 @@ private const val DOUBLE_TAP_SECONDS = 10
/** How long the double-tap seek mark stays up. */
private const val NUDGE_MS = 700L
/** How long the unlock button stays up after a tap on a locked player. */
private const val UNLOCK_SHOWN_MS = 3_000L
/** How close to the end the next episode's opening blocks are fetched. */
private const val WARM_NEXT_SECONDS = 120.0
@@ -185,6 +197,7 @@ fun PlayerScreen(
onClose: () -> Unit,
) {
val context = LocalContext.current
val haptics = LocalHapticFeedback.current
val activity = context as? Activity
val titleKey = title.key
val episode = episodes.getOrNull(index)
@@ -365,8 +378,22 @@ fun PlayerScreen(
// own reading — never on a failure, and never on the outgoing file's.
// Cancelled from the end-of-file card: the viewer chose to stay.
var stay by remember(key) { mutableStateOf(false) }
// The sleep timer set for the end of this episode stops it there instead.
val sleep by nativeHost.sleep.collectAsState()
val stopsAfterThis = (sleep as? SleepTimer.EndOf)?.media == key
LaunchedEffect(state.endReason, state.media) {
if (state.endReason == EndReason.Eof && state.media == key && autoplay && !stay && next != null) advance()
if (state.endReason != EndReason.Eof || state.media != key) return@LaunchedEffect
if (stopsAfterThis) {
nativeHost.setSleepTimer(null)
} else if (autoplay && !stay && next != null) {
advance()
}
}
// A timer for the end of an episode the viewer has since moved on from
// was about that episode.
LaunchedEffect(key) {
val timer = nativeHost.sleep.value
if (timer is SleepTimer.EndOf && timer.media != key) nativeHost.setSleepTimer(null)
}
// A file mpv could not play ends like any other. Saying so is the whole
// difference between a broken episode and an app that silently skipped one.
@@ -420,6 +447,20 @@ fun PlayerScreen(
if (autoSkip && !state.paused && skipped.add(chapter.index)) nativeHost.seek(target)
}
// A file whose chapters name no opening or ending asks AniSkip, once, when
// its length is known. Only with posters and descriptions on: anything
// else, an error included, leaves the file as its chapters made it.
val lengthKnown = playingThis && state.duration > 0.0
LaunchedEffect(key, lengthKnown) {
if (!lengthKnown) return@LaunchedEffect
val named = chapters.entries.any { it.kind == ChapterKind.OPENING || it.kind == ChapterKind.ENDING }
if (named) return@LaunchedEffect
val ranges = runCatching {
NativeRuntime.engine().skipTimes(titleKey, episode.shareId, episode.linkId, state.duration)
}.getOrNull()
if (!ranges.isNullOrEmpty()) nativeHost.setSkipRanges(key, ranges)
}
var landscapeLocked by remember { mutableStateOf(false) }
// Set by the rotation button: from then on the viewer decides.
var rotationChosen by remember { mutableStateOf(false) }
@@ -436,8 +477,12 @@ fun PlayerScreen(
// four seconds after it is reached cannot be found again.
val exploring = rememberTouchExploration()
LaunchedEffect(exploring) { if (exploring) showControls = true }
LaunchedEffect(showControls, state.paused, exploring) {
if (showControls && !state.paused && !exploring) {
// A chooser open over the controls. They hold still under it: the
// choosers live inside the controls, so hiding them closed whatever was
// being picked from, four seconds after the controls came up.
var picking by remember { mutableStateOf(false) }
LaunchedEffect(showControls, state.paused, exploring, picking) {
if (showControls && !state.paused && !exploring && !picking) {
delay(CONTROLS_SECONDS * 1_000)
showControls = false
}
@@ -452,6 +497,21 @@ fun PlayerScreen(
}
}
val current by rememberUpdatedState(state)
// Locked, the picture takes no touches but one: the button that unlocks
// it, which a tap brings up for a moment. For a phone in a pocket, or in
// a child's hands.
var locked by remember { mutableStateOf(false) }
var unlockShown by remember { mutableIntStateOf(0) }
LaunchedEffect(unlockShown) {
if (unlockShown > 0) {
delay(UNLOCK_SHOWN_MS)
unlockShown = 0
}
}
BackHandler(enabled = locked && !minimized) { unlockShown++ }
var swipe by remember { mutableStateOf<Swipe?>(null) }
// Brightness set by a swipe is the player's, not the app's.
DisposableEffect(activity) { onDispose { activity?.releaseBrightness() } }
// Where a remote's keys land while the controls are down. Once they are
// up, the D-pad moves between their buttons as it does anywhere else.
val keys = remember { FocusRequester() }
@@ -495,7 +555,22 @@ fun PlayerScreen(
// Double-tap a side to seek ten seconds, as every phone video
// player has taught; the middle plays or pauses. A single tap
// waits out the double-tap timeout, which is the price of both.
.pointerInput(host) {
.playerSwipes(
enabled = !locked,
activity = activity,
position = { current.position },
duration = { current.duration },
onSwipe = { swipe = it },
onSeek = { target ->
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
host.seek(target)
},
)
.pointerInput(host, locked) {
if (locked) {
detectTapGestures(onTap = { unlockShown++ })
return@pointerInput
}
detectTapGestures(
onTap = { showControls = !showControls },
onDoubleTap = { offset ->
@@ -505,6 +580,9 @@ fun PlayerScreen(
offset.x > size.width - third -> DOUBLE_TAP_SECONDS
else -> 0
}
// Felt as well as seen: the bubble is under the thumb
// that asked for it.
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
if (seconds == 0) {
host.setPaused(!current.paused)
} else {
@@ -536,17 +614,20 @@ fun PlayerScreen(
)
AnimatedVisibility(
visible = showControls,
visible = showControls && !locked,
modifier = Modifier.align(Alignment.TopStart),
enter = fadeIn(),
exit = fadeOut(),
) {
// A gradient rather than a band: dark where the text is, and gone
// by the time it reaches the picture, so the top of the frame is
// dimmed rather than cut off by a grey bar.
Row(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.45f))
.background(Brush.verticalGradient(listOf(Scrim.copy(alpha = 0.75f), Color.Transparent)))
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 8.dp, vertical = 4.dp),
.padding(start = 8.dp, end = 8.dp, top = 4.dp, bottom = 28.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onMinimize) {
@@ -567,8 +648,11 @@ fun PlayerScreen(
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium,
)
// The episode's name, never its file name: a release
// group's tag is no caption, and it is what a screenshot
// of the player would carry away.
Text(
listOfNotNull(title.displayName, episode.detail.takeIf(String::isNotBlank))
listOfNotNull(title.displayName, episode.shownName())
.joinToString(" • "),
color = Color.White.copy(alpha = 0.7f),
maxLines = 1,
@@ -593,7 +677,7 @@ fun PlayerScreen(
}
AnimatedVisibility(
visible = showControls,
visible = showControls && !locked,
modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn(),
exit = fadeOut(),
@@ -616,9 +700,35 @@ fun PlayerScreen(
landscapeLocked = activity?.toggleLandscape() ?: false
},
landscapeLocked = landscapeLocked,
media = key,
sleep = sleep,
onPicking = { picking = it },
onLock = {
locked = true
showControls = false
unlockShown++
},
)
}
swipe?.let { SwipeBubble(it, ::formatTime, Modifier.align(Alignment.Center)) }
AnimatedVisibility(
visible = locked && unlockShown > 0,
modifier = Modifier.align(Alignment.Center),
enter = fadeIn(),
exit = fadeOut(),
) {
TonalButton(onClick = {
locked = false
unlockShown = 0
showControls = true
}) {
Icon(Icons.Default.LockOpen, contentDescription = null)
Text("Unlock", modifier = Modifier.padding(start = 8.dp))
}
}
// Drawn whether or not the rest of the controls are up: an opening lasts
// ninety seconds, and a button you have to wake with a tap is one nobody
// reaches in time.
@@ -628,7 +738,7 @@ fun PlayerScreen(
modifier = Modifier
.align(Alignment.BottomEnd)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
.padding(end = 16.dp, bottom = if (showControls) 160.dp else 24.dp),
) { Text(skip.label) }
}
@@ -655,7 +765,7 @@ fun PlayerScreen(
}
UpNextCard(
next = next,
visible = autoplay && !stay && !state.paused && !state.ended && playingThis &&
visible = autoplay && !stay && !stopsAfterThis && !state.paused && !state.ended && playingThis &&
(tailLeft != null || chapters.creditsStart?.let { state.position >= it } == true),
secondsLeft = tailLeft?.let { ceil(it).toInt() },
onPlayNow = { advance() },
@@ -663,12 +773,12 @@ fun PlayerScreen(
modifier = Modifier
.align(Alignment.BottomEnd)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
.padding(end = 16.dp, bottom = if (showControls) 160.dp else 24.dp),
)
// Played to the end with nothing starting after it: the last episode,
// autoplay off, or the viewer stayed. A black screen said none of that.
if (playingThis && state.endReason == EndReason.Eof && (next == null || !autoplay || stay)) {
if (playingThis && state.endReason == EndReason.Eof && (next == null || !autoplay || stay || stopsAfterThis)) {
EndCard(
finished = episode.label,
show = title.displayName,
@@ -842,12 +952,21 @@ private fun PlayerControls(
onEpisode: (Int) -> Unit,
onRotate: () -> Unit,
landscapeLocked: Boolean,
media: String,
sleep: SleepTimer?,
onPicking: (Boolean) -> Unit,
onLock: () -> Unit,
) {
val scope = rememberCoroutineScope()
var showSleep by remember { mutableStateOf(false) }
var choosing by remember { mutableStateOf<String?>(null) }
var showEpisodes by remember { mutableStateOf(false) }
var showChapters by remember { mutableStateOf(false) }
var showSpeeds by remember { mutableStateOf(false) }
var more by remember { mutableStateOf(false) }
val picking = more || showSleep || choosing != null || showEpisodes || showChapters || showSpeeds
LaunchedEffect(picking) { onPicking(picking) }
DisposableEffect(Unit) { onDispose { onPicking(false) } }
// What the store says, so the dialog opens on the rate that is playing.
var speed by remember { mutableStateOf(1.0) }
LaunchedEffect(Unit) {
@@ -870,9 +989,9 @@ private fun PlayerControls(
Column(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.68f))
.background(Brush.verticalGradient(listOf(Color.Transparent, Scrim.copy(alpha = 0.85f))))
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 12.dp, vertical = 4.dp),
.padding(start = 12.dp, end = 12.dp, top = 32.dp, bottom = 4.dp),
) {
// Where the thumb is while a finger is on it. A seek per touch-move
// aborts every outstanding prefetch, so a one-second drag used to issue
@@ -928,7 +1047,6 @@ private fun PlayerControls(
Row(verticalAlignment = Alignment.CenterVertically) {
if (!roomForAll) {
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
var more by remember { mutableStateOf(false) }
Box {
CompactIcon(Icons.Default.MoreVert, "More") { more = true }
DropdownMenu(expanded = more, onDismissRequest = { more = false }) {
@@ -951,6 +1069,8 @@ private fun PlayerControls(
onRotate,
),
)
add(Triple(Icons.Default.Bedtime, sleepLabel(sleep)) { showSleep = true })
add(Triple(Icons.Default.Lock, "Lock touch", onLock))
}
entries.forEach { (icon, label, action) ->
DropdownMenuItem(
@@ -999,11 +1119,29 @@ private fun PlayerControls(
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
onClick = onRotate,
)
CompactIcon(Icons.Default.Bedtime, sleepLabel(sleep)) { showSleep = true }
CompactIcon(Icons.Default.Lock, "Lock touch", onClick = onLock)
}
}
}
}
if (showSleep) {
SleepChooser(
current = sleep,
onDismiss = { showSleep = false },
onSelect = { minutes ->
showSleep = false
host.setSleepTimer(
when (minutes) {
null -> null
0 -> SleepTimer.EndOf(media)
else -> SleepTimer.At(SystemClock.elapsedRealtime() + minutes * 60_000L)
},
)
},
)
}
if (showSpeeds) {
SpeedChooser(
current = speed,
@@ -1128,6 +1266,59 @@ private fun SpeedChooser(current: Double, onDismiss: () -> Unit, onSelect: (Doub
)
}
/** What the sleep timer button says: whether one is running, and how long it has. */
private fun sleepLabel(sleep: SleepTimer?): String = when (sleep) {
null -> "Sleep timer"
is SleepTimer.EndOf -> "Sleep timer: end of episode"
is SleepTimer.At -> {
val left = ((sleep.at - SystemClock.elapsedRealtime()) / 60_000L + 1).coerceAtLeast(1)
"Sleep timer: $left min"
}
}
/**
* Off, the end of this episode, or a number of minutes. [onSelect] gets
* `null` for off, `0` for the end of the episode, and the minutes otherwise.
*/
@Composable
private fun SleepChooser(current: SleepTimer?, onDismiss: () -> Unit, onSelect: (Int?) -> Unit) {
val choices = listOf<Pair<Int?, String>>(
null to "Off",
0 to "End of this episode",
30 to "30 minutes",
60 to "60 minutes",
)
val chosen: Int? = when (current) {
null -> null
is SleepTimer.EndOf -> 0
is SleepTimer.At -> -1
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Sleep timer") },
text = {
Column(Modifier.fillMaxWidth()) {
if (current is SleepTimer.At) {
Text(
sleepLabel(current).removePrefix("Sleep timer: ") + " left",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp),
)
}
choices.forEach { (minutes, label) ->
EdgedButton(
onClick = { onSelect(minutes) },
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
Text((if (minutes == chosen) "✓ " else "") + label)
}
}
}
},
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
)
}
/** Where each chapter begins, ticked onto the seek bar. */
@Composable
private fun ChapterMarks(chapters: List<ChapterEntry>, duration: Double, modifier: Modifier) {
@@ -1456,3 +1647,9 @@ private fun formatTime(seconds: Double): String {
return if (hours > 0) "%d:%02d:%02d".format(hours, (total % 3_600) / 60, total % 60)
else "%d:%02d".format(total / 60, total % 60)
}
/**
* The player's scrims are black whatever the theme: what is under them is a
* video frame, not the page, and a light theme's scrim would wash it out.
*/
private val Scrim = Color.Black
@@ -28,6 +28,8 @@ import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.theme.AppearanceState
import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.SearchExtrasRecord
import uniffi.pstr_android.LibraryFilter
import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.ShareRecord
@@ -51,7 +53,16 @@ data class AppUiState(
/** A crawl is running, which the banner offers to stop. */
val crawling: Boolean = false,
val query: String = "",
/**
* The whole library, whatever is searched for: the grid narrows to the
* search through [arrangement], and a title opened from a search, or from
* an episode it found, stays one the app can show.
*/
val titles: List<TitleRecord> = emptyList(),
/** What the search found besides titles; null with no search. */
val searchExtras: SearchExtrasRecord? = null,
/** The last few searches on this device, newest first. */
val recentSearches: List<String> = emptyList(),
/**
* The grid: [titles] ordered, each franchise folded into one tile when
* [grouped], and the shelves above it. Null until the bridge has answered,
@@ -168,6 +179,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
viewModelScope.launch {
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
}
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().recentSearches() } }
.onSuccess { recent -> mutableState.update { it.copy(recentSearches = recent) } }
}
viewModelScope.launch {
workManager.workInfosByTagFlow(DownloadCoordinator.TAG)
.map { work -> work.filter { it.state.isFinished }.map { it.id }.toSet() }
@@ -205,6 +220,32 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
searchQuery.value = query
}
/**
* Keep the search being shown as one to offer again: called when a viewer
* opens something it found. Nothing for an empty one.
*/
fun rememberSearch() {
val query = mutableState.value.query.takeIf(String::isNotBlank) ?: return
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().run {
rememberSearch(query)
recentSearches()
}
}
}.onSuccess { recent -> mutableState.update { it.copy(recentSearches = recent) } }
}
}
fun forgetSearches() {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetSearches() } }
.onSuccess { mutableState.update { it.copy(recentSearches = emptyList()) } }
.onFailure(::reportError)
}
}
fun refresh() {
viewModelScope.launch {
busy("Refreshing the library…") {
@@ -591,7 +632,9 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}
}
if (report.settingsChanged) AppearanceState.reload()
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.sharesRemoved.isNotEmpty()) {
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.listChanged ||
report.sharesRemoved.isNotEmpty()
) {
reloadNow()
}
if (report.sharesAdded.isNotEmpty()) {
@@ -625,7 +668,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
* title's positions are what this throws away, and a mis-tap on a tile's
* menu should not cost them.
*/
fun setTitleWatched(title: TitleRecord, watched: Boolean) =
/** Put a title on My List, or take it off. */
fun setOnList(title: TitleRecord, on: Boolean) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().setOnList(title.key, on) }
}.onFailure(::reportError)
reload()
}
}
/** The keys of the titles most like [key], best first; none if the engine cannot say. */
suspend fun moreLike(key: String): List<String> =
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().moreLike(key) } }.getOrDefault(emptyList())
/** The keys of every title [facet] stands for, in library order. */
suspend fun browse(facet: BrowseFacet): List<String> =
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().browse(facet) } }
.onFailure(::reportError)
.getOrDefault(emptyList())
fun setTitleWatched(title: TitleRecord, watched: Boolean) =
markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName)
/**
@@ -727,8 +790,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
val stored = runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
engine.setMetadataApiKey(provider, apiKey)
// TMDB's is the only key: the provider's with TMDB chosen, and
// film art's with AniList.
if (apiKey.isNotBlank()) {
engine.setMetadataApiKey(MetadataProvider.TMDB, apiKey)
}
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
engine.metadataSettings()
@@ -864,7 +929,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
val query = current.query.takeIf(String::isNotBlank)
Reloaded(
engine.shares(),
engine.library(query),
engine.library(null),
engine.arrangement(query, current.filter, current.sort, current.grouped),
engine.offlineFiles(),
engine.metadataSettings(),
@@ -893,10 +958,14 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
val search = query.takeIf(String::isNotBlank)
engine.library(search) to engine.arrangement(search, current.filter, current.sort, current.grouped)
Triple(
engine.library(null),
engine.arrangement(search, current.filter, current.sort, current.grouped),
search?.let(engine::searchExtras),
)
}
}.onSuccess { (titles, arrangement) ->
mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
}.onSuccess { (titles, arrangement, extras) ->
mutableState.update { it.copy(titles = titles, arrangement = arrangement, searchExtras = extras) }
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
}
@@ -4,12 +4,29 @@ import android.net.ConnectivityManager
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.border
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
@@ -137,6 +154,17 @@ internal fun nextUpIndex(playlist: List<EpisodeRecord>, continueIndex: UInt? = n
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
?: 0
/** `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes. */
internal fun formatRuntime(minutes: UInt): String {
val hours = minutes / 60u
val rest = minutes % 60u
return when {
hours == 0u -> "$rest min"
rest == 0u -> "$hours h"
else -> "$hours h $rest min"
}
}
internal fun formatBytes(bytes: ULong): String {
val value = bytes.toDouble()
return when {
@@ -221,6 +249,13 @@ internal fun rememberQueueReason(key: Any?): String? {
}
}
/**
* The episode's own name: the provider's, else the one the filename gave it.
* `null` rather than the filename, which is a release group's tag and the
* episode's numbering again.
*/
internal fun EpisodeRecord.shownName(): String? = providerName ?: detail.takeIf { it != name && it.isNotBlank() }
/**
* What an episode row is headed with: "1. Mother and Children".
*
@@ -229,7 +264,7 @@ internal fun rememberQueueReason(key: Any?): String? {
* of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters.
*/
internal fun EpisodeRecord.heading(position: Int): String {
val named = providerName ?: detail.takeIf { it != name }
val named = shownName()
val number = number?.toInt()
return when {
named != null && number != null -> "$number. $named"
@@ -238,3 +273,86 @@ internal fun EpisodeRecord.heading(position: Int): String {
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" }
}
}
/**
* Something the title is filed under — a genre — as a small rounded label.
* The desktop's `widgets::chip`.
*/
@Composable
internal fun GenreChip(text: String, onClick: (() -> Unit)? = null) {
Text(
text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Browse $text", onClick = onClick) else Modifier)
.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
/**
* Where a title stands — "Airing · episode 8 Sat 1 Nov", "Finished" — as a
* [GenreChip] with a dot in front: the accent while more is coming, muted once
* it is all out. The desktop's `widgets::status_chip`.
*/
@Composable
internal fun StatusChip(text: String, live: Boolean, modifier: Modifier = Modifier) {
val scheme = MaterialTheme.colorScheme
Row(
modifier
.background(scheme.surfaceVariant, RoundedCornerShape(12.dp))
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.size(6.dp)
.background(if (live) scheme.primary else scheme.onSurfaceVariant, CircleShape),
)
Spacer(Modifier.width(6.dp))
Text(text, style = MaterialTheme.typography.labelMedium, color = scheme.onSurface)
}
}
/**
* A fact about the files rather than the title — 4K, HDR, Dual audio — as a
* small outlined mark, the way a box prints them. Quieter than a [GenreChip].
* The desktop's `widgets::spec_badge`.
*/
@Composable
internal fun SpecBadge(text: String) {
val ink = MaterialTheme.colorScheme.onSurfaceVariant
Text(
text,
style = MaterialTheme.typography.labelSmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
color = ink,
modifier = Modifier
.border(1.dp, ink.copy(alpha = 0.7f), MaterialTheme.shapes.extraSmall)
.padding(horizontal = 5.dp, vertical = 1.dp),
)
}
/**
* My List's button: a plus to put the title on it, a tick once it is, which
* takes it off again. [compact] is the icon alone, for the banner's row, where
* a third labelled button does not fit a phone.
*/
@Composable
internal fun ListButton(onList: Boolean, onSet: (Boolean) -> Unit, compact: Boolean = false) {
val icon = if (onList) Icons.Default.Check else Icons.Default.Add
val label = if (onList) "Remove from My List" else "Add to My List"
if (compact) {
FilledTonalIconButton(onClick = { onSet(!onList) }, shape = MaterialTheme.shapes.small) {
Icon(icon, contentDescription = label)
}
} else {
TonalButton(onClick = { onSet(!onList) }, modifier = Modifier.semantics { contentDescription = label }) {
Icon(icon, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text("My List")
}
}
}
@@ -1,6 +1,28 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.background
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -25,6 +47,7 @@ import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.theme.QuietButton
import java.time.LocalDate
import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
@@ -48,6 +71,7 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
@@ -87,12 +111,21 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import uniffi.pstr_android.BrowseFacet
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material.icons.filled.History
import uniffi.pstr_android.FacetCountRecord
import uniffi.pstr_android.SearchExtrasRecord
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.LibraryFilter
import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
import uniffi.pstr_android.airingCountdown
import uniffi.pstr_android.facetKind
/**
* How many shows the continue-watching shelf holds.
@@ -122,6 +155,7 @@ internal fun LibraryFilter.label(): String = when (this) {
LibraryFilter.WATCHING -> "Watching"
LibraryFilter.UNWATCHED -> "Not started"
LibraryFilter.DOWNLOADED -> "Downloaded"
LibraryFilter.MY_LIST -> "My List"
}
/** The offer of posters and synopses, and its two answers. */
@@ -167,6 +201,10 @@ internal fun LibraryScreen(
onAddShare: () -> Unit = {},
onOpenSettings: () -> Unit = {},
onFilter: (LibraryFilter) -> Unit = {},
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
/** Open every title sharing a genre, studio or director a search named. */
onBrowse: (BrowseFacet) -> Unit = {},
onForgetSearches: () -> Unit = {},
) {
val download = rememberDownload()
val context = LocalContext.current
@@ -198,72 +236,93 @@ internal fun LibraryScreen(
onSearch("")
}
BackHandler(enabled = searching, onBack = closeSearch)
// The large title folds into the bar as the grid scrolls, and comes back
// only at the top: the page says what it is on arrival, then gets out of
// the way.
// The banner turns through these. Worked out here rather than in the grid,
// because whether there is one decides the shape of the whole page.
val featured = remember(state.titles, resumable) { featured(state.titles, resumable) }
// With a banner, the bar lies over it, clear, and fills in as the banner
// scrolls away; without one, the large title folds into the bar as the
// grid scrolls and comes back only at the top.
val overBanner = !searching && !state.loading && state.query.isBlank() && featured.isNotEmpty()
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
if (searching) {
SearchBar(state.query, onSearch, closeSearch)
} else {
LargeTopAppBar(
title = { Text("Library") },
actions = {
IconButton(onClick = { searching = true }) {
Icon(Icons.Default.Search, contentDescription = "Search library")
}
ArrangeMenu(state.sort, state.grouped, onArrange)
// Pull to refresh is the gesture; the icon stays for a
// viewer who does not know it.
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
},
colors = pageBarColors(),
scrollBehavior = scroll,
)
val grid = rememberLazyGridState()
val actions: @Composable RowScope.() -> Unit = {
IconButton(onClick = { searching = true }) {
Icon(Icons.Default.Search, contentDescription = "Search library")
}
PullToRefreshBox(
isRefreshing = state.refreshing,
onRefresh = onRefresh,
modifier = Modifier.weight(1f).padding(padding),
) {
when {
state.loading -> LibrarySkeleton()
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
"Nothing matches",
"No title in the library is called “${state.query.trim()}”.",
)
// A share just added: say it is being looked through, rather
// than offer to add the one that is already filling the page.
state.titles.isEmpty() && state.activity != null -> EmptyState(
"Looking through the share",
"${state.activity} Its titles appear here once it has been listed.",
)
state.titles.isEmpty() -> EmptyState(
"Your library is empty",
"Add a Proton Drive share link, and everything playable behind it shows up here.",
action = "Add a share" to onAddShare,
)
else -> LibraryGrid(
state,
resumable,
onResume,
onTitle,
onTitleMenu = { menuFor = it },
onResumeMenu = { menuResume = it },
onFilter = onFilter,
metadataOffer = if (state.metadataSettings.enabled || offerDismissed) {
null
} else {
MetadataOffer(onOpenSettings) {
offerDismissed = true
SettingsStore(context).metadataOfferDismissed = true
}
},
ArrangeMenu(state.sort, state.grouped, onArrange)
// Pull to refresh is the gesture; the icon stays for a viewer who
// does not know it.
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().then(if (overBanner) Modifier else Modifier.nestedScroll(scroll.nestedScrollConnection))) {
if (searching) {
SearchBar(state.query, onSearch, closeSearch)
} else if (!overBanner) {
LargeTopAppBar(
title = { Text("Library") },
actions = actions,
colors = pageBarColors(),
scrollBehavior = scroll,
)
}
PullToRefreshBox(
isRefreshing = state.refreshing,
onRefresh = onRefresh,
modifier = Modifier.weight(1f).padding(padding),
) {
when {
state.loading -> LibrarySkeleton()
// Empty and with the field open: the last few searches.
searching && state.query.isBlank() && state.recentSearches.isNotEmpty() -> RecentSearches(
state.recentSearches,
onPick = onSearch,
onForget = onForgetSearches,
)
state.query.isNotBlank() &&
state.arrangement?.tiles?.isEmpty() == true &&
state.searchExtras?.isEmpty() != false -> EmptyState(
"Nothing matches",
"Nothing in the library is called “${state.query.trim()}”.",
)
// A share just added: say it is being looked through, rather
// than offer to add the one that is already filling the page.
state.titles.isEmpty() && state.activity != null -> EmptyState(
"Looking through the share",
"${state.activity} Its titles appear here once it has been listed.",
)
state.titles.isEmpty() -> EmptyState(
"Your library is empty",
"Add a Proton Drive share link, and everything playable behind it shows up here.",
action = "Add a share" to onAddShare,
)
else -> LibraryGrid(
state,
resumable,
onResume,
onTitle,
onTitleMenu = { menuFor = it },
onResumeMenu = { menuResume = it },
onFilter = onFilter,
metadataOffer = if (state.metadataSettings.enabled || offerDismissed) {
null
} else {
MetadataOffer(onOpenSettings) {
offerDismissed = true
SettingsStore(context).metadataOfferDismissed = true
}
},
featured = if (overBanner) featured else emptyList(),
grid = grid,
onSetOnList = onSetOnList,
onBrowse = onBrowse,
)
}
}
}
if (overBanner) BarOverBanner(grid, actions)
}
menuFor?.let { title ->
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
@@ -273,6 +332,10 @@ internal fun LibraryScreen(
onResume(title, nextUpIndex(playlist, title.continueIndex))
}
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
MenuRow(
if (title.onList) Icons.Default.Check else Icons.Default.Add,
if (title.onList) "Remove from My List" else "Add to My List",
) { onSetOnList(title, !title.onList) }
MenuRow(Icons.Default.Download, "Download all") { download(playlist, title.displayName) }
MenuRow(
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
@@ -457,6 +520,10 @@ private fun LibraryGrid(
onResumeMenu: (Resumable) -> Unit,
onFilter: (LibraryFilter) -> Unit = {},
metadataOffer: MetadataOffer? = null,
featured: List<TitleRecord> = emptyList(),
grid: LazyGridState = rememberLazyGridState(),
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
onBrowse: (BrowseFacet) -> Unit = {},
) {
// The arrangement names titles by key; before it has arrived, the titles
// as they came are the grid.
@@ -475,10 +542,25 @@ private fun LibraryGrid(
LazyVerticalGrid(
columns = GridCells.Adaptive(108.dp),
modifier = Modifier.fillMaxSize(),
state = grid,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
// No room above a banner: it starts under the status bar, and the bar
// lies over it.
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = if (featured.isEmpty()) 8.dp else 0.dp, bottom = 24.dp),
) {
if (featured.isNotEmpty()) {
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
FeaturedBanner(
featured,
resumable,
onPlay = onResume,
onOpen = onTitle,
onSetOnList = onSetOnList,
modifier = Modifier.bleed(16.dp),
)
}
}
// Above the grid, and only when there is something in it: what a viewer
// opening the app wants is almost always the thing they were part way
// through. Inside the grid rather than pinned over it, so the shelf
@@ -486,17 +568,6 @@ private fun LibraryGrid(
if (metadataOffer != null && state.query.isBlank()) {
item(key = "metadata-offer", span = { GridItemSpan(maxLineSpan) }) { MetadataOfferCard(metadataOffer) }
}
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
featured?.let { title ->
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
FeaturedBanner(
title,
resume = resumable.firstOrNull { it.title.key == title.key },
onPlay = { onResume(title, it) },
onOpen = { onTitle(title) },
)
}
}
if (state.query.isBlank() && resumable.isNotEmpty()) {
item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) {
Column {
@@ -535,6 +606,9 @@ private fun LibraryGrid(
onLongClick = { onTitleMenu(title) },
modifier = Modifier.width(SHELF_TILE_WIDTH),
shared = false,
// On a shelf, an airing show says when its
// next episode is out.
badge = title.badge ?: title.countdown(),
)
}
}
@@ -567,6 +641,38 @@ private fun LibraryGrid(
}
}
}
// What the search found besides titles, above the titles it found.
state.searchExtras?.takeIf { state.query.isNotBlank() }?.let { extras ->
facetChips("People and studios", extras.people, onBrowse)
facetChips("Genres", extras.genres, onBrowse)
if (extras.episodes.isNotEmpty()) {
item(key = "search-episodes", span = { GridItemSpan(maxLineSpan) }) {
Column {
Text("Episodes", style = MaterialTheme.typography.titleMedium)
extras.episodes.forEach { hit ->
val title = byKey[hit.titleKey] ?: return@forEach
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
val index = playlist.indexOfFirst { it.linkId == hit.linkId }
if (index < 0) return@forEach
ListItem(
headlineContent = { Text(hit.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = {
Text("${title.displayName} · ${hit.label}", maxLines = 1, overflow = TextOverflow.Ellipsis)
},
leadingContent = { Icon(Icons.Default.PlayArrow, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable(onClickLabel = "Play") { onResume(title, index) },
)
}
}
}
}
if (tiles.isNotEmpty() && !extras.isEmpty()) {
item(key = "search-titles", span = { GridItemSpan(maxLineSpan) }) {
Text("Titles", style = MaterialTheme.typography.titleMedium)
}
}
}
items(tiles, key = { it.title.key }) { tile ->
PosterTile(
tile.title,
@@ -578,6 +684,55 @@ private fun LibraryGrid(
}
}
/** Whether a search found nothing besides titles. */
internal fun SearchExtrasRecord.isEmpty(): Boolean = episodes.isEmpty() && people.isEmpty() && genres.isEmpty()
/** A row of the genres, studios or people a search named, each opening its page. */
private fun LazyGridScope.facetChips(heading: String, facets: List<FacetCountRecord>, onBrowse: (BrowseFacet) -> Unit) {
if (facets.isEmpty()) return
item(key = "search-$heading", span = { GridItemSpan(maxLineSpan) }) {
Column {
Text(heading, style = MaterialTheme.typography.titleMedium)
FlowRow(
Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
facets.forEach { found ->
GenreChip("${found.facet.label} · ${found.count}") { onBrowse(found.facet) }
}
}
}
}
}
/** The last few searches, under an empty search field. */
@Composable
private fun RecentSearches(recent: List<String>, onPick: (String) -> Unit, onForget: () -> Unit) {
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 8.dp)) {
item(key = "heading") {
Text(
"Recent searches",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
items(recent, key = { it }) { query ->
ListItem(
headlineContent = { Text(query) },
leadingContent = { Icon(Icons.Default.History, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable { onPick(query) },
)
}
item(key = "forget") {
QuietButton(onClick = onForget, modifier = Modifier.padding(horizontal = 8.dp)) {
Text("Clear recent searches")
}
}
}
}
/**
* The grid's shape before the catalog has answered: posters and their two
* lines of text as blocks, so the page does not jump when the titles land.
@@ -610,57 +765,200 @@ private val SHELF_TILE_WIDTH = 112.dp
/** Enough placeholder tiles to fill a tablet's first screen. */
private const val SKELETON_TILES = 18
/** How many titles the banner turns through: the desktop's `FEATURED`. */
private const val FEATURED_MAX = 5
/** How long each title holds the banner: the desktop's `HOLD`. */
private const val HOLD_MS = 8_000L
/** How long one title takes to give way to the next: the desktop's `TURN`. */
private const val TURN_MS = 600
/**
* The title the banner shows: the one watched last, or with nothing part
* watched, one of the titles the provider has a backdrop for, turning over
* once a day — the desktop's `featured`.
* The titles the banner turns through: the one watched last first, then titles
* the provider has a backdrop for, starting from a different one each day —
* the desktop's `featured`.
*/
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): TitleRecord? {
resumable.firstOrNull()?.let { return it.title }
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): List<TitleRecord> {
val picked = listOfNotNull(resumable.firstOrNull()?.title).toMutableList()
val pictured = titles.filter { it.backdropUrl != null }
if (pictured.isEmpty()) return null
return pictured[(LocalDate.now().toEpochDay() % pictured.size).toInt()]
if (pictured.isEmpty()) return picked
val start = (LocalDate.now().toEpochDay() % pictured.size).toInt()
for (offset in pictured.indices) {
if (picked.size == FEATURED_MAX) break
val title = pictured[(start + offset) % pictured.size]
if (picked.none { it.key == title.key }) picked += title
}
return picked
}
/** Large art behind the name, rating, genres and three lines of synopsis. */
/**
* Wider than the space it is given by [horizontal] on each side: for the
* banner, which runs edge to edge inside a grid with a margin.
*/
private fun Modifier.bleed(horizontal: Dp): Modifier = layout { measurable, constraints ->
val extra = horizontal.roundToPx() * 2
val placeable = measurable.measure(
constraints.copy(minWidth = constraints.minWidth + extra, maxWidth = constraints.maxWidth + extra),
)
layout(placeable.width - extra, placeable.height) { placeable.place(-extra / 2, 0) }
}
/**
* The bar over the banner: clear at the top, with white icons over the
* banner's own scrim, and filling in to the page's bar as the banner scrolls
* away — at which point it is the page's bar, title and all.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int) -> Unit, onOpen: () -> Unit) {
private fun BarOverBanner(grid: LazyGridState, actions: @Composable RowScope.() -> Unit) {
val reveal = with(LocalDensity.current) { BAR_REVEAL.toPx() }
val solid by remember(grid) {
derivedStateOf {
if (grid.firstVisibleItemIndex > 0) 1f else (grid.firstVisibleItemScrollOffset / reveal).coerceIn(0f, 1f)
}
}
val scheme = MaterialTheme.colorScheme
val ink = lerp(Color.White, scheme.onSurface, solid)
TopAppBar(
title = { Text("Library", modifier = Modifier.graphicsLayer { alpha = solid }) },
actions = actions,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = scheme.surfaceContainer.copy(alpha = solid),
titleContentColor = ink,
actionIconContentColor = ink,
),
)
}
/** How far the banner scrolls before the bar over it is solid. */
private val BAR_REVEAL = 280.dp
/**
* The banner at the top of the library: one title at a time, edge to edge
* under the status bar, turning to the next every [HOLD_MS] unless a finger is
* on it. Each title's veil is tinted with its own picture's colour.
*/
@Composable
private fun FeaturedBanner(
titles: List<TitleRecord>,
resumable: List<Resumable>,
onPlay: (TitleRecord, Int) -> Unit,
onOpen: (TitleRecord) -> Unit,
onSetOnList: (TitleRecord, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val pager = rememberPagerState { titles.size }
val dragged by pager.interactionSource.collectIsDraggedAsState()
if (titles.size > 1 && !dragged) {
// Keyed on the settled page, not the current one: the current page
// flips half way through a turn, and restarting this effect there
// cancelled the turn and left the banner stuck between two titles.
LaunchedEffect(pager.settledPage, titles.size) {
delay(HOLD_MS)
pager.animateScrollToPage((pager.settledPage + 1) % titles.size, animationSpec = tween(TURN_MS))
}
}
// Most of a phone's first screen, but never so much of a tablet's that
// nothing under it shows.
val screenHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
val height = (screenHeight * 0.62f).coerceIn(360.dp, 620.dp)
Box(modifier.fillMaxWidth().height(height)) {
HorizontalPager(pager, Modifier.fillMaxSize(), key = { titles[it].key }) { page ->
val title = titles[page]
FeaturedPage(
title,
resumable.firstOrNull { it.title.key == title.key },
onPlay = { onPlay(title, it) },
onOpen = { onOpen(title) },
onSetOnList = { onSetOnList(title, it) },
)
}
if (titles.size > 1) {
val scope = rememberCoroutineScope()
Row(
Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
titles.forEachIndexed { index, title ->
val shown = index == pager.currentPage
val width by animateDpAsState(if (shown) 18.dp else 6.dp, label = "dot")
Box(
Modifier
.size(width, 6.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onBackground.copy(alpha = if (shown) 1f else 0.4f))
.clickable(onClickLabel = "Show ${title.displayName}") {
scope.launch { pager.animateScrollToPage(index, animationSpec = tween(TURN_MS)) }
}
.semantics { selected = shown },
)
}
}
}
}
}
/** One title on the banner: its art, its veil, and what it is, from the bottom up. */
@Composable
private fun FeaturedPage(
title: TitleRecord,
resume: Resumable?,
onPlay: (Int) -> Unit,
onOpen: () -> Unit,
onSetOnList: (Boolean) -> Unit,
) {
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) {
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.displayName,
Modifier.fillMaxSize(),
fallback = title.thumbnailSource,
labelled = false,
)
var mood by remember(title.key) { mutableStateOf<Color?>(null) }
val background = MaterialTheme.colorScheme.background
val veil = moodVeil(mood)
val ink = MaterialTheme.colorScheme.onBackground
// A logo is lettering made for dark art, white as often as not; over a
// light palette's veil it is gone, so the light palette keeps the name.
val dark = background.luminance() < 0.5f
Box(Modifier.fillMaxSize().clickable(onClickLabel = "Open ${title.displayName}", onClick = onOpen)) {
Backdrop(title, Modifier.fillMaxSize(), onMood = { mood = it })
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(0.35f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.85f)),
Brush.verticalGradient(
// Dark under the status bar and the bar's white icons…
0f to Color.Black.copy(alpha = 0.5f),
0.22f to Color.Transparent,
// …and the picture's own colour under the text, ending in
// the page it hands over to.
0.45f to veil.copy(alpha = 0.35f),
0.78f to veil.copy(alpha = 0.92f),
1f to background,
),
),
)
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
Text(
title.displayName,
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Column(
Modifier.align(Alignment.BottomStart).widthIn(max = 560.dp).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
) {
if (dark) {
RemoteLogo(
title.logoUrl,
title.displayName,
Modifier.fillMaxWidth(0.7f).heightIn(max = 88.dp).padding(bottom = 8.dp),
) { HeroName(title.displayName, ink) }
} else {
HeroName(title.displayName, ink)
}
Text(
listOfNotNull(
title.rating?.let { "★ %.1f".format(it) },
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
).joinToString(" · ").ifEmpty { title.caption() },
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f),
color = ink.copy(alpha = 0.8f),
)
title.overview?.let {
Text(
it.trim(),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f),
maxLines = 3,
color = ink.copy(alpha = 0.8f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
@@ -669,14 +967,49 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist, title.continueIndex)) }) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(if (resume != null) "Resume" else "Play")
Text(if (resume?.episode?.resumeAt != null) "Resume" else "Play")
}
TonalButton(onClick = onOpen) {
Icon(Icons.Default.Info, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text("More info")
}
ListButton(title.onList, onSetOnList, compact = true)
}
val episode = resume?.episode
val progress = episode?.progress?.takeIf { episode.resumeAt != null }
if (progress != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp)) {
AccentProgress(
progress = { progress.toFloat().coerceIn(0f, 1f) },
modifier = Modifier.width(120.dp),
)
episode.timeLeft()?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = ink.copy(alpha = 0.8f),
modifier = Modifier.padding(start = 8.dp),
)
}
}
TonalButton(onClick = onOpen) { Text("More info") }
}
}
}
}
@Composable
private fun HeroName(name: String, ink: Color) {
Text(
name,
style = MaterialTheme.typography.displayLarge,
color = ink,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 4.dp),
)
}
/** A part-watched episode: its still, where it was left, and what is left of it. */
@Composable
private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () -> Unit) {
@@ -739,6 +1072,7 @@ private fun PosterTile(
modifier: Modifier = Modifier,
folded: Int = 0,
shared: Boolean = true,
badge: String? = title.badge,
) {
Column(
modifier
@@ -772,6 +1106,20 @@ private fun PosterTile(
.semantics { contentDescription = "and $folded more" },
)
}
if (badge != null) {
Text(
badge,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier
.align(Alignment.TopStart)
.padding(6.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.primary)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
Spacer(Modifier.height(6.dp))
Text(
@@ -813,3 +1161,79 @@ private fun EpisodeRecord.timeLeft(): String? {
val minutes = ((at / fraction - at) / 60).toInt()
return if (minutes < 1) "under a minute left" else "$minutes min left"
}
/** `Ep 8 · in 2 d` for a show whose next episode is still to air. */
internal fun TitleRecord.countdown(now: Long = System.currentTimeMillis() / 1000): String? {
val at = nextAiringAt?.takeIf { it > now } ?: return null
val episode = nextEpisode ?: return null
return airingCountdown(episode, at, now)
}
/**
* Every title sharing a genre, a studio or a director: what a chip on a title
* page opens. The desktop's `library::browse`.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun BrowseScreen(
facet: BrowseFacet,
titles: List<TitleRecord>,
onBack: () -> Unit,
onTitle: (TitleRecord) -> Unit,
padding: PaddingValues,
) {
BackHandler(onBack = onBack)
Column(Modifier.fillMaxSize()) {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
title = {
Column {
Text(
facetKind(facet),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(facet.label, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
},
colors = pageBarColors(),
)
if (titles.isEmpty()) {
// A recrawl or a new match took the last one away.
Box(Modifier.padding(padding)) {
EmptyState(facet.label, "Nothing in the library has this any more.")
}
return@Column
}
LazyVerticalGrid(
columns = GridCells.Adaptive(108.dp),
modifier = Modifier.fillMaxSize().padding(padding),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
) {
item(key = "count", span = { GridItemSpan(maxLineSpan) }) {
Text(
if (titles.size == 1) "1 title" else "${titles.size} titles",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
items(titles, key = { it.key }) { title ->
PosterTile(title, onClick = { onTitle(title) }, onLongClick = { onTitle(title) }, shared = false)
}
}
}
}
/** The name a [BrowseFacet] stands for: the chip's text and its page's heading. */
internal val BrowseFacet.label: String
get() = when (this) {
is BrowseFacet.Genre -> name
is BrowseFacet.Studio -> name
is BrowseFacet.Person -> name
}
@@ -62,6 +62,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@@ -78,6 +79,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.work.WorkManager
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.accentNavigationColors
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord
@@ -135,6 +137,28 @@ fun ProtonStreamApp(
// into the video they had just left.
var playerMinimized by rememberSaveable { mutableStateOf(false) }
var addingShare by remember { mutableStateOf(false) }
// What a genre, studio or director chip opened, over the library pane.
// As its two parts, which survive process recreation where the record
// would not.
var browseKind by rememberSaveable { mutableStateOf<String?>(null) }
var browseName by rememberSaveable { mutableStateOf("") }
// The title it was opened from, which is where back goes, as on the
// desktop.
var browseFrom by rememberSaveable { mutableStateOf<String?>(null) }
val browsing = when (browseKind) {
"genre" -> BrowseFacet.Genre(browseName)
"studio" -> BrowseFacet.Studio(browseName)
"person" -> BrowseFacet.Person(browseName)
else -> null
}
val browse = { facet: BrowseFacet ->
browseKind = when (facet) {
is BrowseFacet.Genre -> "genre"
is BrowseFacet.Studio -> "studio"
is BrowseFacet.Person -> "person"
}
browseName = facet.label
}
val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
val playing = playingTitleKey
@@ -172,7 +196,7 @@ fun ProtonStreamApp(
Destination.entries.forEach { item ->
item(
selected = destination == item,
onClick = { destination = item; selectedTitleKey = null },
onClick = { destination = item; selectedTitleKey = null; browseKind = null },
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
colors = navigation,
@@ -211,16 +235,34 @@ fun ProtonStreamApp(
// library stays in view while a title is open, so moving
// between titles is one tap rather than back and in again.
Destination.Library -> BoxWithConstraints(Modifier.fillMaxSize()) {
val twoPane = maxWidth >= TWO_PANE_WIDTH
val browsed by produceState(emptyList<String>(), browsing, state.titles) {
value = browsing?.let { model.browse(it) }.orEmpty()
}
val libraryPane = @Composable {
LibraryScreen(
browsing?.let { facet ->
BrowseScreen(
facet,
browsed.mapNotNull { key -> state.titles.firstOrNull { it.key == key } },
onBack = {
browseKind = null
browseFrom?.let { selectedTitleKey = it }
},
onTitle = { selectedTitleKey = it.key },
padding = body,
)
} ?: LibraryScreen(
state,
model::search,
{ title, index ->
// A search that led somewhere is one
// worth offering again.
model.rememberSearch()
playingTitleKey = title.key
playingIndex = index
playerMinimized = false
},
{ selectedTitleKey = it.key },
{ model.rememberSearch(); selectedTitleKey = it.key },
model::refresh,
model::setTitleWatched,
model::forgetPosition,
@@ -234,9 +276,19 @@ fun ProtonStreamApp(
},
onOpenSettings = { destination = Destination.Settings },
onFilter = model::filter,
onSetOnList = model::setOnList,
onBrowse = { facet ->
model.rememberSearch()
browse(facet)
browseFrom = null
},
onForgetSearches = model::forgetSearches,
)
}
val titlePane = @Composable { selected: TitleRecord ->
val similar by produceState(emptyList<String>(), selected.key, state.titles) {
value = model.moreLike(selected.key)
}
TitleScreen(
selected,
playerHost != null,
@@ -255,9 +307,18 @@ fun ProtonStreamApp(
state.titles.firstOrNull { it.key == key }
},
onOpenTitle = { selectedTitleKey = it.key },
onSetOnList = model::setOnList,
moreLike = similar.mapNotNull { key -> state.titles.firstOrNull { it.key == key } },
onBrowse = { facet ->
browse(facet)
browseFrom = selected.key
// On a phone the list is under the title;
// the page it opens is the library pane's.
if (!twoPane) selectedTitleKey = null
},
)
}
if (maxWidth >= TWO_PANE_WIDTH) {
if (twoPane) {
Row(Modifier.fillMaxSize()) {
Box(Modifier.weight(0.45f)) { libraryPane() }
VerticalDivider()
@@ -6,13 +6,16 @@ import android.graphics.BitmapFactory
import android.util.LruCache
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -24,22 +27,30 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.font.FontWeight
import androidx.core.graphics.scale
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.moodVeil as sharedMoodVeil
import uniffi.pstr_android.pictureMood as sharedPictureMood
import java.net.HttpURLConnection
import java.net.URI
import java.io.ByteArrayOutputStream
@@ -77,9 +88,13 @@ internal val EpisodeRecord.thumbnailSource: ThumbnailSource
*
* [fallback] is used only when there is no provider artwork or it fails to
* load, which — with metadata off — is the common case rather than the rare
* one. Until either answers, the box pulses as a skeleton; when neither has
* one. Until either answers, the box shimmers as a skeleton; when neither has
* anything, it shows [ArtworkPlaceholder] rather than an empty card — without
* initials when [labelled] is false, for art with the name already over it.
*
* Art that arrives fades in over the skeleton rather than replacing it in one
* frame, which across a grid reads as flicker. Art already decoded is drawn
* at once: a tile scrolled back into view has nothing to arrive.
*/
@Composable
internal fun RemoteArtwork(
@@ -89,29 +104,200 @@ internal fun RemoteArtwork(
fallback: ThumbnailSource? = null,
labelled: Boolean = true,
maxEdge: Dp? = null,
onMood: ((Color) -> Unit)? = null,
) {
val context = LocalContext.current
val enabled = LocalArtworkLoading.current
// Decoded at the size it is drawn: a grid tile is a few hundred pixels,
// and decoding its poster at 1024 holds four times the memory for nothing.
val edge = with(LocalDensity.current) { maxEdge?.roundToPx() }?.coerceIn(1, MAX_ART_EDGE) ?: MAX_ART_EDGE
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
var image by remember(url, fallback) { mutableStateOf(alreadyDecoded(url, fallback, edge)) }
LaunchedEffect(url, fallback, enabled) {
if (!enabled) {
image = ArtworkLoad.Missing
return@LaunchedEffect
}
image = (loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge))
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
?: ArtworkLoad.Missing
val bitmap = loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge)
image = bitmap?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Missing
if (bitmap != null && onMood != null) {
moodOf(url ?: fallback?.key ?: return@LaunchedEffect, bitmap)?.let(onMood)
}
}
when (val loaded = image) {
ArtworkLoad.Pending -> Skeleton(modifier)
ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled)
is ArtworkLoad.Loaded -> Image(loaded.image, description, modifier, contentScale = ContentScale.Crop)
Crossfade(image, modifier, animationSpec = tween(ARRIVE_MS), label = "artwork") { loaded ->
when (loaded) {
ArtworkLoad.Pending -> Skeleton(Modifier.fillMaxSize())
ArtworkLoad.Missing -> ArtworkPlaceholder(description, Modifier.fillMaxSize(), labelled)
is ArtworkLoad.Loaded -> Image(
loaded.image,
description,
Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
}
}
/** What the decoded cache already holds for this art, so it skips the fade. */
private fun alreadyDecoded(url: String?, fallback: ThumbnailSource?, edge: Int): ArtworkLoad {
val hit = url?.let { decoded.get(sized(it, edge)) } ?: fallback?.let { decoded.get(sized(it.key, edge)) }
return hit?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Pending
}
/**
* How long art takes to fade in over its skeleton: the desktop's
* `motion::ARRIVE`.
*/
private const val ARRIVE_MS = 240
/** One pass of the skeleton's highlight: the desktop's `motion::SHIMMER`. */
private const val SHIMMER_MS = 1_400
/**
* A title's art across the top of a page: its backdrop, or with none, its
* poster as a wash of its own colours — decoded tiny and blurred, so it reads as
* the poster's light rather than a blown-up slice of it. The desktop's
* `Fit::Ambient`. Android 11 and older cannot blur; there the tiny decode,
* stretched, is what softens it.
*/
@Composable
internal fun Backdrop(title: TitleRecord, modifier: Modifier = Modifier, onMood: ((Color) -> Unit)? = null) {
val wash = title.backdropUrl == null && title.posterUrl != null
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.displayName,
if (wash) modifier.blur(WASH_BLUR) else modifier,
fallback = title.thumbnailSource,
labelled = false,
maxEdge = if (wash) WASH_EDGE else null,
onMood = onMood,
)
}
/**
* The colour a picture leaves behind — the shared `appearance::mood`, over the
* picture squeezed to [MOOD_EDGE] pixels a side. Remembered per picture, so a
* banner paging back to a title does not work it out again.
*/
private suspend fun moodOf(key: String, bitmap: Bitmap): Color? {
moods.get(key)?.let { return Color(it) }
val argb = withContext(Dispatchers.Default) {
runCatching {
val small = bitmap.scale(MOOD_EDGE, MOOD_EDGE)
val pixels = IntArray(MOOD_EDGE * MOOD_EDGE)
small.getPixels(pixels, 0, MOOD_EDGE, 0, 0, MOOD_EDGE, MOOD_EDGE)
sharedPictureMood(pixels.map(Int::toUInt))?.toInt()
}.getOrNull()
} ?: return null
moods.put(key, argb)
return Color(argb)
}
private val moods = LruCache<String, Int>(64)
/** A picture squeezed to this many pixels a side is still its colours, and a few hundred values to average. */
private const val MOOD_EDGE = 24
/**
* [background] tinted by a picture's [mood], for the veils over that picture —
* the shared `appearance::mood_veil`. [background] itself until the mood is
* known.
*/
@Composable
internal fun moodVeil(mood: Color?, background: Color = MaterialTheme.colorScheme.background): Color {
val light = background.luminance() > 0.5f
val target = remember(mood, background) {
mood?.let {
runCatching {
Color(sharedMoodVeil(background.toArgb().toUInt(), it.toArgb().toUInt(), light).toInt())
}.getOrNull()
} ?: background
}
return animateColorAsState(target, tween(ARRIVE_MS), label = "veil").value
}
private val WASH_EDGE = 48.dp
private val WASH_BLUR = 24.dp
/**
* A title's logo — its name as lettering — in place of [fallback], once it has
* loaded. [fallback] stands until then, and for good when there is none, so a
* title without a logo looks as it always did and one with a logo only gains.
*
* Trimmed to what is not transparent before it is drawn: TVDB pads every clear
* logo to a square, and the padding would otherwise decide its size and place.
*/
@Composable
internal fun RemoteLogo(
url: String?,
description: String,
modifier: Modifier = Modifier,
fallback: @Composable () -> Unit,
) {
val context = LocalContext.current
val enabled = LocalArtworkLoading.current
var logo by remember(url) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(url, enabled) {
logo = if (enabled) loadLogo(context, url)?.asImageBitmap() else null
}
Crossfade(logo, animationSpec = tween(ARRIVE_MS), label = "logo") { loaded ->
when (loaded) {
null -> fallback()
else -> Image(
loaded,
description,
modifier,
alignment = Alignment.BottomStart,
contentScale = ContentScale.Fit,
)
}
}
}
private suspend fun loadLogo(context: Context, url: String?): Bitmap? {
val requested = url ?: return null
val key = "logo:$requested"
decoded.get(key)?.let { return it }
val bitmap = loadArtwork(context, requested, LOGO_EDGE) ?: return null
return withContext(Dispatchers.Default) { trimTransparent(bitmap) }.also { decoded.put(key, it) }
}
/** [bitmap] cropped to [opaqueBounds], or itself when there is nothing to crop. */
private fun trimTransparent(bitmap: Bitmap): Bitmap {
if (!bitmap.hasAlpha()) return bitmap
val pixels = IntArray(bitmap.width * bitmap.height)
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
val (left, top, right, bottom) = opaqueBounds(pixels, bitmap.width) ?: return bitmap
if (left == 0 && top == 0 && right == bitmap.width - 1 && bottom == bitmap.height - 1) return bitmap
return Bitmap.createBitmap(bitmap, left, top, right - left + 1, bottom - top + 1)
}
/**
* The inclusive `[left, top, right, bottom]` of the pixels in an ARGB [pixels]
* row-major image of [width] that are not (nearly) transparent; `null` when
* none are. Below the threshold is anti-aliasing haze, not lettering.
*/
internal fun opaqueBounds(pixels: IntArray, width: Int): IntArray? {
var left = Int.MAX_VALUE
var top = Int.MAX_VALUE
var right = -1
var bottom = -1
for (index in pixels.indices) {
if ((pixels[index] ushr 24) <= VISIBLE_ALPHA) continue
val x = index % width
val y = index / width
if (x < left) left = x
if (x > right) right = x
if (y < top) top = y
if (y > bottom) bottom = y
}
return if (right < 0) null else intArrayOf(left, top, right, bottom)
}
private const val VISIBLE_ALPHA = 8
/** A logo is drawn a few hundred pixels wide at most. */
private const val LOGO_EDGE = 800
private sealed interface ArtworkLoad {
data object Pending : ArtworkLoad
data object Missing : ArtworkLoad
@@ -128,20 +314,38 @@ private sealed interface ArtworkLoad {
internal val LocalArtworkLoading = staticCompositionLocalOf { true }
/**
* Something being loaded: the surface tint, breathing.
* Something being loaded: the surface tint, with a soft highlight sweeping
* across it on a diagonal.
*
* A spinner per tile is a grid of spinners; a shape where the poster will be
* says what is coming and where, and settles the layout before it arrives.
* The sweep is measured in the tile's own width, so neighbouring tiles of one
* size shimmer in step, as one surface.
*/
@Composable
internal fun Skeleton(modifier: Modifier = Modifier) {
val pulse by rememberInfiniteTransition(label = "skeleton").animateFloat(
initialValue = 0.55f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
label = "skeleton alpha",
val sweep by rememberInfiniteTransition(label = "skeleton").animateFloat(
initialValue = -1f,
targetValue = 2f,
animationSpec = infiniteRepeatable(tween(SHIMMER_MS, easing = LinearEasing)),
label = "skeleton sweep",
)
val base = MaterialTheme.colorScheme.surfaceVariant
val highlight = lerp(base, MaterialTheme.colorScheme.onSurface, 0.07f)
Box(
modifier.drawBehind {
drawRect(base)
val centre = size.width * sweep
val half = size.width * 0.6f
drawRect(
Brush.linearGradient(
listOf(Color.Transparent, highlight, Color.Transparent),
start = Offset(centre - half, 0f),
end = Offset(centre + half, size.height),
),
)
},
)
Box(modifier.graphicsLayer { alpha = pulse }.background(MaterialTheme.colorScheme.surfaceVariant))
}
/**
@@ -438,30 +438,42 @@ private fun MetadataSettingsDialog(
title = { Text("Metadata enrichment") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, the matched ids also go to ani.zip for episode names and wide artwork.")
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, a title it has nothing for is then sent to TVmaze, and the matched ids go to ani.zip for episode names and wide artwork.")
SettingToggle("Enable enrichment", enabled) { enabled = it }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetadataProvider.entries.forEach { option ->
listOf(MetadataProvider.ANI_LIST, MetadataProvider.TMDB).forEach { option ->
if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) }
else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) }
}
}
Text(
if (provider == MetadataProvider.ANI_LIST) "Anime; no account or API key required."
else "Film and television; requires a free TMDB API key.",
if (provider == MetadataProvider.ANI_LIST) {
"Anime, and television from TVmaze; no account or API key required. " +
"With a TMDB API key, films get TMDB's backdrops and logos, asked for by id alone."
} else {
"Film and television; requires a free TMDB API key."
},
style = MaterialTheme.typography.bodySmall,
)
if (provider == MetadataProvider.TMDB) {
OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true)
OutlinedTextField(
apiKey,
{ apiKey = it },
label = { Text(if (current.ready) "TMDB API key (leave blank to keep)" else "TMDB API key") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
singleLine = true,
)
}
OutlinedTextField(
apiKey,
{ apiKey = it },
label = {
Text(
when {
provider == MetadataProvider.ANI_LIST -> "TMDB API key (optional)"
current.ready -> "TMDB API key (leave blank to keep)"
else -> "TMDB API key"
},
)
},
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
singleLine = true,
)
if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error)
// Said before Save rather than after: what is forgotten
// includes matches chosen by hand, which no look-up redoes.
@@ -497,6 +509,7 @@ private fun MetadataSettingsDialog(
internal fun MetadataProvider.displayName(): String = when (this) {
MetadataProvider.ANI_LIST -> "AniList"
MetadataProvider.TMDB -> "TMDB"
MetadataProvider.TV_MAZE -> "TVmaze"
}
private data class LegalDocument(val title: String, val assetPath: String)
@@ -5,6 +5,12 @@ import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalDensity
import kotlin.coroutines.cancellation.CancellationException
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
@@ -12,6 +18,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -43,6 +50,9 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Download
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.RemoveDone
import androidx.compose.material.icons.filled.Pause
@@ -81,6 +91,9 @@ import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton
import io.narl.protonstream.ui.theme.solid
import androidx.compose.foundation.border
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.CastRecord
import androidx.compose.material.icons.filled.Movie
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
import uniffi.pstr_android.EpisodeRecord
@@ -88,10 +101,13 @@ import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.MatchRecord
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material3.FilterChip
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
@@ -109,6 +125,11 @@ internal fun TitleScreen(
/** Every title of its franchise, in release order, this one included. */
franchise: List<TitleRecord> = emptyList(),
onOpenTitle: (TitleRecord) -> Unit = {},
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
/** The titles most like it, best first. */
moreLike: List<TitleRecord> = emptyList(),
/** Open every title sharing a genre, studio or director. */
onBrowse: (BrowseFacet) -> Unit = {},
) {
val context = LocalContext.current
val download = rememberDownload()
@@ -172,8 +193,12 @@ internal fun TitleScreen(
) {
val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp)
val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f)
val list = rememberLazyListState()
var mood by remember(title.key) { mutableStateOf<Color?>(null) }
val veil = moodVeil(mood)
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = list,
contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
@@ -181,37 +206,46 @@ internal fun TitleScreen(
// Edge to edge, fading into the page: the art is the first thing
// the page says, and a framed thumbnail with a margin round it read
// as one card among the many below it.
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) {
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.displayName,
Modifier.fillMaxSize(),
fallback = title.thumbnailSource,
labelled = false,
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight).clipToBounds()) {
// At half the speed of the page, so the picture stays
// behind the page rather than being part of it.
Backdrop(
title,
Modifier.fillMaxSize().graphicsLayer {
translationY = if (list.firstVisibleItemIndex == 0) list.firstVisibleItemScrollOffset * PARALLAX else 0f
},
onMood = { mood = it },
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0.45f to Color.Transparent,
0.4f to Color.Transparent,
// The picture's own colour on the way into the
// page, as on the library's banner.
0.75f to veil.copy(alpha = 0.55f),
1f to MaterialTheme.colorScheme.background,
),
),
)
IconButton(
onClick = onBack,
modifier = Modifier
.statusBarsPadding()
.padding(8.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.4f)),
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
}
}
}
item(key = "heading") {
Column(Modifier.padding(horizontal = side + 16.dp)) {
Text(title.displayName, style = MaterialTheme.typography.headlineMedium)
val heading = @Composable {
Text(title.displayName, style = MaterialTheme.typography.displayLarge)
}
// A light page keeps the name: a clear logo is lettering
// made for dark art, white as often as not.
if (MaterialTheme.colorScheme.background.luminance() < 0.5f) {
RemoteLogo(
title.logoUrl,
title.displayName,
Modifier.fillMaxWidth(0.7f).heightIn(max = 88.dp).padding(bottom = 4.dp),
heading,
)
} else {
heading()
}
// The other names it goes by: the share's when the shown one
// is the provider's, and the original-language one.
listOfNotNull(
@@ -224,6 +258,8 @@ internal fun TitleScreen(
listOfNotNull(
title.formatLabel,
title.seasonLabel ?: (title.metadataYear ?: title.year)?.toString(),
// A series' running time is its episodes', which each row says.
title.runtime?.takeIf { title.kind == TitleType.FILM }?.let(::formatRuntime),
title.rating?.let { "★ %.1f".format(it) },
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
).joinToString(" · "),
@@ -231,19 +267,20 @@ internal fun TitleScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
// Who made it, and whether more is coming — the desktop's
// line of facts, on a line of its own so neither wraps.
listOfNotNull(
title.studios.takeIf { it.isNotEmpty() }?.take(2)?.joinToString(", "),
title.directors.firstOrNull()?.let { "dir. $it" },
airingNote(title),
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
// Where the title stands, then what the files are rather
// than what the title is.
val status = statusText(title)
if (title.quality.isNotEmpty() || status != null) {
Row(
Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
status?.let { (text, live) ->
StatusChip(text, live, Modifier.padding(end = 4.dp))
}
title.quality.forEach { SpecBadge(it) }
}
}
// The one thing the page is for, full width and naming what it
// will play: "Resume" alone left the viewer guessing which
@@ -272,6 +309,13 @@ internal fun TitleScreen(
// three button styles used to wrap over three lines and bury
// the episodes under them.
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
TitleAction(
if (title.onList) Icons.Default.Check else Icons.Default.Add,
"My List",
Modifier.weight(1f).semantics {
stateDescription = if (title.onList) "On My List" else "Not on My List"
},
) { onSetOnList(title, !title.onList) }
// Only where it would do something different: an unstarted
// show already starts at the beginning.
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
@@ -309,15 +353,63 @@ internal fun TitleScreen(
.clickable { overviewOpen = !overviewOpen },
)
}
// In the browser: the app itself never requests it.
title.trailerUrl?.let { url ->
QuietButton(
onClick = {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
}.onFailure(onPreferenceError)
},
modifier = Modifier.padding(top = 4.dp),
) {
Icon(Icons.Default.Movie, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Trailer")
}
}
// Genres as chips, then the provider's tags quieter after
// them: a tag is its community describing it, not what it
// is filed as.
if (title.genres.isNotEmpty() || title.tags.isNotEmpty()) {
if (title.genres.isNotEmpty()) {
FlowRow(
Modifier.padding(top = 12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
title.genres.forEach { genre -> GenreChip(genre) { onBrowse(BrowseFacet.Genre(genre)) } }
}
}
// Who made it, as chips that open everything else by them:
// the desktop's credits row.
val studios = title.studios.take(2)
val director = title.directors.take(1)
if (studios.isNotEmpty() || director.isNotEmpty()) {
FlowRow(
Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
listOf(
Triple("Studio", studios) { name: String -> BrowseFacet.Studio(name) },
Triple("Director", director) { name: String -> BrowseFacet.Person(name) },
).filter { it.second.isNotEmpty() }.forEach { (label, names, facet) ->
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
names.forEach { name -> GenreChip(name) { onBrowse(facet(name)) } }
}
}
}
if (title.tags.isNotEmpty()) {
Text(
(title.genres + title.tags.take(4)).joinToString(" · "),
title.tags.take(4).joinToString(" · "),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 10.dp),
modifier = Modifier.padding(top = 8.dp),
)
}
// Said plainly, because it changes what a re-match will do: a
@@ -356,15 +448,21 @@ internal fun TitleScreen(
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// By position: two seasons can share a label, and a
// repeated key is a crash in a lazy list.
itemsIndexed(title.seasons, key = { index, _ -> index }) { index, entry ->
FilterChip(
selected = index == seasonIndex,
onClick = { seasonIndex = index },
label = { Text(entry.label) },
)
if (title.seasons.size > SEASON_CHIPS) {
// A long-running show's row of chips is a scroll of
// its own; past a handful it is a list to pick from.
SeasonList(title.seasons, seasonIndex, { seasonIndex = it }, Modifier.weight(1f))
} else {
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// By position: two seasons can share a label, and a
// repeated key is a crash in a lazy list.
itemsIndexed(title.seasons, key = { index, _ -> index }) { index, entry ->
FilterChip(
selected = index == seasonIndex,
onClick = { seasonIndex = index },
label = { Text(entry.label) },
)
}
}
}
season?.let {
@@ -411,7 +509,49 @@ internal fun TitleScreen(
}
}
}
if (title.cast.isNotEmpty()) {
item(key = "cast") {
Column(Modifier.padding(top = 16.dp)) {
Text(
"Cast",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = side + 16.dp),
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = side + 16.dp, end = side + 16.dp, top = 10.dp),
) {
// Unkeyed: two characters can share a name.
items(title.cast) { member ->
CastTile(member) { actor -> onBrowse(BrowseFacet.Person(actor)) }
}
}
}
}
}
// Under the episodes rather than over them: where to go once this
// one is done, not what the page is for.
if (moreLike.isNotEmpty()) {
item(key = "more-like-this") {
Column(Modifier.padding(top = 16.dp)) {
Text(
"More like this",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = side + 16.dp),
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(start = side + 16.dp, end = side + 16.dp, top = 10.dp),
) {
items(moreLike, key = { it.key }) { other ->
FranchiseTile(other, here = false) { onOpenTitle(other) }
}
}
}
}
}
}
TitleBar(title.displayName, list, with(LocalDensity.current) { backdropHeight.toPx() }, onBack)
}
if (showMatch) {
ChangeMatchDialog(
@@ -423,9 +563,108 @@ internal fun TitleScreen(
}
}
/** How fast the backdrop moves against the page scrolling over it. */
private const val PARALLAX = 0.5f
/**
* One part of the franchise: its cover, what it is and when. This title's own
* is marked rather than left out, so the row says where it sits in the story.
* The bar over the title page: only the back button while the backdrop is on
* screen, then — once the backdrop has scrolled away — a solid bar with the
* title's name, so the page still says whose episodes these are.
*/
@Composable
private fun TitleBar(name: String, list: LazyListState, backdrop: Float, onBack: () -> Unit) {
val reach = with(LocalDensity.current) { TITLE_BAR.toPx() }
val solid by remember(list, backdrop) {
derivedStateOf {
if (list.firstVisibleItemIndex > 0) {
1f
} else {
((list.firstVisibleItemScrollOffset - (backdrop - 2 * reach)) / reach).coerceIn(0f, 1f)
}
}
}
val scheme = MaterialTheme.colorScheme
Row(
Modifier
.fillMaxWidth()
.background(scheme.surfaceContainer.copy(alpha = solid))
.statusBarsPadding()
.height(TITLE_BAR),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onBack,
modifier = Modifier
.padding(8.dp)
.clip(CircleShape)
// A disc behind the arrow over the picture, gone once the bar
// is there to sit on.
.background(Color.Black.copy(alpha = 0.4f * (1f - solid))),
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back to library",
tint = lerp(Color.White, scheme.onSurface, solid),
)
}
Text(
name,
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(end = 16.dp).graphicsLayer { alpha = solid },
)
}
}
private val TITLE_BAR = 56.dp
/**
* One character: a round portrait over their name and who voices them. A voice
* actor opens every title in the library they are in.
*/
@Composable
private fun CastTile(member: CastRecord, onActor: (String) -> Unit) {
Column(
Modifier
.width(88.dp)
.clip(MaterialTheme.shapes.medium)
.then(
member.actor?.let { actor ->
Modifier.clickable(onClickLabel = "Browse $actor") { onActor(actor) }
} ?: Modifier,
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
RemoteArtwork(
member.characterImage ?: member.actorImage,
member.character,
Modifier.size(76.dp).clip(CircleShape),
labelled = false,
)
Text(
member.character,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
member.actor?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* One part of the franchise, or one title like this one: its cover, what it is
* and when. In the franchise row this title's own is marked rather than left
* out, so the row says where it sits in the story.
*/
@Composable
private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit) {
@@ -472,17 +711,51 @@ private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit
}
/**
* "Episode 7 on Sat 11 Oct" while one is due, "Airing" while it is airing with
* no date yet, else nothing.
* What the status chip says — "Airing · episode 7 Sat 11 Oct", "Finished" —
* and whether more is coming. The desktop's `status_text`.
*/
private fun airingNote(title: TitleRecord): String? {
internal fun statusText(title: TitleRecord): Pair<String, Boolean>? {
val at = title.nextAiringAt
val episode = title.nextEpisode
if (at != null && episode != null) {
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault())
return "episode $episode on " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM"))
val next = at != null && episode != null
val label = title.status ?: (if (next || title.airing) "Airing" else return null)
val live = next || title.airing
if (at == null || episode == null) return label to live
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault())
return "$label · episode $episode " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM")) to live
}
/** The most seasons picked from a row of chips; past it, a list. The desktop's `SEASON_PILLS`. */
private const val SEASON_CHIPS = 5
/** The season shown, as a button that opens the list of all of them. */
@Composable
private fun SeasonList(seasons: List<SeasonRecord>, picked: Int, onPick: (Int) -> Unit, modifier: Modifier = Modifier) {
var open by remember { mutableStateOf(false) }
Box(modifier) {
FilterChip(
selected = true,
onClick = { open = true },
label = { Text(seasons.getOrNull(picked)?.label.orEmpty()) },
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) },
)
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
seasons.forEachIndexed { index, season ->
DropdownMenuItem(
text = { Text(season.label) },
onClick = {
open = false
onPick(index)
},
trailingIcon = if (index == picked) {
{ Icon(Icons.Default.Check, contentDescription = "Shown") }
} else {
null
},
)
}
}
}
return if (title.airing) "airing" else null
}
/** How wide the page's text and controls run on a window wider than a phone. */
@@ -607,6 +880,21 @@ private fun EpisodeRow(
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
)
}
// Seen, said on the picture as well as by the mark at the
// row's end: a season is scanned by its stills.
if (episode.watched) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(5.dp)
.size(18.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.padding(2.dp),
)
}
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(
@@ -625,7 +913,9 @@ private fun EpisodeRow(
Text(
listOfNotNull(
numbering,
episode.size?.let(::formatBytes),
// How long it runs says more than how big it is; the
// size is what there is when no provider said.
episode.runtime?.let(::formatRuntime) ?: episode.size?.let(::formatBytes),
episode.airDate,
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
@@ -740,7 +1030,7 @@ internal fun ChangeMatchDialog(
title = { Text("Change match") },
text = {
Column {
Text("Search the configured metadata provider. Nothing is stored until you choose an entry.")
Text("Search the configured metadata providers. Nothing is stored until you choose an entry.")
OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true)
AccentButton(enabled = term.isNotBlank() && !searching, onClick = {
searching = true
@@ -775,7 +1065,10 @@ internal fun ChangeMatchDialog(
) {
Column(Modifier.fillMaxWidth()) {
Text(option.name, fontWeight = FontWeight.SemiBold)
Text(listOfNotNull(option.year?.toString(), option.originalName).joinToString(" · "))
Text(
listOfNotNull(option.year?.toString(), option.originalName, option.provider.displayName())
.joinToString(" · "),
)
}
}
}
@@ -24,6 +24,13 @@ internal val Inter = FontFamily(
Font(R.font.inter_semibold, FontWeight.SemiBold),
)
/**
* Inter Display Bold, the optical size drawn for large text, for the one
* [HERO] line: the banner's name and the name at the top of a title's page.
* The desktop's `theme::display` family, from the same file.
*/
internal val InterDisplay = FontFamily(Font(R.font.inter_display_bold, FontWeight.Bold))
/**
* The type ramp, which is the desktop client's type ramp.
*
@@ -33,7 +40,10 @@ internal val Inter = FontFamily(
* were built to share one palette; sharing one scale is the other half of why
* they should read as one app.
*
* The rungs, largest first: 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
* The rungs, largest first: 34 / 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
* The top one is the hero line, in [InterDisplay] and set tighter, at
* [HERO_LEADING]: at that size the text face's leading pushes a two-line name
* apart.
*
* Where a role maps is decided by what actually draws with it here, not by
* Material's own sizes — Material's `displayLarge` is 57sp, which is a size for
@@ -44,7 +54,8 @@ internal val Inter = FontFamily(
* [TypeRampTest] pins every one of these against the Rust ramp.
*/
internal val ProtonTypography = Typography(
displayLarge = role(DISPLAY, FontWeight.SemiBold),
// The banner's name and the title page's name, where no logo stands in.
displayLarge = role(HERO, FontWeight.Bold, InterDisplay, HERO_LEADING),
displayMedium = role(DISPLAY, FontWeight.SemiBold),
displaySmall = role(TITLE, FontWeight.SemiBold),
// The name of the title you opened — the desktop's display line.
@@ -69,6 +80,7 @@ internal val ProtonTypography = Typography(
labelSmall = role(MICRO, FontWeight.Medium),
)
internal const val HERO = 34f
internal const val DISPLAY = 26f
internal const val TITLE = 20f
internal const val HEADING = 18f
@@ -79,6 +91,9 @@ internal const val LABEL = 13f
internal const val CAPTION = 12f
internal const val MICRO = 11f
internal const val LEADING = 1.35f
internal const val HERO_LEADING = 1.1f
/**
* One rung as a `TextStyle`.
*
@@ -87,9 +102,14 @@ internal const val MICRO = 11f
* enough for the two-line title names in the grid and tight enough that a
* label on a control does not float.
*/
private fun role(size: Float, weight: FontWeight) = TextStyle(
private fun role(
size: Float,
weight: FontWeight,
family: FontFamily = Inter,
leading: Float = LEADING,
) = TextStyle(
fontSize = size.sp,
lineHeight = (size * 1.35f).sp,
lineHeight = (size * leading).sp,
fontWeight = weight,
fontFamily = Inter,
fontFamily = family,
)
Binary file not shown.
@@ -0,0 +1,24 @@
package io.narl.protonstream.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import uniffi.pstr_android.EpisodeRecord
class EpisodeNameTest {
private fun episode(name: String, detail: String, providerName: String? = null) = EpisodeRecord(
shareId = "s", volumeId = "v", linkId = "l", name = name, label = "S01E01", detail = detail,
season = 1u, number = 1u, size = null, progress = null, resumeAt = null,
watched = false, offline = false, providerName = providerName, providerOverview = null, stillUrl = null,
airDate = null, lastPlayed = 0,
)
@Test
fun `the player captions an episode with its name and never its filename`() {
val file = "[Group] Show - S01E01.mkv"
assertEquals("Dog & Chainsaw", episode(file, file, providerName = "Dog & Chainsaw").shownName())
assertEquals("Winter Is Coming", episode(file, "Winter Is Coming").shownName())
assertNull(episode(file, file).shownName())
assertNull(episode(file, "").shownName())
}
}
@@ -0,0 +1,30 @@
package io.narl.protonstream.ui
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class LogoAndRuntimeTest {
@Test
fun `a padded logo is bounded by its lettering and not its haze`() {
val width = 10
val pixels = IntArray(width * 6)
// Lettering from (2, 1) to (6, 3), and a pixel of haze in the corner.
for (y in 1..3) for (x in 2..6) pixels[y * width + x] = 0xFFFFFFFF.toInt()
pixels[5 * width + 9] = 0x03FFFFFF
assertArrayEquals(intArrayOf(2, 1, 6, 3), opaqueBounds(pixels, width))
}
@Test
fun `a logo with nothing visible has no bounds`() {
assertNull(opaqueBounds(IntArray(16), 4))
}
@Test
fun `a running time reads in hours and minutes`() {
assertEquals("24 min", formatRuntime(24u))
assertEquals("2 h", formatRuntime(120u))
assertEquals("2 h 4 min", formatRuntime(124u))
}
}
@@ -36,6 +36,7 @@ import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.ShelfRecord
import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.TileRecord
import uniffi.pstr_android.CastRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
@@ -291,6 +292,11 @@ class ScreenshotTest {
directors = listOf("Daisuke Hiramaki"),
tags = listOf("Idol", "Reincarnation"),
franchise = listOf("oshi-no-ko", "oshi-no-ko-film"),
trailerUrl = "https://www.youtube.com/watch?v=trailer",
cast = listOf(
CastRecord("Ai Hoshino", null, "Rie Takahashi", null),
CastRecord("Aqua", null, "Takeo Ootsuka", null),
),
)
/** The rest of [SERIES]'s franchise, for the title page's row of it. */
@@ -0,0 +1,39 @@
package io.narl.protonstream.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
class StatusTest {
private fun title(status: String?, airing: Boolean = false, next: Pair<UInt, Long>? = null) = TitleRecord(
key = "k", name = "k", year = null, kind = TitleType.SERIES, watchedCount = 0uL,
episodeCount = 0uL, 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 = emptyList(),
displayName = "k", wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
directors = emptyList(), tags = emptyList(), airing = airing, nextEpisode = next?.first,
nextAiringAt = next?.second, franchise = emptyList(), continueIndex = null, status = status,
)
@Test
fun `the status chip names the next episode while one is due`() {
val (text, live) = statusText(title("Airing", airing = true, next = 8u to 1_800_000_000L))!!
assertTrue(text, text.startsWith("Airing · episode 8 "))
assertTrue(live)
}
@Test
fun `a finished title is said plainly and is not live`() {
assertEquals("Finished" to false, statusText(title("Finished")))
}
@Test
fun `a title with no status and nothing airing has no chip`() {
assertNull(statusText(title(null)))
assertEquals("Airing" to true, statusText(title(null, airing = true)))
}
}
@@ -22,10 +22,10 @@ import org.junit.Test
*/
class TypeRampTest {
/** `Role::size()`, in the order `theme.rs` declares it. */
private val ramp = listOf(26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f)
private val ramp = listOf(34f, 26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f)
private val expected = mapOf(
"displayLarge" to 26f,
"displayLarge" to 34f,
"displayMedium" to 26f,
"displaySmall" to 20f,
"headlineLarge" to 26f,
@@ -62,12 +62,14 @@ class TypeRampTest {
/**
* Leading is derived from the size, so the only thing worth asserting is
* that it *was* derived — a style that kept Material's line height carries
* a number with no relation to its own size.
* a number with no relation to its own size. The hero line is the one set
* tighter.
*/
@Test
fun `line height follows the size it belongs to`() {
styles().forEach { (name, style) ->
assertEquals(name, style.fontSize.value * 1.35f, style.lineHeight.value, 0.01f)
val leading = if (name == "displayLarge") HERO_LEADING else LEADING
assertEquals(name, style.fontSize.value * leading, style.lineHeight.value, 0.01f)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 813 KiB

After

Width:  |  Height:  |  Size: 867 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 768 KiB

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 KiB

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 KiB

After

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 KiB

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 KiB

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+499 -26
View File
@@ -246,6 +246,9 @@ pub struct SyncRecord {
pub settings_changed: bool,
/// Hand-picked matches and per-show track choices taken.
pub titles: u32,
/// Whether My List changed: reload the library.
#[uniffi(default = false)]
pub list_changed: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
@@ -304,6 +307,9 @@ pub enum TitleType {
pub enum MetadataProvider {
AniList,
Tmdb,
/// Never chosen in the settings: what AniList hands a show it does not
/// have. Seen on a [`MatchRecord`] from a hand search.
TvMaze,
}
/// A palette family. The shared `pstr_core::appearance::Flavor`.
@@ -350,10 +356,69 @@ pub enum TitleNamesChoice {
Romaji,
}
/// What a genre, studio or director chip opens: the shared
/// `pstr_core::discover::Facet`.
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
pub enum BrowseFacet {
Genre { name: String },
Studio { name: String },
Person { name: String },
}
/// One episode a search found by its provider name.
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct EpisodeMatchRecord {
pub title_key: String,
pub link_id: String,
/// `S01E05`.
pub label: String,
pub name: String,
}
/// A genre, studio or person a search named, with how many titles carry it.
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct FacetCountRecord {
pub facet: BrowseFacet,
pub count: u32,
}
/// What a search found besides titles; the shared
/// `pstr_core::discover::Found`.
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct SearchExtrasRecord {
pub episodes: Vec<EpisodeMatchRecord>,
/// Directors and studios.
pub people: Vec<FacetCountRecord>,
pub genres: Vec<FacetCountRecord>,
}
impl From<pstr_core::discover::Facet> for BrowseFacet {
fn from(facet: pstr_core::discover::Facet) -> Self {
use pstr_core::discover::Facet;
match facet {
Facet::Genre(name) => Self::Genre { name },
Facet::Studio(name) => Self::Studio { name },
Facet::Person(name) => Self::Person { name },
}
}
}
impl BrowseFacet {
fn core(&self) -> pstr_core::discover::Facet {
use pstr_core::discover::Facet;
match self {
Self::Genre { name } => Facet::Genre(name.clone()),
Self::Studio { name } => Facet::Studio(name.clone()),
Self::Person { name } => Facet::Person(name.clone()),
}
}
}
/// The shared `pstr_core::browse::Filter`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum LibraryFilter {
All,
MyList,
Series,
Films,
Watching,
@@ -454,6 +519,49 @@ pub fn stored_palette(paths: AndroidPaths) -> PaletteRecord {
palette_record(&appearance_record(appearance))
}
/// The colour a picture leaves behind, as `0xAARRGGBB`, opaque: the shared
/// `pstr_core::appearance::mood` over [`pixels`], which are `0xAARRGGBB` too —
/// what `Bitmap.getPixels` hands back. `None` for nothing opaque.
///
/// The client squeezes the picture to a few hundred pixels first; this is the
/// weighting, kept in one place so every client lands on the same colour.
#[uniffi::export]
pub fn picture_mood(pixels: Vec<u32>) -> Option<u32> {
pstr_core::appearance::mood(pixels.into_iter().map(|argb| {
let channel = |shift: u32| ((argb >> shift) & 0xff) as u8;
(
pstr_core::appearance::Rgb::new(channel(16), channel(8), channel(0)),
channel(24),
)
}))
.map(pstr_core::appearance::Rgb::argb)
}
/// What an airing show's tile says about its next episode, `Ep 8 · in 2 d`;
/// see `pstr_core::news::countdown`.
#[uniffi::export]
pub fn airing_countdown(episode: u32, at: i64, now: i64) -> String {
pstr_core::news::countdown(episode, at, now)
}
/// What kind of thing a [`BrowseFacet`] names — `Genre`, `Studio`,
/// `Person` — for over the heading of its page.
#[uniffi::export]
pub fn facet_kind(facet: BrowseFacet) -> String {
facet.core().kind().to_owned()
}
/// The page colour `background` tinted by a picture's [`picture_mood`], for
/// the veils over that picture. All `0xAARRGGBB`; see
/// `pstr_core::appearance::mood_veil`.
#[uniffi::export]
pub fn mood_veil(background: u32, mood: u32, light: bool) -> u32 {
let rgb = |argb: u32| {
pstr_core::appearance::Rgb::new((argb >> 16) as u8, (argb >> 8) as u8, argb as u8)
};
pstr_core::appearance::mood_veil(rgb(background), rgb(mood), light).argb()
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct MetadataSettingsRecord {
pub enabled: bool,
@@ -517,6 +625,9 @@ pub struct EpisodeRecord {
/// What a continue-watching shelf orders by — "where I was last" is a
/// question about time, and progress alone cannot answer it.
pub last_played: i64,
/// The provider's running time, in minutes.
#[uniffi(default = None)]
pub runtime: Option<u32>,
}
#[derive(Debug, Clone, uniffi::Record)]
@@ -574,6 +685,44 @@ pub struct TitleRecord {
/// episode finished last. `None` keeps it off the shelf. See
/// `pstr_core::library::Title::continue_with`.
pub continue_index: Option<u32>,
/// The title's name as lettering, a transparent PNG with its source's
/// padding still around it — trim it before drawing.
#[uniffi(default = None)]
pub logo_url: Option<String>,
/// Minutes: an episode's length for a series, the running time of a film.
#[uniffi(default = None)]
pub runtime: Option<u32>,
/// What most of its files' names say about them — `4K`, `HDR`, `10-bit`,
/// `Dual audio` — in that order. Never a release group's name.
#[uniffi(default = [])]
pub quality: Vec<String>,
/// `Airing`, `Finished`, `Upcoming`, `On hiatus` or `Cancelled`: the
/// shared `TitleDetails::status_label`.
#[uniffi(default = None)]
pub status: Option<String>,
/// On My List.
#[uniffi(default = false)]
pub on_list: bool,
/// `New`, or `3 new` on a show being watched; see `pstr_core::news`.
#[uniffi(default = None)]
pub badge: Option<String>,
/// The characters, main ones first, with who voices them.
#[uniffi(default = [])]
pub cast: Vec<CastRecord>,
/// The trailer's page, to open in the browser; see
/// `pstr_core::metadata::Trailer::url`.
#[uniffi(default = None)]
pub trailer_url: Option<String>,
}
/// One character and who voices them: the shared
/// `pstr_core::metadata::CastMember`.
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct CastRecord {
pub character: String,
pub character_image: Option<String>,
pub actor: Option<String>,
pub actor_image: Option<String>,
}
/// Playback preferences that outlive one file, one title and one launch.
@@ -663,6 +812,16 @@ pub struct ChapterPlan {
pub credits_start: Option<f64>,
}
/// A stretch AniSkip says is the opening or the ending of the open file.
/// See `pstr_core::chapters::with_skip_ranges`.
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct SkipRangeRecord {
/// `Opening` or `Ending`.
pub kind: ChapterKind,
pub start: f64,
pub end: f64,
}
/// What the player may offer to skip right now.
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct SkipOffer {
@@ -674,8 +833,15 @@ pub struct SkipOffer {
/// Resolve a file's chapters through the same rules the desktop player uses
/// (`pstr_core::chapters`), so an opening called `Intro` is read identically on
/// both. Kotlin reads the list out of mpv; the verdict is reached here.
///
/// `skips` is what AniSkip said about the file, empty until it has: cut into
/// the chapters when the file's own name no opening or ending.
#[uniffi::export]
pub fn chapter_plan(chapters: Vec<ChapterRecord>, duration: Option<f64>) -> ChapterPlan {
pub fn chapter_plan(
chapters: Vec<ChapterRecord>,
duration: Option<f64>,
skips: Vec<SkipRangeRecord>,
) -> ChapterPlan {
let duration = duration.filter(|duration| *duration > 0.0);
let chapters: Vec<Chapter> = chapters
.into_iter()
@@ -688,6 +854,22 @@ pub fn chapter_plan(chapters: Vec<ChapterRecord>, duration: Option<f64>) -> Chap
start: chapter.start,
})
.collect();
let skips: Vec<pstr_core::chapters::SkipRange> = skips
.into_iter()
.filter_map(|skip| {
let role = match skip.kind {
ChapterKind::Opening => ChapterRole::Opening,
ChapterKind::Ending => ChapterRole::Ending,
ChapterKind::Preview | ChapterKind::Content => return None,
};
Some(pstr_core::chapters::SkipRange {
role,
start: skip.start,
end: skip.end,
})
})
.collect();
let chapters = pstr_core::chapters::with_skip_ranges(&chapters, &skips, duration);
let roles = pstr_core::chapters::roles(&chapters, duration);
ChapterPlan {
credits_start: pstr_core::chapters::credits_start(&chapters, &roles),
@@ -1278,6 +1460,7 @@ impl AndroidEngine {
shares_removed: profile.shares_removed,
settings_changed: profile.settings,
titles: u32::try_from(profile.titles).unwrap_or(u32::MAX),
list_changed: profile.list,
}))
}
Err(error) if error.is_signed_out() => {
@@ -1605,11 +1788,15 @@ impl AndroidEngine {
pstr_core::browse::search(&cached.library, &cached.metadata, &query)
.into_iter()
.filter(|title| {
filter.admits(title, |node| {
cached
.offline
.contains(&(node.share_id.clone(), node.link_id.clone()))
})
filter.admits(
title,
|node| {
cached
.offline
.contains(&(node.share_id.clone(), node.link_id.clone()))
},
&cached.list,
)
})
.collect();
let tiles = pstr_core::browse::arrange(
@@ -1625,13 +1812,21 @@ impl AndroidEngine {
// Shelves are the whole library's, and only when it is shown
// whole.
let shelves = if query.trim().is_empty() && filter == Filter::All {
pstr_core::shelves::pick(&cached.library.titles, &cached.metadata, SHELVES)
.into_iter()
.map(|shelf| ShelfRecord {
label: shelf.label,
keys: shelf.keys,
})
.collect()
pstr_core::shelves::home(
&cached.library.titles,
&cached.metadata,
cached.names,
&cached.list,
&cached.first_seen,
now(),
SHELVES,
)
.into_iter()
.map(|shelf| ShelfRecord {
label: shelf.label,
keys: shelf.keys,
})
.collect()
} else {
Vec::new()
};
@@ -1648,6 +1843,92 @@ impl AndroidEngine {
})
}
/// The keys of the titles most like `key`, best first: a title page's
/// "More like this" row. Worked out on asking rather than for every
/// record, since it compares the title with the whole library.
pub fn more_like(&self, key: String) -> Result<Vec<String>, BridgeError> {
self.with_library(|cached| {
pstr_core::discover::more_like(&key, &cached.library.titles, &cached.metadata, SIMILAR)
})
}
/// What `search` finds besides titles: episodes by name, and the people,
/// studios and genres it names.
pub fn search_extras(&self, search: String) -> Result<SearchExtrasRecord, BridgeError> {
self.with_library(|cached| {
let found = pstr_core::discover::find(
&cached.library,
&cached.metadata,
&cached.guides,
&search,
);
let facets = |facets: Vec<(pstr_core::discover::Facet, usize)>| {
facets
.into_iter()
.map(|(facet, count)| FacetCountRecord {
facet: BrowseFacet::from(facet),
count: count as u32,
})
.collect()
};
SearchExtrasRecord {
episodes: found
.episodes
.into_iter()
.map(|hit| EpisodeMatchRecord {
title_key: hit.title_key,
link_id: hit.link_id,
label: hit.label,
name: hit.name,
})
.collect(),
people: facets(found.people),
genres: facets(found.genres),
}
})
}
/// The last few searches on this device, newest first.
pub fn recent_searches(&self) -> Result<Vec<String>, BridgeError> {
pstr_core::recent::load(&self.dirs)
.map(|recent| recent.queries)
.map_err(BridgeError::from_display)
}
/// Put `query` at the front of the recent searches. Refused when the
/// file would not parse, rather than writing a new list over it.
pub fn remember_search(&self, query: String) -> Result<(), BridgeError> {
let mut recent = pstr_core::recent::load(&self.dirs).map_err(BridgeError::from_display)?;
if recent.remember(&query) {
pstr_core::recent::save(&self.dirs, &recent).map_err(BridgeError::from_display)?;
}
Ok(())
}
/// Forget every recent search.
pub fn forget_searches(&self) -> Result<(), BridgeError> {
pstr_core::recent::load(&self.dirs).map_err(BridgeError::from_display)?;
pstr_core::recent::save(&self.dirs, &Default::default()).map_err(BridgeError::from_display)
}
/// The keys of every title `facet` stands for, in library order.
pub fn browse(&self, facet: BrowseFacet) -> Result<Vec<String>, BridgeError> {
let facet = facet.core();
self.with_library(|cached| facet.titles(&cached.library.titles, &cached.metadata))
}
/// Put a title on My List or take it off. Refused when the list on disk
/// would not parse, rather than writing an empty one over it.
pub fn set_on_list(&self, key: String, on: bool) -> Result<(), BridgeError> {
let mut list = pstr_core::mylist::load(&self.dirs).map_err(BridgeError::from_display)?;
if on {
list.add(&key, now());
} else {
list.remove(&key);
}
pstr_core::mylist::save(&self.dirs, &list).map_err(BridgeError::from_display)
}
pub fn metadata_settings(&self) -> Result<MetadataSettingsRecord, BridgeError> {
let config = pstr_meta::settings::load(&self.dirs).map_err(BridgeError::from_display)?;
let ready = !config.provider.needs_api_key()
@@ -1723,6 +2004,56 @@ impl AndroidEngine {
.map_err(BridgeError::from_display)?
}
/// Where AniSkip says the opening and ending of one episode are, in a
/// file `duration` seconds long. Empty when the episode's provider entry
/// has no MyAnimeList id or nobody has marked it; an error with enrichment
/// off, like every other metadata call.
pub async fn skip_times(
self: Arc<Self>,
title_key: String,
share_id: String,
link_id: String,
duration: f64,
) -> Result<Vec<SkipRangeRecord>, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let service = self.metadata_service()?;
let found = self.with_library(|cached| {
let title = cached
.library
.titles
.iter()
.find(|title| title.key == title_key)?;
let episode = title.episodes().find(|episode| {
episode.node.share_id == share_id && episode.node.link_id == link_id
})?;
let number = episode.node.parsed.episode?;
let found = cached
.guides
.get(&title.key)?
.get(episode.node.parsed.season, number)?;
Some((found.mal_id?, found.entry_number?))
})?;
let Some((mal_id, number)) = found else {
return Ok(Vec::new());
};
let ranges = service
.skip_times(mal_id, number, duration)
.await
.map_err(BridgeError::from_display)?;
Ok(ranges
.into_iter()
.map(|range| SkipRangeRecord {
kind: chapter_kind(range.role),
start: range.start,
end: range.end,
})
.collect())
})
.await
.map_err(BridgeError::from_display)?
}
/// Pin a title to the entry the viewer picked, then fetch that entry's
/// episodes, backdrop and franchise chain — the same enrichment a match run
/// does, so the title page shows the new match's episodes as soon as this
@@ -1736,7 +2067,7 @@ impl AndroidEngine {
spawned(&runtime, async move {
let service = self.metadata_service()?;
let metadata = title_metadata(found);
if metadata.provider != service.provider() {
if !service.chain().contains(&metadata.provider) {
return Err(BridgeError::Failure {
reason: "match came from a different metadata provider".to_owned(),
});
@@ -1752,7 +2083,7 @@ impl AndroidEngine {
if let Some(enrichment) = &outcome.enrichment {
let stored = self.catalog.lock().set_enrichment(
&title_key,
service.provider(),
outcome.provider,
now(),
enrichment,
);
@@ -2353,21 +2684,13 @@ impl AndroidEngine {
/// behaviour.
async fn match_titles_inner(&self, force: bool) -> Result<MatchSummary, BridgeError> {
let service = Arc::new(self.metadata_service()?);
let provider = service.provider();
let (titles, stored) = self.titles_and_metadata()?;
let enriched = self
.catalog
.lock()
.enrichment_ages()
.map_err(BridgeError::from_display)?;
let pending = pstr_meta::service::plan(
titles,
&stored,
&enriched,
provider,
service.has_details(),
force,
);
let pending = pstr_meta::service::plan(titles, &stored, &enriched, &service.chain(), force);
let permits = Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY));
let mut lookups = tokio::task::JoinSet::new();
@@ -2399,7 +2722,7 @@ impl AndroidEngine {
summary.episodes += enrichment.episodes.len() as u32;
let stored = self.catalog.lock().set_enrichment(
&outcome.title_key,
provider,
outcome.provider,
now(),
enrichment,
);
@@ -2427,7 +2750,7 @@ impl AndroidEngine {
reason: "turn on metadata enrichment first".to_owned(),
});
}
let key = pstr_meta::settings::api_key_in(self.secrets.as_ref(), config.provider);
let key = pstr_meta::settings::api_key_in(self.secrets.as_ref(), ProviderId::Tmdb);
pstr_meta::MetadataService::new(&config, key).map_err(BridgeError::from_display)
}
@@ -2438,11 +2761,15 @@ impl AndroidEngine {
let names = pstr_core::appearance::load(&self.dirs)
.unwrap_or_default()
.names;
// Read each time like the appearance: it is one small file, and the
// phone's list changes without the catalog being written.
let list = pstr_core::mylist::load(&self.dirs).unwrap_or_default();
let catalog = self.catalog.lock();
let writes = catalog.writes();
if let Some(cached) = self.library_cache.lock().as_ref()
&& cached.writes == writes
&& cached.names == names
&& cached.list == list
{
return Ok(read(cached));
}
@@ -2476,6 +2803,7 @@ impl AndroidEngine {
})
.collect();
let now = now();
let badges = pstr_core::news::badges(&library.titles, &first_seen, now);
let titles: Vec<TitleRecord> = library
.titles
.iter()
@@ -2489,6 +2817,8 @@ impl AndroidEngine {
record.display_name =
pstr_core::browse::display_name(title, &metadata, names).to_owned();
record.franchise = franchises.get(&title.key).cloned().unwrap_or_default();
record.on_list = list.contains(&title.key);
record.badge = badges.get(&title.key).map(|badge| badge.label());
if let Some(details) = metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
@@ -2505,12 +2835,14 @@ impl AndroidEngine {
let cached = CachedLibrary {
writes,
names,
list,
library,
metadata,
added,
titles,
offline: offline.into_keys().collect(),
first_seen,
guides,
};
let answer = read(&cached);
*self.library_cache.lock() = Some(cached);
@@ -3383,6 +3715,7 @@ fn normalized_language(language: Option<String>) -> Option<String> {
struct CachedLibrary {
writes: u64,
names: TitleNames,
list: pstr_core::mylist::MyList,
library: Library,
metadata: HashMap<String, MetadataRecord>,
/// When each title last gained a file.
@@ -3392,6 +3725,9 @@ struct CachedLibrary {
offline: HashSet<(String, String)>,
/// When each file first appeared in a crawl; see [`Catalog::first_seen`].
first_seen: HashMap<(String, String), i64>,
/// What the providers said about each title's episodes, for searching
/// them by name.
guides: HashMap<String, EpisodeGuide>,
}
/// The titles whose files first appeared at or after `since`, with how many
@@ -3493,6 +3829,7 @@ fn filter_choice(filter: LibraryFilter) -> Filter {
LibraryFilter::Watching => Filter::Watching,
LibraryFilter::Unwatched => Filter::Unwatched,
LibraryFilter::Downloaded => Filter::Downloaded,
LibraryFilter::MyList => Filter::MyList,
}
}
@@ -3511,6 +3848,9 @@ fn sort_choice(sort: LibrarySort) -> Sort {
/// desktop's four, because on a phone each is a screen's height.
const SHELVES: usize = 3;
/// How many titles a title page's "More like this" row offers.
const SIMILAR: usize = 12;
fn appearance_choice(record: AppearanceRecord) -> Appearance {
Appearance {
flavor: match record.flavor {
@@ -3646,9 +3986,37 @@ fn title_record(
directors: details.map_or_else(Vec::new, |details| details.directors.clone()),
tags: details.map_or_else(Vec::new, |details| details.tags.clone()),
airing: details.is_some_and(|details| details.is_airing()),
status: details
.and_then(|details| details.status_label())
.map(str::to_owned),
next_episode: None,
next_airing_at: None,
on_list: false,
badge: None,
cast: details.map_or_else(Vec::new, |details| {
details
.cast
.iter()
.map(|member| CastRecord {
character: member.character.clone(),
character_image: member.character_image.clone(),
actor: member.actor.clone(),
actor_image: member.actor_image.clone(),
})
.collect()
}),
trailer_url: details
.and_then(|details| details.trailer.as_ref())
.and_then(|trailer| trailer.url()),
franchise: Vec::new(),
logo_url: metadata.and_then(|found| found.logo_url.clone()),
runtime: details.and_then(|details| details.runtime),
quality: title
.quality()
.badges()
.into_iter()
.map(str::to_owned)
.collect(),
continue_index: title.continue_with().and_then(|episode| {
let at = title.position_of(&episode.node.share_id, &episode.node.link_id)?;
u32::try_from(at).ok()
@@ -3673,6 +4041,7 @@ fn metadata_provider(provider: ProviderId) -> MetadataProvider {
match provider {
ProviderId::AniList => MetadataProvider::AniList,
ProviderId::Tmdb => MetadataProvider::Tmdb,
ProviderId::TvMaze => MetadataProvider::TvMaze,
}
}
@@ -3680,6 +4049,7 @@ fn provider_id(provider: MetadataProvider) -> ProviderId {
match provider {
MetadataProvider::AniList => ProviderId::AniList,
MetadataProvider::Tmdb => ProviderId::Tmdb,
MetadataProvider::TvMaze => ProviderId::TvMaze,
}
}
@@ -3730,6 +4100,8 @@ fn title_metadata(record: MatchRecord) -> TitleMetadata {
kind: title_kind(record.kind),
poster_url: record.poster_url,
backdrop_url: record.backdrop_url,
// A search answer carries none; enriching the choice finds it.
logo_url: None,
rating: record.rating.map(|rating| rating as f32),
genres: record.genres,
episodes: record.episode_count,
@@ -3773,6 +4145,7 @@ fn episode_record(
still_url: named.and_then(|found| found.still_url.clone()),
air_date: named.and_then(|found| found.air_date.clone()),
last_played: episode.last_played(),
runtime: named.and_then(|found| found.runtime),
}
}
@@ -3827,6 +4200,22 @@ mod tests {
/// Material's containment ladder has five rungs where the palette names
/// three, and Kotlin lays them out in this order. A rung that is not past
/// the one below it is a rung that reads as the same surface.
#[test]
fn a_picture_handed_over_as_argb_pixels_keeps_its_colour_and_skips_the_clear_ones() {
let teal = 0xff14_8c96;
assert_eq!(super::picture_mood(vec![teal; 9]), Some(teal));
assert_eq!(super::picture_mood(vec![teal, 0x00ff_ffff]), Some(teal));
assert_eq!(super::picture_mood(vec![0x00ff_ffff]), None);
}
#[test]
fn the_veil_over_a_picture_is_the_shared_one_packed_as_argb() {
let background = 0xff0b_0b0f;
let veil = super::mood_veil(background, 0xffdc_1e1e, false);
assert_eq!(veil >> 24, 0xff);
assert!((veil >> 16) & 0xff > 0x0b, "{veil:08x}");
}
#[test]
fn the_derived_rung_continues_the_ladder_in_both_polarities() {
for flavor in FLAVORS {
@@ -4012,6 +4401,7 @@ mod tests {
chapter(3, "Cast", 4680.0),
],
Some(4800.0),
Vec::new(),
);
// Eleven minutes called `Intro` is the story, not a theme song.
@@ -4033,6 +4423,7 @@ mod tests {
chapter(1, "OP", 24.0),
],
None,
Vec::new(),
);
assert_eq!(plan.entries[0].label, "Chapter 1");
@@ -4042,6 +4433,25 @@ mod tests {
assert_eq!(skip_offer(&plan, 30.0), None);
}
/// A file with no chapters of its own offers AniSkip's opening, through
/// the same skip as one that came with chapters.
#[test]
fn aniskips_opening_is_offered_in_a_file_without_chapters() {
let plan = chapter_plan(
Vec::new(),
Some(1440.0),
vec![super::SkipRangeRecord {
kind: ChapterKind::Opening,
start: 60.0,
end: 150.0,
}],
);
let offer = skip_offer(&plan, 70.0).expect("inside the opening");
assert_eq!(offer.label, "Skip opening");
assert_eq!(offer.target, 150.0);
assert_eq!(skip_offer(&plan, 10.0), None);
}
/// A skip is offered inside the opening and nowhere else — including at its
/// very last second, where seeking would land the viewer where they already
/// are, and after it, where the button would never go away.
@@ -4055,6 +4465,7 @@ mod tests {
chapter(3, "ED", 1320.0),
],
Some(1440.0),
Vec::new(),
);
assert_eq!(skip_offer(&plan, 10.0), None, "content is not skippable");
@@ -4110,6 +4521,7 @@ mod tests {
kind: TitleKind::Series,
poster_url: Some("https://example.test/poster.jpg".to_owned()),
backdrop_url: Some("https://example.test/backdrop.jpg".to_owned()),
logo_url: None,
rating: Some(8.7),
genres: vec!["Action".to_owned(), "Sci-Fi".to_owned()],
episodes: Some(26),
@@ -4204,6 +4616,7 @@ mod tests {
kind,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
@@ -4269,6 +4682,66 @@ mod tests {
let titles = engine.library(Some("frieren".into())).expect("library");
assert_eq!(titles[0].display_name, "Sousou no Frieren");
// My List: the record says so straight away, the filter narrows to
// it, and it is a row above the grid.
engine
.set_on_list("sousou no frieren".into(), true)
.expect("list");
let titles = engine.library(Some("frieren".into())).expect("library");
assert!(titles[0].on_list);
let listed = engine
.arrangement(
None,
super::LibraryFilter::MyList,
super::LibrarySort::Name,
false,
)
.expect("arrange");
assert_eq!(listed.tiles.len(), 1);
let whole = engine
.arrangement(
None,
super::LibraryFilter::All,
super::LibrarySort::Name,
true,
)
.expect("arrange");
assert_eq!(whole.shelves[0].label, "My List");
engine
.set_on_list("sousou no frieren".into(), false)
.expect("list");
assert!(!engine.library(None).expect("library")[0].on_list);
// The film is Frieren's own franchise, so nothing else is like it.
assert!(
engine
.more_like("sousou no frieren".into())
.expect("similar")
.is_empty()
);
let genre = super::BrowseFacet::Genre {
name: "Drama".into(),
};
assert!(engine.browse(genre.clone()).expect("browse").is_empty());
assert_eq!(super::facet_kind(genre), "Genre");
// Recent searches: newest first, and forgotten on asking.
engine.remember_search("frieren".into()).expect("remember");
engine.remember_search("abyss".into()).expect("remember");
assert_eq!(
engine.recent_searches().expect("recent"),
["abyss", "frieren"]
);
engine.forget_searches().expect("forget");
assert!(engine.recent_searches().expect("recent").is_empty());
assert!(
engine
.search_extras("frieren".into())
.expect("extras")
.episodes
.is_empty()
);
let _ = std::fs::remove_dir_all(root);
}
Binary file not shown.
+421 -67
View File
@@ -13,6 +13,7 @@ use pstr_core::appearance::Appearance;
use pstr_core::config::AppDirs;
use pstr_core::library::Library;
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames};
use pstr_core::mylist::MyList;
use crate::engine::{
AccountStatus, DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures,
@@ -49,6 +50,7 @@ impl FrameTimer {
Page::Library => "library",
Page::History => "history",
Page::Title(_) => "title",
Page::Browse { .. } => "browse",
Page::Shares => "shares",
Page::Downloads => "downloads",
Page::Settings => "settings",
@@ -96,6 +98,12 @@ pub enum Page {
History,
/// One title, by [`pstr_core::library::Title::key`].
Title(String),
/// The titles a genre, studio or person chip stands for, with the title
/// it was opened from, for the way back.
Browse {
facet: pstr_core::discover::Facet,
from: Option<String>,
},
Shares,
Downloads,
/// How the app looks, how it plays, and what it looks up.
@@ -273,8 +281,10 @@ pub enum Action {
share_id: String,
link_id: String,
},
/// Open or close the list of episodes beside the picture.
ToggleEpisodes,
/// Open or close one of the panels beside the picture.
TogglePanel(ui::player::PlayerPanel),
/// Pause when this runs out, or turn the timer off.
SetSleep(Option<ui::player::SleepChoice>),
/// Put watch states back as they were — what Undo does.
RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>),
ClearStreamCache,
@@ -293,6 +303,11 @@ pub enum Action {
SetShelf(Filter, Sort),
/// Fold each franchise into one tile, or show every title.
SetGrouped(bool),
/// Put a title on My List, or take it off.
SetOnList {
key: String,
on: bool,
},
/// Open or close the list of keyboard shortcuts.
ToggleShortcuts,
/// One page back: a title to the library, the library to nowhere.
@@ -410,6 +425,15 @@ pub struct LibraryView {
pub history: Vec<ui::history::Entry>,
/// The "Continue watching" shelf, most recent first, as indices.
pub resumable: Vec<usize>,
/// What each tile says about what arrived — `New`, `3 new` — by key.
pub badges: HashMap<String, String>,
/// The keys on My List.
pub listed: std::collections::HashSet<String>,
/// What the search found besides titles.
pub found: pstr_core::discover::Found,
/// "More like this" for the title page open, worked out once per title:
/// its key, and the keys of the titles like it.
pub similar: Option<(String, Vec<String>)>,
/// The library or what is known about it changed under the lists.
stale: bool,
/// Only watch state changed: the shelves and franchises still stand,
@@ -445,6 +469,10 @@ impl Default for LibraryView {
shelves: Vec::new(),
history: Vec::new(),
resumable: Vec::new(),
badges: HashMap::new(),
listed: std::collections::HashSet::new(),
similar: None,
found: pstr_core::discover::Found::default(),
stale: false,
progressed: false,
}
@@ -462,11 +490,21 @@ pub struct ViewInputs<'a> {
pub added: &'a HashMap<String, i64>,
/// The files kept on this computer, for the Downloaded filter.
pub offline: &'a std::collections::HashSet<DownloadKey>,
pub list: &'a MyList,
/// When each file first appeared in a crawl, for what is new.
pub first_seen: &'a HashMap<(String, String), i64>,
/// What the providers said about each title's episodes, for searching
/// them by name.
pub guides: &'a HashMap<String, EpisodeGuide>,
pub now: i64,
}
/// How many shelves the library shows above the grid.
const SHELVES: usize = 4;
/// How many titles a title page's "More like this" row offers.
const SIMILAR: usize = 12;
impl LibraryView {
fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) {
use pstr_core::library::Title;
@@ -481,6 +519,7 @@ impl LibraryView {
if !self.stale && self.progressed && unchanged && !watch_shapes_grid {
self.history = ui::history::entries(library);
self.resumable = Self::resumable(library);
self.badges = Self::badges(library, &inputs);
self.progressed = false;
return;
}
@@ -489,6 +528,7 @@ impl LibraryView {
}
let metadata = inputs.metadata;
if self.stale {
self.similar = None;
let all: Vec<&Title> = library.titles.iter().collect();
self.franchises = pstr_core::franchise::group(&all, metadata)
.into_iter()
@@ -508,28 +548,45 @@ impl LibraryView {
.enumerate()
.map(|(at, title)| (title.key.as_str(), at))
.collect();
self.shelves = pstr_core::shelves::pick(&library.titles, metadata, SHELVES)
.into_iter()
.map(|shelf| {
let indices = shelf
.keys
.iter()
.filter_map(|key| index.get(key.as_str()).copied())
.collect();
(shelf.label, indices)
})
self.listed = inputs
.list
.entries()
.map(|(key, _)| key.to_owned())
.collect();
self.shelves = pstr_core::shelves::home(
&library.titles,
metadata,
inputs.names,
inputs.list,
inputs.first_seen,
inputs.now,
SHELVES,
)
.into_iter()
.map(|shelf| {
let indices = shelf
.keys
.iter()
.filter_map(|key| index.get(key.as_str()).copied())
.collect();
(shelf.label, indices)
})
.collect();
}
let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query)
.into_iter()
.filter(|title| {
self.filter.admits(title, |node| {
inputs.offline.contains(&DownloadKey {
share_id: node.share_id.clone(),
link_id: node.link_id.clone(),
})
})
self.filter.admits(
title,
|node| {
inputs.offline.contains(&DownloadKey {
share_id: node.share_id.clone(),
link_id: node.link_id.clone(),
})
},
inputs.list,
)
})
.collect();
let tiles = pstr_core::browse::arrange(
@@ -564,12 +621,23 @@ impl LibraryView {
if self.stale || self.progressed {
self.history = ui::history::entries(library);
self.resumable = Self::resumable(library);
self.badges = Self::badges(library, &inputs);
}
if self.query != inputs.query || self.stale {
self.found = pstr_core::discover::find(library, metadata, inputs.guides, inputs.query);
}
self.query = inputs.query.to_owned();
self.stale = false;
self.progressed = false;
}
fn badges(library: &Library, inputs: &ViewInputs<'_>) -> HashMap<String, String> {
pstr_core::news::badges(&library.titles, inputs.first_seen, inputs.now)
.into_iter()
.map(|(key, badge)| (key, badge.label()))
.collect()
}
/// The "Continue watching" shelf, as indices into the library.
fn resumable(library: &Library) -> Vec<usize> {
library
@@ -628,6 +696,9 @@ pub struct App {
pub metadata: HashMap<String, MetadataRecord>,
/// When each title last gained a file, by key — for "Recently added".
pub added: HashMap<String, i64>,
/// When each file first appeared in a crawl, for the New badges.
first_seen: HashMap<(String, String), i64>,
my_list: MyList,
/// What they said about the episodes under those titles.
pub episodes: HashMap<String, EpisodeGuide>,
pub settings: MetadataConfig,
@@ -643,7 +714,7 @@ pub struct App {
/// Whether the list of keyboard shortcuts is open.
pub shortcuts_open: bool,
/// What a key just did in the player, and when: "Volume 55%", "+30 s".
osd: Option<(String, f64)>,
osd: Option<ui::player::Flash>,
pub view: LibraryView,
/// Whether the catalog has been read at all yet. Until it has, an empty
/// library means "not loaded", not "nothing in it".
@@ -664,8 +735,10 @@ pub struct App {
auto_skipped: (u64, Vec<i64>),
/// The subtitle style changed and has not been written yet.
subtitle_style_unsaved: bool,
/// Whether the episode list is out on the player page.
pub episode_drawer: bool,
/// Which panel is out beside the picture on the player page.
pub player_panel: Option<ui::player::PlayerPanel>,
/// The sleep timer, when one is set.
sleep: Option<Sleep>,
/// Set while the window is fullscreen, so `F` can toggle rather than only
/// ever entering. egui has no way to ask the platform.
pub fullscreen: bool,
@@ -723,6 +796,7 @@ impl App {
let window = crate::window::WindowMemory::new(dirs.window_file());
let notifier = crate::notify::Notifier::new(&dirs.cache);
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
let my_list = engine.my_list();
theme::install_fonts(&cc.egui_ctx);
theme::apply(&cc.egui_ctx, engine.appearance());
cc.egui_ctx.set_zoom_factor(engine.desktop_prefs().zoom());
@@ -752,6 +826,8 @@ impl App {
posters: ImageCache::default(),
metadata: HashMap::new(),
added: HashMap::new(),
first_seen: HashMap::new(),
my_list,
episodes: HashMap::new(),
settings,
api_key: String::new(),
@@ -772,7 +848,8 @@ impl App {
auto_skipped: (0, Vec::new()),
subtitle_style_unsaved: false,
fullscreen: false,
episode_drawer: false,
player_panel: None,
sleep: None,
opening: None,
connecting: true,
crawling: false,
@@ -797,9 +874,26 @@ impl App {
/// Say over the picture what a control just did. Only on the player page;
/// anywhere else the transport bar already shows it.
fn flash(&mut self, ctx: &egui::Context, text: impl Into<String>) {
fn flash(&mut self, ctx: &egui::Context, icon: &'static str, text: impl Into<String>) {
self.flash_level(ctx, icon, text, None);
}
/// The same, with a level drawn under it.
fn flash_level(
&mut self,
ctx: &egui::Context,
icon: &'static str,
text: impl Into<String>,
level: Option<f32>,
) {
if self.page == Page::Player {
self.osd = Some((text.into(), ctx.input(|input| input.time)));
self.osd = Some(ui::player::Flash {
icon,
text: text.into(),
level,
seek: None,
at: ctx.input(|input| input.time),
});
}
}
@@ -886,6 +980,11 @@ impl App {
self.connecting = false;
self.note(ctx, format!("could not open the shares: {error}"), true);
}
Event::FirstSeen(first_seen) => self.first_seen = first_seen,
Event::MyList(list) => {
self.my_list = list;
self.view.stale = true;
}
Event::LibraryLoaded(library, added) => {
self.library = library;
self.added = added;
@@ -1022,6 +1121,11 @@ impl App {
matcher.error = Some(error);
}
}
Event::SkipTimes { player, ranges } => {
if let Some(playback) = self.playback.as_mut().filter(|p| p.id == player) {
playback.set_skip_ranges(ranges);
}
}
Event::PlaybackReady { target, stream } => {
let opening = self.opening.take();
// Dropped before the new one is built: two mpv cores would
@@ -1277,7 +1381,7 @@ impl App {
actions: &mut Vec<Action>,
) -> Option<UpNextCard> {
let has_next = self.neighbours().next;
let autoplay = self.engine.playback_prefs().autoplay_next;
let autoplay = self.engine.playback_prefs().autoplay_next && !self.stops_after_this();
let next = self.adjacent(Adjacent::Next);
let Some(playback) = &self.playback else {
@@ -1363,6 +1467,62 @@ impl App {
})
}
/// Whether the sleep timer is set to stop at the end of what is playing.
fn stops_after_this(&self) -> bool {
matches!(
(self.sleep, &self.playback),
(Some(Sleep::EndOfEpisode(id)), Some(playback)) if playback.id == id
)
}
/// Pause once the sleep timer runs out. A timer for the end of an episode
/// that is no longer playing — the viewer moved on to another — is
/// dropped: it was about that episode.
fn tick_sleep(&mut self, ctx: &egui::Context) {
match (self.sleep, &self.playback) {
(Some(Sleep::At(at)), Some(playback)) if std::time::Instant::now() >= at => {
if !playback.paused {
playback.send(crate::playback::Command::TogglePause);
}
self.sleep = None;
self.note(ctx, "Sleep timer: paused", false);
}
(Some(Sleep::EndOfEpisode(id)), Some(playback)) if playback.id != id => {
self.sleep = None;
}
(Some(_), None) => self.sleep = None,
_ => {}
}
}
/// Once a file has loaded with no chapters naming its opening or ending,
/// ask AniSkip, when the episode's provider entry has a MyAnimeList id.
fn ask_skip_times(&mut self) {
let Some(playback) = &mut self.playback else {
return;
};
let Some(length) = playback.wants_skip_times() else {
return;
};
playback.skips_asked = true;
let target = &playback.target;
let found = self.library.get(&target.title_key).and_then(|title| {
let episode = title.episodes().find(|episode| {
episode.node.share_id == target.share_id && episode.node.link_id == target.link_id
})?;
let number = episode.node.parsed.episode?;
self.episodes
.get(&title.key)?
.get(episode.node.parsed.season, number)
});
if let Some((mal_id, number)) =
found.and_then(|found| Some((found.mal_id?, found.entry_number?)))
{
self.engine
.request_skip_times(playback.id, mal_id, number, length);
}
}
/// Near the end of a file, fetch the opening blocks of the next one.
///
/// The last two minutes, or the credits if the chapters say where they
@@ -1428,6 +1588,11 @@ impl App {
/// end too, and auto-advancing through a broken share one episode at a
/// time is how a player earns being turned off.
fn autoplay_next(&mut self, ctx: &egui::Context) {
if self.stops_after_this() {
// The timer has done its job.
self.sleep = None;
return;
}
if !self.engine.playback_prefs().autoplay_next {
return;
}
@@ -1531,6 +1696,15 @@ impl App {
}
fn apply(&mut self, ctx: &egui::Context, action: Action) {
// A search that led somewhere is one worth offering again.
if self.page == Page::Library
&& matches!(
action,
Action::Goto(Page::Title(_) | Page::Browse { .. }) | Action::Play(_)
)
{
self.engine.remember_search(Some(&self.search));
}
match action {
Action::Goto(page) => self.page = page,
Action::Play(mut target) => {
@@ -1670,7 +1844,10 @@ impl App {
}
Action::WatchToEnd => self.up_next.dismissed = true,
Action::FocusSearch => {
if !matches!(self.page, Page::Library | Page::Title(_)) {
if !matches!(
self.page,
Page::Library | Page::Title(_) | Page::Browse { .. }
) {
self.page = Page::Library;
}
self.focus_search = true;
@@ -1685,8 +1862,15 @@ impl App {
self.view.grouped = grouped;
self.remember_arrangement();
}
Action::Back => match self.page {
Action::SetOnList { key, on } => {
self.my_list = self.engine.set_on_list(&key, on);
self.view.stale = true;
}
Action::Back => match &self.page {
Page::Title(_) => self.page = Page::Library,
Page::Browse { from, .. } => {
self.page = from.clone().map_or(Page::Library, Page::Title)
}
// Back out of a search before out of anything else: it is the
// last thing the viewer did.
Page::Library if !self.search.is_empty() => self.search.clear(),
@@ -1725,7 +1909,7 @@ impl App {
playback.position,
));
self.send_player(crate::playback::Command::Screenshot(path));
self.flash(ctx, "screenshot saved".to_owned());
self.flash(ctx, egui_phosphor::regular::CAMERA, "Screenshot saved");
}
Action::PasteShare(url) => {
self.page = Page::Shares;
@@ -1815,15 +1999,11 @@ impl App {
);
}
Action::Player(command) => {
if let crate::playback::Command::SeekBy(seconds) = command {
self.flash(
ctx,
if seconds < 0.0 {
format!("− {:.0} s", -seconds)
} else {
format!("+ {seconds:.0} s")
},
);
if let crate::playback::Command::SeekBy(seconds) = command
&& self.page == Page::Player
{
let now = ctx.input(|input| input.time);
self.osd = Some(ui::player::Flash::seek(self.osd.as_ref(), seconds, now));
}
if let Some(playback) = &self.playback {
playback.send(command);
@@ -1851,7 +2031,7 @@ impl App {
}
let text = format!("Speed {}", ui::format_speed(speed));
if self.page == Page::Player {
self.flash(ctx, text);
self.flash(ctx, egui_phosphor::regular::GAUGE, text);
} else {
self.note(ctx, text, false);
}
@@ -1936,7 +2116,32 @@ impl App {
Action::RemoveFromHistory { share_id, link_id } => {
self.forget_watch(ctx, share_id, link_id, "history");
}
Action::ToggleEpisodes => self.episode_drawer = !self.episode_drawer,
Action::SetSleep(choice) => {
self.sleep = match choice {
None => None,
Some(ui::player::SleepChoice::EndOfEpisode) => self
.playback
.as_ref()
.map(|playback| Sleep::EndOfEpisode(playback.id)),
Some(ui::player::SleepChoice::Minutes(minutes)) => Some(Sleep::At(
std::time::Instant::now()
+ std::time::Duration::from_secs(u64::from(minutes) * 60),
)),
};
let text = match choice {
None => "Sleep timer off".to_owned(),
Some(ui::player::SleepChoice::EndOfEpisode) => {
"Stopping after this episode".to_owned()
}
Some(ui::player::SleepChoice::Minutes(minutes)) => {
format!("Pausing in {minutes} min")
}
};
self.flash(ctx, egui_phosphor::regular::MOON, text);
}
Action::TogglePanel(panel) => {
self.player_panel = (self.player_panel != Some(panel)).then_some(panel);
}
Action::RestoreWatch(states) => {
for (share_id, link_id, state) in states {
self.engine.save_watch_state(share_id, link_id, state);
@@ -1981,13 +2186,13 @@ impl App {
"Subtitle delay {}",
ui::format_delay(playback.subtitle_delay)
);
self.flash(ctx, text);
self.flash(ctx, egui_phosphor::regular::SUBTITLES, text);
}
}
Action::ResetSubtitleDelay => {
if let Some(playback) = &mut self.playback {
playback.set_subtitle_delay(0.0);
self.flash(ctx, "Subtitle delay 0 s");
self.flash(ctx, egui_phosphor::regular::SUBTITLES, "Subtitle delay 0 s");
}
}
Action::SetAppearance(appearance) => {
@@ -1998,7 +2203,18 @@ impl App {
self.set_volume(volume, commit);
// A slider shows its own value; a key press shows it here.
if commit {
self.flash(ctx, format!("Volume {volume:.0}%"));
self.flash_level(
ctx,
if volume <= 0.0 {
egui_phosphor::regular::SPEAKER_SLASH
} else if volume < 50.0 {
egui_phosphor::regular::SPEAKER_LOW
} else {
egui_phosphor::regular::SPEAKER_HIGH
},
format!("Volume {volume:.0}%"),
Some((volume / pstr_player::MAX_VOLUME) as f32),
);
}
}
Action::ToggleMute => {
@@ -2011,7 +2227,11 @@ impl App {
None => !prefs.muted,
};
self.send_player(crate::playback::Command::SetMuted(prefs.muted));
self.flash(ctx, if prefs.muted { "Muted" } else { "Sound on" });
if prefs.muted {
self.flash(ctx, egui_phosphor::regular::SPEAKER_SLASH, "Muted");
} else {
self.flash(ctx, egui_phosphor::regular::SPEAKER_HIGH, "Sound on");
}
self.engine.set_playback_prefs(prefs, true);
}
Action::SelectTrack { kind, id } => {
@@ -2075,7 +2295,10 @@ impl App {
);
ui.add_space(theme::space::L);
let on_library = matches!(self.page, Page::Library | Page::Title(_));
let on_library = matches!(
self.page,
Page::Library | Page::Title(_) | Page::Browse { .. }
);
let active = self
.downloads
.iter()
@@ -2140,7 +2363,9 @@ impl App {
if on_library && !self.library.is_empty() {
ui.add_space(theme::space::M);
ui::search_field(ui, search, focus_search);
if ui::search_field(ui, search, focus_search, &self.engine.recent_searches()) {
self.engine.remember_search(None);
}
}
});
});
@@ -2180,6 +2405,8 @@ impl eframe::App for App {
}
}
self.warm_next();
self.ask_skip_times();
self.tick_sleep(ctx);
self.auto_skip(ctx);
self.save_subtitle_style(ctx);
self.window.observe(ctx);
@@ -2222,13 +2449,7 @@ impl eframe::App for App {
speed: playback.speed,
skip_to: playback.skippable().map(|(_, end)| end),
});
shortcuts(
ctx,
self.fullscreen,
self.episode_drawer,
state,
&mut actions,
);
shortcuts(ctx, self.fullscreen, self.player_panel, state, &mut actions);
// Before the draw, because drawing does not mutate — and it can
// push an action of its own, which is why it takes the same list.
@@ -2244,7 +2465,8 @@ impl eframe::App for App {
posters,
metadata,
episodes,
episode_drawer,
player_panel,
sleep,
..
} = self;
// Before the list borrows the art caches for the rest of the frame.
@@ -2268,6 +2490,45 @@ impl eframe::App for App {
next: opening.from == Page::Player,
}
});
// Only while paused, which is the only time the pause screen
// can show: the logo is a texture lookup, and could be a fetch.
let about = playback
.as_ref()
.filter(|playback| playback.paused)
.and_then(|playback| {
let title = library.get(&playback.target.title_key)?;
let mut art = ui::Art {
engine,
thumbs,
posters,
metadata,
episodes,
names: engine.appearance().names,
};
let target = &playback.target;
let episode = title
.seasons
.iter()
.flat_map(|season| &season.episodes)
.find(|episode| {
episode.node.share_id == target.share_id
&& episode.node.link_id == target.link_id
});
let synopsis = episode
.and_then(|episode| art.episode(&title.key, episode))
.and_then(|found| found.overview.clone())
.or_else(|| {
art.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|found| found.overview.clone())
});
Some(ui::player::About {
name: art.name(title).to_owned(),
logo: art.logo(title),
synopsis,
})
});
let list = playback
.as_ref()
.and_then(|playback| library.get(&playback.target.title_key))
@@ -2282,7 +2543,6 @@ impl eframe::App for App {
episodes,
names: engine.appearance().names,
},
open: *episode_drawer,
});
// No panels: the controls are drawn over the picture, and a nav bar
// above a film is the one thing every player agrees not to do.
@@ -2299,12 +2559,15 @@ impl eframe::App for App {
up_next,
neighbours,
episodes: list,
panel: *player_panel,
about,
sleep: sleep.map(Sleep::choice),
},
&mut actions,
);
});
if let Some((text, at)) = &self.osd
&& !ui::player::osd(ctx, text, *at)
if let Some(flash) = &self.osd
&& !flash.show(ctx, ctx.content_rect())
{
self.osd = None;
}
@@ -2391,8 +2654,24 @@ impl eframe::App for App {
names: self.engine.appearance().names,
added: &self.added,
offline: &self.offline_files,
list: &self.my_list,
first_seen: &self.first_seen,
guides: &self.episodes,
now: crate::engine::unix_now(),
},
);
// After the refresh, which forgets it when the library changed.
if let Page::Title(key) = &self.page
&& self.view.similar.as_ref().is_none_or(|(of, _)| of != key)
{
let keys = pstr_core::discover::more_like(
key,
&self.library.titles,
&self.metadata,
SIMILAR,
);
self.view.similar = Some((key.clone(), keys));
}
let crawl = self.crawling.then(|| crawl_label(&self.crawl_progress));
let App {
@@ -2471,7 +2750,19 @@ impl eframe::App for App {
.frame(
egui::Frame::new()
.fill(theme::background())
.inner_margin(egui::Margin::symmetric(18, 12)),
.inner_margin(egui::Margin {
left: ui::PAGE_MARGIN.x as i8,
right: ui::PAGE_MARGIN.x as i8,
// The library's banner starts right under the bar;
// the page keeps the margin itself when it has no
// banner to show.
top: if *page == Page::Library {
0
} else {
ui::PAGE_MARGIN.y as i8
},
bottom: ui::PAGE_MARGIN.y as i8,
}),
)
.show(ui, |ui| {
ui.multiply_opacity(fade);
@@ -2507,13 +2798,34 @@ impl eframe::App for App {
&mut art,
library,
key,
view.franchises.get(key.as_str()).map(Vec::as_slice),
ui::title::Context {
franchise: view.franchises.get(key.as_str()).map(Vec::as_slice),
similar: view
.similar
.as_ref()
.filter(|(of, _)| of == key)
.map(|(_, keys)| keys.as_slice())
.unwrap_or_default(),
on_list: view.listed.contains(key.as_str()),
},
ui::title::OfflineView {
downloads,
files: offline_files,
},
&mut actions,
),
Page::Browse { facet, from } => ui::library::browse(
ui,
&mut art,
library,
ui::library::Browsing {
facet,
from: from.as_deref(),
},
view,
engine.appearance().tiles,
&mut actions,
),
Page::Shares => ui::shares::show(
ui,
shares,
@@ -2812,12 +3124,21 @@ fn changed_art(
.map(|(url, _)| url.to_owned()),
found.and_then(|metadata| metadata.poster_url.clone()),
found.and_then(|metadata| metadata.wide_art().map(str::to_owned)),
found.and_then(|metadata| metadata.logo_url.clone()),
// The poster again: the blurred band is made from it.
found.and_then(|metadata| metadata.poster_url.clone()),
]
};
let mut keys: Vec<String> = Vec::new();
for key in before.keys().chain(after.keys()) {
let (old, new) = (urls(before, key), urls(after, key));
let texture_keys = [key.clone(), ui::poster_key(key), ui::wide_key(key)];
let texture_keys = [
key.clone(),
ui::poster_key(key),
ui::wide_key(key),
ui::logo_key(key),
ui::ambient_key(key),
];
for ((old, new), texture) in old.iter().zip(&new).zip(texture_keys) {
if old != new {
keys.push(texture);
@@ -2856,7 +3177,9 @@ fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec<Action>)
}
if input.consume_key(Modifiers::ALT, Key::ArrowLeft)
|| input.pointer.button_pressed(egui::PointerButton::Extra1)
|| (!typing && matches!(page, Page::Title(_)) && input.key_pressed(Key::Escape))
|| (!typing
&& matches!(page, Page::Title(_) | Page::Browse { .. })
&& input.key_pressed(Key::Escape))
|| (!typing && *page == Page::Library && input.key_pressed(Key::Backspace))
{
actions.push(Action::Back);
@@ -2906,9 +3229,33 @@ struct PlayerKeys {
skip_to: Option<f64>,
}
/// A sleep timer that is running.
#[derive(Debug, Clone, Copy)]
enum Sleep {
/// Stop when player `id`'s file ends, rather than going on to the next.
EndOfEpisode(u64),
/// Pause at this moment.
At(std::time::Instant),
}
impl Sleep {
/// What the panel shows as chosen.
fn choice(self) -> ui::player::SleepChoice {
match self {
Self::EndOfEpisode(_) => ui::player::SleepChoice::EndOfEpisode,
Self::At(at) => ui::player::SleepChoice::Minutes(
(at.saturating_duration_since(std::time::Instant::now())
.as_secs()
/ 60) as u32
+ 1,
),
}
}
}
/// The speeds `[` and `]` step through. Fixed steps rather than a nudge of a
/// tenth, because 1.0 has to be somewhere a viewer can land on again.
const SPEEDS: [f64; 8] = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0];
use crate::ui::transport::SPEEDS;
/// The next step from `speed` in `direction`, from wherever it is now.
fn step_speed(speed: f64, faster: bool) -> f64 {
@@ -2931,7 +3278,7 @@ fn step_speed(speed: f64, faster: bool) -> f64 {
fn shortcuts(
ctx: &egui::Context,
fullscreen: bool,
drawer: bool,
panel: Option<ui::player::PlayerPanel>,
playing: Option<PlayerKeys>,
actions: &mut Vec<Action>,
) {
@@ -3010,9 +3357,11 @@ fn shortcuts(
// mpv's own keys for it, and the step mpv uses.
Key::Z => actions.push(Action::NudgeSubtitleDelay(-0.1)),
Key::X => actions.push(Action::NudgeSubtitleDelay(0.1)),
Key::E => actions.push(Action::ToggleEpisodes),
// The episode list is the innermost thing open, so it goes first.
Key::Escape if drawer => actions.push(Action::ToggleEpisodes),
Key::E => actions.push(Action::TogglePanel(ui::player::PlayerPanel::Episodes)),
// A panel is the innermost thing open, so it goes first.
Key::Escape if panel.is_some() => {
actions.push(Action::TogglePanel(panel.expect("checked above")));
}
Key::Escape if fullscreen => actions.push(Action::ToggleFullscreen),
Key::Escape => actions.push(Action::LeavePlayer),
_ => {}
@@ -3073,6 +3422,7 @@ mod tests {
kind: TitleKind::Series,
poster_url: Some(url.into()),
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
@@ -3146,12 +3496,16 @@ mod tests {
record("added", Some("w")),
]
.into();
// The tile and the cover are both drawn from the poster here; the
// wide art has nothing to be.
// The tile, the cover and the blurred band are all drawn from the
// poster here; the wide art has nothing to be.
assert_eq!(
changed_art(&before, &after),
vec![
"added",
"ambient:added",
"ambient:dropped",
"ambient:found",
"ambient:moved",
"dropped",
"found",
"moved",
+337 -26
View File
@@ -33,6 +33,7 @@ use pstr_core::catalog::{
use pstr_core::config::AppDirs;
use pstr_core::library::{Library, Title, TitleKind};
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
use pstr_core::mylist::MyList;
use pstr_core::prefs::PlaybackPrefs;
use pstr_core::profile::ProfileChanges;
use pstr_core::proton_drive_rs::ThumbnailType;
@@ -170,6 +171,35 @@ const THUMBNAIL_MAX_EDGE: u32 = 640;
/// best there is.
pub const WIDE_MAX_EDGE: u32 = 1920;
/// The longest edge of a title's logo once its padding is trimmed. Drawn at
/// most a few hundred pixels wide; this leaves room for a 2× display.
const LOGO_MAX_EDGE: u32 = 800;
/// The size a poster is squeezed to for [`Fit::Ambient`]: 16:9, and small
/// enough that what is left of it is colour and light rather than shapes.
const AMBIENT_SIZE: (u32, u32) = (64, 36);
/// How far the squeezed poster is blurred, in its own pixels. With linear
/// filtering stretching it across the window, this is what keeps the stretch
/// from showing its pixels.
const AMBIENT_BLUR: f32 = 3.0;
/// How a fetched picture is fitted before it becomes a texture.
#[derive(Clone, Copy)]
enum Fit {
/// Scaled down to this edge, nothing else.
Scaled(u32),
/// A logo: cropped to what is not transparent, then scaled to
/// [`LOGO_MAX_EDGE`]. TVDB pads every clear logo to a square, and the
/// padding would otherwise decide where it sits and how big it is drawn.
Logo,
/// A poster made into a wash of its own colours, for a band across the
/// window when a title has no wide art: squeezed to [`AMBIENT_SIZE`] and
/// blurred. A poster cropped to that shape was a blown-up slice of it,
/// which read as broken; this reads as the poster's light.
Ambient,
}
/// What the background side tells the UI.
pub enum Event {
/// The share list changed on disk.
@@ -192,6 +222,12 @@ pub enum Event {
/// The catalog was re-read: the library, and when each title last
/// gained a file.
LibraryLoaded(Library, HashMap<String, i64>),
/// When each file first appeared in a crawl, for the New badges; see
/// [`pstr_core::catalog::Catalog::first_seen`]. Sent just before
/// [`Event::LibraryLoaded`].
FirstSeen(HashMap<(String, String), i64>),
/// My List changed on another device.
MyList(MyList),
/// A crawl finished for one share.
Crawled {
share_id: String,
@@ -256,6 +292,12 @@ pub enum Event {
},
/// The artwork for this title could not be had.
PosterMissing { key: String },
/// Where AniSkip says the opening and ending of what player `player` has
/// open are. Empty when it does not know.
SkipTimes {
player: u64,
ranges: Vec<pstr_core::chapters::SkipRange>,
},
/// A stream opened and is ready to hand to mpv.
PlaybackReady {
target: Box<PlaybackTarget>,
@@ -386,6 +428,12 @@ pub struct Engine {
/// Which theme the window wears. Kept beside the playback preferences for
/// the same reason: it is read from the UI and written to disk off it.
appearance: Arc<Mutex<Appearance>>,
/// My List, read once at launch. `None` when the file would not parse: it
/// is then never written over, and changing the list is refused.
my_list: Arc<Mutex<Option<MyList>>>,
/// The last few searches, newest first. `None`, and never written, when
/// the file would not parse.
recent: Arc<Mutex<Option<pstr_core::recent::RecentSearches>>>,
/// Preloaded so clicking Play never runs SQLite on egui's thread.
track_prefs: Arc<Mutex<HashMap<String, TitleTrackPrefs>>>,
downloads: Arc<Mutex<Downloads>>,
@@ -445,7 +493,7 @@ impl Enrichment {
/// Resolve a service for `config`, if one can be had.
fn build(config: MetadataConfig) -> Self {
let service = config.enabled.then(|| {
let key = pstr_meta::settings::api_key(config.provider);
let key = pstr_meta::settings::api_key(ProviderId::Tmdb);
MetadataService::new(&config, key)
.inspect_err(|error| tracing::debug!("metadata provider unavailable: {error}"))
.ok()
@@ -493,12 +541,20 @@ impl Engine {
Appearance::default()
});
// A provider with an API key needs it out of the OS credential store,
// and that can be a D-Bus round trip to a keyring that has to unlock
// first — not something to hold the first frame for. Built on the
// blocking pool instead; until it lands, everything that needs the
// provider waits for it rather than concluding there is none.
let deferred = config.enabled && config.provider.needs_api_key();
let my_list = pstr_core::mylist::load(&dirs)
.inspect_err(|error| tracing::warn!("read My List: {error}"))
.ok();
let recent = pstr_core::recent::load(&dirs)
.inspect_err(|error| tracing::warn!("read recent searches: {error}"))
.ok();
// The TMDB key, which every provider looks for, comes out of the OS
// credential store, and that can be a D-Bus round trip to a keyring
// that has to unlock first — not something to hold the first frame
// for. Built on the blocking pool instead; until it lands, everything
// that needs the provider waits for it rather than concluding there is
// none.
let deferred = config.enabled;
let enrichment = if deferred {
Enrichment {
config: config.clone(),
@@ -530,6 +586,8 @@ impl Engine {
enrichment_settled: Arc::new(tokio::sync::watch::channel(!deferred).0),
prefs: Arc::new(Mutex::new(prefs)),
appearance: Arc::new(Mutex::new(appearance)),
my_list: Arc::new(Mutex::new(my_list)),
recent: Arc::new(Mutex::new(recent)),
track_prefs: Arc::new(Mutex::new(track_prefs)),
downloads: Arc::new(Mutex::new(Downloads::default())),
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
@@ -594,6 +652,75 @@ impl Engine {
*self.appearance.lock()
}
/// My List as it stands.
pub fn my_list(&self) -> MyList {
self.my_list.lock().clone().unwrap_or_default()
}
/// Put a title on My List or take it off, write the list, and return it
/// as it now is.
pub fn set_on_list(&self, key: &str, on: bool) -> MyList {
let mut guard = self.my_list.lock();
let Some(list) = guard.as_mut() else {
self.emit(Event::Error(
"My List could not be read, so it was left as it is.".to_owned(),
));
return MyList::default();
};
if on {
list.add(key, unix_now());
} else {
list.remove(key);
}
let saved = list.clone();
drop(guard);
let engine = self.clone();
self.runtime.spawn_blocking(move || {
// Whatever the list is by now, under the lock: two clicks in a row
// must not land on disk in the wrong order.
let guard = engine.my_list.lock();
if let Some(list) = guard.as_ref()
&& let Err(error) = pstr_core::mylist::save(&engine.dirs, list)
{
drop(guard);
engine.fail("save My List", error);
}
});
saved
}
/// The last few searches, newest first.
pub fn recent_searches(&self) -> Vec<String> {
self.recent
.lock()
.as_ref()
.map(|recent| recent.queries.clone())
.unwrap_or_default()
}
/// Put `query` at the front of the recent searches, or with `None`
/// forget them all, and write the list off the UI thread.
pub fn remember_search(&self, query: Option<&str>) {
let changed = match (self.recent.lock().as_mut(), query) {
(Some(recent), Some(query)) => recent.remember(query),
(Some(recent), None) => !std::mem::take(&mut recent.queries).is_empty(),
(None, _) => false,
};
if !changed {
return;
}
let engine = self.clone();
self.runtime.spawn_blocking(move || {
let guard = engine.recent.lock();
if let Some(recent) = guard.as_ref()
&& let Err(error) = pstr_core::recent::save(&engine.dirs, recent)
{
drop(guard);
engine.fail("save recent searches", error);
}
});
}
/// Change the theme and write it.
///
/// Always committed, unlike the volume: a theme is picked with a click and
@@ -994,6 +1121,15 @@ impl Engine {
Err(error) => tracing::warn!("reread the metadata settings: {error}"),
}
}
if profile.list {
match pstr_core::mylist::load(&self.dirs) {
Ok(list) => {
*self.my_list.lock() = Some(list.clone());
self.emit(Event::MyList(list));
}
Err(error) => tracing::warn!("reread My List: {error}"),
}
}
if profile.titles > 0 {
match self.catalog.lock().all_title_track_prefs() {
Ok(prefs) => *self.track_prefs.lock() = prefs,
@@ -1259,6 +1395,7 @@ impl Engine {
let library = Library::build(files, &watch);
let targets = download_targets(&library);
let added = library.added_at(&first_seen);
engine.emit(Event::FirstSeen(first_seen));
engine.emit(Event::LibraryLoaded(library, added));
engine.refresh_offline(Some(targets));
}
@@ -2590,8 +2727,6 @@ impl Engine {
}
return self.emit(Event::Error(self.no_service_reason()));
};
let provider = service.provider();
let (stored, enriched) = {
let catalog = self.catalog.lock();
match catalog
@@ -2603,14 +2738,7 @@ impl Engine {
}
};
let pending = pstr_meta::service::plan(
titles,
&stored,
&enriched,
provider,
service.has_details(),
force,
);
let pending = pstr_meta::service::plan(titles, &stored, &enriched, &service.chain(), force);
if pending.is_empty() {
if automatic {
return;
@@ -2621,7 +2749,7 @@ impl Engine {
self.emit(Event::Status(format!(
"matching {} titles against {}…",
pending.len(),
provider.label()
service.label()
)));
let (mut matched, mut unmatched, mut failed) = (0usize, 0usize, 0usize);
@@ -2658,7 +2786,7 @@ impl Engine {
episodes_found += enrichment.episodes.len();
let stored = self.catalog.lock().set_enrichment(
&outcome.title_key,
provider,
outcome.provider,
now(),
enrichment,
);
@@ -2740,7 +2868,7 @@ impl Engine {
if let Some(enrichment) = &outcome.enrichment {
let stored = engine.catalog.lock().set_enrichment(
&title.key,
service.provider(),
outcome.provider,
now(),
enrichment,
);
@@ -2780,6 +2908,26 @@ impl Engine {
path.is_file().then_some(path)
}
/// Ask AniSkip where episode `episode` of the MyAnimeList entry `mal_id`
/// has its opening and ending, for player `player`, whose file runs
/// `length` seconds. Only with enrichment on; a failure is logged and is
/// the same as no answer, which leaves the file as its chapters made it.
pub fn request_skip_times(&self, player: u64, mal_id: u32, episode: u32, length: f64) {
let engine = self.clone();
self.runtime.spawn(async move {
let Some(service) = engine.metadata_service().await else {
return;
};
match service.skip_times(mal_id, episode, length).await {
Ok(ranges) if !ranges.is_empty() => {
engine.emit(Event::SkipTimes { player, ranges });
}
Ok(_) => {}
Err(error) => tracing::debug!("AniSkip for {mal_id}/{episode}: {error}"),
}
});
}
/// Fetch, decode and post one title's artwork.
///
/// Cached on disk under a hash of the URL, so a provider that moves its CDN
@@ -2794,18 +2942,35 @@ impl Engine {
/// pixels rather than a tile's 640. A 640 px still stretched to 1600 was
/// most of why those looked soft.
pub fn request_art(&self, title_key: String, url: String, max_edge: u32) {
self.fetch_art(title_key, url, Fit::Scaled(max_edge));
}
/// [`Engine::request_poster`], for a title's logo: trimmed to its
/// lettering. See [`Fit::Logo`].
pub fn request_logo(&self, key: String, url: String) {
self.fetch_art(key, url, Fit::Logo);
}
/// [`Engine::request_poster`], blurred for a band. See [`Fit::Ambient`].
pub fn request_ambient(&self, key: String, url: String) {
self.fetch_art(key, url, Fit::Ambient);
}
fn fetch_art(&self, title_key: String, url: String, fit: Fit) {
let engine = self.clone();
// A size of its own on disk: the tile-sized copy of the same URL is
// already there under the plain name, scaled down.
let name = match max_edge {
THUMBNAIL_MAX_EDGE => format!("{}.img", digest(&url)),
edge => format!("{}-{edge}.img", digest(&url)),
let name = match fit {
Fit::Scaled(THUMBNAIL_MAX_EDGE) => format!("{}.img", digest(&url)),
Fit::Scaled(edge) => format!("{}-{edge}.img", digest(&url)),
Fit::Logo => format!("{}-logo.img", digest(&url)),
Fit::Ambient => format!("{}-ambient.img", digest(&url)),
};
let path = self.dirs.poster_cache().join(name);
self.runtime.spawn(async move {
if let Ok(bytes) = tokio::fs::read(&path).await {
match decode_scaled(&bytes, max_edge) {
match decode_fitted(&bytes, fit) {
Ok(decoded) => {
if let Some(smaller) = &decoded.smaller {
store_image(&path, smaller).await;
@@ -2832,7 +2997,7 @@ impl Engine {
}
};
match decode_scaled(&bytes, max_edge) {
match decode_fitted(&bytes, fit) {
Ok(decoded) => {
store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await;
engine.emit(Event::Poster {
@@ -2984,11 +3149,43 @@ fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result<Decoded> {
/// [`decode_thumbnail`], to `max_edge` rather than a tile's size.
fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result<Decoded> {
let decoded = image::load_from_memory(bytes)?;
decode_fitted(bytes, Fit::Scaled(max_edge))
}
/// [`decode_scaled`], fitted as `fit` says. A trimmed logo is handed back
/// for the cache like a scaled picture is, so the trim is paid once.
fn decode_fitted(bytes: &[u8], fit: Fit) -> anyhow::Result<Decoded> {
let mut decoded = image::load_from_memory(bytes)?;
let mut changed = false;
let max_edge = match fit {
Fit::Scaled(edge) => edge,
Fit::Logo => {
if let Some(trimmed) = trim_alpha(&decoded) {
decoded = trimmed;
changed = true;
}
LOGO_MAX_EDGE
}
Fit::Ambient => {
// Already blurred when it came out of the cache: only a picture
// of another size is the poster itself.
let (width, height) = AMBIENT_SIZE;
if (decoded.width(), decoded.height()) != AMBIENT_SIZE {
decoded = decoded
.resize_exact(width, height, image::imageops::FilterType::Triangle)
.blur(AMBIENT_BLUR);
changed = true;
}
width
}
};
let (decoded, smaller) = if decoded.width().max(decoded.height()) > max_edge {
let scaled = decoded.thumbnail(max_edge, max_edge);
let encoded = encode_for_cache(&scaled);
(scaled, encoded)
} else if changed {
let encoded = encode_for_cache(&decoded);
(decoded, encoded)
} else {
(decoded, None)
};
@@ -3000,6 +3197,36 @@ fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result<Decoded> {
})
}
/// `image` cropped to the pixels that are not (nearly) transparent, or
/// `None` when there is nothing to crop — no padding, no alpha, or nothing
/// but padding.
fn trim_alpha(image: &image::DynamicImage) -> Option<image::DynamicImage> {
/// Below this the pixel is anti-aliasing haze or compression noise, not
/// lettering.
const VISIBLE: u8 = 8;
if !image.color().has_alpha() {
return None;
}
let rgba = image.to_rgba8();
let (mut left, mut top, mut right, mut bottom) = (u32::MAX, u32::MAX, 0, 0);
for (x, y, pixel) in rgba.enumerate_pixels() {
if pixel[3] > VISIBLE {
left = left.min(x);
top = top.min(y);
right = right.max(x);
bottom = bottom.max(y);
}
}
if left > right {
return None;
}
let (width, height) = (right - left + 1, bottom - top + 1);
if (width, height) == (rgba.width(), rgba.height()) {
return None;
}
Some(image.crop_imm(left, top, width, height))
}
/// Encode a scaled picture for the cache: PNG when it has transparency to keep
/// (a logo), JPEG otherwise. `None` if encoding failed, which leaves the cache
/// holding the original — slower to load, never wrong.
@@ -3076,6 +3303,24 @@ struct Entry {
texture: egui::TextureHandle,
used: u64,
bytes: usize,
/// The picture's colour — see [`pstr_core::appearance::mood`]. Worked out
/// once, at upload, from the pixels on their way to the GPU.
mood: Option<egui::Color32>,
}
/// About how many pixels [`mood_of`] reads. A 64×36 wash is read whole; a
/// 1920 px backdrop is read at a stride that lands on about this many, which
/// is plenty for an average and costs nothing worth a frame.
const MOOD_SAMPLES: usize = 4096;
/// [`pstr_core::appearance::mood`] over a decoded picture.
fn mood_of(image: &egui::ColorImage) -> Option<egui::Color32> {
let stride = (image.pixels.len() / MOOD_SAMPLES).max(1);
pstr_core::appearance::mood(image.pixels.iter().step_by(stride).map(|pixel| {
let [r, g, b, a] = pixel.to_srgba_unmultiplied();
(pstr_core::appearance::Rgb::new(r, g, b), a)
}))
.map(|rgb| egui::Color32::from_rgb(rgb.r, rgb.g, rgb.b))
}
/// Texture memory one [`ImageCache`] may hold. A 640 px card picture is about
@@ -3143,12 +3388,14 @@ impl ImageCache {
break;
};
let bytes = image.pixels.len() * 4;
let mood = mood_of(&image);
let texture = ctx.load_texture(&key, image, egui::TextureOptions::LINEAR);
self.clock += 1;
let entry = Entry {
texture,
used: self.clock,
bytes,
mood,
};
if let Some(old) = self.textures.insert(key, entry) {
self.bytes -= old.bytes;
@@ -3178,6 +3425,12 @@ impl ImageCache {
}
}
/// The colour of the picture under `key`, once it is a texture. A lookup
/// that does not count as asking for it: only drawing a picture keeps it.
pub fn mood(&self, key: &str) -> Option<egui::Color32> {
self.textures.get(key).and_then(|entry| entry.mood)
}
pub fn mark_missing(&mut self, key: String) {
self.missing.insert(key);
}
@@ -3238,6 +3491,18 @@ mod image_cache_tests {
);
}
#[test]
fn an_uploaded_picture_keeps_its_colour_and_a_waiting_one_has_none() {
let ctx = egui::Context::default();
let mut cache = ImageCache::default();
let teal = egui::Color32::from_rgb(20, 140, 150);
cache.texture("a", || {});
cache.insert("a".into(), egui::ColorImage::filled([8, 8], teal));
assert_eq!(cache.mood("a"), None);
cache.upload(&ctx);
assert_eq!(cache.mood("a"), Some(teal));
}
#[test]
fn uploads_are_spread_over_frames() {
let ctx = egui::Context::default();
@@ -3261,6 +3526,52 @@ mod image_cache_tests {
cache.upload(&ctx);
assert!(cache.textures.is_empty());
}
/// TVDB pads a clear logo to a square; trimmed, it is only its lettering.
#[test]
fn a_padded_logo_is_trimmed_to_its_lettering() {
let mut padded = image::RgbaImage::new(100, 100);
for x in 20..80 {
for y in 40..60 {
padded.put_pixel(x, y, image::Rgba([255, 255, 255, 255]));
}
}
// Haze below the visibility threshold does not count.
padded.put_pixel(2, 2, image::Rgba([255, 255, 255, 3]));
let trimmed = trim_alpha(&image::DynamicImage::ImageRgba8(padded)).expect("trimmed");
assert_eq!((trimmed.width(), trimmed.height()), (60, 20));
}
/// A poster for a band is squeezed and blurred once; what the cache keeps
/// is already the wash, and decoding it again leaves it as it is.
#[test]
fn a_poster_for_a_band_becomes_a_small_blurred_wash_once() {
let mut poster = image::RgbImage::new(200, 300);
for y in 0..150 {
for x in 0..200 {
poster.put_pixel(x, y, image::Rgb([255, 255, 255]));
}
}
let poster = encode_for_cache(&image::DynamicImage::ImageRgb8(poster)).expect("encode");
let decoded = decode_fitted(&poster, Fit::Ambient).expect("decode");
assert_eq!(decoded.image.size, [64, 36]);
// White above, black below, and a ramp between rather than an edge.
let middle = decoded.image.pixels[18 * 64 + 32];
assert!(middle.r() > 40 && middle.r() < 215, "{middle:?}");
let cached = decoded.smaller.expect("the wash is what is cached");
let again = decode_fitted(&cached, Fit::Ambient).expect("decode");
assert_eq!(again.image.size, [64, 36]);
assert!(again.smaller.is_none());
}
#[test]
fn a_picture_with_nothing_to_trim_is_left_alone() {
let opaque = image::DynamicImage::ImageRgb8(image::RgbImage::new(10, 10));
assert!(trim_alpha(&opaque).is_none());
let blank = image::DynamicImage::ImageRgba8(image::RgbaImage::new(10, 10));
assert!(trim_alpha(&blank).is_none());
}
}
#[cfg(test)]
+38 -2
View File
@@ -198,8 +198,16 @@ pub struct Playback {
pub subtitle_delay: f64,
/// What the file contains. Empty until mpv has demuxed it.
pub tracks: Vec<Track>,
/// The file's chapters, in order. Empty for a file muxed without them.
/// The chapters to show and act on, in order: the file's own, with
/// AniSkip's opening and ending cut in when the file names neither. Empty
/// for a file muxed without chapters that AniSkip does not know.
pub chapters: Vec<Chapter>,
/// The chapters as the file has them.
file_chapters: Vec<Chapter>,
/// What AniSkip said, once it has.
skip_ranges: Vec<pstr_core::chapters::SkipRange>,
/// Whether AniSkip has been asked about this file, so it is asked once.
pub skips_asked: bool,
/// What each of those chapters is, resolved against the rest of the file —
/// see [`pstr_player::roles`]. Same length as `chapters`, and recomputed
/// whenever either the chapters or the duration change.
@@ -278,6 +286,9 @@ impl Playback {
speed: prefs.speed,
tracks: Vec::new(),
chapters: Vec::new(),
file_chapters: Vec::new(),
skip_ranges: Vec::new(),
skips_asked: false,
roles: Vec::new(),
credits_at: None,
position: target.resume_at.unwrap_or(0.0),
@@ -417,6 +428,11 @@ impl Playback {
/// changed. Both are needed: the length of a chapter is what settles a name
/// that could mean two things.
fn reclassify(&mut self) {
self.chapters = pstr_core::chapters::with_skip_ranges(
&self.file_chapters,
&self.skip_ranges,
self.duration,
);
self.roles = pstr_player::roles(&self.chapters, self.duration);
self.credits_at = pstr_player::credits_start(&self.chapters, &self.roles);
}
@@ -433,7 +449,7 @@ impl Playback {
self.place_subtitles();
}
PlayerEvent::Chapters(chapters) => {
self.chapters = chapters.clone();
self.file_chapters = chapters.clone();
self.reclassify();
}
PlayerEvent::Position(position) => {
@@ -466,6 +482,26 @@ impl Playback {
}
}
/// The file's length, when it is time to ask AniSkip where its opening
/// and ending are: once, after it has loaded, and only when its own
/// chapters name neither.
pub fn wants_skip_times(&self) -> Option<f64> {
if self.skips_asked || !self.loaded {
return None;
}
let own = pstr_player::roles(&self.file_chapters, self.duration);
let named = own
.iter()
.any(|role| matches!(role, ChapterRole::Opening | ChapterRole::Ending));
self.duration.filter(|duration| *duration > 0.0 && !named)
}
/// Take AniSkip's answer.
pub fn set_skip_ranges(&mut self, ranges: Vec<pstr_core::chapters::SkipRange>) {
self.skip_ranges = ranges;
self.reclassify();
}
/// Fraction played, for the seek bar. `None` until mpv reports a duration.
pub fn progress(&self) -> Option<f32> {
let duration = self.duration.filter(|duration| *duration > 0.0)?;
+106 -10
View File
@@ -41,6 +41,10 @@ pub const CARD_GAP: f32 = 14.0;
pub const POSTER_WIDTH: f32 = 168.0;
/// Covers are 2:3.
pub const POSTER_ASPECT: f32 = 3.0 / 2.0;
/// How much a tile's picture grows under the pointer, as a fraction of its
/// size. Half of it lands on each side, which has to fit in half of
/// [`CARD_GAP`] at the widest card, or a lifted tile overlaps its neighbour.
pub const TILE_LIFT: f32 = 0.04;
/// The type ramp, named once.
///
@@ -55,6 +59,11 @@ pub const POSTER_ASPECT: f32 = 3.0 / 2.0;
/// the one monospace style in the app is egui's own, set in [`apply`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
/// The name in the library's banner and at the top of a title's page,
/// where there is no logo to stand in for it. Set in Inter Display, the
/// cut drawn for this size: tighter and heavier than the text face, which
/// at forty points looks like body copy that was zoomed.
Hero,
/// The name of the thing you are looking at, once per page.
Display,
/// An empty state, or the heading of a page that has no display line.
@@ -78,6 +87,7 @@ pub enum Role {
impl Role {
pub const fn size(self) -> f32 {
match self {
Self::Hero => 34.0,
Self::Display => 26.0,
Self::Title => 20.0,
Self::Heading => 18.0,
@@ -90,10 +100,11 @@ impl Role {
}
}
/// Headings are set in the semibold cut; everything below them in the
/// regular one.
/// The hero line is set in the display cut, headings in the semibold
/// one, and everything below them in the regular one.
pub fn family(self) -> egui::FontFamily {
match self {
Self::Hero => display(),
Self::Display | Self::Title | Self::Heading | Self::Section => semibold(),
_ => egui::FontFamily::Proportional,
}
@@ -159,6 +170,9 @@ pub mod motion {
/// Artwork arriving over its placeholder. Slower, because it replaces
/// something rather than adding to it, and a fast swap reads as a flicker.
pub const ARRIVE: f32 = 0.24;
/// One pass of the highlight across a loading skeleton. Slow, because it
/// is something to wait in front of, not something happening.
pub const SHIMMER: f32 = 1.4;
/// Fast out, slow in: the curve every hand-driven transition uses.
pub fn ease_out(t: f32) -> f32 {
@@ -170,7 +184,7 @@ pub mod motion {
/// sets the same on every desktop. Inter, under the OFL — the licence is
/// beside the files.
///
/// Both files have their private use area mappings removed, because the
/// All three files have their private use area mappings removed, because the
/// Phosphor icons live there. Inter maps 745 codepoints in that area to its
/// own alternate glyphs, and as the first font in the family it won: the
/// refresh arrow drew as an accented letter. The glyphs themselves are still
@@ -179,6 +193,9 @@ pub mod motion {
/// U+E000..=U+F8FF).
const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf");
const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.ttf");
/// Inter Display Bold, the optical size for the hero line. Stripped like the
/// other two.
const INTER_DISPLAY: &[u8] = include_bytes!("../assets/fonts/InterDisplay-Bold.ttf");
/// The family the headings are set in. egui has no notion of weight, so the
/// semibold cut is a family of its own.
@@ -186,6 +203,11 @@ pub fn semibold() -> egui::FontFamily {
egui::FontFamily::Name("semibold".into())
}
/// The family [`Role::Hero`] is set in.
pub fn display() -> egui::FontFamily {
egui::FontFamily::Name("display".into())
}
/// The family the subtitle preview is drawn in: the face the chosen subtitle
/// font resolves to, ahead of the app's own fonts, which stand in until it has
/// loaded and for any glyph it lacks.
@@ -238,6 +260,10 @@ fn font_definitions(system: &SystemFonts) -> egui::FontDefinitions {
"inter-semibold".into(),
Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)),
);
fonts.font_data.insert(
"inter-display".into(),
Arc::new(egui::FontData::from_static(INTER_DISPLAY)),
);
fonts
.families
.entry(Proportional)
@@ -258,10 +284,13 @@ fn font_definitions(system: &SystemFonts) -> egui::FontDefinitions {
.filter(|name| *name != "inter")
.cloned()
.collect();
let semibold_chain = std::iter::once("inter-semibold".to_owned())
.chain(fallbacks)
.collect();
fonts.families.insert(semibold(), semibold_chain);
let chain = |first: &str| {
std::iter::once(first.to_owned())
.chain(fallbacks.iter().cloned())
.collect()
};
fonts.families.insert(semibold(), chain("inter-semibold"));
fonts.families.insert(display(), chain("inter-display"));
let mut subtitle_chain = fonts.families[&Proportional].clone();
if let Some((_, face, _)) = &system.subtitle {
let name = "system-subtitle".to_owned();
@@ -794,6 +823,11 @@ pub fn ramp_fill(
/// scrim that starts opaque and lets the picture out, `true` for one that lets
/// the picture *into* the page — which is the one the title backdrop wants,
/// since the page is below the band.
///
/// The ramp itself is white and the colour is the shape's fill, which the GPU
/// multiplies in: one texture per direction, however many colours fade —
/// a banner tinted per title would otherwise build a ramp for every frame of
/// its cross-fade.
pub fn fade_shape(
ctx: &egui::Context,
rect: Rect,
@@ -801,8 +835,29 @@ pub fn fade_shape(
direction: Direction,
arriving: bool,
) -> egui::Shape {
let texture = alpha_ramp(ctx, color, direction, arriving);
textured(rect, CornerRadius::ZERO, &texture)
let texture = alpha_ramp(ctx, Color32::WHITE, direction, arriving);
let mut shape = egui::epaint::RectShape::filled(rect, CornerRadius::ZERO, color);
shape.brush = Some(Arc::new(egui::epaint::Brush {
fill_texture_id: texture.id(),
uv: Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
}));
shape.into()
}
/// The page colour tinted by a picture's colour, for the veils over that
/// picture: see [`pstr_core::appearance::mood_veil`]. The page colour itself
/// while the picture has not arrived.
pub fn mood_veil(mood: Option<Color32>) -> Color32 {
let palette = palette();
let Some(mood) = mood else {
return palette.background;
};
let rgb = |c: Color32| pstr_core::appearance::Rgb::new(c.r(), c.g(), c.b());
color(pstr_core::appearance::mood_veil(
rgb(palette.background),
rgb(mood),
palette.light,
))
}
/// The one gradient that is not the accent: the top bar, which runs from the
@@ -868,6 +923,43 @@ fn shadow(strength: f32, offset: [i8; 2], blur: u8, alpha: u8) -> egui::epaint::
}
}
/// A soft highlight sweeping across a loading skeleton on a diagonal: what
/// says the shape is waiting for something rather than empty.
///
/// The phase runs off the clock rather than per tile, so every skeleton on
/// screen sweeps in step, as one surface. Asks for the next frame itself; a
/// skeleton is only ever on screen for a moment.
pub fn shimmer(painter: &egui::Painter, rect: Rect) {
let time = painter.ctx().input(|input| input.time);
let phase = (time / f64::from(motion::SHIMMER)).fract() as f32;
let span = rect.width() * 0.6;
let centre = rect.left() - span + phase * (rect.width() + 2.0 * span);
let skew = rect.height() * 0.4;
let highlight = if palette().light {
Color32::from_black_alpha(10)
} else {
Color32::from_white_alpha(12)
};
let mut mesh = egui::Mesh::default();
for (x, color) in [
(centre - span, Color32::TRANSPARENT),
(centre, highlight),
(centre + span, Color32::TRANSPARENT),
] {
mesh.colored_vertex(egui::pos2(x + skew, rect.top()), color);
mesh.colored_vertex(egui::pos2(x - skew, rect.bottom()), color);
}
for quad in 0..2 {
let i = quad * 2;
mesh.add_triangle(i, i + 1, i + 2);
mesh.add_triangle(i + 1, i + 3, i + 2);
}
painter
.with_clip_rect(rect.intersect(painter.clip_rect()))
.add(mesh);
painter.ctx().request_repaint();
}
/// The hover and press film on its own, for a control whose fill was painted by
/// somebody else — the selected tab, which sits on the pill.
pub fn veil_over(
@@ -1169,7 +1261,11 @@ mod tests {
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
use skrifa::MetadataProvider as _;
for (name, bytes) in [("regular", INTER), ("semibold", INTER_SEMIBOLD)] {
for (name, bytes) in [
("regular", INTER),
("semibold", INTER_SEMIBOLD),
("display", INTER_DISPLAY),
] {
let font = skrifa::FontRef::new(bytes).expect("bundled Inter parses");
let shadowed: Vec<u32> = font
.charmap()
File diff suppressed because it is too large. Load diff
+6 -4
View File
@@ -55,8 +55,8 @@ pub fn show(
.color(theme::text()),
);
ui.label(ui::muted(format!(
"Everything {} answers with, in its own order — nothing is scored here.",
provider.label()
"Everything {} answer with, in their own order — nothing is scored here.",
pstr_meta::service::chain_label(provider)
)));
ui.add_space(theme::space::L);
@@ -279,8 +279,9 @@ fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option<egui::TextureHandl
.texture(&key, || engine.request_poster(key.clone(), url))
}
/// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out
/// simply absent.
/// `Film · 2017 · 26 episodes · ★ 8.2 · AniList`, with whatever the provider
/// left out simply absent. The provider last, because one search asks more
/// than one, and the same show can come back from each.
fn facts(found: &TitleMetadata) -> String {
let mut parts: Vec<String> = vec![
match found.kind {
@@ -298,5 +299,6 @@ fn facts(found: &TitleMetadata) -> String {
if let Some(rating) = found.rating {
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
}
parts.push(found.provider.label().to_string());
parts.join(" · ")
}
+430 -7
View File
@@ -23,6 +23,7 @@ use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2};
use pstr_core::library::{Episode, Title};
use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord, TitleNames};
use crate::app::Action;
use crate::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key};
use crate::theme;
use widgets::Named;
@@ -80,6 +81,17 @@ impl Art<'_> {
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))
}
/// A provider's picture that belongs to no one title's tile — a cast
/// member's portrait — fetched like a poster the first time it is asked
/// for.
pub fn picture(&mut self, url: &str) -> Option<egui::TextureHandle> {
let key = format!("picture:{url}");
let engine = self.engine;
let url = url.to_owned();
self.posters
.texture(&key, || engine.request_poster(key.clone(), url))
}
/// The name to show for `title`. See [`title_name`].
pub fn name<'t>(&'t self, title: &'t Title) -> &'t str {
title_name(title, self.metadata, self.names)
@@ -107,7 +119,9 @@ impl Art<'_> {
}
/// Art for a strip across the whole window, at full size: fanart, else
/// AniList's banner, else whatever [`Art::of`] has.
/// AniList's banner, else the poster blurred into a wash of its colours —
/// see [`crate::engine::Engine::request_ambient`] — else whatever
/// [`Art::of`] has.
///
/// Its own texture rather than the tile's: a 640 px tile picture
/// stretched to a 1600 px hero is soft, and a 1920 px one in every tile
@@ -128,10 +142,61 @@ impl Art<'_> {
{
return Some((texture, ArtShape::Landscape));
}
} else if let Some(url) = self.poster_url(title) {
let key = ambient_key(&title.key);
let engine = self.engine;
if let Some(texture) = self
.posters
.texture(&key, || engine.request_ambient(key.clone(), url))
{
return Some((texture, ArtShape::Landscape));
}
}
self.of(title)
}
/// The colour of whatever [`Art::wide`] is drawing for `title`, once it
/// has arrived — what that title's veils are tinted with. See
/// [`theme::mood_veil`].
pub fn mood(&self, title: &Title) -> Option<egui::Color32> {
[wide_key, ambient_key, poster_key]
.into_iter()
.map(|key| key(&title.key))
// The tile's own picture last: what `wide` falls back on.
.chain(std::iter::once(title.key.clone()))
.find_map(|key| self.posters.mood(&key))
}
/// Whether `title` has a picture made for a strip across the window, rather
/// than [`Art::wide`] standing one in.
pub fn has_wide_art(&self, title: &Title) -> bool {
self.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.is_some_and(|metadata| metadata.wide_art().is_some())
}
fn poster_url(&self, title: &Title) -> Option<String> {
self.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|metadata| metadata.poster_url.clone())
}
/// The title's name as lettering, trimmed to it, when the provider has
/// one and it has arrived. Drawn over wide art in place of the name.
pub fn logo(&mut self, title: &Title) -> Option<egui::TextureHandle> {
let url = self
.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|metadata| metadata.logo_url.clone())?;
let key = logo_key(&title.key);
let engine = self.engine;
self.posters
.texture(&key, || engine.request_logo(key.clone(), url))
}
/// The picture for a title, and how to fit it.
///
/// Provider artwork first, then Proton's still, then nothing. That order is
@@ -162,6 +227,10 @@ impl Art<'_> {
}
}
/// The central panel's margin around every page. Anything drawn edge to edge —
/// the library's banner, the title page's band — reaches past it.
pub const PAGE_MARGIN: egui::Vec2 = egui::vec2(18.0, 12.0);
/// The texture key of a title's cover, beside [`Art::of`]'s, which is the
/// title key itself.
pub fn poster_key(title_key: &str) -> String {
@@ -173,6 +242,61 @@ pub fn wide_key(title_key: &str) -> String {
format!("wide:{title_key}")
}
/// The texture key of a title's poster blurred for a band.
pub fn ambient_key(title_key: &str) -> String {
format!("ambient:{title_key}")
}
/// A title's logo in place of its name in Display type, when there is one to
/// draw; `None` leaves the caller to set the name as it does.
///
/// At most `max_height` tall and as wide as the space allows, up to 420 px —
/// a logo the width of the column shouts. A light palette keeps the name: a
/// clear logo is lettering made for dark art, white as often as not, and over
/// a near-white veil it is gone.
pub fn logo_heading(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
max_height: f32,
) -> Option<egui::Response> {
if theme::palette().light {
return None;
}
let texture = art.logo(title);
// From nothing rather than from the name it replaces: the name is gone the
// frame the logo is ready, and a logo easing in reads as it arriving.
let shown = ui.ctx().animate_bool_with_time(
ui.id().with(("logo", &title.key)),
texture.is_some(),
theme::motion::ARRIVE,
);
let texture = texture?;
let aspect = texture.aspect_ratio().max(0.1);
let width = ui.available_width().min(420.0).min(max_height * aspect);
let size = egui::vec2(width, width / aspect);
Some(
ui.add(
egui::Image::new((texture.id(), size)).tint(egui::Color32::WHITE.gamma_multiply(shown)),
)
.on_hover_text(art.name(title)),
)
}
/// The texture key of a title's logo.
pub fn logo_key(title_key: &str) -> String {
format!("logo:{title_key}")
}
/// `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes.
pub fn format_runtime(minutes: u32) -> String {
match (minutes / 60, minutes % 60) {
(0, minutes) => format!("{minutes} min"),
(hours, 0) => format!("{hours} h"),
(hours, minutes) => format!("{hours} h {minutes} min"),
}
}
/// "just now", "5 min ago", "3 h ago", "2 days ago": how long before `now`
/// the Unix time `at` was.
pub fn ago(at: i64, now: i64) -> String {
@@ -256,11 +380,53 @@ pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0))
}
/// The button beside the accent one: the same shape, in a film of the text
/// colour, so "Start over" next to "Resume" reads as the alternative rather
/// than as a second, unrelated kind of control.
pub fn tonal_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
filled(ui, text, Fill::Tonal, Vec2::new(0.0, 0.0))
}
/// My List's button: `+ My List` to put the title on it, `✓ My List` once it
/// is, which takes it off again.
pub fn list_button(ui: &mut egui::Ui, key: &str, on_list: bool, actions: &mut Vec<Action>) {
let label = if on_list {
format!("{} My List", egui_phosphor::regular::CHECK)
} else {
format!("{} My List", egui_phosphor::regular::PLUS)
};
let response = tonal_button(ui, &label).on_hover_text(if on_list {
"On My List. Click to take it off"
} else {
"Put it on My List, to find it again on every device"
});
if response.clicked() {
actions.push(Action::SetOnList {
key: key.to_owned(),
on: !on_list,
});
}
}
/// A button laid over the picture: dark glass and white ink, whatever the
/// theme, because what is under it is a video frame and not the page.
pub fn glass_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
filled(ui, text, Fill::Glass, Vec2::new(0.0, 0.0))
}
/// The search box in the top bar: a magnifier, the text, and a way to clear it.
///
/// Escape inside it clears it. `focus` puts the caret in it this frame — which
/// is what `Ctrl+F` and `/` ask for from anywhere in the library.
pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
///
/// Empty and with the caret in it, it offers the `recent` searches under it.
/// Picking one fills the box; the return says the viewer asked to forget them.
pub fn search_field(
ui: &mut egui::Ui,
search: &mut String,
focus: bool,
recent: &[String],
) -> bool {
let id = egui::Id::new("library-search");
let width = 240.0;
let height = ui.spacing().interact_size.y;
@@ -299,7 +465,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
.id(id)
.frame(egui::Frame::NONE)
.hint_text(if has_focus {
"Titles and filenames"
"Titles, episodes, people"
} else {
"Search Ctrl+F"
})
@@ -343,6 +509,56 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
search.clear();
}
}
// Kept up while the pointer is over it as well as while the box has the
// caret: pressing on it takes the caret away before the click lands.
let popup = id.with("recent");
let over = ui
.data(|data| data.get_temp::<bool>(popup))
.unwrap_or(false);
let mut forget = false;
if search.is_empty() && !recent.is_empty() && (response.has_focus() || over) {
let mut picked = None;
let shown = egui::Area::new(popup)
.order(egui::Order::Foreground)
.fixed_pos(outer.left_bottom() + Vec2::new(0.0, 6.0))
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_width(width - 12.0);
ui.label(
theme::Role::Caption
.rich("Recent searches")
.color(theme::muted()),
);
for query in recent {
if ui
.add(
egui::Button::selectable(false, query.as_str())
.frame_when_inactive(false),
)
.clicked()
{
picked = Some(query.clone());
}
}
ui.separator();
forget = ui
.add(
egui::Button::selectable(false, muted("Clear recent searches"))
.frame_when_inactive(false),
)
.clicked();
});
});
let over = shown.response.contains_pointer();
ui.data_mut(|data| data.insert_temp(popup, over && !forget && picked.is_none()));
if let Some(query) = picked {
*search = query;
}
} else {
ui.data_mut(|data| data.remove::<bool>(popup));
}
forget
}
/// A thin bar in the accent, for how far something has got.
@@ -462,6 +678,10 @@ enum Fill {
Ink,
/// Nothing at rest, the hover colour under the pointer.
None,
/// A film of the text colour over whatever is behind it.
Tonal,
/// Dark glass over the picture.
Glass,
}
/// A row of tabs with one accent pill that slides between them.
@@ -582,6 +802,35 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo
}
theme::muted().lerp_to_gamma(theme::text(), hover)
}
Fill::Tonal => {
let film = if theme::palette().light {
Color32::from_black_alpha(16)
} else {
Color32::from_white_alpha(22)
};
ui.painter().rect_filled(rect, radius, film);
theme::veil_over(ui.painter(), rect, radius, lift);
theme::text()
}
Fill::Glass => {
ui.painter()
.rect_filled(rect, radius, Color32::from_black_alpha(140));
ui.painter().rect_stroke(
rect,
radius,
Stroke::new(1.0, Color32::from_white_alpha(18)),
egui::StrokeKind::Inside,
);
theme::veil_over(ui.painter(), rect, radius, lift);
Color32::WHITE
}
};
// A disabled control is drawn at part strength: the ink is what says what
// the button is, so that is what fades.
let ink = if ui.is_enabled() {
ink
} else {
ink.gamma_multiply(0.4)
};
let position = rect.center() - galley.size() / 2.0;
@@ -654,18 +903,27 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
return response;
}
let image_rect = Rect::from_min_size(rect.min, Vec2::new(width, image_height));
let resting = Rect::from_min_size(rect.min, Vec2::new(width, image_height));
// Hover is a value, not a flag: the border, the lift and the shadow all
// ride it, so they arrive together rather than each snapping on its own.
// egui drives this from wall-clock time, so it is the same speed whatever
// the frame rate — and it needs no state kept here.
// Focus lights a tile the way the pointer does, so the keyboard can be
// followed across the grid.
// Focus lifts a tile the way the pointer does, and adds a ring of its own,
// so the keyboard can be followed across the grid.
let hover = ui.ctx().animate_bool_with_time(
response.id.with("hover"),
response.hovered() || response.has_focus(),
theme::motion::HOVER,
);
let focus = ui.ctx().animate_bool_with_time(
response.id.with("focus"),
response.has_focus(),
theme::motion::HOVER,
);
// The picture grows towards the viewer into the gap around it, which is
// what says "this one" across a shelf of near-identical rectangles. The
// name under it stays put, so the row's text never wobbles.
let image_rect = resting.expand2(resting.size() * lift_per_side(width) * hover);
// The picture arrives whenever its download finishes, several frames after
// the tile first drew. Fading it in over the placeholder turns a wall of
// letters popping into stills into one settle.
@@ -781,9 +1039,17 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
egui::StrokeKind::Inside,
);
}
if focus > 0.0 {
painter.rect_stroke(
image_rect.expand(3.0),
CornerRadius::same(theme::radius::MD + 3),
Stroke::new(2.0, theme::text().gamma_multiply(focus)),
egui::StrokeKind::Outside,
);
}
let text_rect = Rect::from_min_size(
image_rect.left_bottom() + Vec2::new(0.0, 6.0),
resting.left_bottom() + Vec2::new(0.0, 6.0),
Vec2::new(width, text_height - 6.0),
);
let painter = painter.with_clip_rect(text_rect);
@@ -814,6 +1080,146 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// Every sideways row in the app: the shelves on the home page and the
/// franchise on a title's page.
///
/// What runs past either edge fades into the page rather than being cut off
/// by it, so a row says there is more without a scroll bar saying it. Under
/// the pointer, a round button at each edge with more beyond it pages the row
/// by most of its width: a mouse has no sideways wheel, and the bar was the
/// only other way across.
///
/// The row is padded by half a gap all round inside its scroll area, and the
/// area reaches the same half gap past the column on either side, so a lifted
/// tile is not clipped by the area and the tiles still line up with the
/// headings above them.
pub fn shelf<R>(
ui: &mut egui::Ui,
id_salt: impl std::hash::Hash + std::fmt::Debug,
add: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
let half = (theme::CARD_GAP / 2.0) as i8;
let id = ui.id().with(("shelf", &id_salt));
let page = id.with("page");
let pending: Option<f32> = ui.ctx().data_mut(|data| data.remove_temp(page));
let output = egui::Frame::new()
.outer_margin(egui::Margin {
left: -half,
right: -half,
top: -half,
bottom: -half,
})
.show(ui, |ui| {
egui::ScrollArea::horizontal()
.id_salt(id)
.auto_shrink([false, true])
.show(ui, |ui| {
if let Some(delta) = pending {
ui.scroll_with_delta_animation(
Vec2::new(delta, 0.0),
egui::style::ScrollAnimation::duration(SHELF_PAGE_SECONDS),
);
}
egui::Frame::new()
.inner_margin(egui::Margin::same(half))
.show(ui, add)
.inner
})
})
.inner;
let rect = output.inner_rect;
let offset = output.state.offset.x;
let overflow = (output.content_size.x - rect.width()).max(0.0);
let ends = [
(offset > 1.0, false, egui_phosphor::regular::CARET_LEFT, 1.0),
(
offset < overflow - 1.0,
true,
egui_phosphor::regular::CARET_RIGHT,
-1.0,
),
];
let pointer_in = ui.rect_contains_pointer(rect);
let painter = ui.painter_at(rect);
for (more, right, icon, direction) in ends {
if !more {
continue;
}
let strip = if right {
Rect::from_min_max(egui::pos2(rect.right() - SHELF_FADE, rect.top()), rect.max)
} else {
Rect::from_min_max(
rect.min,
egui::pos2(rect.left() + SHELF_FADE, rect.bottom()),
)
};
painter.add(theme::fade_shape(
ui.ctx(),
strip,
theme::background(),
theme::Direction::Horizontal,
right,
));
let button_id = id.with(right);
let shown = ui
.ctx()
.animate_bool_with_time(button_id, pointer_in, theme::motion::HOVER);
if shown == 0.0 {
continue;
}
let centre = egui::pos2(
if right {
rect.right() - SHELF_BUTTON / 2.0 - theme::space::M
} else {
rect.left() + SHELF_BUTTON / 2.0 + theme::space::M
},
rect.center().y,
);
let button = Rect::from_center_size(centre, Vec2::splat(SHELF_BUTTON));
let response = ui
.interact(button, button_id.with("button"), Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand);
let response = response.named(if right { "Scroll right" } else { "Scroll left" });
let lit = if response.hovered() { 1.0 } else { 0.0 };
painter.circle_filled(
centre,
SHELF_BUTTON / 2.0,
Color32::from_black_alpha((150.0 + 60.0 * lit) as u8).gamma_multiply(shown),
);
painter.text(
centre,
Align2::CENTER_CENTER,
icon,
egui::FontId::proportional(18.0),
Color32::WHITE.gamma_multiply(shown),
);
if response.clicked() {
let delta = direction * rect.width() * SHELF_PAGE;
ui.ctx().data_mut(|data| data.insert_temp(page, delta));
ui.ctx().request_repaint();
}
}
output.inner
}
/// How wide the fade at a shelf's edge is.
const SHELF_FADE: f32 = 56.0;
/// The diameter of a shelf's paging buttons.
const SHELF_BUTTON: f32 = 40.0;
/// How much of a shelf's width one page moves: most of it, so the tile that
/// was cut by the edge is whole on the next page.
const SHELF_PAGE: f32 = 0.85;
const SHELF_PAGE_SECONDS: f32 = 0.35;
/// How far a lifted tile grows past each edge, as a fraction of its size:
/// [`theme::TILE_LIFT`], but never so far that it reaches into the gap's other
/// half, which belongs to the neighbour. A wide window makes wide tiles.
fn lift_per_side(width: f32) -> f32 {
(theme::TILE_LIFT / 2.0).min((theme::CARD_GAP / 2.0 - 1.0) / width.max(1.0))
}
/// The largest centred rectangle inside `target` with `image`'s aspect ratio.
///
/// The other half of [`cover_uv`]: where that one keeps the whole rectangle and
@@ -938,6 +1344,23 @@ mod tests {
use super::*;
#[test]
fn a_lifted_tile_never_reaches_its_neighbour_however_wide_the_window() {
for available in [400.0, 1280.0, 1919.0, 2560.0, 3840.0] {
for minimum in [theme::CARD_WIDTH, theme::POSTER_WIDTH] {
let width = columns_of(available, minimum).width;
assert!(width * lift_per_side(width) < theme::CARD_GAP / 2.0);
}
}
}
#[test]
fn a_running_time_reads_in_hours_and_minutes() {
assert_eq!(format_runtime(24), "24 min");
assert_eq!(format_runtime(120), "2 h");
assert_eq!(format_runtime(124), "2 h 4 min");
}
#[test]
fn times_are_formatted_by_length() {
assert_eq!(format_time(0.0), "0:00");
File diff suppressed because it is too large. Load diff
+28 -21
View File
@@ -874,8 +874,8 @@ fn metadata_form(
without this every tile is a pair of initials. Turning it on sends the titles \
in your library — not your files, and nothing about what you have watched — to \
the provider you choose, over HTTPS, each time a new one appears. With AniList, \
the ids it matched — not the titles — then go to ani.zip for episode names and \
wide artwork.",
a title it has nothing for is then sent to TVmaze, and the ids AniList matched — \
not the titles — go to ani.zip for episode names and wide artwork.",
));
ui.add_space(theme::space::L);
@@ -889,7 +889,7 @@ fn metadata_form(
if settings.enabled {
ui.add_space(theme::space::L);
ui.label(ui::muted("Provider"));
for provider in ProviderId::ALL {
for provider in ProviderId::CHOOSABLE {
if ui
.radio(settings.provider == provider, provider.label())
.on_hover_text(provider.description())
@@ -901,28 +901,35 @@ fn metadata_form(
}
}
// TMDB's is the only key there is: the provider's with TMDB chosen,
// and with AniList, the art of films TVDB has none for.
ui.add_space(theme::space::L);
if settings.provider.needs_api_key() {
ui.add_space(theme::space::L);
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(api_key)
.password(true)
.hint_text("v3 API key")
.desired_width(300.0),
);
if ui.button("Save key").clicked() {
actions.push(Action::SetApiKey {
provider: settings.provider,
key: std::mem::take(api_key),
});
}
});
ui.label(ui::muted("TMDB API key"));
} else {
ui.label(ui::muted(
"Stored in your system keyring, never in a config file. Leave the box \
empty and save to forget it.",
"TMDB API key, optional. With one, films get TMDB's backdrops and logos, \
asked for by the film's TMDB id alone.",
));
}
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(api_key)
.password(true)
.hint_text("v3 API key")
.desired_width(300.0),
);
if ui.button("Save key").clicked() {
actions.push(Action::SetApiKey {
provider: ProviderId::Tmdb,
key: std::mem::take(api_key),
});
}
});
ui.label(ui::muted(
"Stored in your system keyring, never in a config file. Leave the box \
empty and save to forget it.",
));
ui.add_space(theme::space::L);
ui.horizontal(|ui| {
+4 -1
View File
@@ -39,7 +39,10 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[
("Z / X", "Subtitles 0.1 s earlier and later"),
("E", "The list of episodes"),
("F or double-click", "Fullscreen"),
("Esc", "Leave fullscreen, then the player"),
(
"Esc",
"Close a panel, leave fullscreen, then leave the player",
),
],
),
];
+452 -113
View File
@@ -16,15 +16,29 @@ pub struct OfflineView<'a> {
pub files: &'a std::collections::HashSet<DownloadKey>,
}
/// Where the title stands beyond its own files.
pub struct Context<'a> {
/// The keys of its whole franchise in release order, if it has one.
pub franchise: Option<&'a [String]>,
/// The keys of the titles most like it, best first.
pub similar: &'a [String],
pub on_list: bool,
}
pub fn show(
ui: &mut egui::Ui,
art: &mut Art<'_>,
library: &Library,
key: &str,
franchise: Option<&[String]>,
context: Context<'_>,
offline: OfflineView<'_>,
actions: &mut Vec<Action>,
) {
let Context {
franchise,
similar,
on_list,
} = context;
let Some(title) = library.get(key) else {
// The catalog was replaced under the page — a recrawl that dropped this
// title. Nothing to show, so go back rather than draw an empty shell.
@@ -32,7 +46,7 @@ pub fn show(
return;
};
if back_link(ui).clicked() {
if back_link(ui, "Library").clicked() {
actions.push(Action::Goto(Page::Library));
}
ui.add_space(theme::space::S);
@@ -40,14 +54,21 @@ pub fn show(
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
header(ui, art, title, &offline, actions);
header(ui, art, title, on_list, &offline, actions);
ui.add_space(theme::space::XL);
if let Some(keys) = franchise {
let members: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect();
if members.len() > 1 {
ui::section(ui, "In this franchise");
franchise_row(ui, art, title, &members, actions);
cover_row(
ui,
art,
("franchise", &title.key),
Some(title),
&members,
actions,
);
ui.add_space(theme::space::XL);
}
}
@@ -85,7 +106,27 @@ pub fn show(
.enumerate()
.map(|(index, label)| (index, label.as_str()))
.collect();
if let Some(index) = ui::widgets::segmented(ui, picked, &choices) {
// A long-running show's row of pills ran off the page;
// past a handful it is a list to pick from instead.
let chosen = if choices.len() > SEASON_PILLS {
egui::ComboBox::from_id_salt(("season-list", &title.key))
.selected_text(labels[picked].as_str())
.height(360.0)
.show_ui(ui, |ui| {
let mut chosen = None;
for &(index, label) in &choices {
if ui.selectable_label(index == picked, label).clicked() {
chosen = Some(index);
}
}
chosen
})
.inner
.flatten()
} else {
ui::widgets::segmented(ui, picked, &choices)
};
if let Some(index) = chosen {
picked = index;
ui.data_mut(|data| data.insert_temp(picked_id, index));
}
@@ -109,61 +150,173 @@ pub fn show(
episode_row(ui, art, title, season, episode, &offline, actions);
}
ui.add_space(theme::space::L);
let cast = art
.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|found| found.details.as_ref())
.map(|details| details.cast.clone())
.unwrap_or_default();
if !cast.is_empty() {
ui.add_space(theme::space::L);
ui::section(ui, "Cast");
cast_row(ui, art, &title.key, &cast, actions);
ui.add_space(theme::space::L);
}
// Under the episodes rather than over them: it is where to go
// once this one is done, not what the page is for.
let similar: Vec<&Title> = similar.iter().filter_map(|key| library.get(key)).collect();
if !similar.is_empty() {
ui.add_space(theme::space::L);
ui::section(ui, "More like this");
cover_row(ui, art, ("similar", &title.key), None, &similar, actions);
ui.add_space(theme::space::L);
}
});
}
/// Every title of the franchise, in release order, as covers — this one
/// marked rather than left out, so the row says where it sits in the story.
fn franchise_row(
/// The characters, each a round portrait over their name and who voices
/// them. A voice actor opens every title in the library they are in.
fn cast_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
current: &Title,
key: &str,
cast: &[pstr_core::metadata::CastMember],
actions: &mut Vec<Action>,
) {
const SIZE: f32 = 88.0;
ui::shelf(ui, ("cast", key), |ui| {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = theme::space::L;
for (i, member) in cast.iter().enumerate() {
let tile = ui.allocate_ui(egui::vec2(SIZE + 16.0, SIZE + 52.0), |ui| {
ui.vertical_centered(|ui| {
let (rect, _) =
ui.allocate_exact_size(egui::vec2(SIZE, SIZE), egui::Sense::hover());
let picture = member
.character_image
.as_deref()
.or(member.actor_image.as_deref())
.and_then(|url| art.picture(url));
let painter = ui.painter();
match picture {
Some(texture) => {
let shape = egui::epaint::RectShape::filled(
rect,
egui::CornerRadius::same((SIZE / 2.0) as u8),
egui::Color32::WHITE,
)
.with_texture(
texture.id(),
super::cover_uv(texture.size_vec2(), rect.size()),
);
painter.add(shape);
}
None => {
painter.circle_filled(rect.center(), SIZE / 2.0, theme::card());
}
}
ui.add(
egui::Label::new(
theme::Role::Caption
.rich(&member.character)
.color(theme::text()),
)
.truncate()
.selectable(false),
);
if let Some(actor) = &member.actor {
ui.add(
egui::Label::new(ui::muted(actor))
.truncate()
.selectable(false),
);
}
rect
})
.inner
});
// The whole tile opens the actor's page, names included, not only the portrait.
let Some(actor) = &member.actor else { continue };
let response = ui
.interact(
tile.response.rect,
ui.id().with(("cast", i)),
egui::Sense::click(),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(format!("Everything in the library with {actor}"));
if response.hovered() {
ui.painter().circle_stroke(
tile.inner.center(),
SIZE / 2.0,
egui::Stroke::new(2.0, theme::accent()),
);
}
if response.clicked() {
actions.push(Action::Goto(Page::Browse {
facet: pstr_core::discover::Facet::Person(actor.clone()),
from: Some(key.to_owned()),
}));
}
}
});
});
}
/// A row of covers: every title of the franchise in release order, with
/// `current` marked rather than left out, so the row says where it sits in
/// the story — or, with no `current`, the titles like this one.
fn cover_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
id: impl std::hash::Hash + std::fmt::Debug,
current: Option<&Title>,
members: &[&Title],
actions: &mut Vec<Action>,
) {
egui::ScrollArea::horizontal()
.id_salt(("franchise", &current.key))
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for member in members {
let here = member.key == current.key;
let picture = art.poster(member);
let found = art
.metadata
.get(&member.key)
.and_then(|record| record.metadata.as_ref());
let what = found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.format_label())
.unwrap_or(match member.kind {
TitleKind::Series => "Series",
TitleKind::Film => "Film",
});
let year = found
.and_then(|found| found.year)
.or(member.year)
.map(|year| format!(" · {year}"))
.unwrap_or_default();
let response = ui::card(
ui,
Card {
art: picture,
name: art.name(member),
subtitle: format!("{what}{year}"),
progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: here.then(|| "Here".to_owned()),
width: 128.0,
aspect: theme::POSTER_ASPECT,
},
);
if !here && response.clicked() {
actions.push(Action::Goto(Page::Title(member.key.clone())));
}
ui::shelf(ui, id, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for member in members {
let here = current.is_some_and(|current| member.key == current.key);
let picture = art.poster(member);
let found = art
.metadata
.get(&member.key)
.and_then(|record| record.metadata.as_ref());
let what = found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.format_label())
.unwrap_or(match member.kind {
TitleKind::Series => "Series",
TitleKind::Film => "Film",
});
let year = found
.and_then(|found| found.year)
.or(member.year)
.map(|year| format!(" · {year}"))
.unwrap_or_default();
let response = ui::card(
ui,
Card {
art: picture,
name: art.name(member),
subtitle: format!("{what}{year}"),
progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: here.then(|| "Here".to_owned()),
width: 128.0,
aspect: theme::POSTER_ASPECT,
},
);
if !here && response.clicked() {
actions.push(Action::Goto(Page::Title(member.key.clone())));
}
});
}
});
});
}
/// A title's poster, at the poster's own shape, with how far into it the
@@ -192,9 +345,10 @@ fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64
}
/// "← Library", as a link rather than a button: it is navigation, and a
/// filled button beside the page's real actions competed with them.
fn back_link(ui: &mut egui::Ui) -> egui::Response {
let text = theme::Role::Label.rich(format!("{} Library", egui_phosphor::regular::ARROW_LEFT));
/// filled button beside the page's real actions competed with them. `to`
/// names where it goes.
pub(crate) fn back_link(ui: &mut egui::Ui, to: &str) -> egui::Response {
let text = theme::Role::Label.rich(format!("{} {to}", egui_phosphor::regular::ARROW_LEFT));
let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
if response.hovered() {
ui.painter().hline(
@@ -218,7 +372,7 @@ fn season_watched(ui: &mut egui::Ui, title: &Title, season: &Season, actions: &m
} else {
"Mark season watched"
};
if ui.button(label).clicked() {
if ui::tonal_button(ui, label).clicked() {
actions.push(Action::MarkEpisodes {
key: title.key.clone(),
links: season
@@ -272,6 +426,7 @@ fn header(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
on_list: bool,
offline: &OfflineView<'_>,
actions: &mut Vec<Action>,
) {
@@ -323,7 +478,9 @@ fn header(
// Text in a column a person can read, with the picture to the
// right of it rather than under it.
ui.set_max_width(ui.available_width().min(820.0));
ui.label(theme::Role::Display.rich(&shown).strong());
if ui::logo_heading(ui, art, title, 120.0).is_none() {
ui.label(theme::Role::Hero.rich(&shown).color(theme::text()));
}
// Whichever of the share's name and the provider's is not the one
// shown. Worth showing rather than only replacing: a viewer should
// be able to tell what the match actually matched, and find the
@@ -346,6 +503,30 @@ fn header(
}
ui.add_space(theme::space::XS);
ui.label(ui::muted(meta_line(title, found.as_ref())));
// What the files are, rather than what the title is: set apart
// from the line above as small outlined marks, the way a box
// prints them.
//
// After where the title stands, which is the one fact here that
// changes week to week.
let quality = title.quality().badges();
let status = found
.as_ref()
.and_then(|found| found.details.as_ref())
.and_then(|details| status_text(details, now()));
if !quality.is_empty() || status.is_some() {
ui.add_space(theme::space::XS);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::space::XS;
if let Some((text, live)) = &status {
ui::widgets::status_chip(ui, text, *live);
ui.add_space(theme::space::XS);
}
for badge in quality {
ui::widgets::spec_badge(ui, badge);
}
});
}
ui.add_space(theme::space::L);
ui.horizontal(|ui| {
@@ -372,8 +553,7 @@ fn header(
}
// Beside the button it is the alternative to.
if resuming
&& ui
.button("Start over")
&& ui::tonal_button(ui, "Start over")
.on_hover_text("Play from the beginning")
.clicked()
{
@@ -383,18 +563,17 @@ fn header(
None,
)));
}
ui::list_button(ui, &title.key, on_list, actions);
let title_keys: Vec<_> = title.episodes().map(key_of).collect();
let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
if all_offline {
if ui
.button("Delete show")
if ui::tonal_button(ui, "Delete show")
.on_hover_text("Delete the offline copies; keep the online source")
.clicked()
{
actions.push(Action::DeleteOffline(title_keys, title.name.clone()));
}
} else if ui
.button("Download show")
} else if ui::tonal_button(ui, "Download show")
.on_hover_text("Download every episode for disconnected playback")
.clicked()
{
@@ -429,13 +608,15 @@ fn header(
// wrongly. Offered whether or not anything matched: the two
// cases it exists for are a title with no poster at all and a
// title wearing someone else's.
if ui
.button(match &found {
if ui::tonal_button(
ui,
match &found {
Some(_) => "Change match",
None => "Match…",
})
.on_hover_text("Search the metadata provider and pick the entry yourself")
.clicked()
},
)
.on_hover_text("Search the metadata provider and pick the entry yourself")
.clicked()
{
actions.push(Action::OpenMatcher(title.key.clone()));
}
@@ -443,14 +624,14 @@ fn header(
if let Some(found) = &found {
ui.add_space(theme::space::L);
description(ui, found);
description(ui, found, &title.key, actions);
}
});
});
if let Some((texture, index)) = backdrop {
ui.add_space(theme::space::L);
band(ui, &texture, index);
band(ui, &texture, index, art.mood(title));
}
}
@@ -464,10 +645,15 @@ fn header(
/// page colour so text laid over it stays readable whatever the still happens to
/// be, and a fade into the page along the bottom so the band ends rather than
/// stops.
fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::ShapeIdx) {
fn band(
ui: &mut egui::Ui,
texture: &egui::TextureHandle,
index: egui::layers::ShapeIdx,
mood: Option<egui::Color32>,
) {
// Full width of the window, not of the content: the panel's own margin is
// what the band has to reach past, or it reads as a picture in a box.
const BLEED: f32 = 18.0;
const BLEED: f32 = super::PAGE_MARGIN.x;
let content = ui.min_rect();
let rect = egui::Rect::from_min_max(
egui::pos2(ui.max_rect().left() - BLEED, content.top()),
@@ -501,7 +687,12 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
//
// Heavier still when there is no fade coming, since then these are the only
// things standing between the text and the picture.
//
// Both in the page colour tinted by the picture's own, so each title's
// page carries its light; the fade at the bottom stays the page's, since
// that is what it ends in.
let ramps = theme::ramps_on();
let veil = theme::mood_veil(mood);
layers.push(
egui::epaint::RectShape::filled(
rect,
@@ -509,7 +700,7 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
// Lighter with the ramps on: the horizontal one below is solid
// where the text is, so this coat only has to calm the picture's
// right side, not make it disappear.
theme::background().gamma_multiply(if ramps { 0.72 } else { 0.94 }),
veil.gamma_multiply(if ramps { 0.72 } else { 0.94 }),
)
.into(),
);
@@ -521,7 +712,7 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
layers.push(theme::fade_shape(
ui.ctx(),
text_side,
theme::background(),
veil,
theme::Direction::Horizontal,
// Solid at the left, where the poster and the text are.
false,
@@ -548,17 +739,31 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
}
/// What the provider had to say. Only ever drawn under a match.
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
let tags = found
.details
.as_ref()
///
/// Its genres, studios and director are chips that open every title in the
/// library sharing them.
fn description(ui: &mut egui::Ui, found: &TitleMetadata, key: &str, actions: &mut Vec<Action>) {
use pstr_core::discover::Facet;
let browse = |facet: Facet| {
Action::Goto(Page::Browse {
facet,
from: Some(key.to_owned()),
})
};
let details = found.details.as_ref();
let tags = details
.map(|details| details.tags.as_slice())
.unwrap_or_default();
if !found.genres.is_empty() || !tags.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for genre in &found.genres {
ui::widgets::chip(ui, genre);
if ui::widgets::link_chip(ui, genre)
.on_hover_text(format!("Every {genre} title"))
.clicked()
{
actions.push(browse(Facet::Genre(genre.clone())));
}
}
// After the genres and quieter than them: a tag is the
// provider's community describing it, not what it is filed as.
@@ -568,6 +773,38 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
});
ui.add_space(theme::space::M);
}
// Who made it, out of the grey line and onto chips, so the names lead
// somewhere.
let studios = details
.map(|details| &details.studios[..details.studios.len().min(2)])
.unwrap_or_default();
let directors = details
.map(|details| &details.directors[..details.directors.len().min(1)])
.unwrap_or_default();
if !studios.is_empty() || !directors.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for (label, names, facet) in [
("Studio", studios, Facet::Studio as fn(String) -> Facet),
("Director", directors, Facet::Person),
] {
if names.is_empty() {
continue;
}
ui.label(ui::muted(label));
for name in names {
if ui::widgets::link_chip(ui, name)
.on_hover_text(format!("Everything in the library by {name}"))
.clicked()
{
actions.push(browse(facet(name.clone())));
}
}
ui.add_space(theme::space::S);
}
});
ui.add_space(theme::space::M);
}
if let Some(overview) = &found.overview {
// Four lines, then "More". A provider's synopsis runs to three
// paragraphs on a long show, and all of them pushed the episodes —
@@ -605,12 +842,28 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
}
}
}
if let Some(url) = &found.url {
let trailer = details
.and_then(|details| details.trailer.as_ref())
.and_then(|trailer| trailer.url());
if found.url.is_some() || trailer.is_some() {
ui.add_space(theme::space::M);
ui.hyperlink_to(
ui::muted(format!("More on {}", found.provider.label())),
url,
);
ui.horizontal(|ui| {
// In the browser: the app itself never requests the trailer.
if let Some(trailer) = &trailer {
ui.hyperlink_to(
ui::muted(format!("{} Trailer", egui_phosphor::regular::FILM_STRIP)),
trailer,
)
.on_hover_text("Opens in your browser");
ui.add_space(theme::space::L);
}
if let Some(url) = &found.url {
ui.hyperlink_to(
ui::muted(format!("More on {}", found.provider.label())),
url,
);
}
});
}
}
@@ -629,6 +882,15 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
if let Some(year) = title.year {
parts.push(year.to_string());
}
// A film's running time is what it costs to start it. A series' is its
// episodes', which each row says.
if title.kind == TitleKind::Film
&& let Some(minutes) = found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.runtime)
{
parts.push(ui::format_runtime(minutes));
}
if title.kind == TitleKind::Series {
if title.seasons.len() > 1 {
parts.push(ui::library::plural(title.seasons.len(), "season"));
@@ -640,29 +902,11 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
parts.push(format!("{watched} watched"));
}
if let Some(details) = found.and_then(|found| found.details.as_ref()) {
if let Some(season) = details.season_label() {
parts.push(season);
}
parts.extend(details.studios.iter().take(2).cloned());
if let Some(director) = details.directors.first() {
parts.push(format!("dir. {director}"));
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64);
if let Some((episode, at)) = details.next_airing(now) {
let when = chrono::DateTime::from_timestamp(at, 0)
.map(|at| {
at.with_timezone(&chrono::Local)
.format("%a %-d %b, %H:%M")
.to_string()
})
.unwrap_or_default();
parts.push(format!("episode {episode} airs {when}"));
} else if details.is_airing() {
parts.push("airing".to_owned());
}
if let Some(season) = found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.season_label())
{
parts.push(season);
}
if let Some(found) = found {
if let Some(rating) = found.rating {
@@ -681,6 +925,37 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
parts.join(" · ")
}
/// The most seasons picked from a row of pills; past it, a list.
const SEASON_PILLS: usize = 5;
/// Unix seconds, now.
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64)
}
/// What the status chip says — `Airing · episode 8 Sat 1 Nov, 14:00`,
/// `Finished` — and whether more is coming.
fn status_text(details: &pstr_core::metadata::TitleDetails, now: i64) -> Option<(String, bool)> {
let next = details.next_airing(now);
let label = details.status_label().or(next.map(|_| "Airing"))?;
let live = next.is_some() || details.is_airing();
Some(match next {
Some((episode, at)) => {
let when = chrono::DateTime::from_timestamp(at, 0)
.map(|at| {
at.with_timezone(&chrono::Local)
.format("%a %-d %b, %H:%M")
.to_string()
})
.unwrap_or_default();
(format!("{label} · episode {episode} {when}"), live)
}
None => (label.to_owned(), live),
})
}
/// How big an episode's still is drawn.
const STILL: egui::Vec2 = egui::vec2(160.0, 90.0);
@@ -701,6 +976,7 @@ fn episode_row(
found.name.clone(),
found.overview.clone(),
found.air_date.clone(),
found.runtime,
)
});
let watched = episode.is_watched();
@@ -732,14 +1008,17 @@ fn episode_row(
.unwrap_or_default();
let name = found
.as_ref()
.and_then(|(name, _, _)| name.clone())
.and_then(|(name, ..)| name.clone())
.unwrap_or_else(|| episode.detail().to_string());
let mut job = egui::text::LayoutJob::default();
if !numbering.is_empty() {
job.append(
&format!("{numbering} "),
0.0,
egui::TextFormat::simple(egui::FontId::monospace(13.0), theme::muted()),
egui::TextFormat::simple(
egui::FontId::new(theme::Role::Label.size(), theme::semibold()),
theme::muted(),
),
);
}
job.append(
@@ -762,9 +1041,10 @@ fn episode_row(
ui.label(ui::muted(details(
episode,
found.as_ref().and_then(|f| f.2.as_deref()),
found.as_ref().and_then(|f| f.3),
)));
if let Some((_, Some(overview), _)) = &found {
if let Some((_, Some(overview), ..)) = &found {
let mut job = egui::text::LayoutJob::simple(
overview.clone(),
theme::Role::Caption.font(),
@@ -836,9 +1116,9 @@ fn episode_row(
ui.add_space(theme::space::XXS);
}
/// The grey line under an episode's name: when it aired, how big it is, and
/// where it would resume.
fn details(episode: &Episode, air_date: Option<&str>) -> String {
/// The grey line under an episode's name: when it aired, how long it runs,
/// how big it is, and where it would resume.
fn details(episode: &Episode, air_date: Option<&str>, runtime: Option<u32>) -> String {
let mut parts = Vec::new();
if let Some(at) = episode.resume_at() {
let left = episode
@@ -853,6 +1133,9 @@ fn details(episode: &Episode, air_date: Option<&str>) -> String {
if let Some(date) = air_date {
parts.push(date.to_owned());
}
if let Some(minutes) = runtime {
parts.push(ui::format_runtime(minutes));
}
match episode.node.size {
// A file the share says is empty is one that will not play — an upload
// that never finished, usually. Worth saying on the row rather than
@@ -888,12 +1171,20 @@ pub(crate) fn still(
painter.rect_filled(rect, radius, theme::card_hover());
// Asked for only once the row is on screen: a season of twenty-five is
// twenty-five requests, and most of them are never scrolled to.
if let Some(texture) = art.still(&title.key, episode) {
let texture = art.still(&title.key, episode);
// Faded in like a tile's art, so a season's stills settle rather than pop.
let art_in = ui.ctx().animate_bool_with_time(
response.id.with("art"),
texture.is_some(),
theme::motion::ARRIVE,
);
if let Some(texture) = texture {
let tint = if watched {
egui::Color32::from_gray(150)
} else {
egui::Color32::WHITE
};
}
.gamma_multiply(art_in);
painter.add(
egui::epaint::RectShape::filled(rect, radius, tint)
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
@@ -954,6 +1245,21 @@ pub(crate) fn still(
theme::accent_fill(&painter, played, radius, 0.0);
}
// Seen, said on the picture as well as by the mark at the row's end: a
// season is scanned by its stills, and a dimmed one alone read as one that
// had not loaded.
if watched {
let corner = rect.right_top() + egui::vec2(-14.0, 14.0);
painter.circle_filled(corner, 9.0, theme::accent());
painter.text(
corner,
egui::Align2::CENTER_CENTER,
egui_phosphor::regular::CHECK,
theme::Role::Micro.font(),
theme::on_accent(),
);
}
if response
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(if episode.resume_at().is_some() {
@@ -1037,3 +1343,36 @@ fn key_of(episode: &Episode) -> DownloadKey {
link_id: episode.node.link_id.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn details(status: &str, next: Option<(u32, i64)>) -> pstr_core::metadata::TitleDetails {
pstr_core::metadata::TitleDetails {
status: Some(status.to_owned()),
next_episode: next.map(|(episode, _)| episode),
next_airing_at: next.map(|(_, at)| at),
..Default::default()
}
}
#[test]
fn the_status_chip_says_when_the_next_episode_airs_and_only_while_it_is_ahead() {
let (text, live) = status_text(&details("RELEASING", Some((8, 2_000))), 1_000).unwrap();
assert!(text.starts_with("Airing · episode 8 "), "{text}");
assert!(live);
// Fetched a while ago: the date has passed, so it says nothing of it.
let (text, live) = status_text(&details("RELEASING", Some((8, 500))), 1_000).unwrap();
assert_eq!(text, "Airing");
assert!(live);
assert_eq!(
status_text(&details("FINISHED", None), 1_000),
Some(("Finished".to_owned(), false))
);
assert_eq!(
status_text(&pstr_core::metadata::TitleDetails::default(), 0),
None
);
}
}
+75 -52
View File
@@ -18,6 +18,7 @@ use crate::app::{Action, Adjacent, Page};
use crate::playback::{Command, Playback};
use crate::theme;
use crate::ui;
use crate::ui::player::{PlayerPanel, SleepChoice};
use crate::ui::widgets::Named;
/// Height of the clickable seek bar. Comfortably larger than the few pixels it
@@ -42,6 +43,9 @@ const ROW_HEIGHT_FULL: f32 = 44.0;
/// The same, for the library strip. See [`ROW_HEIGHT_FULL`].
const ROW_HEIGHT_MINI: f32 = 34.0;
/// The speeds on offer, and the steps `[` and `]` walk.
pub const SPEEDS: [f64; 8] = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0];
/// Whether there is a file before and after this one in the same title.
#[derive(Debug, Clone, Copy, Default)]
pub struct Neighbours {
@@ -54,6 +58,8 @@ pub fn full(
ui: &mut egui::Ui,
playback: &Playback,
neighbours: Neighbours,
panel: Option<PlayerPanel>,
sleep: Option<SleepChoice>,
actions: &mut Vec<Action>,
) {
seek_bar(ui, playback, actions, 6.0);
@@ -99,13 +105,11 @@ pub fn full(
}));
}
// Right cluster: everything that is a choice rather than a move.
// Right cluster: everything that is a choice rather than a move. Each
// opens its panel down the right of the picture rather than a menu
// under the pointer: a list of twelve chapters is read, not aimed at.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
track_menus(ui, playback, actions);
ui.add_space(theme::space::M);
speed_menu(ui, playback, actions);
ui.add_space(theme::space::M);
chapter_menu(ui, playback, actions);
panel_buttons(ui, playback, panel, sleep, actions);
ui.add_space(theme::space::M);
volume(ui, playback, actions);
});
@@ -494,7 +498,7 @@ fn track_menus(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>
/// What is worth changing mid-episode without a trip to Settings: the size and
/// place of the line, and its timing for a release that is out of step.
fn subtitle_adjustments(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
pub fn subtitle_adjustments(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
let mut style = playback.subtitle_style.clone();
egui::Grid::new("subtitle-adjustments")
.num_columns(2)
@@ -547,54 +551,73 @@ fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions:
}
}
/// How fast it plays. Says so on the button only when it is not normal speed,
/// because that is the one case worth noticing at a glance.
fn speed_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
let label = if (playback.speed - 1.0).abs() < 1e-6 {
"Speed".to_owned()
} else {
format!("Speed · {}", ui::format_speed(playback.speed))
/// The buttons that open the player's panels, right to left: tracks, speed,
/// chapters, sleep. Each shows only when there is a choice to make, and says
/// what is chosen when that is worth noticing at a glance.
fn panel_buttons(
ui: &mut egui::Ui,
playback: &Playback,
open: Option<PlayerPanel>,
sleep: Option<SleepChoice>,
actions: &mut Vec<Action>,
) {
let mut button = |ui: &mut egui::Ui, panel: PlayerPanel, text: String, hint: &str| {
let response = if open == Some(panel) {
ui::accent_button(ui, &text)
} else {
ui::glass_button(ui, &text)
};
if response.on_hover_text(hint).clicked() {
actions.push(Action::TogglePanel(panel));
}
ui.add_space(theme::space::S);
};
ui.menu_button(label, |ui| {
// The same steps `[` and `]` walk.
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0] {
if ui
.selectable_label(
(playback.speed - speed).abs() < 1e-6,
ui::format_speed(speed),
)
.clicked()
{
actions.push(Action::SetSpeed(speed));
ui.close();
}
}
})
.response
.on_hover_text("Playback speed ([ and ])");
}
/// Jump to a chapter. Only drawn for a file that has more than one, which for
/// most releases means anime and not much else.
fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
if playback.chapters.len() < 2 {
return;
let subtitles = playback.tracks_of(TrackKind::Subtitle).count();
let audio = playback.tracks_of(TrackKind::Audio).count();
if subtitles > 0 || audio > 1 {
let shown = match playback.selected_track(TrackKind::Subtitle) {
Some(track) => short(track),
None if subtitles > 0 => "Off".to_owned(),
None => playback
.selected_track(TrackKind::Audio)
.map(short)
.unwrap_or_default(),
};
button(
ui,
PlayerPanel::Tracks,
format!("{} {shown}", egui_phosphor::regular::SUBTITLES),
"Audio and subtitles",
);
}
let current = playback.chapter().map(|chapter| chapter.index);
ui.menu_button("Chapters", |ui| {
for chapter in &playback.chapters {
let label = format!("{} {}", ui::format_time(chapter.start), chapter.label());
if ui
.selectable_label(current == Some(chapter.index), label)
.clicked()
{
actions.push(Action::Player(Command::SeekTo(chapter.start)));
ui.close();
}
}
})
.response
.on_hover_text("Chapters");
let speed = if (playback.speed - 1.0).abs() < 1e-6 {
egui_phosphor::regular::GAUGE.to_owned()
} else {
format!(
"{} {}",
egui_phosphor::regular::GAUGE,
ui::format_speed(playback.speed)
)
};
button(ui, PlayerPanel::Speed, speed, "Playback speed ([ and ])");
// Only for a file with more than one, which for most releases means anime
// and not much else.
if playback.chapters.len() > 1 {
button(
ui,
PlayerPanel::Chapters,
egui_phosphor::regular::LIST_NUMBERS.to_owned(),
"Chapters",
);
}
let moon = egui_phosphor::regular::MOON;
let sleep = match sleep {
None => moon.to_owned(),
Some(SleepChoice::EndOfEpisode) => format!("{moon} End"),
Some(SleepChoice::Minutes(left)) => format!("{moon} {left} min"),
};
button(ui, PlayerPanel::Sleep, sleep, "Sleep timer");
}
/// A track in as few words as fit on a button: what distinguishes it from the
+72
View File
@@ -390,6 +390,52 @@ pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Respons
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A fact about the files rather than the title — 4K, HDR, Dual audio — as
/// a small outlined mark: quieter than a [`chip`], which is something the
/// title is filed under.
pub fn spec_badge(ui: &mut egui::Ui, text: &str) -> egui::Response {
egui::Frame::new()
.stroke(egui::Stroke::new(1.0, theme::muted().gamma_multiply(0.7)))
.corner_radius(CornerRadius::same(theme::radius::BADGE))
.inner_margin(egui::Margin::symmetric(5, 1))
.show(ui, |ui| {
ui.label(
theme::Role::Micro
.rich(text)
.family(theme::semibold())
.color(theme::muted()),
);
})
.response
}
/// Where a title stands — `Airing · episode 8 Sat 1 Nov, 14:00`, `Finished` —
/// as a [`chip`] with a dot in front: the accent while more is coming, muted
/// once it is all out.
pub fn status_chip(ui: &mut egui::Ui, text: &str, live: bool) -> egui::Response {
egui::Frame::new()
.fill(theme::card_hover().gamma_multiply(0.8))
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::symmetric(9, 3))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::space::XS;
let (dot, _) = ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover());
ui.painter().circle_filled(
dot.center(),
3.0,
if live {
theme::accent()
} else {
theme::muted()
},
);
ui.label(theme::Role::Caption.rich(text).color(theme::text()));
});
})
.response
}
/// A small rounded label: a genre, a year, a fact.
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
egui::Frame::new()
@@ -401,3 +447,29 @@ pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
})
.response
}
/// A [`chip`] that opens something: lighter under the pointer, with a ring
/// when keyboard focus is on it.
pub fn link_chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
let mut prepared = egui::Frame::new()
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::symmetric(9, 3))
.begin(ui);
prepared
.content_ui
.label(theme::Role::Caption.rich(text).color(theme::text()));
let response = prepared
.allocate_space(ui)
.interact(egui::Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand);
prepared.frame.fill = if response.hovered() {
theme::card_hover()
} else {
theme::card_hover().gamma_multiply(0.8)
};
if response.has_focus() {
prepared.frame.stroke = egui::Stroke::new(1.5, theme::accent());
}
prepared.paint(ui);
response
}
+39 -30
View File
@@ -546,15 +546,16 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
settings.provider.label(),
settings.provider.description()
);
if settings.provider.needs_api_key() {
println!(
"api key: {}",
match pstr_meta::settings::api_key(settings.provider) {
Some(_) => "stored in the credential store",
None => "not set — run `pstr metadata set --api-key`",
}
);
}
let stored_key = pstr_meta::settings::api_key(ProviderId::Tmdb).is_some();
println!(
"tmdb key: {}",
match (stored_key, settings.provider.needs_api_key()) {
(true, true) => "stored in the credential store",
(true, false) => "stored — used for the art of films TVDB has none for",
(false, true) => "not set — run `pstr metadata set --api-key`",
(false, false) => "not set (optional, for film art)",
}
);
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let stored = catalog.all_metadata()?;
@@ -575,12 +576,16 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
api_key,
} => {
if let Some(name) = provider {
settings.provider = ProviderId::parse(&name).ok_or_else(|| {
anyhow::anyhow!(
"unknown provider {name:?}; try one of: {}",
ProviderId::ALL.map(|provider| provider.as_str()).join(", ")
)
})?;
settings.provider = ProviderId::parse(&name)
.filter(|provider| ProviderId::CHOOSABLE.contains(provider))
.ok_or_else(|| {
anyhow::anyhow!(
"unknown provider {name:?}; try one of: {}",
ProviderId::CHOOSABLE
.map(|provider| provider.as_str())
.join(", ")
)
})?;
}
if on {
settings.enabled = true;
@@ -590,12 +595,11 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
}
if api_key {
let key = rpassword::prompt_password(format!(
"{} API key (empty to forget it): ",
settings.provider.label()
))
.context("read the API key")?;
pstr_meta::settings::set_api_key(settings.provider, &key)
// TMDB's is the only key there is: the provider with TMDB
// chosen, and film art with AniList.
let key = rpassword::prompt_password("TMDB API key (empty to forget it): ")
.context("read the API key")?;
pstr_meta::settings::set_api_key(ProviderId::Tmdb, &key)
.context("store the API key")?;
}
@@ -606,10 +610,11 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
// `Engine::set_metadata_config`.
if off || settings.enabled {
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let chain = pstr_meta::service::chain_of(settings.provider);
let stale = catalog
.all_metadata()?
.values()
.any(|record| record.provider != settings.provider);
.any(|record| !chain.contains(&record.provider));
if off || stale {
catalog.clear_metadata()?;
println!("cleared the stored answers");
@@ -627,13 +632,13 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
anyhow::bail!(
"lookups are off — run `pstr metadata set --on` first.\n\
Matching sends the titles in your catalog to {}.",
settings.provider.label()
pstr_meta::service::chain_label(settings.provider)
);
}
let service = pstr_meta::MetadataService::new(
&settings,
pstr_meta::settings::api_key(settings.provider),
pstr_meta::settings::api_key(ProviderId::Tmdb),
)
.context("build the metadata provider")?;
@@ -646,8 +651,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
library.titles,
&stored,
&enriched,
settings.provider,
service.has_details(),
&service.chain(),
force,
);
if let Some(limit) = limit {
@@ -661,7 +665,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
println!(
"looking up {} titles against {}…",
pending.len(),
settings.provider.label()
service.label()
);
// Serial, not concurrent: both providers rate-limit by the minute,
@@ -691,12 +695,17 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
Some(found) => {
matched += 1;
println!(
" {name:<40} → {} ({})",
" {name:<40} → {} ({}){}",
found.name,
found
.year
.map(|year| year.to_string())
.unwrap_or_else(|| "-".into())
.unwrap_or_else(|| "-".into()),
if found.provider == service.provider() {
String::new()
} else {
format!(" on {}", found.provider.label())
}
);
}
None => println!(" {name:<40} → no match"),
@@ -717,7 +726,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
);
catalog.set_enrichment(
&outcome.title_key,
settings.provider,
outcome.provider,
unix_now(),
enrichment,
)?;
+120
View File
@@ -280,6 +280,81 @@ pub fn worst_contrast(from: Rgb, to: Rgb, ink: Rgb) -> f32 {
/// a tab, a play glyph, never body copy.
pub const MIN_CONTRAST: f32 = 3.0;
/// The colour a picture leaves behind: its pixels averaged, with the vivid
/// ones counting for more. `None` for a picture with nothing opaque in it.
///
/// Weighted rather than a plain mean, because a plain mean of almost any
/// still is a brownish grey — the sky and the shadows cancel out, and the one
/// red coat that makes the poster what it is barely moves the sum. Each pixel
/// counts once plus [`MOOD_CHROMA_WEIGHT`] times its chroma, so a saturated
/// patch pulls the result towards itself without a single pixel deciding it.
///
/// Takes whatever pixels the caller has, in any order and at any size: every
/// client hands over a picture already squeezed small, so this is a few
/// hundred values, not a frame.
pub fn mood(pixels: impl IntoIterator<Item = (Rgb, u8)>) -> Option<Rgb> {
let (mut r, mut g, mut b, mut total) = (0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32);
for (pixel, alpha) in pixels {
// Half transparent and below is a logo's anti-aliasing or a poster's
// rounded corner, not the picture.
if alpha < 128 {
continue;
}
let high = pixel.r.max(pixel.g).max(pixel.b);
let low = pixel.r.min(pixel.g).min(pixel.b);
let weight = 1.0 + MOOD_CHROMA_WEIGHT * f32::from(high - low) / 255.0;
r += weight * f32::from(pixel.r);
g += weight * f32::from(pixel.g);
b += weight * f32::from(pixel.b);
total += weight;
}
(total > 0.0).then(|| {
let channel = |sum: f32| (sum / total).round().clamp(0.0, 255.0) as u8;
Rgb::new(channel(r), channel(g), channel(b))
})
}
/// How much more a fully saturated pixel counts towards [`mood`] than a grey
/// one.
pub const MOOD_CHROMA_WEIGHT: f32 = 4.0;
/// The brightest channel a mood is brought down to before it tints a dark
/// palette. Any brighter and the veil stops being the page with a hint of the
/// picture in it, and text over it loses the contrast the veil is there for.
pub const MOOD_DEPTH: u8 = 60;
/// How far a dark palette's veil moves from the page colour towards the mood.
pub const MOOD_DARK: f32 = 0.55;
/// The same for a light palette — much less, because a light page tinted as
/// hard reads as a coloured page rather than a lit one.
pub const MOOD_LIGHT: f32 = 0.12;
/// The page colour, tinted by a picture's [`mood`]: what the veils over a
/// title's backdrop are painted in, so each title's banner carries its own
/// light rather than the same flat colour.
///
/// On a dark palette the mood is first darkened to [`MOOD_DEPTH`] — only its
/// hue and saturation are wanted, never its brightness — and the page moves
/// [`MOOD_DARK`] of the way to it. A light palette moves [`MOOD_LIGHT`] of the
/// way to the mood as it is.
pub fn mood_veil(background: Rgb, mood: Rgb, light: bool) -> Rgb {
if light {
return mix(background, mood, MOOD_LIGHT);
}
let high = mood.r.max(mood.g).max(mood.b);
if high == 0 {
return mix(background, mood, MOOD_DARK);
}
let scale = f32::from(MOOD_DEPTH.min(high)) / f32::from(high);
let deep = |channel: u8| (f32::from(channel) * scale).round() as u8;
mix(
background,
Rgb::new(deep(mood.r), deep(mood.g), deep(mood.b)),
MOOD_DARK,
)
}
/// Every colour a client draws with, resolved from a [`Flavor`] and an
/// [`Accent`].
///
@@ -627,6 +702,51 @@ mod tests {
}
}
#[test]
fn a_picture_of_one_colour_has_that_colour_as_its_mood() {
let teal = Rgb::new(20, 140, 150);
assert_eq!(mood(std::iter::repeat_n((teal, 255), 16)), Some(teal));
}
#[test]
fn a_vivid_patch_outweighs_as_much_grey_in_the_mood() {
let red = Rgb::new(220, 30, 30);
let grey = Rgb::new(120, 120, 120);
let found = mood([(red, 255), (grey, 255)]).unwrap();
assert!(found.r > 180, "{found:?}");
assert!(found.g < 60 && found.b < 60, "{found:?}");
}
#[test]
fn transparent_pixels_do_not_count_towards_the_mood() {
let blue = Rgb::new(30, 60, 200);
assert_eq!(
mood([(blue, 255), (Rgb::new(255, 255, 255), 0)]),
Some(blue)
);
assert_eq!(mood([(blue, 10)]), None);
assert_eq!(mood(std::iter::empty()), None);
}
#[test]
fn a_dark_veil_takes_the_moods_hue_but_never_its_brightness() {
let background = Rgb::new(11, 11, 15);
let veil = mood_veil(background, Rgb::new(255, 240, 40), false);
// Warmer than the page…
assert!(veil.r > background.r && veil.g > background.g, "{veil:?}");
// …and still dark enough to be a page under white text.
assert!(luminance(veil) < 0.05, "{veil:?}");
assert!(contrast(veil, Rgb::new(255, 255, 255)) > 12.0, "{veil:?}");
}
#[test]
fn a_light_veil_moves_only_a_little_way_to_the_mood() {
let background = Rgb::new(239, 241, 245);
let veil = mood_veil(background, Rgb::new(200, 20, 20), true);
assert!(veil.r >= veil.g, "{veil:?}");
assert!(luminance(veil) > 0.6, "{veil:?}");
}
#[test]
fn a_first_run_gets_the_palette_the_app_shipped_with() {
let appearance = Appearance::default();
+38 -9
View File
@@ -10,6 +10,7 @@ use std::collections::{HashMap, HashSet};
use crate::catalog::CatalogNode;
use crate::library::{Library, Title, TitleKind, sort_name};
use crate::metadata::{MetadataRecord, TitleNames};
use crate::mylist::MyList;
/// What order a grid is in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
@@ -66,11 +67,14 @@ pub enum Filter {
Unwatched,
/// With at least one episode kept on this device.
Downloaded,
/// On My List.
MyList,
}
impl Filter {
pub const ALL: [Self; 6] = [
pub const ALL: [Self; 7] = [
Self::All,
Self::MyList,
Self::Series,
Self::Films,
Self::Watching,
@@ -86,12 +90,18 @@ impl Filter {
Self::Watching => "Watching",
Self::Unwatched => "Not started",
Self::Downloaded => "Downloaded",
Self::MyList => "My List",
}
}
/// Whether the grid shows `title`. `offline` says whether a file is kept
/// on this device, which only the front end knows.
pub fn admits(self, title: &Title, offline: impl Fn(&CatalogNode) -> bool) -> bool {
pub fn admits(
self,
title: &Title,
offline: impl Fn(&CatalogNode) -> bool,
list: &MyList,
) -> bool {
let watched = title.watched_count();
match self {
Self::All => true,
@@ -102,6 +112,7 @@ impl Filter {
}
Self::Unwatched => watched == 0 && title.resume().is_none(),
Self::Downloaded => title.episodes().any(|episode| offline(&episode.node)),
Self::MyList => list.contains(&title.key),
}
}
}
@@ -312,6 +323,7 @@ mod tests {
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: Some(rating),
genres: Vec::new(),
episodes: None,
@@ -454,12 +466,18 @@ mod tests {
let film = title("f", "Film", TitleKind::Film);
let series = title("s", "Series", TitleKind::Series);
let none = |_: &CatalogNode| false;
assert!(Filter::All.admits(&film, none) && Filter::All.admits(&series, none));
assert!(Filter::Films.admits(&film, none) && !Filter::Films.admits(&series, none));
assert!(Filter::Series.admits(&series, none) && !Filter::Series.admits(&film, none));
let list = MyList::default();
assert!(Filter::All.admits(&film, none, &list) && Filter::All.admits(&series, none, &list));
assert!(
Filter::Films.admits(&film, none, &list) && !Filter::Films.admits(&series, none, &list)
);
assert!(
Filter::Series.admits(&series, none, &list)
&& !Filter::Series.admits(&film, none, &list)
);
// Nothing watched of either: not started, and not being watched.
assert!(Filter::Unwatched.admits(&film, none));
assert!(!Filter::Watching.admits(&film, none));
assert!(Filter::Unwatched.admits(&film, none, &list));
assert!(!Filter::Watching.admits(&film, none, &list));
}
#[test]
@@ -485,7 +503,18 @@ mod tests {
let library = Library::build(vec![node("a", 1), node("b", 2)], &HashMap::new());
let show = &library.titles[0];
assert!(Filter::Downloaded.admits(show, |node| node.link_id == "b"));
assert!(!Filter::Downloaded.admits(show, |_| false));
let list = MyList::default();
assert!(Filter::Downloaded.admits(show, |node| node.link_id == "b", &list));
assert!(!Filter::Downloaded.admits(show, |_| false, &list));
}
#[test]
fn the_my_list_filter_admits_only_what_is_on_the_list() {
let film = title("f", "Film", TitleKind::Film);
let series = title("s", "Series", TitleKind::Series);
let mut list = MyList::default();
list.add("s", 1);
assert!(Filter::MyList.admits(&series, |_| false, &list));
assert!(!Filter::MyList.admits(&film, |_| false, &list));
}
}
+155 -15
View File
@@ -24,7 +24,7 @@ use crate::metadata::{
use crate::naming::{self, ParsedName};
/// Bump when adding a migration.
pub const SCHEMA_VERSION: i64 = 12;
pub const SCHEMA_VERSION: i64 = 14;
const MIGRATION_V1: &str = r#"
CREATE TABLE nodes (
@@ -242,6 +242,29 @@ CREATE TABLE sidecars (
CREATE INDEX sidecars_by_video ON sidecars (share_id, video_link_id);
"#;
/// Title logos and running times.
///
/// ani.zip sent both all along and neither was kept. Every AniList title is
/// marked un-enriched so the next match run asks for them; nothing is deleted,
/// and the stored episodes and art stay on screen until the answer replaces
/// them. The title runtime lives in `details`, which is refetched by
/// `TitleDetails::revision` rather than blanked here.
const MIGRATION_V13: &str = r#"
ALTER TABLE title_metadata ADD COLUMN logo_url TEXT;
ALTER TABLE episode_metadata ADD COLUMN runtime INTEGER;
UPDATE title_metadata SET enriched_at = NULL WHERE provider = 'anilist';
"#;
/// Each episode's MyAnimeList id, for AniSkip.
///
/// ani.zip sends it with every entry and it was not kept. As with V13, every
/// AniList title is marked un-enriched so the next match run fills it in, and
/// nothing is deleted meanwhile.
const MIGRATION_V14: &str = r#"
ALTER TABLE episode_metadata ADD COLUMN mal_id INTEGER;
UPDATE title_metadata SET enriched_at = NULL WHERE provider = 'anilist';
"#;
/// How old a share's last crawl may be before an app looks again on its own.
/// Long enough that a launch rarely costs a crawl; short enough that a new
/// episode turns up the same day.
@@ -497,6 +520,18 @@ impl Catalog {
tx.pragma_update(None, "user_version", 12)?;
tx.commit()?;
}
if version < 13 {
let tx = self.conn.transaction()?;
tx.execute_batch(MIGRATION_V13)?;
tx.pragma_update(None, "user_version", 13)?;
tx.commit()?;
}
if version < 14 {
let tx = self.conn.transaction()?;
tx.execute_batch(MIGRATION_V14)?;
tx.pragma_update(None, "user_version", 14)?;
tx.commit()?;
}
Ok(())
}
@@ -1037,7 +1072,7 @@ impl Catalog {
let mut statement = self.conn.prepare(
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
manual, details, chain
manual, details, chain, logo_url
FROM title_metadata",
)?;
let rows = statement.query_map([], row_to_metadata)?;
@@ -1058,7 +1093,7 @@ impl Catalog {
.query_row(
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes,
url, manual, details, chain
url, manual, details, chain, logo_url
FROM title_metadata WHERE title_key = ?1",
params![title_key],
row_to_metadata,
@@ -1103,9 +1138,9 @@ impl Catalog {
&"INSERT INTO title_metadata (
title_key, provider, matched, fetched_at, remote_id, name, original_name,
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
manual, details
manual, details, logo_url
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
?17, ?18)
?17, ?18, ?19)
ON CONFLICT (title_key) DO UPDATE SET
provider = excluded.provider,
matched = excluded.matched,
@@ -1120,6 +1155,9 @@ impl Catalog {
backdrop_url = CASE WHEN same THEN COALESCE(excluded.backdrop_url,
title_metadata.backdrop_url)
ELSE excluded.backdrop_url END,
logo_url = CASE WHEN same THEN COALESCE(excluded.logo_url,
title_metadata.logo_url)
ELSE excluded.logo_url END,
rating = excluded.rating,
genres = excluded.genres,
episodes = excluded.episodes,
@@ -1153,6 +1191,7 @@ impl Catalog {
data.and_then(|data| data.url.as_deref()),
record.manual as i64,
details,
data.and_then(|data| data.logo_url.as_deref()),
],
)?;
Ok(())
@@ -1220,7 +1259,8 @@ impl Catalog {
replace_episodes(&tx, title_key, provider, fetched_at, &enrichment.episodes)?;
tx.execute(
"UPDATE title_metadata
SET enriched_at = ?3, backdrop_url = COALESCE(?4, backdrop_url), chain = ?5
SET enriched_at = ?3, backdrop_url = COALESCE(?4, backdrop_url), chain = ?5,
logo_url = COALESCE(?6, logo_url)
WHERE title_key = ?1 AND provider = ?2",
params![
title_key,
@@ -1228,6 +1268,7 @@ impl Catalog {
fetched_at,
enrichment.backdrop_url.as_deref(),
enrichment.chain.join("\n"),
enrichment.logo_url.as_deref(),
],
)?;
tx.commit()?;
@@ -1241,7 +1282,7 @@ impl Catalog {
pub fn all_episode_metadata(&self) -> Result<HashMap<String, EpisodeGuide>> {
let mut statement = self.conn.prepare(
"SELECT title_key, season, number, name, overview, still_url, air_date,
absolute, entry_season, entry_number
absolute, entry_season, entry_number, runtime, mal_id
FROM episode_metadata",
)?;
let rows = statement.query_map([], |row| {
@@ -1259,6 +1300,8 @@ impl Catalog {
overview: row.get(4)?,
still_url: row.get(5)?,
air_date: row.get(6)?,
runtime: row.get(10)?,
mal_id: row.get(11)?,
},
))
})?;
@@ -1365,8 +1408,8 @@ fn replace_episodes(
"INSERT OR REPLACE INTO episode_metadata (
title_key, season, number, provider, fetched_at,
name, overview, still_url, air_date,
absolute, entry_season, entry_number
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
absolute, entry_season, entry_number, runtime, mal_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
)?;
for episode in episodes {
insert.execute(params![
@@ -1382,6 +1425,8 @@ fn replace_episodes(
episode.absolute,
episode.entry_season,
episode.entry_number,
episode.runtime,
episode.mal_id,
])?;
}
Ok(())
@@ -1432,6 +1477,7 @@ fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<MetadataR
kind: parse_kind(row.get::<_, Option<String>>(9)?.as_deref()),
poster_url: row.get(10)?,
backdrop_url: row.get(11)?,
logo_url: row.get(19)?,
rating: row.get(12)?,
genres: genres
.unwrap_or_default()
@@ -1981,6 +2027,7 @@ mod tests {
kind: TitleKind::Series,
poster_url: Some("https://example.invalid/p.jpg".into()),
backdrop_url: None,
logo_url: None,
rating: Some(8.4),
genres: vec!["Action".into(), "Drama".into()],
episodes: Some(25),
@@ -2076,6 +2123,8 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}],
)
.expect("store episodes");
@@ -2168,6 +2217,8 @@ mod tests {
overview: Some("Ed and Al…".into()),
still_url: None,
air_date: Some("2010-11-14".into()),
runtime: None,
mal_id: None,
},
EpisodeMetadata {
season: Some(1),
@@ -2179,6 +2230,8 @@ mod tests {
overview: None,
still_url: Some("still.jpg".into()),
air_date: None,
runtime: None,
mal_id: None,
},
];
catalog
@@ -2221,6 +2274,8 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: Some(24),
mal_id: None,
};
catalog
.set_episode_metadata(
@@ -2264,6 +2319,8 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}],
)
.expect("store episodes");
@@ -2320,6 +2377,8 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}],
)
.expect("store episodes");
@@ -2336,16 +2395,18 @@ mod tests {
assert!(catalog.all_episode_metadata().expect("read").is_empty());
}
/// Enrichment adds a backdrop when it found one and otherwise leaves the
/// match's own alone; a new match makes the title un-enriched again.
/// Enrichment adds a backdrop or a logo when it found one and otherwise
/// leaves the match's own alone; a new match makes the title un-enriched
/// again, and refreshing the same one keeps both.
#[test]
fn enrichment_keeps_the_backdrop_it_did_not_replace_and_a_different_match_resets_it() {
fn enrichment_keeps_the_art_it_did_not_replace_and_a_different_match_resets_it() {
let mut catalog = Catalog::in_memory().expect("open");
let record = MetadataRecord {
title_key: "attack on titan".into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
backdrop_url: Some("old.jpg".into()),
logo_url: Some("old.png".into()),
..found()
}),
fetched_at: 1,
@@ -2357,8 +2418,9 @@ mod tests {
.metadata("attack on titan")
.expect("read")
.and_then(|record| record.metadata)
.and_then(|found| found.backdrop_url)
.map(|found| (found.backdrop_url, found.logo_url))
};
let art = |backdrop: &str, logo: &str| Some((Some(backdrop.into()), Some(logo.into())));
catalog
.set_enrichment(
@@ -2368,16 +2430,26 @@ mod tests {
&Enrichment::default(),
)
.expect("enrich");
assert_eq!(backdrop(&catalog).as_deref(), Some("old.jpg"));
assert_eq!(backdrop(&catalog), art("old.jpg", "old.png"));
let enrichment = Enrichment {
backdrop_url: Some("fanart.jpg".into()),
logo_url: Some("clearlogo.png".into()),
..Enrichment::default()
};
catalog
.set_enrichment("attack on titan", ProviderId::AniList, 3, &enrichment)
.expect("enrich again");
assert_eq!(backdrop(&catalog).as_deref(), Some("fanart.jpg"));
assert_eq!(backdrop(&catalog), art("fanart.jpg", "clearlogo.png"));
// A refresh of the same entry answers with no art of its own.
catalog
.set_metadata(&MetadataRecord {
metadata: Some(found()),
..record.clone()
})
.expect("refresh");
assert_eq!(backdrop(&catalog), art("fanart.jpg", "clearlogo.png"));
catalog
.set_metadata(&MetadataRecord {
@@ -2414,8 +2486,11 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}],
backdrop_url: None,
logo_url: None,
chain: Vec::new(),
};
catalog
@@ -2473,6 +2548,8 @@ mod tests {
overview: None,
still_url: None,
air_date: Some(air_date.into()),
runtime: None,
mal_id: None,
};
let enrichment = Enrichment {
episodes: vec![
@@ -2480,6 +2557,7 @@ mod tests {
episode(2, None, "2026-10-09"),
],
backdrop_url: None,
logo_url: None,
chain: Vec::new(),
};
catalog
@@ -2538,8 +2616,11 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}],
backdrop_url: Some("fanart.jpg".into()),
logo_url: None,
chain: vec!["457".into(), "20595".into()],
};
catalog
@@ -2699,6 +2780,63 @@ mod tests {
assert!(catalog.sidecars("s", "a").expect("read").is_empty());
}
/// Schema v13 asks every AniList title to be enriched again, for the
/// logos and running times it never kept — and deletes nothing: the
/// match, its details and its episode names stay on screen meanwhile.
#[test]
fn upgrading_to_v13_re_enriches_anilist_titles_and_keeps_everything_they_had() {
let conn = Connection::open_in_memory().expect("open");
for migration in [
MIGRATION_V1,
MIGRATION_V2,
MIGRATION_V3,
MIGRATION_V4,
MIGRATION_V5,
MIGRATION_V6,
MIGRATION_V7,
MIGRATION_V8,
MIGRATION_V9,
MIGRATION_V10,
MIGRATION_V11,
MIGRATION_V12,
] {
conn.execute_batch(migration).expect("migrate");
}
conn.pragma_update(None, "user_version", 12)
.expect("set version");
conn.execute_batch(
"INSERT INTO title_metadata (title_key, provider, matched, fetched_at, remote_id,
name, kind, backdrop_url, manual, details, enriched_at)
VALUES ('aot', 'anilist', 1, 1, '16498', 'Attack on Titan', 'series',
'fanart.jpg', 1, '{\"format\":\"TV\"}', 5),
('got', 'tmdb', 1, 1, '1399', 'Game of Thrones', 'series',
'backdrop.jpg', 0, NULL, 5);
INSERT INTO episode_metadata (title_key, season, number, provider, fetched_at, name)
VALUES ('aot', 1, 1, 'anilist', 1, 'To You, in 2000 Years');",
)
.expect("seed");
let catalog = Catalog::from_connection(conn).expect("upgrade");
let records = catalog.all_metadata().expect("read");
assert!(records["aot"].manual);
let aot = records["aot"].metadata.as_ref().expect("still matched");
assert_eq!(aot.backdrop_url.as_deref(), Some("fanart.jpg"));
assert_eq!(aot.logo_url, None);
let details = aot.details.as_ref().expect("details kept");
assert_eq!(details.format.as_deref(), Some("TV"));
assert!(!details.is_current());
let ages = catalog.enrichment_ages().expect("ages");
assert!(!ages.contains_key("aot"));
assert!(ages.contains_key("got"));
let episodes = catalog.all_episode_metadata().expect("read");
assert_eq!(
episodes["aot"].get(Some(1), 1).and_then(|e| e.runtime),
None
);
assert_eq!(episodes["aot"].len(), 1);
}
/// A file keeps the moment it first appeared across recrawls, and one
/// that was there before this was recorded reads as 0.
#[test]
@@ -2797,6 +2935,8 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}],
)
.expect("store");
+229
View File
@@ -294,6 +294,116 @@ pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
}
/// A stretch of a file someone has marked as its opening or its ending, for
/// a file whose own chapters do not say: AniSkip's answer, in the file's
/// seconds.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SkipRange {
/// [`ChapterRole::Opening`] or [`ChapterRole::Ending`].
pub role: ChapterRole,
pub start: f64,
pub end: f64,
}
impl SkipRange {
/// What the chapter cut for it is called, which is also what [`roles`]
/// reads it back as.
fn label(self) -> Option<&'static str> {
match self.role {
ChapterRole::Opening => Some("Opening"),
ChapterRole::Ending => Some("Ending"),
ChapterRole::Preview | ChapterRole::Content => None,
}
}
}
/// How close two chapter starts may be before they are the same place.
const SAME_PLACE_SECONDS: f64 = 1.0;
/// The file's chapters with `ranges` cut into them, as chapters named
/// `Opening` and `Ending` — so that skipping, skipping on its own, the up-next
/// countdown and the marks on the seek bar all work for a file that came with
/// no such chapters, through the same code as one that did.
///
/// **The file wins.** When its own chapters already mark an opening or an
/// ending, it is returned as it is: what the release says about its own cut
/// beats what a viewer of some other release said. A file chapter that starts
/// inside a range is dropped, since it would split the range in two; one that
/// starts where a range ends gives that place its name. Every chapter is
/// numbered again from zero, which is what the players key "already skipped"
/// by.
pub fn with_skip_ranges(
chapters: &[Chapter],
ranges: &[SkipRange],
duration: Option<f64>,
) -> Vec<Chapter> {
let own = roles(chapters, duration);
if ranges.is_empty()
|| own
.iter()
.any(|role| matches!(role, ChapterRole::Opening | ChapterRole::Ending))
{
return chapters.to_vec();
}
let mut ranges: Vec<SkipRange> = ranges
.iter()
.copied()
.filter(|range| range.label().is_some() && range.start >= 0.0 && range.end > range.start)
.collect();
ranges.sort_by(|a, b| a.start.total_cmp(&b.start));
// Two answers that overlap cannot both be right; the earlier one stays.
let mut kept: Vec<SkipRange> = Vec::with_capacity(ranges.len());
for range in ranges {
if kept.last().is_none_or(|last| range.start >= last.end) {
kept.push(range);
}
}
let title_at = |position: f64| {
chapter_at(chapters, position).and_then(|index| chapters[index].title.clone())
};
let mut points: Vec<(f64, Option<String>)> = Vec::new();
for range in &kept {
points.push((range.start, range.label().map(str::to_owned)));
let ends_the_file =
duration.is_some_and(|duration| range.end >= duration - SAME_PLACE_SECONDS);
if !ends_the_file {
points.push((range.end, title_at(range.end)));
}
}
let near_a_point = |start: f64, points: &[(f64, Option<String>)]| {
points
.iter()
.any(|(at, _)| (at - start).abs() < SAME_PLACE_SECONDS)
};
let inside_a_range = |start: f64| {
kept.iter()
.any(|range| start > range.start && start < range.end)
};
let from_ranges = points.clone();
for chapter in chapters {
if !near_a_point(chapter.start, &from_ranges) && !inside_a_range(chapter.start) {
points.push((chapter.start, chapter.title.clone()));
}
}
// Whatever comes before the first mark is a place in the file too.
if !points.iter().any(|(at, _)| *at < SAME_PLACE_SECONDS) {
points.push((0.0, None));
}
points.sort_by(|a, b| a.0.total_cmp(&b.0));
points
.into_iter()
.enumerate()
.map(|(index, (start, title))| Chapter {
index: index as i64,
title,
start,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -480,4 +590,123 @@ mod tests {
// and a button that seeks to zero would be worse than no button.
assert_eq!(chapter_end(&chapters, 1, None), None);
}
fn range(role: ChapterRole, start: f64, end: f64) -> SkipRange {
SkipRange { role, start, end }
}
fn shape(chapters: &[Chapter]) -> Vec<(i64, Option<&str>, f64)> {
chapters
.iter()
.map(|chapter| (chapter.index, chapter.title.as_deref(), chapter.start))
.collect()
}
#[test]
fn a_file_with_no_chapters_gets_its_opening_and_ending_as_chapters_that_skip() {
let ranges = [
range(ChapterRole::Opening, 90.0, 180.0),
range(ChapterRole::Ending, 1300.0, 1390.0),
];
let chapters = with_skip_ranges(&[], &ranges, Some(1420.0));
assert_eq!(
shape(&chapters),
vec![
(0, None, 0.0),
(1, Some("Opening"), 90.0),
(2, None, 180.0),
(3, Some("Ending"), 1300.0),
(4, None, 1390.0),
]
);
let roles = roles(&chapters, Some(1420.0));
assert_eq!(roles[1], ChapterRole::Opening);
assert_eq!(roles[3], ChapterRole::Ending);
}
#[test]
fn a_files_own_opening_beats_anything_aniskip_says() {
let own = vec![
Chapter {
index: 0,
title: Some("Prologue".into()),
start: 0.0,
},
Chapter {
index: 1,
title: Some("OP".into()),
start: 60.0,
},
Chapter {
index: 2,
title: Some("Part A".into()),
start: 150.0,
},
];
let ranges = [range(ChapterRole::Opening, 70.0, 160.0)];
assert_eq!(with_skip_ranges(&own, &ranges, Some(1420.0)), own);
}
#[test]
fn unnamed_chapters_inside_a_range_give_way_and_one_at_its_end_keeps_its_name() {
let own = vec![
Chapter {
index: 0,
title: None,
start: 0.0,
},
Chapter {
index: 1,
title: None,
start: 89.6,
},
Chapter {
index: 2,
title: None,
start: 120.0,
},
Chapter {
index: 3,
title: Some("Part A".into()),
start: 180.0,
},
];
let ranges = [range(ChapterRole::Opening, 90.0, 180.0)];
let chapters = with_skip_ranges(&own, &ranges, Some(1420.0));
assert_eq!(
shape(&chapters),
vec![
(0, None, 0.0),
(1, Some("Opening"), 90.0),
(2, Some("Part A"), 180.0)
]
);
}
#[test]
fn an_ending_that_runs_to_the_end_of_the_file_adds_no_chapter_after_it() {
let ranges = [range(ChapterRole::Ending, 1330.0, 1419.8)];
let chapters = with_skip_ranges(&[], &ranges, Some(1420.0));
assert_eq!(
shape(&chapters),
vec![(0, None, 0.0), (1, Some("Ending"), 1330.0)]
);
assert_eq!(
credits_start(&chapters, &roles(&chapters, Some(1420.0))),
Some(1330.0)
);
}
#[test]
fn overlapping_answers_keep_the_earlier_one() {
let ranges = [
range(ChapterRole::Opening, 0.0, 90.0),
range(ChapterRole::Ending, 60.0, 150.0),
];
let chapters = with_skip_ranges(&[], &ranges, Some(1420.0));
assert_eq!(
shape(&chapters),
vec![(0, Some("Opening"), 0.0), (1, None, 90.0)]
);
}
}
+599
View File
@@ -0,0 +1,599 @@
//! Getting from one title to the next: "More like this", the "Because you
//! watched" row, and the pages a genre, studio or person chip opens.
//!
//! All of it is read off what the provider already said about each title —
//! genres, tags, studios, directors, format — so none of it costs a request,
//! and it only ever offers what the library holds.
use std::collections::{HashMap, HashSet};
use crate::library::Title;
use crate::metadata::{MetadataRecord, TitleMetadata, TitleNames};
use crate::shelves::Shelf;
/// What a chip on a title page stands for, and the page it opens.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Facet {
Genre(String),
Studio(String),
/// A director, or a voice actor in the cast.
Person(String),
}
impl Facet {
/// The name on the chip, and the heading of its page.
pub fn name(&self) -> &str {
match self {
Self::Genre(name) | Self::Studio(name) | Self::Person(name) => name,
}
}
/// What kind of thing the name is, said over the heading.
pub fn kind(&self) -> &'static str {
match self {
Self::Genre(_) => "Genre",
Self::Studio(_) => "Studio",
Self::Person(_) => "Person",
}
}
/// Whether a title the provider described as `found` belongs on the page.
pub fn admits(&self, found: &TitleMetadata) -> bool {
let details = found.details.as_ref();
match self {
Self::Genre(name) => found.genres.iter().any(|genre| genre == name),
Self::Studio(name) => {
details.is_some_and(|details| details.studios.iter().any(|studio| studio == name))
}
Self::Person(name) => details.is_some_and(|details| {
details.directors.iter().any(|person| person == name)
|| details
.cast
.iter()
.any(|member| member.actor.as_deref() == Some(name.as_str()))
}),
}
}
/// The keys of the titles in `titles` the page lists, in library order.
pub fn titles(
&self,
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
) -> Vec<String> {
titles
.iter()
.filter(|title| {
metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.is_some_and(|found| self.admits(found))
})
.map(|title| title.key.clone())
.collect()
}
}
/// One episode a search found by the name the provider gave it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpisodeMatch {
pub title_key: String,
pub share_id: String,
pub link_id: String,
/// `S01E05`, as the row everywhere else says it.
pub label: String,
/// The provider's name for it.
pub name: String,
}
/// What a search found besides titles: episodes by name, and the people,
/// studios and genres whose names hold the query, each with how many titles
/// in the library carry it.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Found {
pub episodes: Vec<EpisodeMatch>,
/// Directors and studios.
pub people: Vec<(Facet, usize)>,
pub genres: Vec<(Facet, usize)>,
}
impl Found {
pub fn is_empty(&self) -> bool {
self.episodes.is_empty() && self.people.is_empty() && self.genres.is_empty()
}
}
/// How many episodes a search lists: past this the query is too loose for a
/// list of episodes to help.
const EPISODES: usize = 12;
/// Everything but titles that `query` finds. Nothing for a query under two
/// letters, which would hold half of every name.
pub fn find(
library: &crate::library::Library,
metadata: &HashMap<String, MetadataRecord>,
guides: &HashMap<String, crate::metadata::EpisodeGuide>,
query: &str,
) -> Found {
let needle = query.trim().to_lowercase();
if needle.chars().count() < 2 {
return Found::default();
}
let holds = |name: &str| name.to_lowercase().contains(&needle);
let mut episodes = Vec::new();
'titles: for title in &library.titles {
let Some(guide) = guides.get(&title.key) else {
continue;
};
for episode in title.episodes() {
let node = &episode.node;
let Some(name) = node
.parsed
.episode
.and_then(|number| guide.get(node.parsed.season, number))
.and_then(|found| found.name.as_deref())
.filter(|name| holds(name))
else {
continue;
};
episodes.push(EpisodeMatch {
title_key: title.key.clone(),
share_id: node.share_id.clone(),
link_id: node.link_id.clone(),
label: episode.numbering().unwrap_or_else(|| episode.label()),
name: name.to_owned(),
});
if episodes.len() == EPISODES {
break 'titles;
}
}
}
let mut people: HashMap<Facet, usize> = HashMap::new();
let mut genres: HashMap<Facet, usize> = HashMap::new();
for title in &library.titles {
let Some(found) = metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
else {
continue;
};
for genre in found.genres.iter().filter(|genre| holds(genre)) {
*genres.entry(Facet::Genre(genre.clone())).or_default() += 1;
}
let Some(details) = &found.details else {
continue;
};
// One count per title, however many characters a voice actor has
// in it.
let made: HashSet<Facet> = details
.directors
.iter()
.chain(
details
.cast
.iter()
.filter_map(|member| member.actor.as_ref()),
)
.map(|name| Facet::Person(name.clone()))
.chain(
details
.studios
.iter()
.map(|name| Facet::Studio(name.clone())),
)
.filter(|facet| holds(facet.name()))
.collect();
for facet in made {
*people.entry(facet).or_default() += 1;
}
}
// Most titles first, then by name, so the order does not change from one
// keystroke to the next.
let ranked = |found: HashMap<Facet, usize>| {
let mut found: Vec<(Facet, usize)> = found.into_iter().collect();
found.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name().cmp(b.0.name())));
found
};
Found {
episodes,
people: ranked(people),
genres: ranked(genres),
}
}
/// How much one shared trait says two titles are alike. A director is close
/// to a signature; a genre is one of three or four on every entry.
const DIRECTOR: u32 = 4;
const STUDIO: u32 = 3;
const GENRE: u32 = 2;
const TAG: u32 = 1;
const FORMAT: u32 = 1;
/// How many of a title's tags count. A provider ranks them, and the tail of
/// the list ("Male Protagonist", "Primarily Teen Cast") is on half of
/// everything.
const TAGS: usize = 8;
/// The least score a title needs to be offered: one director, or a studio and
/// a tag, or two genres. One genre in common is not a recommendation.
const MIN_SCORE: u32 = 4;
/// What a title is, for comparing: only the parts that score.
struct Traits<'a> {
genres: HashSet<&'a str>,
tags: HashSet<&'a str>,
studios: HashSet<&'a str>,
directors: HashSet<&'a str>,
format: Option<&'a str>,
popularity: u32,
}
impl<'a> Traits<'a> {
fn of(found: &'a TitleMetadata) -> Self {
let details = found.details.as_ref();
let strings = |list: Option<&'a Vec<String>>, take: usize| -> HashSet<&'a str> {
list.into_iter()
.flatten()
.take(take)
.map(String::as_str)
.collect()
};
Self {
genres: found.genres.iter().map(String::as_str).collect(),
tags: strings(details.map(|details| &details.tags), TAGS),
studios: strings(details.map(|details| &details.studios), usize::MAX),
directors: strings(details.map(|details| &details.directors), usize::MAX),
format: details.and_then(|details| details.format.as_deref()),
popularity: details.and_then(|details| details.popularity).unwrap_or(0),
}
}
fn score(&self, other: &Self) -> u32 {
let shared = |a: &HashSet<&str>, b: &HashSet<&str>| a.intersection(b).count() as u32;
let traits = DIRECTOR * shared(&self.directors, &other.directors)
+ STUDIO * shared(&self.studios, &other.studios)
+ GENRE * shared(&self.genres, &other.genres)
+ TAG * shared(&self.tags, &other.tags);
// Only on top of something else: a TV series is like every other one.
if traits > 0 && self.format.is_some() && self.format == other.format {
traits + FORMAT
} else {
traits
}
}
}
/// Up to `limit` keys of the titles most like `key`, best first.
///
/// Never its own franchise — that is the row above it — and one title per
/// other franchise, its best scoring: three films of one story are one
/// suggestion, not three.
pub fn more_like(
key: &str,
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
limit: usize,
) -> Vec<String> {
ranked(key, titles, metadata, |_| true, limit)
}
/// The row seeded by the title most recently watched to the end: titles like
/// it the viewer has not started. `None` when nothing is finished, or nothing
/// is like it.
pub fn because_you_watched(
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
names: TitleNames,
limit: usize,
) -> Option<Shelf> {
let seed = titles
.iter()
.filter(|title| {
title.episode_count() > 0
&& title.watched_count() == title.episode_count()
&& metadata
.get(&title.key)
.is_some_and(|record| record.metadata.is_some())
})
.max_by_key(|title| title.last_played())?;
let keys = ranked(
&seed.key,
titles,
metadata,
|title| title.last_played() == 0,
limit,
);
(!keys.is_empty()).then(|| Shelf {
label: format!(
"Because you watched {}",
crate::browse::display_name(seed, metadata, names)
),
keys,
})
}
fn ranked(
key: &str,
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
admit: impl Fn(&Title) -> bool,
limit: usize,
) -> Vec<String> {
let found = |title: &Title| {
metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
};
let Some(seed) = titles
.iter()
.find(|title| title.key == key)
.and_then(found)
.map(Traits::of)
else {
return Vec::new();
};
let refs: Vec<&Title> = titles.iter().collect();
let franchise_of: Vec<usize> = {
let mut of = vec![0; titles.len()];
for (at, franchise) in crate::franchise::group(&refs, metadata)
.into_iter()
.enumerate()
{
for member in franchise.members {
of[member] = at;
}
}
of
};
let own = titles
.iter()
.position(|title| title.key == key)
.map(|at| franchise_of[at]);
// The best of each franchise: (score, popularity, index).
let mut best: HashMap<usize, (u32, u32, usize)> = HashMap::new();
for (index, title) in titles.iter().enumerate() {
if Some(franchise_of[index]) == own || !admit(title) {
continue;
}
let Some(traits) = found(title).map(Traits::of) else {
continue;
};
let score = seed.score(&traits);
if score < MIN_SCORE {
continue;
}
let candidate = (score, traits.popularity, index);
best.entry(franchise_of[index])
.and_modify(|held| {
if (candidate.0, candidate.1) > (held.0, held.1) {
*held = candidate;
}
})
.or_insert(candidate);
}
let mut picked: Vec<(u32, u32, usize)> = best.into_values().collect();
// Best score, then most followed, then library order for a stable row.
picked.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
picked
.into_iter()
.take(limit)
.map(|(_, _, index)| titles[index].key.clone())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::{CatalogNode, WatchState};
use crate::library::{Library, TitleKind};
use crate::metadata::{ProviderId, TitleDetails};
struct Entry {
key: &'static str,
genres: &'static [&'static str],
studio: &'static str,
director: &'static str,
related: &'static [&'static str],
}
fn record(entry: &Entry) -> (String, MetadataRecord) {
(
entry.key.into(),
MetadataRecord {
title_key: entry.key.into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
provider: ProviderId::AniList,
remote_id: entry.key.into(),
name: entry.key.into(),
original_name: None,
overview: None,
year: None,
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: entry.genres.iter().map(|genre| genre.to_string()).collect(),
episodes: None,
url: None,
details: Some(TitleDetails {
studios: vec![entry.studio.into()],
directors: vec![entry.director.into()],
related: entry.related.iter().map(|id| id.to_string()).collect(),
format: Some("TV".into()),
..TitleDetails::default()
}),
}),
fetched_at: 0,
manual: false,
},
)
}
fn file(key: &str) -> CatalogNode {
let name = format!("{key} - 01.mkv");
CatalogNode {
share_id: "s".into(),
link_id: key.into(),
volume_id: "v".into(),
parent_link_id: None,
name: name.clone(),
is_folder: false,
media_type: Some("video/x-matroska".into()),
size: Some(1),
active_revision_id: Some("r".into()),
parsed: crate::naming::parse(&name),
}
}
const ENTRIES: [Entry; 5] = [
Entry {
key: "frieren",
genres: &["Adventure", "Drama", "Fantasy"],
studio: "Madhouse",
director: "Keiichirou Saitou",
related: &[],
},
Entry {
key: "frieren film",
genres: &["Adventure", "Drama", "Fantasy"],
studio: "Madhouse",
director: "Keiichirou Saitou",
related: &["frieren"],
},
Entry {
key: "bocchi",
genres: &["Comedy", "Music"],
studio: "CloverWorks",
director: "Keiichirou Saitou",
related: &[],
},
Entry {
key: "mushishi",
genres: &["Adventure", "Drama", "Fantasy", "Mystery"],
studio: "Artland",
director: "Hiroshi Nagahama",
related: &[],
},
Entry {
key: "lucky star",
genres: &["Comedy"],
studio: "Kyoto Animation",
director: "Yasuhiro Takemoto",
related: &[],
},
];
fn library(watched: &[&str]) -> (Library, HashMap<String, MetadataRecord>) {
let watch: HashMap<(String, String), WatchState> = watched
.iter()
.enumerate()
.map(|(at, key)| {
(
("s".to_owned(), key.to_string()),
WatchState {
position_secs: 0.0,
duration_secs: Some(1_440.0),
watched: true,
updated_at: 100 + at as i64,
},
)
})
.collect();
let library = Library::build(
ENTRIES.iter().map(|entry| file(entry.key)).collect(),
&watch,
);
// The library's keys are the folder names, normalised; the fixture's
// already are.
(library, ENTRIES.iter().map(record).collect())
}
#[test]
fn more_like_this_leaves_out_the_franchise_and_ranks_a_shared_director_and_genres() {
let (library, metadata) = library(&[]);
let similar = more_like("frieren", &library.titles, &metadata, 10);
// Mushishi shares three genres, Bocchi only the director; both the
// format.
// Lucky Star shares nothing and its film is Frieren's own franchise.
assert_eq!(similar, ["mushishi", "bocchi"]);
}
#[test]
fn one_genre_in_common_is_not_a_recommendation() {
let (library, metadata) = library(&[]);
assert!(more_like("lucky star", &library.titles, &metadata, 10).is_empty());
}
#[test]
fn because_you_watched_follows_the_last_finished_title_and_skips_what_was_started() {
let (library, metadata) = library(&["mushishi", "frieren"]);
let row =
because_you_watched(&library.titles, &metadata, TitleNames::default(), 10).unwrap();
assert_eq!(row.label, "Because you watched frieren");
// Mushishi is like it, but already watched.
assert_eq!(row.keys, ["bocchi"]);
assert!(
because_you_watched(&library.titles, &HashMap::new(), TitleNames::default(), 10)
.is_none()
);
}
#[test]
fn a_search_finds_episodes_by_name_and_the_people_and_genres_it_names() {
let (library, metadata) = library(&[]);
let guide = crate::metadata::EpisodeGuide::new(vec![crate::metadata::EpisodeMetadata {
season: None,
number: 1,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("The Journey's End".into()),
overview: None,
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}]);
let guides = HashMap::from([("frieren".to_owned(), guide)]);
let found = find(&library, &metadata, &guides, "journey");
assert_eq!(found.episodes.len(), 1);
assert_eq!(found.episodes[0].title_key, "frieren");
assert_eq!(found.episodes[0].name, "The Journey's End");
let found = find(&library, &metadata, &guides, "saitou");
assert_eq!(
found.people,
[(Facet::Person("Keiichirou Saitou".into()), 3)]
);
let found = find(&library, &metadata, &guides, "com");
assert_eq!(found.genres, [(Facet::Genre("Comedy".into()), 2)]);
assert!(find(&library, &metadata, &guides, "c").is_empty());
}
#[test]
fn a_facet_page_lists_the_titles_that_carry_it() {
let (library, metadata) = library(&[]);
let person = Facet::Person("Keiichirou Saitou".into());
assert_eq!(
person.titles(&library.titles, &metadata).len(),
3,
"both Frieren titles and Bocchi"
);
assert_eq!(person.kind(), "Person");
let genre = Facet::Genre("Comedy".into());
let mut comedy = genre.titles(&library.titles, &metadata);
comedy.sort();
assert_eq!(comedy, ["bocchi", "lucky star"]);
assert!(
Facet::Studio("MAPPA".into())
.titles(&library.titles, &metadata)
.is_empty()
);
}
}
+1
View File
@@ -177,6 +177,7 @@ mod tests {
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
+4
View File
@@ -11,14 +11,18 @@ pub mod browse;
pub mod catalog;
pub mod chapters;
pub mod config;
pub mod discover;
pub mod error;
pub mod franchise;
pub mod language;
pub mod library;
pub mod metadata;
pub mod mylist;
pub mod naming;
pub mod news;
pub mod prefs;
pub mod profile;
pub mod recent;
pub mod shares;
pub mod shelves;
pub mod subtitles;
+59
View File
@@ -18,6 +18,7 @@
use std::collections::HashMap;
use crate::catalog::{CatalogNode, WatchState};
use crate::naming;
/// A file has been watched once playback passed this fraction of it. Short of
/// 1.0 because nobody sits through the credits, and a title that never reaches
@@ -158,6 +159,34 @@ impl Title {
.sum()
}
/// What most of its files' names say about their quality — see
/// [`naming::quality`]. A fact holds when more than half the files state
/// it, so one 720p special does not mark a 1080p show down.
pub fn quality(&self) -> naming::Quality {
let stated: Vec<naming::Quality> = self
.episodes()
.map(|episode| naming::quality(&episode.node.name))
.collect();
let most = |holds: &dyn Fn(&naming::Quality) -> bool| {
stated.iter().filter(|quality| holds(quality)).count() * 2 > stated.len()
};
// The height most files have, if most have the same one.
let mut heights: HashMap<u32, usize> = HashMap::new();
for height in stated.iter().filter_map(|quality| quality.height) {
*heights.entry(height).or_default() += 1;
}
let height = heights
.into_iter()
.find(|(_, files)| files * 2 > stated.len())
.map(|(height, _)| height);
naming::Quality {
height,
hdr: most(&|quality| quality.hdr),
ten_bit: most(&|quality| quality.ten_bit),
dual_audio: most(&|quality| quality.dual_audio),
}
}
pub fn watched_count(&self) -> usize {
self.episodes().filter(|e| e.is_watched()).count()
}
@@ -567,6 +596,36 @@ mod tests {
assert_eq!(title.preceding("s1", "a"), None);
}
/// One odd file out — a 720p special, a release with no dub — does not
/// change what the title as a whole is said to be.
#[test]
fn a_title_states_the_quality_most_of_its_files_have() {
let library = Library::build(
vec![
node(
"s1",
"a",
"Show - S01E01 [1080p][Dual-Audio].mkv",
Some(1),
Some(1),
),
node(
"s1",
"b",
"Show - S01E02 [1080p][Dual-Audio].mkv",
Some(1),
Some(2),
),
node("s1", "c", "Show - S01E03 [720p].mkv", Some(1), Some(3)),
],
&HashMap::new(),
);
assert_eq!(
library.titles[0].quality().badges(),
["1080p", "Dual audio"]
);
}
#[test]
fn a_file_from_another_share_is_not_this_titles_neighbour() {
let library = Library::build(
+208 -10
View File
@@ -39,15 +39,23 @@ pub enum ProviderId {
AniList,
/// The Movie Database. Film and television, needs a free API key.
Tmdb,
/// TVmaze. Television, no API key. Never chosen on its own: it answers
/// for what AniList has not got — see `pstr_meta::service`.
TvMaze,
}
impl ProviderId {
pub const ALL: [Self; 2] = [Self::AniList, Self::Tmdb];
/// Every provider a stored row can name.
pub const ALL: [Self; 3] = [Self::AniList, Self::Tmdb, Self::TvMaze];
/// The ones a viewer picks between in the settings.
pub const CHOOSABLE: [Self; 2] = [Self::AniList, Self::Tmdb];
pub fn as_str(self) -> &'static str {
match self {
Self::AniList => "anilist",
Self::Tmdb => "tmdb",
Self::TvMaze => "tvmaze",
}
}
@@ -56,6 +64,7 @@ impl ProviderId {
match self {
Self::AniList => "AniList",
Self::Tmdb => "TMDB",
Self::TvMaze => "TVmaze",
}
}
@@ -64,9 +73,11 @@ impl ProviderId {
match self {
Self::AniList => {
"Anime, with no API key and no account. Episode names and wide art come from \
ani.zip, which is sent the matched ids."
ani.zip, which is sent the matched ids. Shows AniList does not have are \
looked up on TVmaze."
}
Self::Tmdb => "Film and television. Needs a free API key from themoviedb.org.",
Self::TvMaze => "Television, with no API key and no account.",
}
}
@@ -104,6 +115,11 @@ pub struct TitleMetadata {
/// Landscape art, around 16:9. Much rarer than a poster, and the reason the
/// grid cannot simply prefer one shape.
pub backdrop_url: Option<String>,
/// The title's name as its own lettering: a transparent PNG, drawn over
/// wide art in place of the name in type. Found by enrichment, like the
/// backdrop, and padded by its source — see `pstr-app`'s `trim_alpha`.
#[serde(default)]
pub logo_url: Option<String>,
/// Out of 10, however the provider scores.
pub rating: Option<f32>,
pub genres: Vec<String>,
@@ -161,6 +177,20 @@ pub struct TitleDetails {
/// side stories, recaps, retellings. What the library groups a franchise
/// by; see `crate::franchise`.
pub related: Vec<String>,
/// Minutes: an episode's length for a series, the whole running time for
/// a film.
pub runtime: Option<u32>,
/// The main characters and then the supporting ones, each with who voices
/// them in the original language.
pub cast: Vec<CastMember>,
/// Where the provider says its trailer is.
pub trailer: Option<Trailer>,
/// Which [`TitleDetails::REVISION`] wrote this. A stored record from an
/// older one lacks a field a newer build asks for, and is fetched again
/// by id — see `pstr_meta::service::plan`. Not a reason to blank it in a
/// migration: until the refetch lands, the old details still group the
/// library and fill its shelves.
pub revision: u32,
/// The entries enrichment walked through to find the title's episodes —
/// its own sequels, by provider id. Stored apart from the rest, because
/// it is enrichment's answer rather than the match's.
@@ -168,7 +198,63 @@ pub struct TitleDetails {
pub chain: Vec<String>,
}
/// One character, and who voices them.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CastMember {
pub character: String,
/// The character's portrait.
pub character_image: Option<String>,
/// `MAIN`, `SUPPORTING` or `BACKGROUND`.
pub role: Option<String>,
/// The voice actor in the original language.
pub actor: Option<String>,
pub actor_image: Option<String>,
}
/// A trailer, as the provider names it: a site and that site's id for it.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Trailer {
/// `youtube` or `dailymotion`.
pub site: String,
pub id: String,
}
impl Trailer {
/// The trailer's page, to open in the browser. The app itself never
/// requests it. `None` for a site this build does not know.
pub fn url(&self) -> Option<String> {
let id: String = self
.id
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
.collect();
if id.is_empty() {
return None;
}
match self.site.to_ascii_lowercase().as_str() {
"youtube" => Some(format!("https://www.youtube.com/watch?v={id}")),
"dailymotion" => Some(format!("https://www.dailymotion.com/video/{id}")),
_ => None,
}
}
}
impl TitleDetails {
/// What a provider that fills these in today writes into
/// [`TitleDetails::revision`]. Raise it with a field that existing
/// records should be fetched again for. `1` added
/// [`TitleDetails::runtime`], `2` [`TitleDetails::cast`] and
/// [`TitleDetails::trailer`].
pub const REVISION: u32 = 2;
/// Whether these were written by this build's [`TitleDetails::REVISION`]
/// or a later one.
pub fn is_current(&self) -> bool {
self.revision >= Self::REVISION
}
/// `TV` → `TV`, `MOVIE` → `Film`, `TV_SHORT` → `TV short`.
pub fn format_label(&self) -> Option<&'static str> {
Some(match self.format.as_deref()? {
@@ -201,6 +287,20 @@ impl TitleDetails {
self.status.as_deref() == Some("RELEASING")
}
/// [`TitleDetails::status`] said for a person: `Airing`, `Finished`,
/// `Upcoming`, `On hiatus` or `Cancelled`. `None` for a status nobody
/// filled in, or one this build does not know.
pub fn status_label(&self) -> Option<&'static str> {
Some(match self.status.as_deref()? {
"RELEASING" => "Airing",
"FINISHED" => "Finished",
"NOT_YET_RELEASED" => "Upcoming",
"HIATUS" => "On hiatus",
"CANCELLED" => "Cancelled",
_ => return None,
})
}
/// The next episode and when it airs, when that is still after `now`.
pub fn next_airing(&self, now: i64) -> Option<(u32, i64)> {
let at = self.next_airing_at.filter(|at| *at > now)?;
@@ -317,6 +417,14 @@ pub struct EpisodeMetadata {
/// sorted on, and a provider that answers `2009` should not be a parse
/// error.
pub air_date: Option<String>,
/// Minutes.
#[serde(default)]
pub runtime: Option<u32>,
/// The provider entry's id on MyAnimeList, which is what AniSkip knows
/// openings and endings by. With `entry_number`, it names this episode
/// there.
#[serde(default)]
pub mal_id: Option<u32>,
}
/// What enriching a matched title found: its episodes, and art the search
@@ -331,6 +439,9 @@ pub struct Enrichment {
/// A 16:9 picture of the whole title, when one was found. `None` leaves
/// whatever backdrop the match already had.
pub backdrop_url: Option<String>,
/// The title's logo, when one was found. `None` leaves whatever the match
/// already had, as with the backdrop.
pub logo_url: Option<String>,
/// Every provider entry the episodes were taken from, the match's own
/// included. See [`TitleDetails::chain`].
pub chain: Vec<String>,
@@ -496,8 +607,13 @@ pub const MISS_TTL_SECS: i64 = 60 * 60 * 24 * 3;
impl MetadataRecord {
/// Whether this answer is still worth using rather than asking again.
pub fn is_fresh(&self, now: i64, provider: ProviderId) -> bool {
if self.provider != provider {
///
/// `chain` is the providers asked, in order. A match from any of them
/// stands. A miss is stored under the last one asked, and only stands for
/// a chain that ends there: one an earlier provider stored was never
/// followed by the providers after it.
pub fn is_fresh(&self, now: i64, chain: &[ProviderId]) -> bool {
if !chain.contains(&self.provider) {
return false;
}
// A hand-picked match does not go stale: nothing a re-ask could learn
@@ -507,8 +623,10 @@ impl MetadataRecord {
}
let ttl = if self.metadata.is_some() {
MATCH_TTL_SECS
} else {
} else if chain.last() == Some(&self.provider) {
MISS_TTL_SECS
} else {
return false;
};
now.saturating_sub(self.fetched_at) < ttl
}
@@ -539,6 +657,57 @@ impl Default for MetadataConfig {
mod tests {
use super::*;
#[test]
fn a_trailer_opens_on_its_own_site_and_an_unknown_site_or_odd_id_opens_nothing() {
let trailer = |site: &str, id: &str| Trailer {
site: site.into(),
id: id.into(),
};
assert_eq!(
trailer("youtube", "tR8YH0G67Rk").url().as_deref(),
Some("https://www.youtube.com/watch?v=tR8YH0G67Rk")
);
assert_eq!(
trailer("dailymotion", "x7tgad0").url().as_deref(),
Some("https://www.dailymotion.com/video/x7tgad0")
);
assert_eq!(trailer("vimeo", "1").url(), None);
// Whatever the provider sent, only an id's own characters reach the
// address.
assert_eq!(
trailer("youtube", "a&b=c").url().as_deref(),
Some("https://www.youtube.com/watch?v=abc")
);
assert_eq!(trailer("youtube", "&?").url(), None);
}
#[test]
fn details_stored_before_the_cast_existed_still_read() {
let old = r#"{"studios":["MAPPA"],"revision":1}"#;
let details: TitleDetails = serde_json::from_str(old).unwrap();
assert!(details.cast.is_empty());
assert_eq!(details.trailer, None);
assert!(!details.is_current(), "fetched again for the cast");
}
#[test]
fn a_status_is_said_for_a_person_and_an_unknown_one_is_not_said_at_all() {
let status = |value: &str| {
TitleDetails {
status: Some(value.to_owned()),
..TitleDetails::default()
}
.status_label()
};
assert_eq!(status("RELEASING"), Some("Airing"));
assert_eq!(status("FINISHED"), Some("Finished"));
assert_eq!(status("NOT_YET_RELEASED"), Some("Upcoming"));
assert_eq!(status("HIATUS"), Some("On hiatus"));
assert_eq!(status("CANCELLED"), Some("Cancelled"));
assert_eq!(status("SOMETHING_NEW"), None);
assert_eq!(TitleDetails::default().status_label(), None);
}
fn metadata() -> TitleMetadata {
TitleMetadata {
provider: ProviderId::AniList,
@@ -550,6 +719,7 @@ mod tests {
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: Some(26),
@@ -569,6 +739,8 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}
}
@@ -752,13 +924,13 @@ mod tests {
fetched_at: now - MISS_TTL_SECS - 1,
manual: false,
};
assert!(!miss.is_fresh(now, ProviderId::AniList));
assert!(!miss.is_fresh(now, &[ProviderId::AniList]));
let matched = MetadataRecord {
metadata: Some(metadata()),
..miss.clone()
};
assert!(matched.is_fresh(now, ProviderId::AniList));
assert!(matched.is_fresh(now, &[ProviderId::AniList]));
}
/// What the viewer picked by hand outlives every TTL: it is not an answer
@@ -772,9 +944,9 @@ mod tests {
fetched_at: 0,
manual: true,
};
assert!(record.is_fresh(MATCH_TTL_SECS * 100, ProviderId::AniList));
assert!(record.is_fresh(MATCH_TTL_SECS * 100, &[ProviderId::AniList]));
// But it is still one provider's id, and it means nothing to the other.
assert!(!record.is_fresh(MATCH_TTL_SECS * 100, ProviderId::Tmdb));
assert!(!record.is_fresh(MATCH_TTL_SECS * 100, &[ProviderId::Tmdb]));
}
/// Switching provider must re-ask rather than trust the other one's answer —
@@ -788,6 +960,32 @@ mod tests {
fetched_at: 1_000_000_000,
manual: false,
};
assert!(!record.is_fresh(1_000_000_000, ProviderId::Tmdb));
assert!(!record.is_fresh(1_000_000_000, &[ProviderId::Tmdb]));
}
/// AniList's miss was stored before TVmaze followed it, and so says
/// nothing about what TVmaze has; TVmaze's miss, last in the chain, does.
/// A match from either stands.
#[test]
fn a_miss_only_stands_when_the_last_provider_asked_stored_it() {
let chain = [ProviderId::AniList, ProviderId::TvMaze];
let now = 1_000_000_000;
let miss = |provider| MetadataRecord {
title_key: "game of thrones".into(),
provider,
metadata: None,
fetched_at: now,
manual: false,
};
assert!(!miss(ProviderId::AniList).is_fresh(now, &chain));
assert!(miss(ProviderId::TvMaze).is_fresh(now, &chain));
assert!(miss(ProviderId::AniList).is_fresh(now, &[ProviderId::AniList]));
let found = |provider| MetadataRecord {
metadata: Some(metadata()),
..miss(provider)
};
assert!(found(ProviderId::AniList).is_fresh(now, &chain));
assert!(found(ProviderId::TvMaze).is_fresh(now, &chain));
}
}
+141
View File
@@ -0,0 +1,141 @@
//! My List: the titles the viewer put aside to watch, kept in
//! `my-list.json` beside the other settings.
//!
//! Keyed by title key, like a hand-picked match, so a title keeps its place on
//! the list across a recrawl and on every device that has the same share. A
//! title that leaves the library stays on the list, unseen, and is back on it
//! when the share brings it back.
//!
//! It travels with the profile, one `list/<title key>` register per title
//! ([`crate::profile`]): adding a title on the phone puts it on the laptop's
//! list, and taking it off on one takes it off on all of them.
use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::config::{AppDirs, read_json, write_json};
use crate::error::Result;
/// The titles on the list, by key, with when each was put there in Unix
/// seconds.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MyList {
#[serde(default)]
titles: BTreeMap<String, i64>,
}
impl MyList {
pub fn contains(&self, key: &str) -> bool {
self.titles.contains_key(key)
}
pub fn is_empty(&self) -> bool {
self.titles.is_empty()
}
/// When `key` was put on the list, if it is on it.
pub fn added_at(&self, key: &str) -> Option<i64> {
self.titles.get(key).copied()
}
/// Put `key` on the list at `at`. One already there keeps its place.
pub fn add(&mut self, key: &str, at: i64) {
self.titles.entry(key.to_owned()).or_insert(at);
}
pub fn remove(&mut self, key: &str) {
self.titles.remove(key);
}
/// Put `key` on the list or take it off, saying which.
pub fn toggle(&mut self, key: &str, at: i64) -> bool {
if self.contains(key) {
self.remove(key);
false
} else {
self.add(key, at);
true
}
}
/// Every key with when it was added, in key order.
pub fn entries(&self) -> impl Iterator<Item = (&str, i64)> {
self.titles.iter().map(|(key, at)| (key.as_str(), *at))
}
/// The keys, most recently added first: the order the row is in.
pub fn newest_first(&self) -> Vec<&str> {
let mut keys: Vec<(&str, i64)> = self.entries().collect();
keys.sort_by_key(|(_, at)| std::cmp::Reverse(*at));
keys.into_iter().map(|(key, _)| key).collect()
}
/// Set `key`'s entry as another device had it: on the list since `at`,
/// or, with `None`, off it.
pub(crate) fn set(&mut self, key: &str, at: Option<i64>) {
match at {
Some(at) => {
self.titles.insert(key.to_owned(), at);
}
None => self.remove(key),
}
}
}
pub(crate) fn file(dirs: &AppDirs) -> PathBuf {
dirs.config.join("my-list.json")
}
/// The list, empty when there is no file yet.
pub fn load(dirs: &AppDirs) -> Result<MyList> {
Ok(read_json::<MyList>(&file(dirs))?.unwrap_or_default())
}
pub fn save(dirs: &AppDirs, list: &MyList) -> Result<()> {
write_json(&file(dirs), list)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_title_added_twice_keeps_the_time_it_was_first_added() {
let mut list = MyList::default();
list.add("frieren", 10);
list.add("frieren", 20);
assert_eq!(list.added_at("frieren"), Some(10));
}
#[test]
fn toggling_puts_a_title_on_the_list_and_takes_it_off_again() {
let mut list = MyList::default();
assert!(list.toggle("frieren", 10));
assert!(list.contains("frieren"));
assert!(!list.toggle("frieren", 20));
assert!(list.is_empty());
}
#[test]
fn the_row_shows_the_most_recently_added_title_first() {
let mut list = MyList::default();
list.add("a", 30);
list.add("b", 10);
list.add("c", 20);
assert_eq!(list.newest_first(), ["a", "c", "b"]);
}
#[test]
fn the_list_survives_a_save_and_a_load() {
let root = std::env::temp_dir().join(format!("pstr-mylist-{}", std::process::id()));
let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap();
assert!(load(&dirs).unwrap().is_empty());
let mut list = MyList::default();
list.add("one piece", 5);
save(&dirs, &list).unwrap();
assert_eq!(load(&dirs).unwrap(), list);
let _ = std::fs::remove_dir_all(&root);
}
}
+168 -10
View File
@@ -22,8 +22,9 @@ pub struct ParsedName {
/// Release year, from a parenthesised or bracketed four-digit group.
pub year: Option<u32>,
/// The episode's own name, when the filename states one after the
/// numbering (`S01E01-Mother and Children`). Never the quality and codec
/// tags that follow numbering in scene releases — see [`episode_title`].
/// numbering (`S01E01-Mother and Children`, `S01E01.Winter.Is.Coming.2160p`).
/// Never the quality and codec tags that follow numbering in scene
/// releases — see [`episode_title`].
pub episode_title: Option<String>,
}
@@ -98,17 +99,21 @@ struct NumberingMatch {
/// The episode's own name out of what followed the numbering.
///
/// Only taken when a dash introduces it. Scene releases separate their quality,
/// source and codec tags with the same character as their title words (`.`), so
/// without that requirement `Show.Name.S02E05.1080p.WEB-DL.x265` would yield an
/// "episode title" of `1080p WEB-DL x265`.
/// Taken when a dash introduces it, or from a scene release's dotted words up
/// to its first release tag. Scene releases separate their quality, source and
/// codec tags with the same character as their title words (`.`), so the tag
/// is what ends the name: `Show.S01E01.Winter.Is.Coming.2160p.BluRay` names
/// its episode, `Show.Name.S02E05.1080p.WEB-DL.x265` does not, and a dotted
/// run that no tag ends is not trusted to be a name at all.
fn episode_title(tail: &str) -> Option<String> {
let rest = tail.trim_start();
let rest = rest
let cleaned = match rest
.strip_prefix('-')
.or_else(|| rest.strip_prefix('\u{2013}'))?;
let cleaned = clean_title(rest, None);
.or_else(|| rest.strip_prefix('\u{2013}'))
{
Some(rest) => clean_title(rest, None),
None => dotted_title(rest.strip_prefix('.')?)?,
};
let cleaned = cleaned.trim().to_string();
if cleaned.is_empty() {
None
@@ -117,6 +122,110 @@ fn episode_title(tail: &str) -> Option<String> {
}
}
/// `Winter.Is.Coming.2160p.UHD…` → `Winter Is Coming`.
fn dotted_title(rest: &str) -> Option<String> {
let words: Vec<&str> = rest.split('.').collect();
let end = words.iter().position(|word| is_release_tag(word))?;
Some(words[..end].join(" "))
}
/// Whether a dotted word is a release's quality, source or codec rather than
/// part of a name.
fn is_release_tag(word: &str) -> bool {
const TAGS: &[&str] = &[
"4k", "uhd", "bluray", "blu-ray", "bdrip", "brrip", "bdremux", "remux", "web", "web-dl",
"webdl", "webrip", "hdtv", "dvdrip", "hdrip", "x264", "x265", "h264", "h265", "hevc",
"avc", "hdr", "hdr10", "dv", "10bit", "10-bit", "proper", "repack", "amzn", "nf", "dsnp",
"hmax", "atvp",
];
let word = word.to_ascii_lowercase();
let resolution = word.strip_suffix('p').is_some_and(|digits| {
(3..=4).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_digit())
});
resolution || TAGS.contains(&word.as_str())
}
/// What a release name says about its picture and sound.
///
/// Read for the facts line of a title's page, and nothing else: no release
/// group's name is ever taken, and a name that says nothing yields the default.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Quality {
/// Lines of picture: `2160` for `2160p`, `4K` or `UHD`.
pub height: Option<u32>,
/// `HDR`, `HDR10`, `HDR10+` or Dolby Vision.
pub hdr: bool,
pub ten_bit: bool,
/// Two audio languages, as anime releases mark a dub beside the original.
pub dual_audio: bool,
}
impl Quality {
/// The short labels to show, in a fixed order: `4K`, `HDR`, `10-bit`,
/// `Dual audio`.
pub fn badges(&self) -> Vec<&'static str> {
let resolution = self.height.map(|height| match height {
2160.. => "4K",
1440.. => "1440p",
1080.. => "1080p",
720.. => "720p",
_ => "SD",
});
resolution
.into_iter()
.chain(self.hdr.then_some("HDR"))
.chain(self.ten_bit.then_some("10-bit"))
.chain(self.dual_audio.then_some("Dual audio"))
.collect()
}
}
/// The [`Quality`] a file name states.
///
/// Words are taken apart on everything but letters, digits and `+`, so
/// `[1080p][10-bit]`, `.10bit.` and `(BD 1080p HEVC 10bit Dual Audio)` read
/// the same.
pub fn quality(file_name: &str) -> Quality {
let lower = strip_extension(file_name).to_ascii_lowercase();
let words: Vec<&str> = lower
.split(|c: char| !c.is_ascii_alphanumeric() && c != '+')
.filter(|word| !word.is_empty())
.collect();
let mut quality = Quality::default();
for (index, word) in words.iter().enumerate() {
let next = words.get(index + 1).copied();
let height = match *word {
"4k" | "uhd" => Some(2160),
_ => resolution(word),
};
if let Some(height) = height {
quality.height = quality.height.max(Some(height));
}
match (*word, next) {
("hdr" | "hdr10" | "hdr10+" | "dv" | "dovi", _) | ("dolby", Some("vision")) => {
quality.hdr = true;
}
("10bit" | "10bits" | "hi10" | "hi10p", _) | ("10", Some("bit" | "bits")) => {
quality.ten_bit = true;
}
("dualaudio", _) | ("dual" | "multi", Some("audio")) => quality.dual_audio = true,
_ => {}
}
}
quality
}
/// `1080` out of `1080p`, and out of `bd1080p`, which some groups write.
fn resolution(word: &str) -> Option<u32> {
let digits = word
.strip_suffix('p')?
.trim_start_matches(|c: char| c.is_ascii_alphabetic());
if !(3..=4).contains(&digits.len()) {
return None;
}
digits.parse().ok()
}
/// Drop a trailing extension, but only one that looks like an extension.
///
/// A bare `rsplit('.')` would eat the tail of `Serial.Experiments.Lain`, so the
@@ -1112,6 +1221,55 @@ mod tests {
assert_eq!(p.title, "Show Name");
}
/// A scene release names its episode in dotted words before its tags, and
/// the first tag is where the name ends.
#[test]
fn a_dotted_episode_name_before_the_release_tags_is_captured() {
let p = parse(
"Game.of.Thrones.S01E01.Winter.Is.Coming.2160p.UHD.BluRay.10bit.HDR.x265-GRP.mkv",
);
assert_eq!(p.title, "Game of Thrones");
assert_eq!((p.season, p.episode), (Some(1), Some(1)));
assert_eq!(p.episode_title.as_deref(), Some("Winter Is Coming"));
let p = parse("Game.of.Thrones.S06E05.The.Door.1080p.WEB-DL.mkv");
assert_eq!(p.episode_title.as_deref(), Some("The Door"));
}
/// Without a tag to end it, a dotted run could be a group's name or
/// anything else, and is not taken.
#[test]
fn a_dotted_run_no_tag_ends_is_not_an_episode_name() {
assert_eq!(parse("Show.S01E01.Pilot.mkv").episode_title, None);
}
#[test]
fn a_scene_release_states_4k_hdr_and_10_bit() {
let stated =
quality("Game.of.Thrones.S01E01.Winter.Is.Coming.2160p.UHD.BluRay.x265.10bit.HDR.mkv");
assert_eq!(stated.badges(), ["4K", "HDR", "10-bit"]);
}
#[test]
fn a_fansub_release_states_its_tags_in_brackets() {
let bracketed =
quality("[Group] Show - S01E01 [1080p][BD][x265][10-bit][Dual-Audio][1A2B3C4D].mkv");
assert_eq!(bracketed.badges(), ["1080p", "10-bit", "Dual audio"]);
let spaced = quality("Film (BD 1080p HEVC 10bit Dual Audio).mkv");
assert_eq!(spaced.badges(), ["1080p", "10-bit", "Dual audio"]);
assert_eq!(
quality("[Group] Show - 01 [BD1080p].mkv").height,
Some(1080)
);
}
/// Most of a real library: a group, a number and nothing else to say.
#[test]
fn a_name_with_no_tags_states_nothing() {
assert_eq!(quality("[Group] Bleach - S17E01.mkv"), Quality::default());
assert!(Quality::default().badges().is_empty());
}
/// A library that names its files with nothing but the episode marker.
#[test]
fn a_bare_episode_marker_is_read_as_an_episode() {
+304
View File
@@ -0,0 +1,304 @@
//! What arrived since the viewer last looked: the **New** and **3 new**
//! badges, the "New episodes" and "Recently added" rows, and the countdown on
//! an airing show's tile.
//!
//! All of it is read off [`crate::catalog::Catalog::first_seen`], the time a
//! crawl first found each file. A file the first crawl of its share found is
//! not new, however recent that crawl: it was there before this device looked,
//! and a share added today would otherwise badge its whole library.
use std::collections::HashMap;
use crate::library::{Episode, Title, TitleKind};
use crate::shelves::Shelf;
/// How long a title that arrived stays **New**, unless it is started first.
pub const NEW_FOR: i64 = 14 * 86_400;
/// What a tile says about what arrived.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Badge {
/// A title that arrived in the last [`NEW_FOR`] and was never played.
New,
/// A series being watched, with this many unwatched episodes that arrived
/// after it was last played.
Episodes(usize),
}
impl Badge {
pub fn label(self) -> String {
match self {
Self::New => "New".to_owned(),
Self::Episodes(count) => format!("{count} new"),
}
}
}
/// When each file arrived, read against when its share was first crawled.
pub struct Arrivals<'a> {
first_seen: &'a HashMap<(String, String), i64>,
/// The earliest sighting in each share: its first crawl.
first_crawl: HashMap<&'a str, i64>,
}
impl<'a> Arrivals<'a> {
pub fn new(first_seen: &'a HashMap<(String, String), i64>) -> Self {
let mut first_crawl: HashMap<&str, i64> = HashMap::new();
for ((share, _), seen) in first_seen {
first_crawl
.entry(share.as_str())
.and_modify(|first| *first = (*first).min(*seen))
.or_insert(*seen);
}
Self {
first_seen,
first_crawl,
}
}
/// When `episode`'s file arrived, if a crawl after its share's first one
/// found it.
pub fn arrived(&self, episode: &Episode) -> Option<i64> {
let node = &episode.node;
let seen = *self
.first_seen
.get(&(node.share_id.clone(), node.link_id.clone()))?;
let first = self.first_crawl.get(node.share_id.as_str())?;
(seen > *first).then_some(seen)
}
/// The badge for `title`, if it has one.
pub fn badge(&self, title: &Title, now: i64) -> Option<Badge> {
let played = title.last_played();
if played == 0 {
// New only if every file of it arrived: a show the first crawl
// found is not new because a crawl since added an episode.
let arrived: Option<Vec<i64>> = title
.episodes()
.map(|episode| self.arrived(episode))
.collect();
let first = arrived?.into_iter().min()?;
return (now - first < NEW_FOR).then_some(Badge::New);
}
if title.kind != TitleKind::Series {
return None;
}
let count = title
.episodes()
.filter(|episode| !episode.is_watched())
.filter(|episode| self.arrived(episode).is_some_and(|at| at > played))
.count();
(count > 0).then_some(Badge::Episodes(count))
}
/// When `title` last gained a file that counts as arrived.
fn newest(&self, title: &Title) -> Option<i64> {
title
.episodes()
.filter_map(|episode| self.arrived(episode))
.max()
}
}
/// Every badge in `titles`, by key.
pub fn badges(
titles: &[Title],
first_seen: &HashMap<(String, String), i64>,
now: i64,
) -> HashMap<String, Badge> {
let arrivals = Arrivals::new(first_seen);
titles
.iter()
.filter_map(|title| Some((title.key.clone(), arrivals.badge(title, now)?)))
.collect()
}
/// The two rows of what arrived, newest arrival first. `None` for a row with
/// nothing in it.
#[derive(Debug, Default)]
pub struct Rows {
/// "New episodes": the shows being watched that gained episodes.
pub episodes: Option<Shelf>,
/// "Recently added": the titles that are [`Badge::New`].
pub added: Option<Shelf>,
}
pub fn rows(titles: &[Title], first_seen: &HashMap<(String, String), i64>, now: i64) -> Rows {
let arrivals = Arrivals::new(first_seen);
let mut episodes: Vec<(&Title, i64)> = Vec::new();
let mut added: Vec<(&Title, i64)> = Vec::new();
for title in titles {
let (Some(badge), Some(newest)) = (arrivals.badge(title, now), arrivals.newest(title))
else {
continue;
};
match badge {
Badge::Episodes(_) => episodes.push((title, newest)),
Badge::New => added.push((title, newest)),
}
}
let shelf = |label: &str, mut row: Vec<(&Title, i64)>| {
row.sort_by_key(|(_, newest)| std::cmp::Reverse(*newest));
(!row.is_empty()).then(|| Shelf {
label: label.to_owned(),
keys: row
.into_iter()
.map(|(title, _)| title.key.clone())
.collect(),
})
};
Rows {
episodes: shelf("New episodes", episodes),
added: shelf("Recently added", added),
}
}
/// What an airing show's tile says about its next episode: `Ep 8 · in 2 d`,
/// `Ep 8 · in 5 h`, `Ep 8 · soon`.
pub fn countdown(episode: u32, at: i64, now: i64) -> String {
let left = (at - now).max(0);
let when = match left {
0..3_600 => "soon".to_owned(),
3_600..86_400 => format!("in {} h", left / 3_600),
_ => format!("in {} d", left / 86_400),
};
format!("Ep {episode} · {when}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::{CatalogNode, WatchState};
use crate::library::Library;
const DAY: i64 = 86_400;
fn file(share: &str, link: &str, title: &str, episode: u32) -> CatalogNode {
CatalogNode {
share_id: share.into(),
link_id: link.into(),
volume_id: "v".into(),
parent_link_id: None,
name: format!("{title} - {episode:02}.mkv"),
is_folder: false,
media_type: Some("video/x-matroska".into()),
size: Some(1),
active_revision_id: Some("r".into()),
parsed: crate::naming::parse(&format!("{title} - {episode:02}.mkv")),
}
}
fn watched(at: i64) -> WatchState {
WatchState {
position_secs: 1_400.0,
duration_secs: Some(1_440.0),
watched: true,
updated_at: at,
}
}
/// One share whose first crawl at day 1 found Frieren 1–2, and Dandadan 1
/// arriving at day 20 next to Frieren 3.
fn share(frieren_played: Option<i64>) -> (Library, HashMap<(String, String), i64>) {
let files = vec![
file("s", "f1", "Frieren", 1),
file("s", "f2", "Frieren", 2),
file("s", "f3", "Frieren", 3),
file("s", "d1", "Dandadan", 1),
];
let mut watch = HashMap::new();
if let Some(at) = frieren_played {
watch.insert(("s".to_owned(), "f1".to_owned()), watched(at));
watch.insert(("s".to_owned(), "f2".to_owned()), watched(at));
}
let seen = [("f1", 1), ("f2", 1), ("f3", 20), ("d1", 20)]
.into_iter()
.map(|(link, day)| (("s".to_owned(), link.to_owned()), day * DAY))
.collect();
(Library::build(files, &watch), seen)
}
fn named<'a>(library: &'a Library, name: &str) -> &'a Title {
library
.titles
.iter()
.find(|title| title.name == name)
.unwrap()
}
#[test]
fn what_the_first_crawl_of_a_share_found_is_never_new() {
let (library, seen) = share(None);
let arrivals = Arrivals::new(&seen);
let frieren = named(&library, "Frieren");
assert_eq!(arrivals.badge(frieren, 21 * DAY), None);
}
#[test]
fn a_title_that_arrived_is_new_for_two_weeks_unless_it_is_started() {
let (library, seen) = share(None);
let arrivals = Arrivals::new(&seen);
let dandadan = named(&library, "Dandadan");
assert_eq!(arrivals.badge(dandadan, 21 * DAY), Some(Badge::New));
assert_eq!(arrivals.badge(dandadan, 20 * DAY + NEW_FOR), None);
}
#[test]
fn a_show_being_watched_counts_the_episodes_that_arrived_after_the_last_watch() {
let (library, seen) = share(Some(10 * DAY));
let arrivals = Arrivals::new(&seen);
let frieren = named(&library, "Frieren");
assert_eq!(arrivals.badge(frieren, 21 * DAY), Some(Badge::Episodes(1)));
assert_eq!(Badge::Episodes(1).label(), "1 new");
// Watched after episode 3 arrived: nothing is new to the viewer.
let (library, seen) = share(Some(25 * DAY));
let arrivals = Arrivals::new(&seen);
assert_eq!(arrivals.badge(named(&library, "Frieren"), 26 * DAY), None);
}
#[test]
fn the_rows_hold_new_episodes_and_recent_arrivals_and_leave_out_an_empty_one() {
let (library, seen) = share(Some(10 * DAY));
let found = rows(&library.titles, &seen, 21 * DAY);
let labels: Vec<(&str, Vec<&str>)> = [&found.episodes, &found.added]
.into_iter()
.flatten()
.map(|row| {
let names = row
.keys
.iter()
.map(|key| {
library
.titles
.iter()
.find(|title| &title.key == key)
.unwrap()
.name
.as_str()
})
.collect();
(row.label.as_str(), names)
})
.collect();
assert_eq!(
labels,
[
("New episodes", vec!["Frieren"]),
("Recently added", vec!["Dandadan"])
]
);
let (library, seen) = share(None);
let found = rows(&library.titles, &seen, 21 * DAY);
assert!(found.episodes.is_none());
assert_eq!(found.added.unwrap().label, "Recently added");
}
#[test]
fn the_countdown_says_days_then_hours_then_soon() {
assert_eq!(countdown(8, 3 * DAY + 100, 0), "Ep 8 · in 3 d");
assert_eq!(countdown(8, 5 * 3_600 + 10, 0), "Ep 8 · in 5 h");
assert_eq!(countdown(8, 600, 0), "Ep 8 · soon");
}
}
+96 -6
View File
@@ -1,6 +1,6 @@
//! The rest of what makes the app look the same on every device: the shares,
//! the settings, the hand-picked metadata matches and the per-show track
//! choices. Synced next to watch history by [`crate::sync`], in the same file.
//! the settings, the hand-picked metadata matches, the per-show track choices
//! and My List. Synced next to watch history by [`crate::sync`], in the same file.
//!
//! ## Registers
//!
@@ -8,8 +8,8 @@
//! `settings/playback`, `tracks/<title>`, …), its latest value, and when it
//! last changed. Merging is the same rule as watch history — the newer
//! `updated_at` wins, per key — so it is idempotent and order-free across any
//! number of devices. A removed share is a register with no value, so a
//! removal travels like any other change.
//! number of devices. A removed share, or a title taken off My List, is a
//! register with no value, so a removal travels like any other change.
//!
//! ## Noticing changes
//!
@@ -56,6 +56,7 @@ use crate::shares::{AccountFolder, Share, ShareSecrets, ShareStore};
const SHARE: &str = "share/";
const TRACKS: &str = "tracks/";
const MATCH: &str = "match/";
const LIST: &str = "list/";
/// The settings files that travel, by register, and the fields in each that
/// stay on the device that wrote them.
@@ -98,6 +99,12 @@ struct SyncedShare {
folder: Option<AccountFolder>,
}
/// A title on My List.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SyncedListEntry {
added_at: i64,
}
/// A match the viewer picked by hand. Automatic ones are not synced: every
/// device with enrichment on finds them again, and they expire anyway.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -123,6 +130,8 @@ pub struct ProfileChanges {
pub settings: bool,
/// Hand-picked matches and per-show track choices taken.
pub titles: usize,
/// Whether My List changed.
pub list: bool,
}
impl ProfileChanges {
@@ -154,6 +163,8 @@ struct Observed {
/// Whether the share list itself could be read. Without it, a share
/// missing from `values` says nothing about whether it was removed.
shares_listed: bool,
/// The same for My List.
list_read: bool,
}
fn state_file(dirs: &AppDirs) -> PathBuf {
@@ -248,6 +259,18 @@ impl Profile<'_> {
}
}
match crate::mylist::load(self.dirs()) {
Ok(list) => {
observed.list_read = true;
for (title, added_at) in list.entries() {
if let Ok(value) = serde_json::to_value(SyncedListEntry { added_at }) {
observed.values.insert(format!("{LIST}{title}"), value);
}
}
}
Err(error) => tracing::warn!(%error, "My List was not synced"),
}
let catalog = self.catalog.lock();
if let Ok(tracks) = catalog.all_title_track_prefs() {
for (title, prefs) in tracks {
@@ -320,6 +343,19 @@ impl Profile<'_> {
}
return Ok(());
}
if let Some(title) = key.strip_prefix(LIST) {
let added_at = value
.map(parse::<SyncedListEntry>)
.transpose()?
.map(|entry| entry.added_at);
let mut list = crate::mylist::load(self.dirs())?;
if list.added_at(title) != added_at {
list.set(title, added_at);
crate::mylist::save(self.dirs(), &list)?;
changes.list = true;
}
return Ok(());
}
if key == TMDB_KEY {
match value.and_then(Value::as_str) {
Some(secret) => self.store.secrets.set(&tmdb_secret(), secret)?,
@@ -428,9 +464,10 @@ impl Profile<'_> {
/// Record what changed on this device since the last sync, stamped `now`.
fn stamp(registers: &mut Registers, observed: Observed, now: i64) {
for (key, register) in registers.iter_mut() {
let removable = (key.starts_with(SHARE) && observed.shares_listed)
|| (key.starts_with(LIST) && observed.list_read);
if register.value.is_some()
&& key.starts_with(SHARE)
&& observed.shares_listed
&& removable
&& !observed.values.contains_key(key)
&& !observed.unknown.contains(key)
{
@@ -747,6 +784,7 @@ pub(crate) mod tests {
kind: crate::library::TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
@@ -788,6 +826,58 @@ pub(crate) mod tests {
);
}
#[test]
fn a_title_put_on_my_list_on_one_device_is_on_the_list_of_another() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 1);
let mut list = crate::mylist::MyList::default();
list.add("frieren", 5);
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
let (sent, _) = laptop.sync(&[], 10);
let (_, changes) = phone.sync(&[&sent], 20);
assert!(changes.list);
let taken = crate::mylist::load(&phone.store.dirs).unwrap();
assert_eq!(taken.added_at("frieren"), Some(5));
}
#[test]
fn a_title_taken_off_my_list_on_one_device_leaves_every_list() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
let mut list = crate::mylist::MyList::default();
list.add("frieren", 5);
list.add("one piece", 6);
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
let (sent, _) = laptop.sync(&[], 10);
let (phone_sent, _) = phone.sync(&[&sent], 20);
let mut list = crate::mylist::load(&phone.store.dirs).unwrap();
list.remove("frieren");
crate::mylist::save(&phone.store.dirs, &list).unwrap();
let (phone_sent, _) = phone.sync(&[&sent, &phone_sent], 30);
let (sent, changes) = laptop.sync(&[&phone_sent], 40);
assert!(changes.list);
let left = crate::mylist::load(&laptop.store.dirs).unwrap();
assert!(!left.contains("frieren"));
assert!(left.contains("one piece"));
// Put back later, it is back everywhere: the removal is older.
let mut list = left;
list.add("frieren", 50);
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
let (sent, _) = laptop.sync(&[&sent], 50);
phone.sync(&[&sent], 60);
assert!(
crate::mylist::load(&phone.store.dirs)
.unwrap()
.contains("frieren")
);
}
#[test]
fn a_synced_share_that_names_another_id_is_refused() {
let phone = Device::new("phone");
+81
View File
@@ -0,0 +1,81 @@
//! The last few searches, offered again under an empty search box.
//!
//! Kept on this device only, in `recent-searches.json` beside the other
//! settings, and never synced: what someone typed is not a setting, and
//! another device's list is not what they were just looking for.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::config::{AppDirs, read_json, write_json};
use crate::error::Result;
/// How many searches are kept.
pub const KEEP: usize = 8;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecentSearches {
/// Newest first.
#[serde(default)]
pub queries: Vec<String>,
}
impl RecentSearches {
/// Put `query` first, saying whether the list changed. The same search
/// in other letter case moves rather than repeats, and one under two
/// letters is not kept.
pub fn remember(&mut self, query: &str) -> bool {
let query = query.trim();
if query.chars().count() < 2 {
return false;
}
if self.queries.first().map(String::as_str) == Some(query) {
return false;
}
let lower = query.to_lowercase();
self.queries.retain(|kept| kept.to_lowercase() != lower);
self.queries.insert(0, query.to_owned());
self.queries.truncate(KEEP);
true
}
}
fn file(dirs: &AppDirs) -> PathBuf {
dirs.config.join("recent-searches.json")
}
/// The list, empty when there is no file yet.
pub fn load(dirs: &AppDirs) -> Result<RecentSearches> {
Ok(read_json::<RecentSearches>(&file(dirs))?.unwrap_or_default())
}
pub fn save(dirs: &AppDirs, recent: &RecentSearches) -> Result<()> {
write_json(&file(dirs), recent)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_search_repeated_moves_to_the_front_instead_of_appearing_twice() {
let mut recent = RecentSearches::default();
assert!(recent.remember("frieren"));
assert!(recent.remember("kon"));
assert!(recent.remember("Frieren "));
assert_eq!(recent.queries, ["Frieren", "kon"]);
assert!(!recent.remember("Frieren"));
assert!(!recent.remember("x"));
}
#[test]
fn only_the_newest_searches_are_kept() {
let mut recent = RecentSearches::default();
for at in 0..KEEP + 3 {
recent.remember(&format!("search {at}"));
}
assert_eq!(recent.queries.len(), KEEP);
assert_eq!(recent.queries[0], format!("search {}", KEEP + 2));
}
}
+62
View File
@@ -135,6 +135,46 @@ pub fn pick(
picked
}
/// How many titles the "Because you watched" row offers.
const BECAUSE: usize = 12;
/// Every row above the library grid, in the order all three apps show them:
/// new episodes of what is being watched, My List, what was recently added,
/// what is like the last thing finished, then up to `limit` [`pick`]ed
/// shelves.
pub fn home(
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
names: crate::metadata::TitleNames,
list: &crate::mylist::MyList,
first_seen: &HashMap<(String, String), i64>,
now: i64,
limit: usize,
) -> Vec<Shelf> {
let news = crate::news::rows(titles, first_seen, now);
let present: std::collections::HashSet<&str> =
titles.iter().map(|title| title.key.as_str()).collect();
let keys: Vec<String> = list
.newest_first()
.into_iter()
.filter(|key| present.contains(key))
.map(str::to_owned)
.collect();
let mine = (!keys.is_empty()).then(|| Shelf {
label: "My List".into(),
keys,
});
news.episodes
.into_iter()
.chain(mine)
.chain(news.added)
.chain(crate::discover::because_you_watched(
titles, metadata, names, BECAUSE,
))
.chain(pick(titles, metadata, limit))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -168,6 +208,7 @@ mod tests {
kind: TitleKind::Film,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: genres.iter().map(|genre| genre.to_string()).collect(),
episodes: None,
@@ -258,4 +299,25 @@ mod tests {
assert_eq!(shelves[0].label, "Airing now");
assert_eq!(shelves[0].keys, vec!["a"]);
}
#[test]
fn my_list_is_a_row_of_what_the_library_holds_newest_first() {
let titles = vec![title("a"), title("b")];
let mut list = crate::mylist::MyList::default();
list.add("a", 1);
list.add("b", 3);
list.add("gone", 5);
let rows = home(
&titles,
&HashMap::new(),
Default::default(),
&list,
&HashMap::new(),
10,
4,
);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].label, "My List");
assert_eq!(rows[0].keys, ["b", "a"]);
}
}
+150 -7
View File
@@ -18,7 +18,8 @@ use std::collections::HashSet;
use pstr_core::library::{self, TitleKind};
use pstr_core::metadata::{
Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
CastMember, Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
Trailer,
};
use serde::Deserialize;
@@ -27,6 +28,7 @@ use crate::error::{Error, Result};
use crate::limiter::RateLimiter;
use crate::matching::{Candidate, Query};
use crate::provider::Provider;
use crate::tmdb::{Tmdb, TmdbId};
const ENDPOINT: &str = "https://graphql.anilist.co";
@@ -59,7 +61,9 @@ const SCORE_SCALE: f32 = 10.0;
/// Everything one entry is asked for, by search and by id alike.
///
/// The relations are the franchise's — see `pstr_core::franchise` — and the
/// staff are only the most relevant few, which is where a director sits.
/// staff are only the most relevant few, which is where a director sits. The
/// characters are the first ten by role, main ones first, each with their
/// Japanese voice actor.
macro_rules! entry_fragment {
() => {
r#"
@@ -77,11 +81,20 @@ fragment entry on Media {
genres
tags { name rank isMediaSpoiler }
episodes
duration
format
status
siteUrl
studios(isMain: true) { nodes { name } }
staff(sort: RELEVANCE, perPage: 6) { edges { role node { name { full } } } }
characters(sort: [ROLE, RELEVANCE, ID], perPage: 10) {
edges {
role
node { name { full } image { medium } }
voiceActors(language: JAPANESE, sort: [RELEVANCE, ID]) { name { full } image { medium } }
}
}
trailer { id site }
nextAiringEpisode { episode airingAt }
relations { edges { relationType node { id type } } }
}
@@ -158,16 +171,20 @@ pub struct AniList {
/// Shared by every lookup in flight — see [`crate::limiter`].
limiter: RateLimiter,
anizip: AniZip,
/// Asked by id for the art TVDB has not got, when a TMDB key is stored.
tmdb: Option<Tmdb>,
}
impl AniList {
/// `language` is the one episode names are wanted in, where ani.zip has
/// them in it.
pub fn new(http: reqwest::Client, language: String) -> Self {
/// them in it. `tmdb` is asked for the art of entries TVDB has none for —
/// see [`Tmdb::art`].
pub fn new(http: reqwest::Client, language: String, tmdb: Option<Tmdb>) -> Self {
Self {
anizip: AniZip::new(http.clone(), language),
http,
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
tmdb,
}
}
}
@@ -203,7 +220,8 @@ impl Provider for AniList {
}
/// The episodes of every entry the library's files are spread across, and
/// the series' fanart.
/// the series' fanart and logo — from TMDB, by the id ani.zip gives, for
/// an entry TVDB has no art for.
///
/// AniList files a sequel as its own entry — Bleach's seventeenth season is
/// four of them, Mushoku Tensei's first is two — so a title's match is
@@ -234,6 +252,8 @@ impl Provider for AniList {
// The same ids as `visited`, in the order they were read.
let mut chain = Vec::new();
let mut series_entries = 0;
// The match's own: a sequel's art is not the title's.
let mut tmdb_id: Option<TmdbId> = None;
let mut next = Some(Link {
id: first,
series: found.kind == TitleKind::Series,
@@ -246,9 +266,15 @@ impl Provider for AniList {
series_entries
});
let mapping = self.anizip.mapping(link.id, entry_season).await?;
if chain.len() == 1 {
tmdb_id = mapping.tmdb;
}
if enrichment.backdrop_url.is_none() {
enrichment.backdrop_url = mapping.fanart;
}
if enrichment.logo_url.is_none() {
enrichment.logo_url = mapping.logo;
}
// Where two entries both list an episode, the earlier one — the
// one closer to the match — keeps it.
enrichment.episodes.extend(
@@ -264,6 +290,14 @@ impl Provider for AniList {
next = self.sequel(link.id, &visited).await?;
}
if let (Some(tmdb), Some(id)) = (&self.tmdb, tmdb_id)
&& (enrichment.backdrop_url.is_none() || enrichment.logo_url.is_none())
{
let art = tmdb.art_or_nothing(id).await;
enrichment.backdrop_url = enrichment.backdrop_url.or(art.backdrop);
enrichment.logo_url = enrichment.logo_url.or(art.logo);
}
tracing::debug!(
"{}: {} episodes over {} entries",
found.name,
@@ -308,7 +342,6 @@ impl AniList {
Ok(next_in_chain(edges, visited))
}
/// One GraphQL requestimpl AniList {
/// One GraphQL request, paced, with AniList's two ways of failing — an HTTP
/// status and an `errors` array under a 200 — folded into one.
///
@@ -560,6 +593,8 @@ struct Media {
#[serde(default)]
tags: Vec<Tag>,
episodes: Option<u32>,
/// Minutes per episode, or the film's running time.
duration: Option<u32>,
format: Option<String>,
season: Option<String>,
status: Option<String>,
@@ -567,6 +602,8 @@ struct Media {
site_url: Option<String>,
studios: Option<Studios>,
staff: Option<Staff>,
characters: Option<Characters>,
trailer: Option<AniTrailer>,
#[serde(rename = "nextAiringEpisode")]
next_airing_episode: Option<NextAiring>,
relations: Option<Relations>,
@@ -612,6 +649,32 @@ struct StaffEdge {
#[derive(Deserialize)]
struct Person {
name: PersonName,
image: Option<Image>,
}
#[derive(Deserialize)]
struct Image {
medium: Option<String>,
}
#[derive(Deserialize)]
struct Characters {
#[serde(default)]
edges: Vec<CharacterEdge>,
}
#[derive(Deserialize)]
struct CharacterEdge {
role: Option<String>,
node: Option<Person>,
#[serde(rename = "voiceActors", default)]
voice_actors: Vec<Person>,
}
#[derive(Deserialize)]
struct AniTrailer {
id: Option<String>,
site: Option<String>,
}
#[derive(Deserialize)]
@@ -734,6 +797,37 @@ impl Media {
.collect(),
popularity: self.popularity,
status: self.status,
runtime: self.duration.filter(|minutes| *minutes > 0),
cast: self
.characters
.map(|characters| {
characters
.edges
.into_iter()
.filter_map(|edge| {
let character = edge.node?;
let actor = edge.voice_actors.into_iter().next();
Some(CastMember {
character: character.name.full?,
character_image: character.image.and_then(|image| image.medium),
role: edge.role,
actor_image: actor
.as_ref()
.and_then(|actor| actor.image.as_ref())
.and_then(|image| image.medium.clone()),
actor: actor.and_then(|actor| actor.name.full),
})
})
.collect()
})
.unwrap_or_default(),
trailer: self.trailer.and_then(|trailer| {
Some(Trailer {
site: trailer.site?,
id: trailer.id?,
})
}),
revision: TitleDetails::REVISION,
next_episode: self
.next_airing_episode
.as_ref()
@@ -774,6 +868,8 @@ impl Media {
// backdrop is ani.zip's fanart, found when the match is
// enriched.
backdrop_url: None,
// ani.zip's too, like the fanart.
logo_url: None,
rating: self.average_score.map(|score| score / SCORE_SCALE),
genres: self.genres,
episodes: self.episodes,
@@ -803,7 +899,7 @@ fn kind_of(format: Option<&str>) -> TitleKind {
/// AniList descriptions carry a little HTML even with `asHtml: false` — `<br>`
/// mostly, and the occasional `<i>`. egui draws no markup, so tags become
/// nothing and `<br>` becomes the line break it stands for.
fn strip_markup(text: &str) -> String {
pub(crate) fn strip_markup(text: &str) -> String {
let text = text
.replace("<br>", "\n")
.replace("<br/>", "\n")
@@ -945,6 +1041,51 @@ mod tests {
assert_eq!(candidate.metadata.poster_url.as_deref(), Some("big.jpg"));
}
#[test]
fn the_cast_comes_with_its_voice_actors_and_the_trailer_with_its_site() {
let media: Media = serde_json::from_str(
r#"{
"id": 154587,
"title": {"romaji": "Sousou no Frieren", "english": null, "native": null},
"characters": {"edges": [
{"role": "MAIN",
"node": {"name": {"full": "Frieren"}, "image": {"medium": "frieren.png"}},
"voiceActors": [{"name": {"full": "Atsumi Tanezaki"},
"image": {"medium": "tanezaki.png"}}]},
{"role": "SUPPORTING",
"node": {"name": {"full": "Himmel"}, "image": null},
"voiceActors": []},
{"role": "BACKGROUND", "node": {"name": {"full": null}}, "voiceActors": []}
]},
"trailer": {"id": "tR8YH0G67Rk", "site": "youtube"}
}"#,
)
.expect("parse");
let details = media.into_candidate().metadata.details.expect("details");
assert_eq!(
details.cast,
[
CastMember {
character: "Frieren".into(),
character_image: Some("frieren.png".into()),
role: Some("MAIN".into()),
actor: Some("Atsumi Tanezaki".into()),
actor_image: Some("tanezaki.png".into()),
},
// No voice actor yet is still a character; no name is not.
CastMember {
character: "Himmel".into(),
role: Some("SUPPORTING".into()),
..CastMember::default()
},
]
);
assert_eq!(
details.trailer.and_then(|trailer| trailer.url()).as_deref(),
Some("https://www.youtube.com/watch?v=tR8YH0G67Rk")
);
}
fn edge(relation: &str, id: i64, kind: &str, format: &str) -> RelationEdge {
RelationEdge {
relation_type: Some(relation.into()),
@@ -1022,6 +1163,8 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
};
let season_one = vec![episode(1, 1, 1), episode(1, 2, 2)];
assert!(answers_all(&season_one, &[(Some(1), 1), (None, 2)]));
+139
View File
@@ -0,0 +1,139 @@
//! AniSkip: where an episode's opening and ending are, as its viewers marked
//! them.
//!
//! For the files that come with no chapters saying so — which is most of what
//! is not a fansub release. Asked once per episode, when it starts playing, by
//! MyAnimeList id and episode number, with the file's length so the answer is
//! one marked against a cut of the same length. Nothing is stored: the answer
//! is the player's for as long as the file is open. Like ani.zip it needs no
//! key and no account, and it learns nothing about the library that the match
//! did not already send AniList.
use pstr_core::chapters::{ChapterRole, SkipRange};
use serde::Deserialize;
use crate::error::{Error, Result};
const ENDPOINT: &str = "https://api.aniskip.com/v2/skip-times";
/// Where `episode` of the MyAnimeList entry `mal_id` has its opening and its
/// ending, in a cut `length` seconds long. Empty when nobody has marked it.
pub async fn skip_times(
http: &reqwest::Client,
mal_id: u32,
episode: u32,
length: f64,
) -> Result<Vec<SkipRange>> {
let response = http
.get(format!("{ENDPOINT}/{mal_id}/{episode}"))
.query(&[
("types", "op"),
("types", "ed"),
("episodeLength", &format!("{length:.0}")),
])
.send()
.await?;
let status = response.status();
// "No skip times found" comes back as a 404 with a body saying so.
if status.as_u16() == 404 {
return Ok(Vec::new());
}
if !status.is_success() {
return Err(Error::Http(format!("AniSkip answered {status}")));
}
let payload: Payload = response.json().await?;
Ok(payload.ranges())
}
#[derive(Deserialize, Default)]
struct Payload {
#[serde(default)]
found: bool,
#[serde(default)]
results: Vec<Skip>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Skip {
interval: Interval,
skip_type: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Interval {
start_time: f64,
end_time: f64,
}
impl Payload {
fn ranges(self) -> Vec<SkipRange> {
if !self.found {
return Vec::new();
}
self.results
.into_iter()
.filter_map(|skip| {
let role = match skip.skip_type.as_str() {
"op" => ChapterRole::Opening,
"ed" => ChapterRole::Ending,
_ => return None,
};
(skip.interval.end_time > skip.interval.start_time).then_some(SkipRange {
role,
start: skip.interval.start_time.max(0.0),
end: skip.interval.end_time,
})
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_answer_parses_into_an_opening_and_an_ending() {
let payload: Payload = serde_json::from_str(
r#"{"found":true,"results":[
{"interval":{"startTime":1.35,"endTime":91.35},"skipType":"op",
"skipId":"9a85542c","episodeLength":1469.979},
{"interval":{"startTime":1367,"endTime":1459},"skipType":"ed",
"skipId":"f6f905e7","episodeLength":1471}],
"message":"Successfully found skip times","statusCode":200}"#,
)
.expect("parse");
assert_eq!(
payload.ranges(),
vec![
SkipRange {
role: ChapterRole::Opening,
start: 1.35,
end: 91.35,
},
SkipRange {
role: ChapterRole::Ending,
start: 1367.0,
end: 1459.0,
},
]
);
}
#[test]
fn nothing_found_and_kinds_not_asked_for_parse_to_nothing() {
let none: Payload = serde_json::from_str(
r#"{"found":false,"results":[],"message":"No skip times found","statusCode":404}"#,
)
.expect("parse");
assert!(none.ranges().is_empty());
let recap: Payload = serde_json::from_str(
r#"{"found":true,"results":[{"interval":{"startTime":0,"endTime":60},"skipType":"recap"}]}"#,
)
.expect("parse");
assert!(recap.ranges().is_empty());
}
}
+112 -9
View File
@@ -20,6 +20,7 @@ use serde::Deserialize;
use crate::error::{Error, Result};
use crate::limiter::RateLimiter;
use crate::tmdb::TmdbId;
const ENDPOINT: &str = "https://api.ani.zip/mappings";
@@ -36,6 +37,10 @@ pub struct Mapping {
pub episodes: Vec<EpisodeMetadata>,
/// TVDB's fanart for the whole series: 16:9, usually 1920×1080.
pub fanart: Option<String>,
/// TVDB's clear logo: the series' name as lettering, a transparent PNG.
pub logo: Option<String>,
/// The same entry on TMDB, where TVDB has none of the films.
pub tmdb: Option<TmdbId>,
}
pub struct AniZip {
@@ -90,6 +95,34 @@ struct Payload {
episodes: std::collections::BTreeMap<String, Episode>,
#[serde(default)]
images: Vec<Image>,
mappings: Option<Ids>,
}
/// The entry's ids elsewhere.
#[derive(Deserialize)]
struct Ids {
/// AniList's format, upper-cased: `TV`, `MOVIE`, `OVA`…
#[serde(rename = "type")]
kind: Option<String>,
#[serde(default, deserialize_with = "lenient_number")]
themoviedb_id: Option<u32>,
/// The same entry on MyAnimeList, which AniSkip is keyed by.
#[serde(default, deserialize_with = "lenient_number")]
mal_id: Option<u32>,
}
impl Ids {
/// The TMDB entry, where its kind says which list the id is from. An OVA
/// or a special is filed either way on TMDB, so it is left alone rather
/// than guessed.
fn tmdb(&self) -> Option<TmdbId> {
let id = self.themoviedb_id?;
match self.kind.as_deref()? {
"MOVIE" => Some(TmdbId::Movie(id)),
"TV" | "TV_SHORT" | "ONA" => Some(TmdbId::Tv(id)),
_ => None,
}
}
}
#[derive(Deserialize)]
@@ -108,6 +141,12 @@ struct Episode {
summary: Option<String>,
image: Option<String>,
air_date: Option<String>,
/// TVDB's running time, in minutes.
#[serde(default, deserialize_with = "lenient_number")]
runtime: Option<u32>,
/// AniDB's, which is there when TVDB's is not.
#[serde(default, deserialize_with = "lenient_number")]
length: Option<u32>,
}
#[derive(Deserialize)]
@@ -119,11 +158,16 @@ struct Image {
impl Payload {
fn into_mapping(self, language: &str, entry_season: Option<u32>) -> Mapping {
let fanart = self
.images
.into_iter()
.find(|image| image.cover_type.as_deref() == Some("Fanart"))
.and_then(|image| image.url);
let image = |kind: &str| {
self.images
.iter()
.find(|image| image.cover_type.as_deref() == Some(kind))
.and_then(|image| text(image.url.clone()))
};
let fanart = image("Fanart");
let logo = image("Clearlogo");
let tmdb = self.mappings.as_ref().and_then(Ids::tmdb);
let mal_id = self.mappings.as_ref().and_then(|ids| ids.mal_id);
let episodes = self
.episodes
@@ -150,11 +194,21 @@ impl Payload {
overview: text(episode.overview).or_else(|| text(episode.summary)),
still_url: text(episode.image),
air_date: text(episode.air_date),
runtime: episode
.runtime
.or(episode.length)
.filter(|minutes| *minutes > 0),
mal_id,
})
})
.collect();
Mapping { episodes, fanart }
Mapping {
episodes,
fanart,
logo,
tmdb,
}
}
}
@@ -206,15 +260,16 @@ mod tests {
"title": {"en": "Beast Titan", "ja": "獣の巨人", "x-jat": "Kemono no Kyojin"},
"overview": "Titans appear inside Wall Rose.",
"image": "https://artworks.thetvdb.com/still.jpg",
"airDate": "2017-04-01"},
"airDate": "2017-04-01", "runtime": 24, "length": 25},
"S1": {"seasonNumber": "0", "episodeNumber": "7", "episode": "S1",
"title": {"en": "TBA", "x-jat": "Ilse no Techou"},
"summary": "An OVA."},
"summary": "An OVA.", "length": "30"},
"2": {"episode": "2", "title": {"en": "Unplaced"}}
},
"images": [
{"coverType": "Banner", "url": "banner.jpg"},
{"coverType": "Fanart", "url": "fanart.jpg"}
{"coverType": "Fanart", "url": "fanart.jpg"},
{"coverType": "Clearlogo", "url": "clearlogo.png"}
]
}"#;
@@ -241,6 +296,8 @@ mod tests {
Some("Titans appear inside Wall Rose.")
);
assert_eq!(first.air_date.as_deref(), Some("2017-04-01"));
// TVDB's running time wins over AniDB's.
assert_eq!(first.runtime, Some(24));
}
/// A special is numbered in strings and filed under season zero, and no
@@ -260,6 +317,8 @@ mod tests {
assert_eq!(special.name.as_deref(), Some("Ilse no Techou"));
// AniDB's summary stands in for TVDB's missing overview.
assert_eq!(special.overview.as_deref(), Some("An OVA."));
// And AniDB's length for TVDB's missing running time.
assert_eq!(special.runtime, Some(30));
}
#[test]
@@ -285,6 +344,11 @@ mod tests {
assert_eq!(mapping(None).fanart.as_deref(), Some("fanart.jpg"));
}
#[test]
fn the_clear_logo_is_kept() {
assert_eq!(mapping(None).logo.as_deref(), Some("clearlogo.png"));
}
#[test]
fn an_entry_ani_zip_has_nothing_for_parses_to_nothing() {
let mapping = serde_json::from_str::<Payload>("{}")
@@ -292,5 +356,44 @@ mod tests {
.into_mapping("en", Some(1));
assert!(mapping.episodes.is_empty());
assert_eq!(mapping.fanart, None);
assert_eq!(mapping.logo, None);
assert_eq!(mapping.tmdb, None);
}
/// Paprika: a film with no TVDB art at all, and a TMDB id written as a
/// string.
#[test]
fn a_films_tmdb_id_is_kept_as_a_film() {
let mapping = serde_json::from_str::<Payload>(
r#"{"images": [], "mappings": {"type": "MOVIE", "themoviedb_id": "4977"}}"#,
)
.expect("parse")
.into_mapping("en", None);
assert_eq!(mapping.tmdb, Some(TmdbId::Movie(4977)));
}
#[test]
fn every_episode_carries_its_entrys_myanimelist_id() {
let mapping = serde_json::from_str::<Payload>(
r#"{"episodes": {"1": {"seasonNumber": 1, "episodeNumber": 1}},
"mappings": {"type": "TV", "mal_id": 52991}}"#,
)
.expect("parse")
.into_mapping("en", Some(1));
assert_eq!(mapping.episodes[0].mal_id, Some(52991));
}
#[test]
fn a_series_tmdb_id_is_kept_and_a_specials_is_not() {
let tmdb = |kind: &str| {
serde_json::from_str::<Payload>(&format!(
r#"{{"mappings": {{"type": "{kind}", "themoviedb_id": 209867}}}}"#
))
.expect("parse")
.into_mapping("en", Some(1))
.tmdb
};
assert_eq!(tmdb("TV"), Some(TmdbId::Tv(209867)));
assert_eq!(tmdb("OVA"), None);
}
}
+4 -2
View File
@@ -8,10 +8,10 @@
//!
//! ```text
//! Title ──▶ Query ──▶ Provider::search ──▶ [Candidate] ──▶ matching::best
//! (anilist | tmdb) │
//! (anilist, then tvmaze | tmdb) │
//! ▼
//! Enrichment ◀── Provider::enrich ◀──────────────────── TitleMetadata
//! (episodes, fanart) (anilist relations + ani.zip | tmdb)
//! (episodes, fanart) (anilist relations + ani.zip | tvmaze | tmdb)
//! ```
//!
//! Three things are worth knowing before touching any of it:
@@ -29,6 +29,7 @@
//! without either depending on this crate.
pub mod anilist;
pub mod aniskip;
pub mod anizip;
pub mod error;
pub mod limiter;
@@ -37,6 +38,7 @@ pub mod provider;
pub mod service;
pub mod settings;
pub mod tmdb;
pub mod tvmaze;
pub use error::{Error, Result};
pub use matching::{Candidate, Query};
+1
View File
@@ -204,6 +204,7 @@ mod tests {
kind,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
+357 -124
View File
@@ -5,8 +5,17 @@
//! │ miss / stale
//! ▼
//! provider.search ──▶ matching::best ──▶ store ──▶ MetadataRecord
//! │ no match
//! ▼
//! next provider in the chain, the same way
//! ```
//!
//! The providers are asked in a chain: AniList, then TVmaze for what AniList
//! has not got. The first match wins and is stored under the provider that
//! gave it, so a library of anime with one live-action show in it is matched
//! against both without anyone choosing per title — and everything after a
//! match is asked of that match's own provider.
//!
//! Two rules govern what gets written back, and they are the difference between
//! a library that settles down and one that hammers a third party forever:
//!
@@ -25,7 +34,8 @@ use std::sync::Arc;
use pstr_core::library::{Title, TitleKind, title_key};
use pstr_core::metadata::{
Enrichment, MATCH_TTL_SECS, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
Enrichment, MATCH_TTL_SECS, MetadataConfig, MetadataRecord, ProviderId, TitleDetails,
TitleMetadata,
};
use crate::anilist::AniList;
@@ -33,6 +43,7 @@ use crate::error::{Error, Result};
use crate::matching::{self, Query};
use crate::provider::Provider;
use crate::tmdb::Tmdb;
use crate::tvmaze::TvMaze;
/// How long to wait on a provider.
///
@@ -43,10 +54,11 @@ const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Identifies this client to the providers, which both ask for it.
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
/// The configured provider, resolved.
/// One provider in the chain, resolved.
enum Source {
AniList(AniList),
Tmdb(Tmdb),
TvMaze(TvMaze),
}
impl Source {
@@ -54,6 +66,7 @@ impl Source {
match self {
Self::AniList(provider) => provider.id(),
Self::Tmdb(provider) => provider.id(),
Self::TvMaze(provider) => provider.id(),
}
}
@@ -61,78 +74,153 @@ impl Source {
match self {
Self::AniList(provider) => provider.search(query).await,
Self::Tmdb(provider) => provider.search(query).await,
Self::TvMaze(provider) => provider.search(query).await,
}
}
/// The entry a stored match names, fetched again by its id. `None` from a
/// provider that has no details to add.
/// provider that has no details to add — see [`gives_details`].
async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> {
match self {
Self::AniList(provider) => provider.entry(id).await,
Self::TvMaze(provider) => provider.entry(id).await,
Self::Tmdb(_) => Ok(None),
}
}
/// Whether [`Source::entry`] has anything to give.
fn has_details(&self) -> bool {
matches!(self, Self::AniList(_))
}
async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
match self {
Self::AniList(provider) => provider.enrich(title, found).await,
Self::Tmdb(provider) => provider.enrich(title, found).await,
Self::TvMaze(provider) => provider.enrich(title, found).await,
}
}
}
/// Metadata lookups, against one provider.
/// The providers asked when the viewer chose `chosen`, in order.
///
/// AniList is followed by TVmaze, which has the television AniList does not
/// and needs no key either. TMDB has that television itself, so it is asked
/// alone. A stored answer from a provider outside the chain is the previous
/// choice's, and is re-asked.
pub fn chain_of(chosen: ProviderId) -> &'static [ProviderId] {
match chosen {
ProviderId::AniList => &[ProviderId::AniList, ProviderId::TvMaze],
ProviderId::Tmdb => &[ProviderId::Tmdb],
ProviderId::TvMaze => &[ProviderId::TvMaze],
}
}
/// [`chain_of`] `chosen`, for a sentence: `AniList and TVmaze`.
pub fn chain_label(chosen: ProviderId) -> String {
chain_of(chosen)
.iter()
.map(|provider| provider.label())
.collect::<Vec<_>>()
.join(" and ")
}
/// Whether `provider` fills in [`TitleMetadata::details`], and so whether a
/// match it gave without them, or with an older revision of them, is worth
/// fetching again.
fn gives_details(provider: ProviderId) -> bool {
matches!(provider, ProviderId::AniList | ProviderId::TvMaze)
}
/// Metadata lookups, against a chain of providers.
///
/// Cheap to clone — the HTTP client inside is a connection pool that wants
/// sharing, not duplicating.
#[derive(Clone)]
pub struct MetadataService {
source: Arc<Source>,
/// In the order they are asked. Never empty.
sources: Arc<[Source]>,
http: reqwest::Client,
}
impl MetadataService {
/// Build a service for `config`.
/// Build a service for `config`, asking [`chain_of`] its provider.
///
/// `api_key` is only consulted for a provider that needs one; for AniList it
/// is ignored, and passing `None` there is not an error.
pub fn new(config: &MetadataConfig, api_key: Option<String>) -> Result<Self> {
/// `tmdb_key` is the stored TMDB key, if there is one. TMDB as the provider
/// cannot do without it; AniList uses one for the art of films TVDB has
/// none for, and does without it otherwise.
pub fn new(config: &MetadataConfig, tmdb_key: Option<String>) -> Result<Self> {
let http = reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(TIMEOUT)
.build()
.map_err(Error::Network)?;
let source = match config.provider {
ProviderId::AniList => {
Source::AniList(AniList::new(http.clone(), config.language.clone()))
}
ProviderId::Tmdb => Source::Tmdb(Tmdb::new(
http.clone(),
api_key.ok_or(Error::MissingApiKey(ProviderId::Tmdb))?,
config.language.clone(),
)),
let tmdb_key = tmdb_key.filter(|key| !key.trim().is_empty());
let tmdb = || {
tmdb_key
.clone()
.map(|key| Tmdb::new(http.clone(), key, config.language.clone()))
.ok_or(Error::MissingApiKey(ProviderId::Tmdb))
};
let sources = chain_of(config.provider)
.iter()
.map(|provider| {
Ok(match provider {
ProviderId::AniList => Source::AniList(AniList::new(
http.clone(),
config.language.clone(),
tmdb().ok(),
)),
ProviderId::Tmdb => Source::Tmdb(tmdb()?),
ProviderId::TvMaze => Source::TvMaze(TvMaze::new(http.clone())),
})
})
.collect::<Result<Vec<_>>>()?;
Ok(Self {
source: Arc::new(source),
sources: sources.into(),
http,
})
}
pub fn provider(&self) -> ProviderId {
self.source.id()
/// Where AniSkip says an episode's opening and ending are. See
/// [`crate::aniskip`]; on the service so that it is only ever asked with
/// enrichment on, like every other source.
pub async fn skip_times(
&self,
mal_id: u32,
episode: u32,
length: f64,
) -> Result<Vec<pstr_core::chapters::SkipRange>> {
crate::aniskip::skip_times(&self.http, mal_id, episode, length).await
}
/// Ask the provider about one title.
/// The provider the viewer chose: the first one asked.
pub fn provider(&self) -> ProviderId {
self.sources[0].id()
}
/// Every provider asked, in order. What [`plan`] and
/// [`MetadataRecord::is_fresh`] judge a stored answer against.
pub fn chain(&self) -> Vec<ProviderId> {
self.sources.iter().map(Source::id).collect()
}
/// The chain, for a status line: `AniList and TVmaze`.
pub fn label(&self) -> String {
chain_label(self.provider())
}
fn source(&self, provider: ProviderId) -> Result<&Source> {
self.sources
.iter()
.find(|source| source.id() == provider)
.ok_or_else(|| Error::Config(format!("{} is not in use", provider.label())))
}
/// Ask the chain about one title, stopping at the first match.
///
/// `Ok(None)` is a real answer — nothing matched well enough — and the
/// caller should store it. An `Err` is not, and the caller should not.
/// `Ok(None)` is a real answer — nothing matched well enough anywhere —
/// and the caller should store it. An `Err` is not, and the caller should
/// not: a provider that could not be asked has not said it lacks the
/// title, and settling for the next one's answer would store a guess in
/// its place.
pub async fn lookup(&self, title: &Title) -> Result<Option<TitleMetadata>> {
let mut query = Query::new(title.name.clone(), title.year, title.kind);
// Nothing in the files numbered itself, so the kind came from counting
@@ -140,34 +228,48 @@ impl MetadataService {
if !title.states_its_numbering() {
query = query.with_guessed_kind();
}
let candidates = self.source.search(&query).await?;
let found = candidates.len();
let best = matching::best(&query, candidates);
tracing::debug!(
"{}: {found} candidates from {}, {}",
title.name,
self.provider().label(),
match &best {
Some(found) => format!("matched {:?}", found.name),
None => "no match".to_string(),
for source in self.sources.iter() {
let candidates = source.search(&query).await?;
let found = candidates.len();
let best = matching::best(&query, candidates);
tracing::debug!(
"{}: {found} candidates from {}, {}",
title.name,
source.id().label(),
match &best {
Some(found) => format!("matched {:?}", found.name),
None => "no match".to_string(),
}
);
if best.is_some() {
return Ok(best);
}
);
Ok(best)
}
Ok(None)
}
/// The same lookup, as a record ready to store — misses included.
///
/// A match is stored under the provider that gave it; a miss under the
/// last one asked, which is what makes it stand — see
/// [`MetadataRecord::is_fresh`].
pub async fn record(&self, title: &Title) -> Result<MetadataRecord> {
let metadata = self.lookup(title).await?;
Ok(MetadataRecord {
title_key: title_key(&title.name),
provider: self.provider(),
provider: metadata
.as_ref()
.map_or_else(|| self.last(), |found| found.provider),
metadata,
fetched_at: now(),
manual: false,
})
}
fn last(&self) -> ProviderId {
self.sources[self.sources.len() - 1].id()
}
/// Everything the provider thinks `name` might be, unscored and in its own
/// order.
///
@@ -182,39 +284,57 @@ impl MetadataService {
/// Takes a kind because the providers key their search on it, and no year:
/// the point of a hand search is that the library's own guesses are what
/// went wrong.
///
/// Every provider in the chain is asked, the first one's answers first,
/// and each answer says which provider gave it. One that fails leaves its
/// answers out; only when all of them fail is that an error.
pub async fn search(&self, name: &str, kind: TitleKind) -> Result<Vec<TitleMetadata>> {
let query = Query::new(name.trim().to_string(), None, kind).with_guessed_kind();
if query.name.is_empty() {
return Ok(Vec::new());
}
let candidates = self.source.search(&query).await?;
tracing::debug!(
"hand search {:?}: {} candidates from {}",
query.name,
candidates.len(),
self.provider().label()
);
Ok(candidates
.into_iter()
.map(|candidate| candidate.metadata)
.collect())
let mut options = Vec::new();
let mut failure = None;
for source in self.sources.iter() {
match source.search(&query).await {
Ok(candidates) => {
tracing::debug!(
"hand search {:?}: {} candidates from {}",
query.name,
candidates.len(),
source.id().label()
);
options.extend(candidates.into_iter().map(|candidate| candidate.metadata));
}
Err(error) => {
tracing::warn!("hand search on {}: {error}", source.id().label());
failure.get_or_insert(error);
}
}
}
match failure {
Some(error) if options.is_empty() => Err(error),
_ => Ok(options),
}
}
/// A record for an entry the viewer picked themselves.
/// A record for an entry the viewer picked themselves, under the provider
/// it came from.
///
/// Marked [`MetadataRecord::manual`], which is what keeps the next match run
/// — including a forced one — from undoing it.
pub fn chosen(&self, title_key: String, found: TitleMetadata) -> MetadataRecord {
MetadataRecord {
title_key,
provider: self.provider(),
provider: found.provider,
metadata: Some(found),
fetched_at: now(),
manual: true,
}
}
/// The episodes of a matched title, and any art its match lacked.
/// The episodes of a matched title, and any art its match lacked, from
/// the provider that matched it.
///
/// Only ever called for a title that already has a match, because the
/// provider's own id is what it takes — there is no second search here, and
@@ -224,12 +344,12 @@ impl MetadataService {
/// un-enriched and the next match run asks again. An empty answer is an
/// answer, and is — see `Catalog::set_enrichment`.
pub async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
let enrichment = self.source.enrich(title, found).await?;
let enrichment = self.source(found.provider)?.enrich(title, found).await?;
tracing::debug!(
"{}: {} episodes from {}{}",
title.name,
enrichment.episodes.len(),
self.provider().label(),
found.provider.label(),
if enrichment.backdrop_url.is_some() {
", with a backdrop"
} else {
@@ -261,8 +381,9 @@ pub enum Work {
Match(Title),
/// It is already matched; only its enrichment is missing or old.
Enrich(Title, Box<TitleMetadata>),
/// It is matched, but stored before [`TitleMetadata::details`] existed:
/// fetch the same entry again by id — never searched for, so a match the
/// It is matched, but stored before [`TitleMetadata::details`] existed or
/// by an older [`TitleDetails::REVISION`] of them: fetch the same entry
/// again by id — never searched for, so a match the
/// viewer picked by hand stays theirs — and enrich it too if `enrich`.
Refresh {
title: Title,
@@ -283,6 +404,9 @@ impl Work {
#[derive(Debug)]
pub struct Outcome {
pub title_key: String,
/// The provider the title is matched to, and so the one
/// [`Outcome::enrichment`] is stored under.
pub provider: ProviderId,
/// The new answer, for [`Work::Match`] and [`Work::Refresh`]. Store it —
/// misses included.
pub record: Option<MetadataRecord>,
@@ -298,44 +422,46 @@ pub struct Outcome {
/// already has a good match but has not been enriched — or was enriched longer
/// ago than a match is trusted — needs enriching only, and searching for it
/// again would ask the provider something it has already answered. One whose
/// match predates its details is fetched again by id, which is one request
/// and decides nothing.
/// match predates its details, or this build's revision of them, is fetched
/// again by id, which is one request and decides nothing.
///
/// A hand-picked entry is never searched for again, not even when `force`d —
/// that means "the automatic answers are wrong", and re-deciding the one title
/// the viewer already fixed by hand is the opposite of what they asked for. It
/// is still enriched and refreshed.
/// is still enriched and refreshed, and `force` enriches it again.
///
/// `details` is whether the provider has [`TitleMetadata::details`] to give
/// — see [`MetadataService::has_details`].
/// `chain` is [`MetadataService::chain`]. Enriching and refreshing go to the
/// provider each title is matched to.
pub fn plan(
titles: Vec<Title>,
stored: &HashMap<String, MetadataRecord>,
enriched: &HashMap<String, (ProviderId, i64)>,
provider: ProviderId,
details: bool,
chain: &[ProviderId],
force: bool,
) -> Vec<Work> {
let now = now();
let mut work = Vec::new();
for title in titles {
let record = stored.get(&title.key);
let pinned = record.is_some_and(|record| record.manual && record.provider == provider);
if !pinned && (force || !is_usable(record, provider)) {
let pinned = record.is_some_and(|record| record.manual && chain.contains(&record.provider));
if !pinned && (force || !is_usable(record, chain)) {
work.push(Work::Match(title));
continue;
}
let Some(record) = record.filter(|record| record.metadata.is_some()) else {
continue;
};
let fresh = enriched.get(&title.key).is_some_and(|(asked, at)| {
*asked == provider && now.saturating_sub(*at) < MATCH_TTL_SECS
});
let stale_details = details
// Forced, a pinned title is enriched again even when it is fresh: art
// a newly saved key reaches is the usual reason to force.
let fresh = !force
&& enriched.get(&title.key).is_some_and(|(asked, at)| {
*asked == record.provider && now.saturating_sub(*at) < MATCH_TTL_SECS
});
let stale_details = gives_details(record.provider)
&& record
.metadata
.as_ref()
.is_some_and(|found| found.details.is_none());
.is_some_and(|found| !found.details.as_ref().is_some_and(TitleDetails::is_current));
if stale_details {
work.push(Work::Refresh {
title,
@@ -350,12 +476,6 @@ pub fn plan(
}
impl MetadataService {
/// Whether this provider fills in [`TitleMetadata::details`], and so
/// whether a match stored without them is worth fetching again.
pub fn has_details(&self) -> bool {
self.source.has_details()
}
/// Do one piece of [`Work`].
///
/// A failed search is an `Err` and nothing is stored. A failed enrichment
@@ -373,12 +493,14 @@ impl MetadataService {
};
Ok(Outcome {
title_key: record.title_key.clone(),
provider: record.provider,
record: Some(record),
enrichment,
})
}
Work::Enrich(title, found) => Ok(Outcome {
title_key: title.key.clone(),
provider: found.provider,
record: None,
enrichment: Some(self.enrich(title, found).await?),
}),
@@ -390,18 +512,22 @@ impl MetadataService {
let Some(stored) = &record.metadata else {
return Ok(Outcome {
title_key: title.key.clone(),
provider: record.provider,
record: None,
enrichment: None,
});
};
// An id the provider no longer has keeps what was stored, with
// empty details so it is not asked about on every run.
// An id the provider no longer has keeps what was stored, its
// details marked current so it is not asked about on every run.
let found = self
.source
.source(stored.provider)?
.entry(&stored.remote_id)
.await?
.unwrap_or_else(|| TitleMetadata {
details: Some(Default::default()),
details: Some(TitleDetails {
revision: TitleDetails::REVISION,
..stored.details.clone().unwrap_or_default()
}),
..stored.clone()
});
let enrichment = match enrich {
@@ -410,6 +536,7 @@ impl MetadataService {
};
Ok(Outcome {
title_key: title.key.clone(),
provider: record.provider,
record: Some(MetadataRecord {
metadata: Some(found),
fetched_at: now(),
@@ -432,6 +559,7 @@ impl MetadataService {
let enrichment = self.enrich_or_log(title, &found).await;
Outcome {
title_key: title.key.clone(),
provider: found.provider,
record: Some(self.chosen(title.key.clone(), found)),
enrichment,
}
@@ -445,9 +573,9 @@ impl MetadataService {
}
}
/// Whether a stored answer can be used as-is.
pub fn is_usable(record: Option<&MetadataRecord>, provider: ProviderId) -> bool {
record.is_some_and(|record| record.is_fresh(now(), provider))
/// Whether a stored answer can be used as-is by `chain`.
pub fn is_usable(record: Option<&MetadataRecord>, chain: &[ProviderId]) -> bool {
record.is_some_and(|record| record.is_fresh(now(), chain))
}
fn now() -> i64 {
@@ -469,6 +597,8 @@ mod tests {
..MetadataConfig::default()
};
assert!(MetadataService::new(&anilist, None).is_ok());
// A stored TMDB key is used for art, not demanded.
assert!(MetadataService::new(&anilist, Some("key".into())).is_ok());
let tmdb = MetadataConfig {
provider: ProviderId::Tmdb,
@@ -506,17 +636,34 @@ mod tests {
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
url: None,
details: None,
details: Some(TitleDetails {
revision: TitleDetails::REVISION,
..TitleDetails::default()
}),
}),
fetched_at: now(),
manual,
}
}
/// [`matched`], stored before there were details.
fn without_details(key: &str, manual: bool, provider: ProviderId) -> MetadataRecord {
let mut record = matched(key, manual);
record.provider = provider;
if let Some(found) = record.metadata.as_mut() {
found.provider = provider;
found.details = None;
}
record
}
const ANILIST: &[ProviderId] = &[ProviderId::AniList, ProviderId::TvMaze];
fn kinds(work: &[Work]) -> Vec<(&str, &str)> {
work.iter()
.map(|work| match work {
@@ -545,14 +692,7 @@ mod tests {
]);
let titles = ["enriched", "stale", "bare", "new"].map(title).to_vec();
let work = plan(
titles,
&stored,
&enriched,
ProviderId::AniList,
false,
false,
);
let work = plan(titles, &stored, &enriched, ANILIST, false);
assert_eq!(
kinds(&work),
vec![("stale", "enrich"), ("bare", "enrich"), ("new", "match")]
@@ -569,14 +709,7 @@ mod tests {
]);
let titles = ["pinned", "automatic"].map(title).to_vec();
let work = plan(
titles,
&stored,
&HashMap::new(),
ProviderId::AniList,
false,
true,
);
let work = plan(titles, &stored, &HashMap::new(), ANILIST, true);
assert_eq!(
kinds(&work),
vec![("pinned", "enrich"), ("automatic", "match")]
@@ -584,23 +717,30 @@ mod tests {
}
/// A match stored before details existed is fetched again by id — a
/// pinned one included, and without being searched for — and only by a
/// pinned one included, and without being searched for — and only from a
/// provider that has details to give.
#[test]
fn a_match_without_details_is_refreshed_rather_than_searched_for() {
let stored = HashMap::from([
("pinned".to_string(), matched("pinned", true)),
("automatic".to_string(), matched("automatic", false)),
]);
let enriched = HashMap::from([("pinned".to_string(), (ProviderId::AniList, now()))]);
let records = |provider| {
HashMap::from([
(
"pinned".to_string(),
without_details("pinned", true, provider),
),
(
"automatic".to_string(),
without_details("automatic", false, provider),
),
])
};
let enriched = |provider| HashMap::from([("pinned".to_string(), (provider, now()))]);
let titles = ["pinned", "automatic"].map(title).to_vec();
let work = plan(
titles.clone(),
&stored,
&enriched,
ProviderId::AniList,
true,
&records(ProviderId::AniList),
&enriched(ProviderId::AniList),
ANILIST,
false,
);
assert_eq!(
@@ -612,17 +752,89 @@ mod tests {
let work = plan(
titles,
&stored,
&enriched,
ProviderId::AniList,
false,
&records(ProviderId::Tmdb),
&enriched(ProviderId::Tmdb),
&[ProviderId::Tmdb],
false,
);
assert_eq!(kinds(&work), vec![("automatic", "enrich")]);
}
/// A record from the other provider is never usable — switching provider has
/// to re-ask, including about titles the previous one found nothing for.
/// A title AniList had nothing for is asked of TVmaze at once, rather than
/// waiting out AniList's stored miss; a match from TVmaze is enriched by
/// TVmaze, and a hand-picked one stays pinned — re-enriched when forced,
/// never searched for.
#[test]
fn titles_are_planned_against_the_provider_each_is_matched_to() {
let miss = MetadataRecord {
metadata: None,
..matched("anilist miss", false)
};
let tvmaze = |key: &str, manual| {
let mut record = matched(key, manual);
record.provider = ProviderId::TvMaze;
if let Some(found) = record.metadata.as_mut() {
found.provider = ProviderId::TvMaze;
}
record
};
let stored = HashMap::from([
("anilist miss".to_string(), miss),
("on tvmaze".to_string(), tvmaze("on tvmaze", false)),
("pinned".to_string(), tvmaze("pinned", true)),
]);
let enriched = HashMap::from([("pinned".to_string(), (ProviderId::TvMaze, now()))]);
let titles = ["anilist miss", "on tvmaze", "pinned"].map(title).to_vec();
let work = plan(titles.clone(), &stored, &enriched, ANILIST, false);
assert_eq!(
kinds(&work),
vec![("anilist miss", "match"), ("on tvmaze", "enrich")]
);
// Forced, the pinned title is not searched for, but is enriched again
// although it is fresh.
let work = plan(titles, &stored, &enriched, ANILIST, true);
assert_eq!(
kinds(&work),
vec![
("anilist miss", "match"),
("on tvmaze", "match"),
("pinned", "enrich")
]
);
}
/// Details from an older build are refetched by id, and kept meanwhile;
/// details this build wrote are not asked for again.
#[test]
fn details_from_an_older_revision_are_refreshed_and_current_ones_are_not() {
let with = |revision| {
let mut record = matched("k", false);
if let Some(found) = record.metadata.as_mut() {
found.details = Some(TitleDetails {
revision,
..TitleDetails::default()
});
}
record
};
let enriched = HashMap::from([("k".to_string(), (ProviderId::AniList, now()))]);
let run = |record: MetadataRecord| {
let stored = HashMap::from([("k".to_string(), record)]);
let work = plan(vec![title("k")], &stored, &enriched, ANILIST, false);
kinds(&work)
.into_iter()
.map(|(_, kind)| kind.to_string())
.collect::<Vec<_>>()
};
assert_eq!(run(with(0)), vec!["refresh"]);
assert!(run(with(TitleDetails::REVISION)).is_empty());
}
/// A record from a provider outside the chain is never usable — switching
/// provider has to re-ask, including about titles the previous one found
/// nothing for.
#[test]
fn a_stored_answer_is_only_usable_for_the_provider_that_gave_it() {
let record = MetadataRecord {
@@ -632,8 +844,29 @@ mod tests {
fetched_at: now(),
manual: false,
};
assert!(is_usable(Some(&record), ProviderId::AniList));
assert!(!is_usable(Some(&record), ProviderId::Tmdb));
assert!(!is_usable(None, ProviderId::AniList));
assert!(is_usable(Some(&record), &[ProviderId::AniList]));
assert!(!is_usable(Some(&record), &[ProviderId::Tmdb]));
assert!(!is_usable(None, &[ProviderId::AniList]));
}
/// AniList is followed by TVmaze, which needs no key either; TMDB, which
/// has television of its own, is asked alone.
#[test]
fn anilist_is_followed_by_tvmaze_and_tmdb_stands_alone() {
let config = MetadataConfig {
enabled: true,
..MetadataConfig::default()
};
let service = MetadataService::new(&config, None).expect("service");
assert_eq!(service.chain(), ANILIST);
assert_eq!(service.provider(), ProviderId::AniList);
assert_eq!(service.label(), "AniList and TVmaze");
let tmdb = MetadataConfig {
provider: ProviderId::Tmdb,
..config
};
let service = MetadataService::new(&tmdb, Some("key".into())).expect("service");
assert_eq!(service.chain(), vec![ProviderId::Tmdb]);
}
}
+205 -10
View File
@@ -9,6 +9,11 @@
//! not evidence enough to pick an endpoint. The `media_type` on each result is
//! better evidence than the guess, and [`crate::matching`] only nudges on kind
//! anyway.
//!
//! A stored key is also used with AniList chosen, by id alone: ani.zip names
//! the TMDB entry an AniList match is, and [`Tmdb::art`] fetches its backdrop
//! and logo — what the films TVDB has no art for are missing. No title is
//! searched for that way.
use pstr_core::library::{Title, TitleKind};
use pstr_core::metadata::{Enrichment, EpisodeMetadata, ProviderId, TitleMetadata};
@@ -34,13 +39,16 @@ const STILL_SIZE: &str = "w300";
/// Where TMDB serves artwork from, and the sizes worth asking for.
///
/// `w500` for a poster and `w780` for a backdrop are what the images are drawn
/// at here, give or take; `original` is several megabytes of print-resolution
/// artwork per title, which is bandwidth spent on pixels no tile can show.
/// `w500` for a poster and a logo and `w1280` for a backdrop are what the
/// images are drawn at here, give or take — a backdrop fills the width of the
/// title page and the library's banner; `original` is several megabytes of
/// print-resolution artwork per title, which is bandwidth spent on pixels no
/// window shows.
const IMAGE_BASE: &str = "https://image.tmdb.org/t/p";
const POSTER_SIZE: &str = "w500";
const BACKDROP_SIZE: &str = "w780";
const BACKDROP_SIZE: &str = "w1280";
const LOGO_SIZE: &str = "w500";
/// TMDB's published ceiling is around fifty requests a second and it no longer
/// enforces a daily one. This is well under it: the point of pacing here is a
@@ -68,6 +76,30 @@ impl Tmdb {
}
}
/// A TMDB entry, by id. Films and television are numbered separately, so the
/// id alone does not name one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TmdbId {
Movie(u32),
Tv(u32),
}
impl TmdbId {
fn path(self) -> String {
match self {
Self::Movie(id) => format!("movie/{id}"),
Self::Tv(id) => format!("tv/{id}"),
}
}
}
/// An entry's wide picture and its logo.
#[derive(Debug, Default, PartialEq)]
pub struct Art {
pub backdrop: Option<String>,
pub logo: Option<String>,
}
impl Provider for Tmdb {
fn id(&self) -> ProviderId {
ProviderId::Tmdb
@@ -95,13 +127,25 @@ impl Provider for Tmdb {
.collect())
}
/// The episode list, and nothing else: the match already carried a proper
/// The episode list and the logo. The match already carried a proper
/// backdrop, and TMDB files every season under one show, so the library's
/// seasons need no walking.
async fn enrich(&self, _title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
let logo_url = match found.remote_id.parse() {
Ok(id) => {
self.art_or_nothing(match found.kind {
TitleKind::Series => TmdbId::Tv(id),
TitleKind::Film => TmdbId::Movie(id),
})
.await
.logo
}
Err(_) => None,
};
Ok(Enrichment {
episodes: self.episodes(found).await?,
backdrop_url: None,
logo_url,
chain: Vec::new(),
})
}
@@ -168,14 +212,47 @@ impl Tmdb {
still_url: episode
.still_path
.as_deref()
.map(|path| image(STILL_SIZE, path)),
.map(|path| image_url(STILL_SIZE, path)),
air_date: episode.air_date.clone().filter(|date| !date.is_empty()),
runtime: episode.runtime.filter(|minutes| *minutes > 0),
mal_id: None,
}
}));
}
Ok(episodes)
}
/// The best backdrop and logo TMDB has for `id`.
///
/// Artwork in the viewer's language, English or Japanese is asked for, and
/// artwork with no language at all, which for a backdrop is the one without
/// lettering on it. One request.
pub async fn art(&self, id: TmdbId) -> Result<Art> {
let languages = format!("{},en,ja,null", self.language_code());
let images: Images = self
.get(
&format!("{API_BASE}/{}/images", id.path()),
&[("include_image_language", languages.as_str())],
)
.await?;
Ok(images.into_art(self.language_code()))
}
/// [`Tmdb::art`], with a failure logged and taken as no art: the art is
/// decoration on an answer that is already complete, and a bad key or a
/// slow minute must not stop the episodes beside it being stored.
pub async fn art_or_nothing(&self, id: TmdbId) -> Art {
self.art(id)
.await
.inspect_err(|error| tracing::debug!("TMDB art for {id:?}: {error}"))
.unwrap_or_default()
}
/// `de` out of `de-DE`: images are tagged by language alone.
fn language_code(&self) -> &str {
self.language.split('-').next().unwrap_or("en")
}
/// One authenticated GET, paced, with TMDB's failures named.
///
/// TMDB is far more generous than AniList and a library-sized run rarely
@@ -272,6 +349,66 @@ struct EpisodeRow {
overview: Option<String>,
still_path: Option<String>,
air_date: Option<String>,
/// Minutes.
runtime: Option<u32>,
}
/// `/{movie,tv}/{id}/images`.
#[derive(Deserialize, Default)]
struct Images {
#[serde(default)]
backdrops: Vec<Image>,
#[serde(default)]
logos: Vec<Image>,
}
#[derive(Deserialize)]
struct Image {
file_path: String,
iso_639_1: Option<String>,
#[serde(default)]
vote_average: f32,
}
impl Images {
fn into_art(self, language: &str) -> Art {
// Bare is best for a backdrop: a logo or a title is drawn over it,
// and lettering already in the picture fights that.
let backdrop = best(&self.backdrops, |image| match image.iso_639_1.as_deref() {
None => 2,
Some(tag) if tag == language || tag == "en" => 1,
Some(_) => 0,
});
// A logo is lettering, so it is wanted in a language the viewer reads.
// Only PNGs: an SVG is nothing the art loaders here can draw.
let logos: Vec<&Image> = self
.logos
.iter()
.filter(|image| image.file_path.ends_with(".png"))
.collect();
let logo = best(&logos, |image| match image.iso_639_1.as_deref() {
Some(tag) if tag == language => 3,
Some("en") => 2,
None => 1,
Some(_) => 0,
});
Art {
backdrop: backdrop.map(|image| image_url(BACKDROP_SIZE, &image.file_path)),
logo: logo.map(|image| image_url(LOGO_SIZE, &image.file_path)),
}
}
}
/// The image `rank` likes most, the better-voted one among equals.
fn best<T: std::borrow::Borrow<Image>>(
images: &[T],
rank: impl Fn(&Image) -> u8,
) -> Option<&Image> {
images.iter().map(|image| image.borrow()).max_by(|a, b| {
rank(a)
.cmp(&rank(b))
.then(a.vote_average.total_cmp(&b.vote_average))
})
}
#[derive(Deserialize)]
@@ -335,8 +472,11 @@ impl Result_ {
overview: self.overview.filter(|text| !text.is_empty()),
year: year_of(self.first_air_date.or(self.release_date).as_deref()),
kind,
poster_url: self.poster_path.map(|path| image(POSTER_SIZE, &path)),
backdrop_url: self.backdrop_path.map(|path| image(BACKDROP_SIZE, &path)),
poster_url: self.poster_path.map(|path| image_url(POSTER_SIZE, &path)),
backdrop_url: self
.backdrop_path
.map(|path| image_url(BACKDROP_SIZE, &path)),
logo_url: None,
rating: self.vote_average.filter(|rating| *rating > 0.0),
// `search/multi` carries neither, and a detail request per
// candidate would be a round-trip per *result* rather than per
@@ -363,7 +503,7 @@ impl Result_ {
}
}
fn image(size: &str, path: &str) -> String {
fn image_url(size: &str, path: &str) -> String {
format!("{IMAGE_BASE}/{size}{path}")
}
@@ -437,7 +577,7 @@ mod tests {
);
assert_eq!(
series.metadata.backdrop_url.as_deref(),
Some("https://image.tmdb.org/t/p/w780/b.jpg")
Some("https://image.tmdb.org/t/p/w1280/b.jpg")
);
let film: Result_ = serde_json::from_str(
@@ -451,6 +591,61 @@ mod tests {
assert_eq!(film.metadata.year, Some(1988));
}
/// Paprika's images as TMDB answers them, trimmed: a bare backdrop beside
/// better-voted lettered ones, and logos in three languages and two
/// formats.
#[test]
fn the_bare_backdrop_and_a_png_logo_in_the_viewers_language_are_chosen() {
let images: Images = serde_json::from_str(
r#"{
"backdrops": [
{"file_path": "/en.jpg", "iso_639_1": "en", "vote_average": 5.5},
{"file_path": "/bare.jpg", "iso_639_1": null, "vote_average": 5.2},
{"file_path": "/bare-worse.jpg", "iso_639_1": null, "vote_average": 4.0},
{"file_path": "/ja.jpg", "iso_639_1": "ja", "vote_average": 6.0}
],
"logos": [
{"file_path": "/ja.png", "iso_639_1": "ja", "vote_average": 6.0},
{"file_path": "/de.svg", "iso_639_1": "de", "vote_average": 5.0},
{"file_path": "/en.png", "iso_639_1": "en", "vote_average": 3.0},
{"file_path": "/de.png", "iso_639_1": "de", "vote_average": 1.0}
]
}"#,
)
.expect("parse");
let art = images.into_art("en");
assert_eq!(
art.backdrop.as_deref(),
Some("https://image.tmdb.org/t/p/w1280/bare.jpg")
);
assert_eq!(
art.logo.as_deref(),
Some("https://image.tmdb.org/t/p/w500/en.png")
);
}
#[test]
fn a_logo_in_the_viewers_own_language_beats_an_english_one() {
let images: Images = serde_json::from_str(
r#"{"logos": [
{"file_path": "/en.png", "iso_639_1": "en", "vote_average": 9.0},
{"file_path": "/de.svg", "iso_639_1": "de", "vote_average": 9.0},
{"file_path": "/de.png", "iso_639_1": "de", "vote_average": 1.0}
]}"#,
)
.expect("parse");
assert_eq!(
images.into_art("de").logo.as_deref(),
Some("https://image.tmdb.org/t/p/w500/de.png")
);
}
#[test]
fn an_entry_with_no_images_has_no_art() {
let images: Images = serde_json::from_str("{}").expect("parse");
assert_eq!(images.into_art("en"), Art::default());
}
/// A rating of zero means "nobody has voted", not "everybody hated it", and
/// showing it as 0.0/10 would be a lie about the title.
#[test]
+447
View File
@@ -0,0 +1,447 @@
//! TVmaze, over its public REST API.
//!
//! Television AniList does not have — which is everything that is not anime —
//! with no API key and no account. It is asked only about what AniList found
//! nothing for (see [`crate::service`]), so a library of anime sends it
//! nothing, and a library with one live-action show in it sends it that one
//! name.
//!
//! What it does not have is film: TVmaze indexes shows and nothing else, so a
//! title the library knows to be a film is not asked about at all. A show's
//! name is also a film's often enough that asking would mostly find the wrong
//! thing.
//!
//! Its art is good where it matters here: a 16:9 still for most episodes and a
//! full-HD background for most shows. Its "typography" image would be a logo
//! but is a JPEG on a solid ground, so there is no logo from here.
use pstr_core::library::{Title, TitleKind};
use pstr_core::metadata::{Enrichment, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata};
use serde::Deserialize;
use crate::anilist::strip_markup;
use crate::error::{Error, Result};
use crate::limiter::RateLimiter;
use crate::matching::{Candidate, Query};
use crate::provider::Provider;
const API_BASE: &str = "https://api.tvmaze.com";
/// TVmaze allows at least twenty calls every ten seconds per address. A little
/// under it, because the limit is counted on its side and a burst that lands
/// at the edge of its window gets a 429 for nothing.
const REQUESTS_PER_MINUTE: u32 = 100;
/// As [`crate::anilist`]'s.
const RETRIES: usize = 2;
/// The background width worth asking for. Shows often carry a 4K one beside
/// it, which is four times the bytes for a band no window draws that wide.
const BACKDROP_WIDTH: u32 = 1920;
pub struct TvMaze {
http: reqwest::Client,
limiter: RateLimiter,
}
impl TvMaze {
pub fn new(http: reqwest::Client) -> Self {
Self {
http,
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
}
}
}
impl Provider for TvMaze {
fn id(&self) -> ProviderId {
ProviderId::TvMaze
}
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
if !asks_about(query) {
return Ok(Vec::new());
}
let hits: Vec<SearchHit> = self
.get("/search/shows", &[("q", query.name.as_str())])
.await?
.unwrap_or_default();
Ok(hits
.into_iter()
.map(|hit| hit.show.into_candidate())
.collect())
}
/// Every episode and the show's background, in one request.
async fn enrich(&self, _title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
let show: Option<Show> = self
.get(
&format!("/shows/{}", found.remote_id),
&[("embed[]", "episodes"), ("embed[]", "images")],
)
.await?;
Ok(show.map(Show::into_enrichment).unwrap_or_default())
}
}
impl TvMaze {
/// The show the TVmaze id `id` names, as search would have found it.
/// `None` for an id TVmaze no longer has.
pub async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> {
let show: Option<Show> = self.get(&format!("/shows/{id}"), &[]).await?;
Ok(show.map(|show| show.into_candidate().metadata))
}
/// One GET, paced. `None` for a 404, which TVmaze answers for an id it
/// does not have.
async fn get<T: serde::de::DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<Option<T>> {
let mut attempts = 0usize;
let response = loop {
self.limiter.acquire().await;
let response = self
.http
.get(format!("{API_BASE}{path}"))
.query(query)
.send()
.await?;
let status = response.status();
if status.as_u16() == 429 {
let waited = self.limiter.throttled(response.headers()).await;
attempts += 1;
if attempts > RETRIES {
return Err(Error::RateLimited);
}
tracing::debug!("TVmaze is throttling; waiting {waited:?}");
continue;
}
if status.as_u16() == 404 {
return Ok(None);
}
if !status.is_success() {
return Err(Error::Http(format!("TVmaze answered {status}")));
}
break response;
};
Ok(Some(response.json().await?))
}
}
/// Whether TVmaze could have `query` at all: not when the library knows it is
/// a film.
fn asks_about(query: &Query) -> bool {
!(query.kind_known && query.kind == TitleKind::Film)
}
#[derive(Deserialize)]
struct SearchHit {
show: Show,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Show {
id: i64,
name: String,
url: Option<String>,
#[serde(default)]
genres: Vec<String>,
status: Option<String>,
runtime: Option<u32>,
average_runtime: Option<u32>,
premiered: Option<String>,
rating: Option<Rating>,
network: Option<Channel>,
web_channel: Option<Channel>,
image: Option<Image>,
summary: Option<String>,
#[serde(rename = "_embedded")]
embedded: Option<Embedded>,
}
#[derive(Deserialize)]
struct Rating {
average: Option<f32>,
}
#[derive(Deserialize)]
struct Channel {
name: String,
}
#[derive(Deserialize)]
struct Image {
medium: Option<String>,
original: Option<String>,
}
#[derive(Deserialize, Default)]
struct Embedded {
#[serde(default)]
episodes: Vec<Episode>,
#[serde(default)]
images: Vec<Picture>,
}
#[derive(Deserialize)]
struct Episode {
name: Option<String>,
season: Option<u32>,
/// `null` on a special, which is numbered nowhere a file name would say.
number: Option<u32>,
airdate: Option<String>,
runtime: Option<u32>,
image: Option<Image>,
summary: Option<String>,
}
#[derive(Deserialize)]
struct Picture {
#[serde(rename = "type")]
kind: Option<String>,
#[serde(default)]
main: bool,
resolutions: Resolutions,
}
#[derive(Deserialize)]
struct Resolutions {
original: Option<Resolution>,
}
#[derive(Deserialize)]
struct Resolution {
url: String,
width: Option<u32>,
}
impl Show {
fn into_candidate(self) -> Candidate {
let status = match self.status.as_deref() {
Some("Running") => Some("RELEASING"),
Some("Ended") => Some("FINISHED"),
Some("To Be Determined") => Some("HIATUS"),
Some("In Development") => Some("NOT_YET_RELEASED"),
_ => None,
};
let channel = self.network.or(self.web_channel);
let details = TitleDetails {
start_date: self.premiered.clone().filter(|date| !date.is_empty()),
// Not a studio, but what the facts line names in its place: who
// made it, as a viewer would put it.
studios: channel.map(|channel| channel.name).into_iter().collect(),
status: status.map(str::to_string),
runtime: self
.runtime
.or(self.average_runtime)
.filter(|minutes| *minutes > 0),
revision: TitleDetails::REVISION,
..TitleDetails::default()
};
Candidate {
aliases: vec![self.name.clone()],
metadata: TitleMetadata {
provider: ProviderId::TvMaze,
remote_id: self.id.to_string(),
year: self
.premiered
.as_deref()
.and_then(|date| date.get(..4)?.parse().ok()),
name: self.name,
original_name: None,
overview: self.summary.as_deref().and_then(overview),
kind: TitleKind::Series,
poster_url: self.image.and_then(|image| image.original.or(image.medium)),
backdrop_url: None,
logo_url: None,
rating: self
.rating
.and_then(|rating| rating.average)
.filter(|rating| *rating > 0.0),
genres: self.genres,
episodes: None,
url: self.url,
details: Some(details),
},
// Always: TVmaze has nothing but shows.
kind_known: true,
}
}
fn into_enrichment(self) -> Enrichment {
let embedded = self.embedded.unwrap_or_default();
let episodes = embedded
.episodes
.into_iter()
.filter_map(|episode| {
Some(EpisodeMetadata {
season: episode.season,
number: episode.number?,
absolute: None,
entry_season: None,
entry_number: None,
name: episode.name.filter(|name| !name.is_empty()),
overview: episode.summary.as_deref().and_then(overview),
// The row draws a still about this wide; the original is
// a full-HD frame per episode.
still_url: episode
.image
.and_then(|image| image.medium.or(image.original)),
air_date: episode.airdate.filter(|date| !date.is_empty()),
runtime: episode.runtime.filter(|minutes| *minutes > 0),
mal_id: None,
})
})
.collect();
Enrichment {
episodes,
backdrop_url: backdrop(&embedded.images),
logo_url: None,
chain: Vec::new(),
}
}
}
/// The background to draw: the show's main one if it marked one, else the one
/// nearest [`BACKDROP_WIDTH`].
fn backdrop(images: &[Picture]) -> Option<String> {
images
.iter()
.filter(|picture| picture.kind.as_deref() == Some("background"))
.filter_map(|picture| Some((picture, picture.resolutions.original.as_ref()?)))
.min_by_key(|(picture, resolution)| {
(
!picture.main,
resolution.width.unwrap_or(0).abs_diff(BACKDROP_WIDTH),
)
})
.map(|(_, resolution)| resolution.url.clone())
}
/// TVmaze's summaries are HTML paragraphs. Each one keeps its own line.
fn overview(html: &str) -> Option<String> {
let text = strip_markup(&html.replace("</p>", "</p>\n"));
(!text.is_empty()).then_some(text)
}
#[cfg(test)]
mod tests {
use super::*;
const SHOW: &str = r#"{
"id": 82, "url": "https://www.tvmaze.com/shows/82/game-of-thrones",
"name": "Game of Thrones", "type": "Scripted",
"genres": ["Drama", "Adventure", "Fantasy"], "status": "Ended",
"runtime": 60, "averageRuntime": 61, "premiered": "2011-04-17",
"rating": {"average": 8.9}, "network": {"id": 8, "name": "HBO"},
"webChannel": null,
"image": {"medium": "https://static.tvmaze.com/m/1.jpg",
"original": "https://static.tvmaze.com/o/1.jpg"},
"summary": "<p>Based on the novels.</p><p>Seven noble families fight.</p>",
"_embedded": {
"episodes": [
{"name": "Winter is Coming", "season": 1, "number": 1,
"type": "regular", "airdate": "2011-04-17", "runtime": 60,
"image": {"medium": "https://static.tvmaze.com/m/e1.jpg",
"original": "https://static.tvmaze.com/o/e1.jpg"},
"summary": "<p>Lord Stark is troubled.</p>"},
{"name": "The Kingsroad", "season": 1, "number": 2,
"type": "regular", "airdate": "2011-04-24", "runtime": null,
"image": null, "summary": null},
{"name": "Inside the Making", "season": 1, "number": null,
"type": "significant_special", "airdate": "2011-04-10"}
],
"images": [
{"type": "poster", "main": true, "resolutions":
{"original": {"url": "poster", "width": 2030}}},
{"type": "background", "main": false, "resolutions":
{"original": {"url": "uhd", "width": 3840}}},
{"type": "background", "main": false, "resolutions":
{"original": {"url": "hd", "width": 1920}}},
{"type": "typography", "main": false, "resolutions":
{"original": {"url": "lettering", "width": 840}}}
]
}
}"#;
#[test]
fn a_show_becomes_a_series_with_its_details() {
let show: Show = serde_json::from_str(SHOW).expect("parse");
let found = show.into_candidate().metadata;
assert_eq!(found.provider, ProviderId::TvMaze);
assert_eq!(found.remote_id, "82");
assert_eq!(found.kind, TitleKind::Series);
assert_eq!(found.year, Some(2011));
assert_eq!(found.rating, Some(8.9));
assert_eq!(
found.poster_url.as_deref(),
Some("https://static.tvmaze.com/o/1.jpg")
);
assert_eq!(
found.overview.as_deref(),
Some("Based on the novels.\nSeven noble families fight.")
);
let details = found.details.expect("details");
assert!(details.is_current());
assert_eq!(details.studios, vec!["HBO"]);
assert_eq!(details.status.as_deref(), Some("FINISHED"));
assert_eq!(details.runtime, Some(60));
assert_eq!(details.start_date.as_deref(), Some("2011-04-17"));
}
/// Specials are numbered nowhere a file would be, so they are left out
/// rather than colliding with the episode they share a season with.
#[test]
fn enrichment_keeps_numbered_episodes_and_the_full_hd_background() {
let show: Show = serde_json::from_str(SHOW).expect("parse");
let enrichment = show.into_enrichment();
assert_eq!(enrichment.episodes.len(), 2);
let first = &enrichment.episodes[0];
assert_eq!((first.season, first.number), (Some(1), 1));
assert_eq!(first.name.as_deref(), Some("Winter is Coming"));
assert_eq!(first.overview.as_deref(), Some("Lord Stark is troubled."));
assert_eq!(
first.still_url.as_deref(),
Some("https://static.tvmaze.com/m/e1.jpg")
);
assert_eq!(first.runtime, Some(60));
assert_eq!(enrichment.episodes[1].runtime, None);
assert_eq!(enrichment.backdrop_url.as_deref(), Some("hd"));
assert_eq!(enrichment.logo_url, None);
}
#[test]
fn a_background_the_show_marked_main_wins_over_a_better_sized_one() {
let images: Vec<Picture> = serde_json::from_str(
r#"[{"type": "background", "main": false, "resolutions":
{"original": {"url": "hd", "width": 1920}}},
{"type": "background", "main": true, "resolutions":
{"original": {"url": "chosen", "width": 1280}}}]"#,
)
.expect("parse");
assert_eq!(backdrop(&images).as_deref(), Some("chosen"));
assert_eq!(backdrop(&[]), None);
}
/// TVmaze has no films, and a show sharing a film's name is the wrong
/// answer far more often than it is an adaptation.
#[test]
fn a_known_film_is_not_asked_about_and_a_guessed_one_is() {
let film = Query::new("Paprika", None, TitleKind::Film);
assert!(!asks_about(&film));
assert!(asks_about(&film.with_guessed_kind()));
assert!(asks_about(&Query::new(
"Game of Thrones",
None,
TitleKind::Series
)));
}
}
+5 -3
View File
@@ -4,8 +4,8 @@ Signing in to Proton is optional. Without it, the app plays public share links a
everything on one device. Signing in adds two things:
- **Folders of your own Drive** as shares, with no link to copy.
- **Sync** of your place in every episode, your shares and your settings, across every device
you sign in on.
- **Sync** of your place in every episode, your shares, your settings and My List, across every
device you sign in on.
## Signing in
@@ -51,6 +51,8 @@ When you are signed in, each device keeps a small file in your Drive. You find t
your Drive)
- Settings: playback, appearance and posters and descriptions, including a TMDB key
- Matches you picked by hand, and your audio and subtitle choice for each show
- My List: a title you add on one device is on the list of every other, and one you remove is
removed from all of them
**What stays on each device:** volume and mute, the streaming cache size, text size, Wi-Fi only,
background audio, hardware decoding, window size, and the library's filter and sort order.
@@ -60,7 +62,7 @@ and when it closes. **Sync now** runs it at once. Android also syncs in the back
after you leave the app.
**How conflicts are settled:** each device writes only its own file, and reads everyone else's.
For each episode and each setting, the newest change wins. A device that signs in for the first
For each episode, each setting and each title on My List, the newest change wins. A device that signs in for the first
time takes the others' state and does not overwrite it with its own defaults.
A device that has not synced for 90 days has its file moved to the Drive trash.
+17 -10
View File
@@ -12,20 +12,27 @@ All of B14–B33 came out of a static review of the Android client
device** — there was no Android acceptance harness to reproduce them with, which
is itself B42. Each states the reasoning that identifies it; treat the
reproduction step as part of the fix. The remediation order is `plan.md` at the
repository root; Phases 1 to 5 of it have landed except for the tail of B41 and
one row of Phase 5's table — everything from B14 to B39 is below in **Fixed**,
and B41 states which of its items are done.
repository root; Phases 1 to 5 of it have landed except for one row of Phase
5's table — everything from B14 to B39 is below in **Fixed**, and B41's items
have all landed.
### B41 — assorted correctness and hygiene defects
Individually small, grouped so they are not lost. The items marked **Landed**
came with Phases 4 and 5; the rest are still open, which is why this entry is.
What remains is one item: the asynchronous track-list read-back.
Individually small, grouped so they are not lost. Every item has now landed;
the entry stays here, with B42, until a device run confirms them.
- `NativeMpvHost.kt:131-135` reads the track list back immediately after an
asynchronous property set, so the selection tick lags by up to a second;
`alang`/`slang` are applied only at load (`pstr_mpv.cpp:225-227`), so a
language change takes effect one episode late.
- **Landed.** `NativeMpvHost.selectTrack` read the track list back straight
after setting `aid`/`sid`, before mpv had made the switch, so the tick in the
track chooser stayed on the old track until the next poll, up to a second
later. The tick now moves when the track is chosen, and the list is read again
300 ms after. The second half of the report, a language change landing one
episode late, did not hold up: the choice is saved from the chosen track, and
the next episode loads with it. What was wrong nearby is that `load` set
`alang` and `slang` only when they were not empty, and mpv keeps both across
`loadfile`, so a file with no language preference took the previous file's.
Both are set on every load now. The iOS client had the same early read in
`MpvPlayer.select` and takes the same fix; it already set both languages on
every load.
- **Landed.** `NativeMpvHost.kt` polled on `Dispatchers.Main.immediate`, and
`tracks_json` issues five `mpv_get_property` calls per track — 51 synchronous
property reads on the UI thread for a 10-track file, contending on the core
+22 -4
View File
@@ -20,12 +20,13 @@ is the contract, and each side's copy is asserted against it by a test.
## Type
Nine rungs. The desktop names them; Android restates them because Material's own
Ten rungs. The desktop names them; Android restates them because Material's own
scale is a different one (`displayLarge` is 57sp, which is a size for a lock
screen and not for anything in a library grid).
| Role | Size | What it is |
|---|---|---|
| Hero | 34 | the banner's name and a title page's name, where no logo stands in |
| Display | 26 | the name of the thing you are looking at, once per page |
| Title | 20 | an empty state, or a page heading with no display line |
| Heading | 18 | the brand in the nav bar; a dialog's heading |
@@ -39,8 +40,11 @@ screen and not for anything in a library grid).
Weight and leading are each side's own. The desktop bundles Inter
(`pstr-app/assets/fonts`, OFL) and sets Display, Title, Heading and Section in
its semibold cut, which egui sees as a family of its own (`theme::semibold`);
everything below Section is the regular cut. Compose derives line height as
1.35 × size rather than naming it nine more times.
everything below Section is the regular cut. Hero is set in Inter Display Bold,
the optical size Inter draws for large text (`theme::display`); Android and iOS
map it to `displayLarge`. Compose derives line height as 1.35 × size rather
than naming it ten more times, except Hero at 1.1, where the text leading
pushes a two-line name apart.
Icons on the desktop come from the Phosphor icon font (`egui-phosphor`,
regular weight), installed right behind Inter so an icon can sit inside an
@@ -59,9 +63,23 @@ this module, and keep Material's sizes. Nothing in the app draws with them.
buttons, the tab pill. **4** (`BADGE`) for a badge over artwork. The desktop
also has **3** (`BAR`) for progress bars and edge strips, **6** (`SM`) for a
small control inside something else, and **12** (`LG`) for what floats — row
cards, dialogs, toasts, the hero. **16** for an Android bottom sheet, which has
cards, dialogs, toasts. The library's banner is square: it runs edge to edge. **16** for an Android bottom sheet, which has
no desktop counterpart.
## A picture's colour
The veils over a title's backdrop — the library's banner, the top of a title's
page — are the page colour tinted by the picture under them, so each title
carries its own light. All three clients get the same colour from the same
code: `pstr_core::appearance::mood` averages the picture, squeezed to a few
hundred pixels, counting a saturated pixel up to five times a grey one
(`MOOD_CHROMA_WEIGHT`), and `mood_veil` turns that into the veil. On a dark
palette the mood is darkened until its brightest channel is 60 (`MOOD_DEPTH`)
and the page moves 55% of the way to it (`MOOD_DARK`); a light palette moves
12% (`MOOD_LIGHT`). The phones reach both through the bridge, as
`pictureMood` and `moodVeil`. The fade at the bottom stays the page colour,
because that is what it ends in.
## Spacing
The desktop spaces things from one scale, `theme::space`: 2, 4, 6, 8, 12, 16,
+1 -1
View File
@@ -10,7 +10,7 @@
| `pstr-stream` — block cache, read-ahead | **done, live-verified** — 68 offline tests; benchmarked against a real 761 MiB episode (see `docs/TESTING.md`) |
| `pstr-player` — libmpv `stream_cb` + render API | **done, live-verified** — 33 offline tests; plays a real 1080p HEVC episode over a public link, seeks, resumes from the disk cache, and renders into the app's GL context |
| `pstr-app` — egui UI, poster grid, embedded video | **done, offline-tested** — 15 offline tests; library wall, title pages, player page with overlay controls, themes, share management |
| Metadata providers (AniList / TMDB) | **done** — 32 offline tests; posters, synopses, per-episode titles, hand-pinned matches. Opt-in, off by default |
| Metadata providers (AniList, then TVmaze / TMDB) | **done** — 32 offline tests; posters, synopses, per-episode titles, hand-pinned matches. Opt-in, off by default |
| Packaging | **done, locally verified** — `scripts/build.sh` (tarball, `.deb`, `.rpm`, `.app`, `.dmg`) and `scripts/build.ps1` (portable `.zip`, WiX `.msi`), plus `packaging/PKGBUILD`. See `packaging/README.md`. The macOS half is written but has not been run on a Mac |
## Build order from here
+6
View File
@@ -90,6 +90,12 @@ Each of these is the platform's doing, not a choice to diverge.
a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`)
stand in for the ripple, and a settings page goes back by an edge strip of its
own, as a title page does, where Android has the system back.
- **No zoom into a title page.** iOS 18's `.navigationTransition(.zoom)` only
runs inside a `NavigationStack`, and the title page is shown over the library
by hand, so it keeps its slide in from the edge — Android's shared poster has
no counterpart here.
- **The volume swipe turns the player's own volume**, the one its slider sets.
iOS lets no app set the device's media volume, which Android's swipe turns.
- **Nothing is backed up**, as on Android (`allowBackup="false"`). The share
secrets are this-device-only Keychain items, so a restored share list would
arrive without them, and a restored `sync.json` would give two devices one sync
+131 -6
View File
@@ -45,8 +45,9 @@ good. What hung off it was not:
|---|---|---|
| **AniList** GraphQL | none | Identity. Search, every alias, cover art, relations (`SEQUEL`, `PREQUEL`, `SIDE_STORY`…), studios, staff, tags, format, season, popularity, next airing episode, the MAL id. |
| **ani.zip** `api.ani.zip/mappings?anilist_id=` | none | Everything AniList lacks, per AniList id: TVDB season, episode and absolute numbers; episode titles in several languages; TVDB synopses; 16:9 stills; air dates; runtime. Series art: TVDB **fanart at 1920×1080**, poster, banner, logo. Cross-ids for MAL, AniDB, TVDB, TMDB, Kitsu, IMDb. A community service — cache what it says. |
| **TMDB** | free key | Film backdrops and logos; live-action TV. Already implemented. |
| **TVmaze** | none | Live-action TV episodes, keyless. |
| **AniSkip** `api.aniskip.com/v2/skip-times/` | none | Where an episode's opening and ending are, by MyAnimeList id and episode number, as viewers marked them. Asked at play time for files without such chapters; never stored. |
| **TMDB** | free key | Film backdrops and logos; live-action TV. Chosen as the provider, or with AniList chosen, asked by id for art alone. |
| **TVmaze** | none | Live-action TV: episodes with 16:9 stills and runtimes, network, status, full-HD backgrounds. No films; its "typography" art is a JPEG, so no logos. In use after AniList. |
| **AniSkip** | none | Opening and ending timestamps by MAL id and episode. |
| **Jikan** (MAL) | none | Filler and recap flags. Timed out when tried; optional. |
| **manami-project/anime-offline-database** | none | Every anime with its synonyms, ids and relations, as one 6 MB `.zst`. Matching against it locally sends no title anywhere. |
@@ -148,8 +149,30 @@ titles are one director's, and that is not a shelf — and a genre only under 40
of the library. Four at most, over the unfiltered library. Here: *Directed by
Satoshi Kon*, *From MADHOUSE*, *From MAPPA*, *From Production I.G*.
**The title page** adds the season, studios, director and next airing episode
to its line of facts, and the tags beside the genres.
**The title page** adds the season and next airing episode to its line of
facts, the tags beside the genres, and the studios and director as chips of
their own. Each genre, studio and director chip opens a page of every title
carrying it (`pstr_core::discover::Facet`).
**More like this.** `pstr_core::discover::more_like` scores every other title
by what it shares with this one — a director 4, a studio 3, a genre 2, one of
the first eight tags 1, the format 1 on top of anything else — and offers those
scoring 4 or more: a director, or two genres, but never one genre alone. The
title's own franchise is left out and each other franchise offers only its best
match. The same scoring, seeded by the title most recently watched to the end
and limited to titles not started, is the library's *Because you watched* row.
No request: it reads only what the match already stored.
**Cast and trailer.** The AniList query also asks for the first ten
characters by role, main ones first, each with their Japanese voice actor and
both portraits, and for the trailer's site and id. They are
`TitleDetails::cast` and `TitleDetails::trailer`, in the same JSON column — no
migration. `TitleDetails::REVISION` is 2, so a match stored before them is
fetched again by id on the next match run, as `runtime` was. A voice actor's
chip opens the same page as a director's (`Facet::Person`). The trailer is
only ever a link: `Trailer::url` builds the YouTube or Dailymotion address,
keeping only an id's own characters, and the browser opens it. Portraits are
fetched like posters, from AniList's image host.
**Android.** The same, through the bridge: `AndroidEngine::arrangement`
returns the grid's tiles and the shelves as keys into `library`'s titles, and
@@ -170,11 +193,113 @@ AniList, so a search that finds nothing is asked again without it.
Not done: TMDB fills no details.
**Logos and running times.** ani.zip sent both from the start and neither was
kept. Enrichment now keeps the series' TVDB *Clearlogo* — a transparent PNG —
in `title_metadata.logo_url`, kept across a refresh of the same entry like the
fanart, and each episode's running time (TVDB's, else AniDB's `length`) in
`episode_metadata.runtime`. AniList's `duration` lands in
`TitleDetails::runtime`: minutes per episode for a series, the whole running
time for a film. TMDB's episode runtimes are kept the same way.
A clear logo is padded to a square, so every client crops it to its visible
pixels before drawing it (`trim_alpha` on the desktop, `opaqueBounds` on
Android and iOS). It replaces the name over the library's banner and at the
top of the title page — except on a light theme's title page, where lettering
made for dark art could vanish. A film's running time joins its line of facts;
an episode row shows its own (on Android and iOS in place of the file size).
**Schema v13** adds the two columns and marks every AniList title un-enriched,
deleting nothing. `TitleDetails::revision` makes details written before
`runtime` existed refetch by id on the next run; they stay on screen until the
answer lands, rather than being blanked by the migration as schema v10 did.
**AniSkip.** A file whose chapters name no opening or ending — most files
that are not fansub releases — gets them from [AniSkip](https://aniskip.com),
where viewers mark where an episode's opening and ending are. ani.zip sends each
entry's MyAnimeList id with its mappings; enrichment now keeps it on every
episode row (`episode_metadata.mal_id`), next to the episode's number within
that entry. When such a file has loaded and its length is known, the player asks
`api.aniskip.com/v2/skip-times/{mal_id}/{number}?types=op&types=ed&episodeLength=`
once, with enrichment on only. The answer is kept in memory for as long as the
file is open and never stored.
`pstr_core::chapters::with_skip_ranges` cuts the answer into the file's
chapters as chapters named `Opening` and `Ending`, so the skip button, skipping
on its own, the up-next countdown and the marks on the seek bar all work through
the same code as for a file that came with them. A file whose own chapters
already name an opening or an ending keeps them: what a release says about its
own cut beats what a viewer of another release marked.
**Schema v14** adds the column and marks every AniList title un-enriched,
deleting nothing, as v13 did. Until the next match run fills it in, AniSkip is
not asked.
## Phase 3 — reach
**Routing by title (done).** One provider for the whole library left Game of
Thrones unmatched in a share of anime, and switching the library to TMDB would
have thrown away every AniList match, chain and shelf. So the providers are now
asked in a chain (`pstr_meta::service::chain_of`): with AniList chosen, a title
AniList has no match for is searched on TVmaze, keyless, before it is given up
on. TMDB, which has television itself, is still asked alone.
- A match is stored under the provider that gave it, and everything after it
— enrichment, refreshing details by id — goes to that provider.
- A miss is stored under the *last* provider asked. `MetadataRecord::is_fresh`
takes the chain, and a miss only stands when the chain ends with the provider
that stored it. So an AniList miss from before TVmaze joined is not trusted,
and the next run asks TVmaze at once rather than after the miss expires.
- A provider that fails (a timeout, a 429) ends the lookup with an error rather
than falling through. The next provider's answer for a title AniList could
not be asked about is a guess about an anime, and a guess stored as a match
would stay.
- TVmaze is not asked about a title the library knows to be a film. It has
none, and a show sharing a film's name is far more often wrong than an
adaptation.
- A hand search asks every provider in the chain and labels each result with
the provider it came from; picking one pins the title to that provider.
TVmaze answers search with whole show records — genres, network, status,
premiere date, runtime, rating — so a match carries its details with no second
request. Enrichment is one more: `/shows/{id}` with the episodes and images
embedded. Specials, which TVmaze numbers nowhere a file would, are left out.
**Episode names from scene releases (done).** `Game.of.Thrones.S01E01.Winter.Is.Coming.2160p…`
names its episode in dotted words, and `naming::episode_title` now reads them,
stopping at the first release tag. A dotted run that no tag ends is not taken.
This is the fallback for a title no provider has.
**Film art from TMDB by id (done).** TVDB has no films, so ani.zip has no
fanart or logo for one, and seventeen of the library's titles — nearly all
films — had nothing wide to draw. ani.zip does give the TMDB id
(`mappings.themoviedb_id`, with `mappings.type` saying whether it is a film's
or a show's). With a TMDB key stored and AniList chosen, an entry left without a
backdrop or a logo is asked for `/{movie,tv}/{id}/images`: one request, by id,
so no title name goes to TMDB. A backdrop without lettering is preferred, and a
PNG logo in the viewer's language, then English. An OVA's or a special's id is
left alone, since TMDB files those either way. A failure there is logged and
the enrichment stored without the art, rather than lost over decoration.
With TMDB itself chosen, enrichment now makes the same request for the logo,
and backdrops are fetched at `w1280` rather than `w780`, since they fill the
width of the title page.
**A band that never looks broken (done).** A title with no wide art drew
its poster cropped to the band's shape: a blown-up slice of the middle of it.
Now the poster is squeezed to 64×36 and blurred once, when it is fetched
(`Fit::Ambient`; the cache keeps the wash, not the poster), and that is
stretched across the band — the poster's colours and light rather than a
crop. The library's banner then stands the poster itself at its right. Android
and iOS blur the poster the same way (Android 12 and later; older versions
decode it tiny, which softens it less).
**Quality from file names (done).** `naming::quality` reads `2160p`, `HDR`,
`10-bit` and `Dual-Audio` out of a release name, and a title's page shows what
most of its files say. No release group is read.
Still to do:
- **Local matching** against the offline database, so only ids leave the
machine.
- **Routing by title**: AniList first, then TMDB (with a key) or TVmaze for
whatever is not anime — Game of Thrones in a mostly-anime share.
- **Skip intro** from AniSkip for files without chapters, and filler flags from
Jikan.
+11 -2
View File
@@ -31,8 +31,17 @@ your Drive**. See [Account and sync](ACCOUNT_AND_SYNC.md).
- Tap to show or hide the controls.
- **Double-tap** the left or right third of the screen to go back or forward 10 seconds. Double-tap
the middle to pause.
- The menu has **Episodes**, **Chapters**, **Playback speed**, the audio and subtitle tracks, and
**Lock to landscape**.
- **Swipe** up and down on the left half for brightness and on the right half for volume. Swipe
sideways to seek: the bubble shows where you will land, and the seek happens when you let go.
Swipes that start at the very top or bottom of the screen are left to the system.
- The menu has **Episodes**, **Chapters**, **Playback speed**, the audio and subtitle tracks,
**Lock to landscape**, **Sleep timer** and **Lock touch**.
- **Sleep timer** pauses after 30 or 60 minutes, or stops at the end of this episode instead of
going on to the next. It keeps running with the screen off.
- **Lock touch** makes the picture ignore touches, for a phone in a pocket or a child's hands. Tap
to bring up **Unlock**.
- **Skip opening** and **Skip ending** work as on the desktop, AniSkip included: see
[The player](USER_GUIDE.md#the-player).
- Sound keeps playing with the screen off or the app in the background, with controls on the lock
screen. Turn off **Background audio** in Settings to stop that.
+10 -1
View File
@@ -9,6 +9,9 @@ and to nothing else unless you turn on posters and descriptions. This page lists
|---|---|---|
| Proton | Requests for the shares you added and, if you signed in, your account's Drive | Always |
| AniList and ani.zip, or TMDB | The names of the titles in your library, and the ids of matched titles | Only with **Posters and descriptions** on |
| TVmaze | With AniList chosen: the names of the titles AniList has no match for, and the ids of those TVmaze matched | Only with **Posters and descriptions** on |
| TMDB, with AniList chosen | The TMDB ids of matched titles that have no wide picture or logo yet. Never a title's name | Only with **Posters and descriptions** on and a TMDB key saved |
| AniSkip | The MyAnimeList id and episode number of what you play, and the file's length. Never a name | Only with **Posters and descriptions** on, when you play an episode whose file does not mark its opening and ending |
Nothing else leaves your device. There is no telemetry, no crash reporting and no update check.
@@ -16,6 +19,9 @@ Nothing else leaves your device. There is no telemetry, no crash reporting and n
of the titles in your library. It does not learn your files, your watch history or your shares.
Turning them off deletes every stored answer.
**Trailer** on a title's page opens the trailer's YouTube or Dailymotion page in your web
browser. The app sends nothing there itself; your browser loads the page as it would any link.
## Secrets
- **The part of a share link after `#`** is the key that decrypts the share. The app treats it as
@@ -45,7 +51,7 @@ player is handed an opaque token, not a link, so no secret ends up in its log or
| What | Where on Linux |
|---|---|
| Settings and the share list (without secrets) | `~/.config/proton-stream/` |
| Settings, the share list (without secrets), My List and your last eight searches (`recent-searches.json`) | `~/.config/proton-stream/` |
| The library and watch history (`catalog.db`) and downloads (`offline/`) | `~/.local/share/proton-stream/` |
| Streamed video (`blocks/`), thumbnails and posters | `~/.cache/proton-stream/` |
@@ -60,6 +66,9 @@ can play them. Empty the cache in **Settings → Storage**, and delete downloads
The library database holds file names and your watch history, in plain text. It holds no
secrets.
Recent searches are saved only when you open something a search found, are never synced, and
**Clear recent searches** under the empty search box deletes them.
## Other people on the same computer
- A share link given on the command line, as in `proton-stream 'https://…#key'`, is visible in
+89 -17
View File
@@ -13,24 +13,53 @@ The app has five pages, in the bar at the top: **Library**, **History**, **Share
The library shows every title from every share in one place. Two shares that hold the same show
show it once.
- **The banner** at the top shows the title you watched last, with **Resume** and **More info**.
- **The banner** across the top shows the title you watched last first, with **Resume**, how far
into it you are, **More info** and **My List**. It then turns through up to four more titles that have a wide
picture, a different set each day, moving on every eight seconds. It holds still while the
pointer is over it; on a phone, swipe it or tap a dot under it to move on yourself. Its shading
takes on the colours of the picture behind it, and so does the top of a title's page.
- **Continue watching** has the episode you stopped in for each show, or the next one when you
finished an episode. To take a show off this row, right-click it and choose **Remove from
Continue watching**.
- **New episodes** has the shows you are watching that got episodes since you last watched them.
Their tiles say how many, for example **3 new**.
- **My List** has the titles you put aside to watch, the last one added first.
- **Recently added** has the titles that arrived in the last two weeks and that you have not
started. Their tiles say **New**. What was already there when you added a share is not new.
- **Because you watched Frieren** has titles like the last one you watched to the end, that you
have not started. It needs posters and descriptions.
- **Shelves** such as **Airing now**, **Directed by Satoshi Kon** or **From MADHOUSE** appear when
posters and descriptions are on. The app shows at most four, and only for a director, studio or
genre with at least three titles.
genre with at least three titles. On a shelf, a show that is still airing says when its next
episode is out, for example **Ep 8 · in 2 d**.
- **All titles** is the full grid. Choose a filter and a sort order above it.
Rows that run past the window fade out at the edge. Point at a row to show the arrows at its
ends, which move it along by a page.
| Filters | Sort orders |
|---|---|
| All, Series, Films, Watching, Not started, Downloaded | A – Z, Recently watched, Recently added, Newest, Highest rated, Most popular |
| All, My List, Series, Films, Watching, Not started, Downloaded | A – Z, Recently watched, Recently added, Newest, Highest rated, Most popular |
**My List** is a list of titles to watch later. Add a title with **My List** on its page or on
the banner, or right-click its tile and choose **Add to My List**. Choose the button again, or
**Remove from My List**, to take it off. When you are signed in, the list syncs to your other
devices ([Account and sync](ACCOUNT_AND_SYNC.md)).
**Group franchises** folds a series, its sequels and its films into one tile with a **+N** badge.
It is on by default, and needs posters and descriptions to know what belongs together.
**Search** with **Ctrl+F** or **/**. It matches every name a title has: the English one, the
romaji one and the name of its folder.
romaji one and the name of its folder. With posters and descriptions on, it also finds:
- **People and studios**: directors and studios whose names match. Choose one to see all their
titles.
- **Genres** whose names match.
- **Episodes** whose names match. Choose one to play it.
The titles it finds come after these. Click the empty search box to see your last eight
searches. A search is kept only when you open something it found. **Clear recent searches**
removes the list. The list stays on this device and does not sync.
**Refresh** (or **F5**) reads every share again. The app also does this by itself when a share
has not been read for 6 hours.
@@ -60,18 +89,46 @@ containers.
## A title's page
Click a title to open its page. **Esc** goes back.
<img src="../images/desktop-title.png" alt="A title's page" width="820">
Click a title to open its page. **Esc** goes back. On a phone, the picture at the top slides
away more slowly than the page, and once it has gone a bar with the title's name stays at the
top.
- **Resume S01E03 · 12:04** continues where you stopped. **Start over** plays the episode from
the beginning.
- **Download show** keeps every episode on this device. See [Downloads](#downloads).
- **Match…** opens the matcher, where you choose which AniList or TMDB entry the title is. Use it
when the app guessed wrong or did not guess at all. A match you pick is never replaced.
- **More on AniList** (or TMDB) opens the title's page there.
- Each season has **Mark season watched** and **Download season**.
- Each episode shows a tick when you have watched it. Right-click an episode and choose **Mark
- **Match…** opens the matcher, where you choose which AniList, TVmaze or TMDB entry the title
is. Each result names the site it came from. Use it when the app guessed wrong or did not guess
at all. A match you pick is never replaced.
- **More on AniList** (or TVmaze, or TMDB) opens the title's page there.
- Each season has **Mark season watched** and **Download season**. A show with more than five
seasons lists them in a drop-down instead of a row of buttons.
- With posters and descriptions on, a chip under the name says where the show stands: **Airing**
with the day the next episode comes out, **Finished**, **Upcoming**, **On hiatus** or
**Cancelled**.
- Each episode shows a tick when you have watched it, at the end of its row and in the corner of
its picture. Right-click an episode and choose **Mark
watched up to here** to tick it and every episode before it.
- **In this franchise** lists the other parts of the series, when posters and descriptions are on.
- **More like this**, under the episodes, lists titles in your library that share the most with
this one: its director, studio, genres, tags and format. It leaves out the title's own
franchise, and shows one title from each other franchise. It needs posters and descriptions.
- The genre chips under the description open a page with every title of that genre in your
library. So do the **Studio** and **Director** chips. Back, or **Esc**, returns to the title.
- **Cast** lists the main characters and who voices them in Japanese. Click one to see every title
in your library with that voice actor. Search finds voice actors too.
- **Trailer** opens the title's trailer in your web browser. The app does not play it or load it
itself.
- Titles matched before this version get their cast and trailer by themselves the next time the
app looks up posters and descriptions.
- With posters and descriptions on, a show's logo takes the place of its name at the top of the
page and on the library's banner, and each episode says how long it runs. A film says how long
it runs in the line under its name.
- A title with no wide picture shows its poster blurred across the top of the page instead, and
on the library's banner the poster itself beside it.
- Small marks under the name say what most of the files are, when their names say it: **4K**,
**1080p**, **HDR**, **10-bit** or **Dual audio**.
An episode counts as watched once you have seen 90% of it.
@@ -80,15 +137,25 @@ An episode counts as watched once you have seen 90% of it.
The picture plays inside the app's window. Move the mouse to show the controls.
- **Skip opening** and **Skip ending** appear when the file's chapters mark an opening, an ending
or a preview. Files without chapter names get no skip button. Turn on **Skip openings and
or a preview. For an anime whose file marks neither, the app asks AniSkip where they are, with
**Posters and descriptions** on; they then show as chapters too. Turn on **Skip openings and
credits** in Settings to skip them without asking.
- **Up next** counts down 10 seconds when the credits start, then plays the next episode. Choose
**Play now**, **Cancel** or **Watch till the end**.
- **Subtitles and audio** are chosen from the buttons at the bottom right. The app remembers your
choice for each show. The subtitle menu also has **Size** and **Position**, which are saved like
the ones in Settings and change styled subtitles only when they are restyled, and **Delay**,
which moves subtitles earlier or later for this file only.
- **Subtitles and audio**, **Speed**, **Chapters** and **Sleep timer** are buttons at the bottom
right. Each opens a panel down the right of the picture; click the button again, or press Esc,
to close it.
- **Subtitles and audio** remembers your choice for each show. The panel also has **Size** and
**Position**, which are saved like the ones in Settings and change styled subtitles only when
they are restyled, and **Delay**, which moves subtitles earlier or later for this file only.
- **Speed** goes from 0.5× to 3×, and stays for the next episode.
- **Sleep timer**, the moon, pauses after 30 or 60 minutes, or stops at the end of this episode
instead of going on to the next.
- **Seeking and volume** show what they did in the middle of the picture. Several presses of ← or
→ add up, so three presses of → say "+ 90 s".
- **Paused** for 5 seconds with the mouse still, the picture dims and shows what you are watching:
the show's logo or name, the episode, and what it is about. Move the mouse to get the controls
back.
### Keyboard
@@ -118,7 +185,7 @@ Press **?** anywhere to see this list in the app.
| E | The list of episodes |
| F or double-click | Fullscreen |
| Ctrl+S | Save the frame to `Pictures/proton-stream` |
| Esc | Leave fullscreen, then the player |
| Esc | Close a panel, leave fullscreen, then leave the player |
Media keys and your desktop's media controls work too.
@@ -217,6 +284,11 @@ a video that is playing changes at once.
**Posters and descriptions**
- Off by default. When it is on, the app sends the names of your titles to the provider you pick:
**AniList** (no key needed) or **TMDB** (needs a free API key). See [Privacy](PRIVACY.md).
**AniList** (no key needed) or **TMDB** (needs a free API key). With AniList, a title it does
not have, such as a live-action show, is looked up on **TVmaze** next, which needs no key
either. See [Privacy](PRIVACY.md).
- **TMDB API key:** needed for TMDB, optional with AniList. With a key, films and shows that have
no wide picture or logo get TMDB's, asked for by their TMDB id alone. After saving a key,
**Match everything again** fetches them for titles already matched.
- **Match the library** looks up new titles now. **Match everything again** looks up every title,
except those you matched by hand.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+77 -6
View File
@@ -41,7 +41,14 @@ final class AppModel {
/// A crawl is running, which the banner offers to stop.
private(set) var crawling = false
private(set) var query = ""
/// The whole library, whatever is searched for: the grid narrows to the
/// search through `arrangement`, and a title opened from a search stays one
/// the app can show. Android's `AppUiState.titles`.
var titles: [TitleRecord] = []
/// What the search found besides titles; nil with no search.
private(set) var searchExtras: SearchExtrasRecord?
/// The last few searches on this device, newest first.
private(set) var recentSearches: [String] = []
/// The grid: `titles` ordered, each franchise folded into one tile when
/// `grouped`, and the shelves above it. Nil until the bridge has answered,
/// when the grid falls back to `titles` as they are.
@@ -85,6 +92,7 @@ final class AppModel {
started = true
reload()
enteredForeground()
Task { recentSearches = (try? await run { try $0.recentSearches() }) ?? [] }
DownloadCoordinator.shared.onFinished = { [weak self] in self?.reload() }
DownloadCoordinator.shared.resumeQueued()
Task {
@@ -136,6 +144,32 @@ final class AppModel {
}
}
/// Keep the search being shown as one to offer again: called when a viewer
/// opens something it found. Nothing for an empty one.
func rememberSearch() {
let query = query.trimmingCharacters(in: .whitespaces)
if query.isEmpty { return }
Task {
if let recent = try? await run({ engine in
try engine.rememberSearch(query: query)
return try engine.recentSearches()
}) {
recentSearches = recent
}
}
}
func forgetSearches() {
Task {
do {
try await run { try $0.forgetSearches() }
recentSearches = []
} catch {
reportError(error)
}
}
}
func refresh() {
crawl(nil)
}
@@ -480,7 +514,7 @@ final class AppModel {
if report.settingsChanged {
await AppearanceState.shared.reload()
}
if report.applied > 0 || report.titles > 0 || report.settingsChanged || !report.sharesRemoved.isEmpty {
if report.applied > 0 || report.titles > 0 || report.settingsChanged || report.listChanged || !report.sharesRemoved.isEmpty {
await reloadNow()
}
// A crawl takes longer than the grace iOS gives a backgrounded app.
@@ -534,6 +568,36 @@ final class AppModel {
}
}
// MARK: - My List
/// Put a title on My List, or take it off.
func setOnList(_ title: TitleRecord, _ on: Bool) {
Task {
do {
try await run { try $0.setOnList(key: title.key, on: on) }
} catch {
reportError(error)
}
reload()
}
}
/// The keys of the titles most like `key`, best first; none if the engine
/// cannot say.
func moreLike(_ key: String) async -> [String] {
(try? await run { try $0.moreLike(key: key) }) ?? []
}
/// The keys of every title `facet` stands for, in library order.
func browse(_ facet: BrowseFacet) async -> [String] {
do {
return try await run { try $0.browse(facet: facet) }
} catch {
reportError(error)
return []
}
}
// MARK: - Watch state
/// Every episode of a title watched or unwatched, with an Undo: a whole
@@ -637,8 +701,10 @@ final class AppModel {
func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) async -> String? {
do {
metadataSettings = try await run { engine in
if provider == .tmdb, !apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
try engine.setMetadataApiKey(provider: provider, key: apiKey)
// TMDB's is the only key: the provider's with TMDB chosen, and
// film art's with AniList.
if !apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
try engine.setMetadataApiKey(provider: .tmdb, key: apiKey)
}
try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true))
return try engine.metadataSettings()
@@ -808,7 +874,7 @@ final class AppModel {
_ = try engine.pruneOfflineFiles()
return Reloaded(
shares: try engine.shares(),
titles: try engine.library(search: query),
titles: try engine.library(search: nil),
arrangement: try engine.arrangement(search: query, filter: filter, sort: sort, grouped: grouped),
offline: try engine.offlineFiles(),
metadataSettings: try engine.metadataSettings(),
@@ -834,11 +900,16 @@ final class AppModel {
let sort = sort
let grouped = grouped
do {
let (titles, arrangement) = try await run { engine in
(try engine.library(search: search), try engine.arrangement(search: search, filter: filter, sort: sort, grouped: grouped))
let (titles, arrangement, extras) = try await run { engine in
(
try engine.library(search: nil),
try engine.arrangement(search: search, filter: filter, sort: sort, grouped: grouped),
try search.map { try engine.searchExtras(search: $0) }
)
}
self.titles = titles
self.arrangement = arrangement
self.searchExtras = extras
} catch {
message = errorMessage(error)
}
+69 -15
View File
@@ -89,6 +89,13 @@ struct RootView: View {
@SceneStorage("selectedTitle") private var selectedTitleKey: String?
@State private var incomingLink: String?
@State private var host = PlayerHost.shared
/// What a genre, studio or director chip opened, over the library pane,
/// and the title it was opened from, which is where back goes.
@State private var browsing: BrowseFacet?
@State private var browseFrom: String?
@State private var browsed: [String] = []
/// "More like this" for the open title.
@State private var similar: [String] = []
var body: some View {
GeometryReader { geometry in
@@ -175,6 +182,7 @@ struct RootView: View {
private func select(_ target: Destination) {
destination = target
selectedTitleKey = nil
browsing = nil
}
private func play(_ title: TitleRecord, _ index: Int) {
@@ -209,22 +217,57 @@ struct RootView: View {
/// tap rather than back and in again.
@ViewBuilder private func libraryPage(width: CGFloat) -> some View {
let selected = selectedTitleKey.flatMap { key in model.titles.first { $0.key == key } }
let library = LibraryView(
onResume: play,
onTitle: { title in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } },
// The form as a pasted link opens it, only with nothing in it.
onAddShare: {
incomingLink = ""
destination = .shares
},
onOpenSettings: { select(.settings) }
)
if width >= twoPaneWidth {
let twoPane = width >= twoPaneWidth
let open = { (title: TitleRecord) in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } }
let library = Group {
if let browsing {
BrowseView(
facet: browsing,
titles: browsed.compactMap { key in model.titles.first { $0.key == key } },
onBack: {
self.browsing = nil
if let browseFrom { selectedTitleKey = browseFrom }
},
onTitle: open
)
} else {
LibraryView(
// A search that led somewhere is one worth offering again.
onResume: { title, index in
model.rememberSearch()
play(title, index)
},
onTitle: { title in
model.rememberSearch()
open(title)
},
// The form as a pasted link opens it, only with nothing in it.
onAddShare: {
incomingLink = ""
destination = .shares
},
onOpenSettings: { select(.settings) },
onBrowse: { facet in
model.rememberSearch()
browsing = facet
browseFrom = nil
}
)
}
}
.task(id: [browsing.map { "\($0)" } ?? "", "\(model.titles.count)"]) {
if let browsing {
browsed = await model.browse(browsing)
} else {
browsed = []
}
}
if twoPane {
HStack(spacing: 0) {
library.frame(width: width * 0.45)
Rectangle().fill(scheme.outlineVariant).frame(width: 1).ignoresSafeArea()
Group {
if let selected { titlePane(selected) } else { EmptyState("Choose a title", "Its seasons and episodes open here.") }
if let selected { titlePane(selected, twoPane: true) } else { EmptyState("Choose a title", "Its seasons and episodes open here.") }
}
.frame(maxWidth: .infinity)
}
@@ -237,7 +280,7 @@ struct RootView: View {
.allowsHitTesting(selected == nil)
.accessibilityHidden(selected != nil)
if let selected {
titlePane(selected)
titlePane(selected, twoPane: false)
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity.combined(with: .scale(scale: 0.9))))
.zIndex(1)
}
@@ -245,15 +288,26 @@ struct RootView: View {
}
}
private func titlePane(_ selected: TitleRecord) -> some View {
private func titlePane(_ selected: TitleRecord, twoPane: Bool) -> some View {
TitleView(
title: selected,
onPlay: { _, index in host.play(selected, at: index) },
onBack: { withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = nil } },
franchise: selected.franchise.compactMap { key in model.titles.first { $0.key == key } },
onOpenTitle: { selectedTitleKey = $0.key }
onOpenTitle: { selectedTitleKey = $0.key },
moreLike: similar.compactMap { key in model.titles.first { $0.key == key } },
onBrowse: { facet in
browsing = facet
browseFrom = selected.key
// On a phone the list is under the title; the page it opens
// is the library pane's.
if !twoPane { withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = nil } }
}
)
.id(selected.key)
.task(id: [selected.key, "\(model.titles.count)"]) {
similar = await model.moreLike(selected.key)
}
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ struct SettingsStore {
nonmutating set { defaults.set(newValue, forKey: Key.newEpisodeNotifications) }
}
static let filters: [LibraryFilter] = [.all, .series, .films, .watching, .unwatched, .downloaded]
static let filters: [LibraryFilter] = [.all, .myList, .series, .films, .watching, .unwatched, .downloaded]
private static let sorts: [LibrarySort] = [.name, .recent, .added, .release, .rating, .popularity]
private func bool(_ key: String, default value: Bool) -> Bool {
+13 -2
View File
@@ -32,7 +32,7 @@ struct MpvTrack: Identifiable, Hashable {
let type: String
let language: String
let title: String
let selected: Bool
var selected: Bool
/// The container marks it as the default choice.
var isDefault = false
/// The container marks it as forced: on-screen text only, by convention.
@@ -303,7 +303,18 @@ final class MpvPlayer {
/// A track, or none: `nil` turns subtitles off.
func select(type: String, track: MpvTrack?) {
setString(type == "audio" ? "aid" : "sid", track.map { String($0.id) } ?? "no")
refreshTracks()
// mpv makes the switch on its own thread and lists it a moment later,
// so a track list read back now still ticks the old track. The tick
// moves here, and a read shortly after confirms it.
tracks = tracks.map { listed in
var listed = listed
if listed.type == type { listed.selected = listed.id == track?.id }
return listed
}
Task { [weak self] in
try? await Task.sleep(for: .milliseconds(300))
self?.refreshTracks()
}
}
/// Showing no picture while backgrounded keeps the audio playing: iOS
+71 -2
View File
@@ -105,6 +105,10 @@ final class PlayerHost {
private(set) var autoplay = true
/// The rate playing, so the speed list opens on it.
var speed = 1.0
/// The sleep timer, when one is running. Here rather than on the view,
/// because the episode it is about plays on with the screen off.
private(set) var sleep: SleepTimer?
@ObservationIgnored private var sleepTask: Task<Void, Never>?
/// Persist where an episode was left: position, duration, watched.
@ObservationIgnored var onSaveProgress: ((EpisodeRecord, Double, Double, Bool) -> Void)?
@@ -120,6 +124,9 @@ final class PlayerHost {
@ObservationIgnored private var skipped = Set<Int64>()
/// The episode whose next one has been warmed; see `tick`.
@ObservationIgnored private var warmedAfter: String?
/// The episode AniSkip has been asked about, and what it said.
@ObservationIgnored private var skipsAskedFor: String?
@ObservationIgnored private var skipRanges: (key: String, ranges: [SkipRangeRecord])?
@ObservationIgnored private var pausedByInterruption = false
@ObservationIgnored private var artworkFor: String?
@@ -374,11 +381,32 @@ final class PlayerHost {
// MARK: - Reacting to the player
/// Start a sleep timer, replacing any running one, or turn it off.
func setSleep(_ timer: SleepTimer?) {
sleepTask?.cancel()
sleep = timer
guard case let .at(when) = timer else { return }
sleepTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(max(when.timeIntervalSinceNow, 0)))
guard !Task.isCancelled, let self else { return }
self.sleep = nil
self.setPaused(true)
}
}
/// Whether the sleep timer stops this episode at its end.
var stopsAfterThis: Bool {
if case let .endOf(media) = sleep { media == key } else { false }
}
private func ended(_ reason: EndReason, _ media: String) {
guard media == key else { return }
save()
// Autoplay fires on a clean end of file only — never on a failure.
if reason == .eof, autoplay, !stay, next != nil {
// The sleep timer set for the end of this episode stops it there.
if reason == .eof, stopsAfterThis {
setSleep(nil)
} else if reason == .eof, autoplay, !stay, next != nil {
advance()
} else if reason == .failed {
playbackError = player?.problem ?? "This episode could not be played."
@@ -387,6 +415,9 @@ final class PlayerHost {
}
private func tick() {
// A timer for the end of an episode the viewer has since moved on
// from was about that episode.
if case let .endOf(media) = sleep, media != key { setSleep(nil) }
guard let player, playingOpenEpisode else { return }
replanChapters(player)
// A stall that does not clear is a dropped connection far more often
@@ -414,6 +445,26 @@ final class PlayerHost {
player.duration - player.position < warmNextSeconds
|| chapters.creditsStart.map { player.position >= $0 } == true
)
// A file whose chapters name no opening or ending asks AniSkip, once,
// when its length is known. Only with posters and descriptions on:
// anything else, an error included, leaves the file as its chapters
// made it.
if player.duration > 0, let key, skipsAskedFor != key, let title, let episode {
skipsAskedFor = key
let named = chapters.entries.contains { $0.kind == .opening || $0.kind == .ending }
if !named {
let (titleKey, shareId, linkId, duration) = (title.key, episode.shareId, episode.linkId, player.duration)
Task { [weak self] in
let ranges = (try? await NativeRuntime.engine().skipTimes(
titleKey: titleKey, shareId: shareId, linkId: linkId, duration: duration
)) ?? []
guard let self, !ranges.isEmpty, self.key == key, let player = self.player else { return }
self.skipRanges = (key, ranges)
self.plannedFor = nil
self.replanChapters(player)
}
}
}
if nearEnd, let key, warmedAfter != key, let next {
warmedAfter = key
let (shareId, volumeId, linkId) = (next.shareId, next.volumeId, next.linkId)
@@ -431,7 +482,8 @@ final class PlayerHost {
let wanted = (player.duration, player.generation)
if let plannedFor, plannedFor == wanted, !(chapters.entries.isEmpty && !player.chapters.isEmpty) { return }
plannedFor = wanted
chapters = chapterPlan(chapters: player.chapters, duration: player.duration > 0 ? player.duration : nil)
let skips = skipRanges.flatMap { $0.key == key ? $0.ranges : nil } ?? []
chapters = chapterPlan(chapters: player.chapters, duration: player.duration > 0 ? player.duration : nil, skips: skips)
}
/// Play the dialogue rather than the signs and songs mpv picked by
@@ -678,3 +730,20 @@ final class PlayerHost {
}
}
}
/// A sleep timer that is running.
enum SleepTimer: Equatable {
/// Stop when this episode ends, rather than going on to the next.
case endOf(String)
/// Pause at this moment.
case at(Date)
/// What its button says: whether one is running, and how long it has.
static func label(_ timer: SleepTimer?) -> String {
switch timer {
case nil: "Sleep timer"
case .endOf: "Sleep timer: end of episode"
case let .at(when): "Sleep timer: \(Int(max(when.timeIntervalSinceNow, 0) / 60) + 1) min"
}
}
}
+228 -14
View File
@@ -15,6 +15,16 @@ private let inlineControlsMinWidth: CGFloat = 560
/// How far a double tap on either side of the picture seeks.
private let doubleTapSeconds = 10
/// How much of the file one swipe across the whole width is worth, in seconds.
private let seekSpanSeconds = 90.0
/// A finger that comes down this close to the top or bottom of the screen is
/// the system's: Control Centre, the home indicator.
private let systemEdge: CGFloat = 48
/// How long the unlock button stays up after a tap on a locked player.
private let unlockShownSeconds = 3.0
/// The rates worth offering. A short list rather than a slider: the useful
/// rates are a handful of ratios, and a slider lands on 1.07 as often as 1.0.
private let speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
@@ -37,6 +47,16 @@ struct PlayerView: View {
/// to have happened; the count makes a second tap on one side show again.
@State private var nudge: (seconds: Int, count: Int)?
@Environment(\.accessibilityVoiceOverEnabled) private var voiceOver
/// Locked, the picture takes no touches but one: the button that unlocks
/// it, which a tap brings up for a moment.
@State private var locked = false
@State private var unlockShown = 0
/// What a swipe on the picture is doing, while it is under way.
@State private var swipe: Swipe?
@State private var swipeStart: (axis: SwipeAxis, level: Double, position: Double)?
/// The screen's brightness before the first swipe changed it, put back
/// when the player goes.
@State private var brightnessBefore: CGFloat?
var body: some View {
GeometryReader { geometry in
@@ -49,8 +69,13 @@ struct PlayerView: View {
Color.clear
.contentShape(Rectangle())
.ignoresSafeArea()
.onTapGesture(count: 2) { location in doubleTap(at: location.x, width: geometry.size.width) }
.onTapGesture { setControls(!showControls) }
.onTapGesture(count: 2) { location in
if !locked { doubleTap(at: location.x, width: geometry.size.width) }
}
.onTapGesture {
if locked { unlockShown += 1 } else { setControls(!showControls) }
}
.gesture(swipeGesture(geometry.size), including: locked ? .none : .all)
.accessibilityElement()
.accessibilityLabel(showControls ? "Hide controls" : "Show controls")
.accessibilityAddTraits(.isButton)
@@ -72,10 +97,28 @@ struct PlayerView: View {
}
}
if let swipe {
SwipeBubble(swipe: swipe).allowsHitTesting(false).accessibilityHidden(true)
}
if locked, unlockShown > 0 {
Button("Unlock", systemImage: "lock.open") {
locked = false
unlockShown = 0
setControls(true)
}
.buttonStyle(.tonal)
.transition(.opacity)
.task(id: unlockShown) {
try? await Task.sleep(for: .seconds(unlockShownSeconds))
if !Task.isCancelled { unlockShown = 0 }
}
}
VStack(spacing: 0) {
if showControls { topBar.transition(.opacity) }
if showControls, !locked { topBar.transition(.opacity) }
Spacer(minLength: 0)
if showControls {
if showControls, !locked {
PlayerControls(
host: host,
player: player,
@@ -87,7 +130,12 @@ struct PlayerView: View {
landscapeLocked = OrientationLock.toggle()
},
onSheet: { sheet = $0 },
onInteract: { setControls(true) }
onInteract: { setControls(true) },
onLock: {
locked = true
unlockShown = 1
setControls(false)
}
)
.transition(.opacity)
}
@@ -113,7 +161,7 @@ struct PlayerView: View {
}()
UpNextCard(
next: host.next,
visible: host.autoplay && !host.stay && !player.paused && player.endReason == nil
visible: host.autoplay && !host.stay && !host.stopsAfterThis && !player.paused && player.endReason == nil
&& host.playingOpenEpisode
&& (tailLeft != nil || host.chapters.creditsStart.map { player.position >= $0 } == true),
secondsLeft: tailLeft.map { Int($0.rounded(.up)) },
@@ -123,7 +171,7 @@ struct PlayerView: View {
}
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.trailing, 16)
.padding(.bottom, showControls ? 132 : 24)
.padding(.bottom, showControls ? 160 : 24)
// Only while nothing else explains the still picture: an error
// is the more useful thing to say.
@@ -144,7 +192,7 @@ struct PlayerView: View {
// Played to the end with nothing starting after it: the last
// episode, autoplay off, or the viewer stayed.
if host.playingOpenEpisode, player.endReason == .eof,
host.next == nil || !host.autoplay || host.stay {
host.next == nil || !host.autoplay || host.stay || host.stopsAfterThis {
EndCard(host: host)
}
@@ -174,6 +222,10 @@ struct PlayerView: View {
.statusBarHidden(!showControls)
.persistentSystemOverlays(.hidden)
.onAppear { setControls(true) }
// Brightness set by a swipe is the player's, not the phone's.
.onDisappear {
if let brightnessBefore { Screen.brightness = brightnessBefore }
}
.onChange(of: player.paused) { setControls(showControls) }
// VoiceOver moves between controls by swiping, and a control that
// fades four seconds after it is reached cannot be found again.
@@ -192,6 +244,8 @@ struct PlayerView: View {
case .chapters: ChapterSheet(chapters: host.chapters.entries, position: player.position) { host.seek(to: $0.start) }
case let .tracks(type):
TrackSheet(type: type, tracks: player.tracks.filter { $0.type == type }) { host.select(type: type, track: $0) }
case .sleep:
SleepSheet(current: host.sleep, media: host.key, onSelect: host.setSleep)
}
}
}
@@ -210,7 +264,9 @@ struct PlayerView: View {
// thing a viewer who has just woken the controls is checking.
VStack(alignment: .leading, spacing: 0) {
Text(host.episode?.label ?? "").textStyle(.titleMedium).foregroundStyle(.white).lineLimit(1)
Text([host.title?.displayName, host.episode?.detail].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " • "))
// The episode's name, never its file name: a release group's
// tag is no caption, and it is what a screenshot would carry away.
Text([host.title?.displayName, host.episode?.shownName].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " • "))
.textStyle(.bodySmall).foregroundStyle(.white.opacity(0.7)).lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -222,8 +278,15 @@ struct PlayerView: View {
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(scheme.scrim.opacity(0.45).ignoresSafeArea(edges: [.top, .horizontal]))
.padding(.top, 4)
.padding(.bottom, 28)
// A gradient rather than a band: dark where the text is, and gone by
// the time it reaches the picture. Black whatever the theme, since
// what is under it is a video frame.
.background(
LinearGradient(colors: [.black.opacity(0.75), .clear], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea(edges: [.top, .horizontal])
)
}
/// Show or hide the controls; shown and playing, they hide themselves.
@@ -237,9 +300,59 @@ struct PlayerView: View {
}
}
/// The swipes every phone video player has taught: up and down on the
/// left for brightness, on the right for volume, and sideways to seek —
/// shown while the finger is down and only seeking when it lifts. Volume
/// is the player's own: iOS lets no app set the device's.
private func swipeGesture(_ size: CGSize) -> some Gesture {
DragGesture(minimumDistance: 16)
.onChanged { drag in
if swipeStart == nil {
let start = drag.startLocation
guard start.y > systemEdge, start.y < size.height - systemEdge else { return }
let axis: SwipeAxis = abs(drag.translation.width) > abs(drag.translation.height)
? .seek : start.x < size.width / 2 ? .brightness : .volume
let level: Double = switch axis {
case .brightness: Double(Screen.brightness)
case .volume: player.volume / 100
case .seek: 0
}
if axis == .brightness, brightnessBefore == nil { brightnessBefore = Screen.brightness }
swipeStart = (axis, level, player.position)
}
guard let start = swipeStart else { return }
switch start.axis {
// A swipe the whole height of the screen goes from none to all.
case .brightness:
let level = min(max(start.level - drag.translation.height / size.height, 0.01), 1)
Screen.brightness = CGFloat(level)
swipe = .brightness(level)
case .volume:
let level = min(max(start.level - drag.translation.height / size.height, 0), 1)
player.setVolume(level * 100)
swipe = .volume(level)
case .seek:
guard player.duration > 0 else { return }
let target = min(max(start.position + drag.translation.width / size.width * seekSpanSeconds, 0), player.duration)
swipe = .seek(from: start.position, to: target)
}
}
.onEnded { _ in
if case let .seek(from, to) = swipe, to != from {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
host.seek(to: to)
}
if case let .volume(level) = swipe { host.rememberVolume(volume: level * 100) }
swipe = nil
swipeStart = nil
}
}
private func doubleTap(at x: CGFloat, width: CGFloat) {
let third = width / 3
let seconds = x < third ? -doubleTapSeconds : x > width - third ? doubleTapSeconds : 0
// Felt as well as seen: the bubble is under the thumb that asked for it.
UIImpactFeedbackGenerator(style: .light).impactOccurred()
if seconds == 0 {
host.setPaused(!player.paused)
} else {
@@ -250,7 +363,7 @@ struct PlayerView: View {
}
enum PlayerSheet: Identifiable {
case speed, episodes, chapters
case speed, episodes, chapters, sleep
case tracks(String)
var id: String {
@@ -258,11 +371,66 @@ enum PlayerSheet: Identifiable {
case .speed: "speed"
case .episodes: "episodes"
case .chapters: "chapters"
case .sleep: "sleep"
case let .tracks(type): "tracks-\(type)"
}
}
}
/// Which way a swipe went, settled by its first movement.
private enum SwipeAxis { case brightness, volume, seek }
/// What a swipe on the picture is doing, for the bubble that says so.
private enum Swipe {
/// From 0 to 1.
case brightness(Double)
case volume(Double)
/// Where the playhead will land when the finger lifts, and where it was.
case seek(from: Double, to: Double)
}
/// The screen the player is on, for its brightness.
@MainActor
private enum Screen {
static var brightness: CGFloat {
get { scene?.screen.brightness ?? 0.5 }
set { scene?.screen.brightness = newValue }
}
private static var scene: UIWindowScene? {
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
}
}
/// The bubble in the middle of the picture while a swipe is under way.
private struct SwipeBubble: View {
let swipe: Swipe
@Environment(\.scheme) private var scheme
var body: some View {
VStack(spacing: 8) {
switch swipe {
case let .brightness(value): level(icon: "sun.max.fill", label: "Brightness", value: value)
case let .volume(value): level(icon: "speaker.wave.2.fill", label: "Volume", value: value)
case let .seek(from, to):
Image(systemName: to < from ? "backward.fill" : "forward.fill").font(.system(size: 28))
Text((to < from ? "−" : "+") + clock(abs(to - from))).textStyle(.titleMedium)
Text(clock(to)).textStyle(.labelMedium).opacity(0.7)
}
}
.foregroundStyle(.white)
.padding(.horizontal, 24)
.padding(.vertical, 16)
.background(.black.opacity(0.6), in: RoundedRectangle(cornerRadius: 20))
}
@ViewBuilder private func level(icon: String, label: String, value: Double) -> some View {
Image(systemName: icon).font(.system(size: 28))
Text("\(label) \(Int((value * 100).rounded()))%").textStyle(.labelLarge)
ProgressView(value: value).tint(scheme.primary).frame(width: 120)
}
}
/// Everything the transport offers, in two rows: the seek bar on its own,
/// then the transport cluster on the left, where a thumb already is, and the
/// pickers on the right.
@@ -275,6 +443,7 @@ private struct PlayerControls: View {
let onRotate: () -> Void
let onSheet: (PlayerSheet) -> Void
let onInteract: () -> Void
let onLock: () -> Void
@Environment(\.scheme) private var scheme
// Where the thumb is while a finger is on it: a seek per drag step is a
// block fetch per drag step, and the thumb would snap back under the
@@ -341,6 +510,8 @@ private struct PlayerControls: View {
systemImage: landscapeLocked ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
action: onRotate
)
Button(SleepTimer.label(host.sleep), systemImage: "moon.zzz") { onSheet(.sleep) }
Button("Lock touch", systemImage: "lock", action: onLock)
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 20))
@@ -353,8 +524,12 @@ private struct PlayerControls: View {
}
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(scheme.scrim.opacity(0.68).ignoresSafeArea(edges: [.bottom, .horizontal]))
.padding(.top, 32)
.padding(.bottom, 4)
.background(
LinearGradient(colors: [.clear, .black.opacity(0.85)], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea(edges: [.bottom, .horizontal])
)
}
private func toggleMute() {
@@ -398,6 +573,8 @@ private struct PlayerControls: View {
landscapeLocked ? "Unlock rotation" : "Lock to landscape",
action: onRotate
)
CompactIcon("moon.zzz", SleepTimer.label(host.sleep)) { onSheet(.sleep) }
CompactIcon("lock", "Lock touch", action: onLock)
}
}
@@ -535,6 +712,43 @@ private struct EndCard: View {
}
}
/// Off, the end of this episode, or a number of minutes.
private struct SleepSheet: View {
let current: SleepTimer?
/// The open episode, which "End of this episode" is about.
let media: String?
let onSelect: (SleepTimer?) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
DialogSheet("Sleep timer") {
if case .at = current {
Text(SleepTimer.label(current).replacingOccurrences(of: "Sleep timer: ", with: "") + " left")
.textStyle(.bodyMedium)
}
choice("Off", chosen: current == nil) { nil }
if let media {
choice("End of this episode", chosen: current == .endOf(media)) { .endOf(media) }
}
ForEach([30, 60], id: \.self) { minutes in
choice("\(minutes) minutes", chosen: false) { .at(.now.addingTimeInterval(Double(minutes) * 60)) }
}
} buttons: {
Button("Close") { dismiss() }.buttonStyle(.tonal)
}
}
private func choice(_ label: String, chosen: Bool, timer: @escaping () -> SleepTimer?) -> some View {
Button {
onSelect(timer())
dismiss()
} label: {
Text((chosen ? "✓ " : "") + label).frame(maxWidth: .infinity)
}
.buttonStyle(.edgedWide)
}
}
private struct SpeedSheet: View {
let current: Double
let onSelect: (Double) -> Void
+130 -1
View File
@@ -316,6 +316,14 @@ extension View {
}
}
/// `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes.
func formatRuntime(_ minutes: UInt32) -> String {
let hours = minutes / 60, rest = minutes % 60
if hours == 0 { return "\(rest) min" }
if rest == 0 { return "\(hours) h" }
return "\(hours) h \(rest) min"
}
func formatBytes(_ bytes: UInt64) -> String {
let value = Double(bytes)
let gib = 1024.0 * 1024 * 1024
@@ -342,12 +350,19 @@ func downloadStatusLine(_ download: RetainedDownload, waitingFor: String? = nil)
}
extension EpisodeRecord {
/// The episode's own name: the provider's, else the one the filename gave
/// it. `nil` rather than the filename, which is a release group's tag and
/// the episode's numbering again.
var shownName: String? {
providerName ?? (detail != name && !detail.isEmpty ? detail : nil)
}
/// What an episode row is headed with: "1. Mother and Children".
///
/// The provider's name for the episode, else the name the filename gave it,
/// else "Episode 1". The bare filename only when none of those exist.
func heading(_ position: Int) -> String {
let named = providerName ?? (detail != name ? detail : nil)
let named = shownName
switch (named, number) {
case let (named?, number?): return "\(number). \(named)"
case let (named?, nil): return named
@@ -385,3 +400,117 @@ private extension UIColor {
return (red, green, blue, alpha)
}
}
/// Where a title stands — "Airing · episode 8 Sat 1 Nov", "Finished" — as a
/// `GenreChip` with a dot in front: the accent while more is coming, muted
/// once it is all out. Android's `StatusChip`.
struct StatusChip: View {
@Environment(\.scheme) private var scheme
let text: String
let live: Bool
var body: some View {
HStack(spacing: 6) {
Circle().fill(live ? scheme.primary : scheme.onSurfaceVariant).frame(width: 6, height: 6)
Text(text).textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(scheme.surfaceVariant, in: RoundedRectangle(cornerRadius: 12))
}
}
/// Something the title is filed under — a genre, a studio — as a small rounded
/// label, which opens every title sharing it when given an action. Android's
/// `GenreChip`.
struct GenreChip: View {
@Environment(\.scheme) private var scheme
let text: String
var action: (() -> Void)?
init(text: String, action: (() -> Void)? = nil) {
self.text = text
self.action = action
}
var body: some View {
let label = Text(text)
.textStyle(.labelMedium)
.foregroundStyle(scheme.onSurface)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(scheme.surfaceVariant, in: RoundedRectangle(cornerRadius: 12))
if let action {
Button(action: action) { label }
.buttonStyle(.plain)
.accessibilityHint("Browse \(text)")
} else {
label
}
}
}
/// A fact about the files rather than the title — 4K, HDR, Dual audio — as a
/// small outlined mark, the way a box prints them. Android's `SpecBadge`.
struct SpecBadge: View {
@Environment(\.scheme) private var scheme
let text: String
var body: some View {
Text(text)
.textStyle(.labelSmall, semibold: true)
.foregroundStyle(scheme.onSurfaceVariant)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(scheme.onSurfaceVariant.opacity(0.7), lineWidth: 1))
}
}
/// Children left to right, wrapping onto a new line when the next one would
/// not fit: Compose's `FlowRow`, which SwiftUI does not have.
struct FlowLayout: Layout {
var spacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = arrange(width: proposal.width ?? .infinity, subviews: subviews)
let width = rows.map(\.width).max() ?? 0
let height = rows.map(\.height).reduce(0, +) + spacing * CGFloat(max(rows.count - 1, 0))
return CGSize(width: width, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var y = bounds.minY
for row in arrange(width: bounds.width, subviews: subviews) {
var x = bounds.minX
for index in row.members {
let size = subviews[index].sizeThatFits(.unspecified)
subviews[index].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width + spacing
}
y += row.height + spacing
}
}
private struct Row {
var members: [Int] = []
var width: CGFloat = 0
var height: CGFloat = 0
}
private func arrange(width: CGFloat, subviews: Subviews) -> [Row] {
var rows: [Row] = [Row()]
for index in subviews.indices {
let size = subviews[index].sizeThatFits(.unspecified)
let needed = rows[rows.count - 1].members.isEmpty ? size.width : rows[rows.count - 1].width + spacing + size.width
if needed > width, !rows[rows.count - 1].members.isEmpty {
rows.append(Row())
}
var row = rows[rows.count - 1]
row.width = row.members.isEmpty ? size.width : row.width + spacing + size.width
row.height = max(row.height, size.height)
row.members.append(index)
rows[rows.count - 1] = row
}
return rows.filter { !$0.members.isEmpty }
}
}
+376 -71
View File
@@ -59,6 +59,8 @@ struct LibraryView: View {
let onTitle: (TitleRecord) -> Void
var onAddShare: () -> Void = {}
var onOpenSettings: () -> Void = {}
/// Open every title sharing a genre, studio or director a search named.
var onBrowse: (BrowseFacet) -> Void = { _ in }
// What a long press opened a menu for: a title, or a Continue watching card.
@State private var menuFor: TitleRecord?
@@ -77,35 +79,57 @@ struct LibraryView: View {
// library spent a band of every visit on something used in few of them.
@SceneStorage("library.searching") private var searching = false
@State private var collapsed = false
/// How far the bar over the banner has filled in, from clear to solid.
@State private var reveal: CGFloat = 0
/// The page's bar: its title, shown as much as `solid` says, and its
/// actions, in `ink` or the scheme's own.
private func bar(solid: CGFloat, ink: Color?) -> some View {
HStack(spacing: 0) {
Text("Library")
.textStyle(.titleLarge)
.foregroundStyle(scheme.onSurface)
.padding(.leading, 16)
.opacity(solid)
Spacer()
IconButton("magnifyingglass", "Search library", tint: ink) { searching = true }
ArrangeMenu(sort: model.sort, grouped: model.grouped, onArrange: model.arrange)
// Pull to refresh is the gesture; the icon stays for a viewer who
// does not know it.
IconButton("arrow.clockwise", "Refresh", tint: ink, enabled: !model.refreshing) { model.refresh() }
}
.frame(height: 56)
.padding(.trailing, 4)
}
var body: some View {
let resumable = resumable(model.titles)
VStack(spacing: 0) {
if searching {
SearchBar(query: Binding(get: { model.query }, set: model.search)) {
searching = false
model.search("")
let browsing = model.query.trimmingCharacters(in: .whitespaces).isEmpty
let featured = featuredTitles(model.titles, resumable)
// With a banner, the bar lies over it, clear, and fills in as the
// banner scrolls away; without one, the large title folds into the bar
// as the grid scrolls.
let overBanner = !searching && !model.loading && browsing && !featured.isEmpty
ZStack(alignment: .top) {
VStack(spacing: 0) {
if searching {
SearchBar(query: Binding(get: { model.query }, set: model.search)) {
searching = false
model.search("")
}
} else if !overBanner {
bar(solid: collapsed ? 1 : 0, ink: nil)
.background(collapsed ? scheme.surfaceContainer : scheme.background)
}
} else {
HStack(spacing: 0) {
// The large title folds into the bar as the grid scrolls.
Text("Library")
.textStyle(.titleLarge)
.foregroundStyle(scheme.onSurface)
.padding(.leading, 16)
.opacity(collapsed ? 1 : 0)
Spacer()
IconButton("magnifyingglass", "Search library") { searching = true }
ArrangeMenu(sort: model.sort, grouped: model.grouped, onArrange: model.arrange)
// Pull to refresh is the gesture; the icon stays for a
// viewer who does not know it.
IconButton("arrow.clockwise", "Refresh", enabled: !model.refreshing) { model.refresh() }
}
.frame(height: 56)
.padding(.trailing, 4)
.background(collapsed ? scheme.surfaceContainer : scheme.background)
content(resumable, featured: overBanner ? featured : [])
}
if overBanner {
// White over the banner's own scrim, then the page's ink once
// the bar has filled in.
bar(solid: reveal, ink: reveal > 0.5 ? nil : .white)
.background(scheme.surfaceContainer.opacity(reveal).ignoresSafeArea(edges: .top))
.animation(.easeOut(duration: 0.15), value: reveal > 0.5)
}
content(resumable)
}
.background(scheme.background)
.sheet(item: $menuFor, onDismiss: {
@@ -126,6 +150,9 @@ struct LibraryView: View {
onResume(title, nextUpIndex(playlist, continueIndex: title.continueIndex))
}
MenuRow("info.circle", "Open") { onTitle(title) }
MenuRow(title.onList ? "checkmark" : "plus", title.onList ? "Remove from My List" : "Add to My List") {
model.setOnList(title, !title.onList)
}
MenuRow("arrow.down.circle", "Download all") { pendingDownload = download(playlist, title.displayName) }
MenuRow(allWatched ? "checkmark.circle" : "checkmark.circle.fill", allWatched ? "Mark unwatched" : "Mark watched") {
model.setTitleWatched(title, !allWatched)
@@ -150,11 +177,16 @@ struct LibraryView: View {
}
}
@ViewBuilder private func content(_ resumable: [Resumable]) -> some View {
@ViewBuilder private func content(_ resumable: [Resumable], featured: [TitleRecord]) -> some View {
if model.loading {
LibrarySkeleton()
} else if model.titles.isEmpty && !model.query.trimmingCharacters(in: .whitespaces).isEmpty {
EmptyState("Nothing matches", "No title in the library is called “\(model.query.trimmingCharacters(in: .whitespaces))”.")
} else if searching && model.query.trimmingCharacters(in: .whitespaces).isEmpty && !model.recentSearches.isEmpty {
// Empty and with the field open: the last few searches.
recentSearches
} else if !model.query.trimmingCharacters(in: .whitespaces).isEmpty,
model.arrangement?.tiles.isEmpty == true,
model.searchExtras?.isEmpty ?? true {
EmptyState("Nothing matches", "Nothing in the library is called “\(model.query.trimmingCharacters(in: .whitespaces))”.")
} else if model.titles.isEmpty, let activity = model.activity {
// A share just added: say it is being looked through, rather than
// offer to add the one that is already filling the page.
@@ -170,11 +202,11 @@ struct LibraryView: View {
}
.refreshable { await model.crawlNow(nil) }
} else {
grid(resumable)
grid(resumable, featured: featured)
}
}
private func grid(_ resumable: [Resumable]) -> some View {
private func grid(_ resumable: [Resumable], featured: [TitleRecord]) -> some View {
// The arrangement names titles by key; before it has arrived, the
// titles as they came are the grid.
let byKey = Dictionary(model.titles.map { ($0.key, $0) }, uniquingKeysWith: { first, _ in first })
@@ -184,11 +216,25 @@ struct LibraryView: View {
.map { Shelf(label: $0.label, titles: $0.keys.compactMap { byKey[$0] }) }
.filter { !$0.titles.isEmpty }
let browsing = model.query.trimmingCharacters(in: .whitespaces).isEmpty
let featured = browsing ? featuredTitle(model.titles, resumable) : nil
return ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
if !searching {
if !featured.isEmpty {
FeaturedBanner(titles: featured, resumable: resumable, onPlay: onResume, onOpen: onTitle)
// Edge to edge, out of the grid's margin.
.padding(.horizontal, -16)
.background {
GeometryReader { geometry in
Color.clear.onChange(of: geometry.frame(in: .named("library")).minY) { _, top in
reveal = min(max(-top / 280, 0), 1)
}
}
}
// Scrolled far enough to leave the lazy stack: the bar
// is long since solid.
.onDisappear { reveal = 1 }
.onAppear { reveal = 0 }
} else if !searching {
Text("Library")
.textStyle(.headlineMedium)
.foregroundStyle(scheme.onSurface)
@@ -206,14 +252,6 @@ struct LibraryView: View {
SettingsStore().metadataOfferDismissed = true
}
}
if let featured {
FeaturedBanner(
title: featured,
resume: resumable.first { $0.title.key == featured.key },
onPlay: { onResume(featured, $0) },
onOpen: { onTitle(featured) }
)
}
if browsing && !resumable.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("Continue watching").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
@@ -239,7 +277,9 @@ struct LibraryView: View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: 12) {
ForEach(shelf.titles, id: \.key) { title in
PosterTile(title: title)
// On a shelf, an airing show says when its
// next episode is out.
PosterTile(title: title, badge: title.badge ?? title.countdown())
.frame(width: 112)
.tapAndHold { onTitle(title) } onHold: { longPressed { menuFor = title } }
}
@@ -251,6 +291,14 @@ struct LibraryView: View {
Text("All titles").textStyle(.titleMedium).foregroundStyle(scheme.onSurface).padding(.top, 8)
}
}
// What the search found besides titles, above the titles it
// found.
if !browsing, let extras = model.searchExtras {
searchExtras(extras, byKey: byKey)
if !tiles.isEmpty && !extras.isEmpty {
Text("Titles").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
}
}
if browsing {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
@@ -273,13 +321,81 @@ struct LibraryView: View {
}
}
}
.padding(EdgeInsets(top: 8, leading: 16, bottom: 24, trailing: 16))
// No room above a banner: it starts under the status bar, and the
// bar lies over it.
.padding(EdgeInsets(top: featured.isEmpty ? 8 : 0, leading: 16, bottom: 24, trailing: 16))
}
.ignoresSafeArea(edges: featured.isEmpty ? [] : .top)
.coordinateSpace(name: "library")
.refreshable { await model.crawlNow(nil) }
.scrollDismissesKeyboard(.immediately)
}
/// The people, studios and genres a search named as chips, then the
/// episodes it found by name. Android's `facetChips` and its episode rows.
@ViewBuilder private func searchExtras(_ extras: SearchExtrasRecord, byKey: [String: TitleRecord]) -> some View {
ForEach([("People and studios", extras.people), ("Genres", extras.genres)], id: \.0) { heading, facets in
if !facets.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text(heading).textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
FlowLayout(spacing: 6) {
ForEach(facets, id: \.facet.label) { found in
GenreChip(text: "\(found.facet.label) · \(found.count)") { onBrowse(found.facet) }
}
}
}
}
}
if !extras.episodes.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Episodes").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
ForEach(extras.episodes, id: \.linkId) { hit in
if let title = byKey[hit.titleKey],
let index = title.playlist.firstIndex(where: { $0.linkId == hit.linkId }) {
Button { onResume(title, index) } label: {
HStack(spacing: 12) {
Image(systemName: "play.fill").foregroundStyle(scheme.onSurfaceVariant)
VStack(alignment: .leading, spacing: 2) {
Text(hit.name).textStyle(.bodyLarge).foregroundStyle(scheme.onSurface).lineLimit(1)
Text("\(title.displayName) · \(hit.label)")
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
}
Spacer(minLength: 0)
}
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint("Play")
}
}
}
}
}
/// The last few searches, under an empty search field.
private var recentSearches: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Recent searches").textStyle(.titleMedium).foregroundStyle(scheme.onSurface).padding(.vertical, 8)
ForEach(model.recentSearches, id: \.self) { query in
Button { model.search(query) } label: {
Label(query, systemImage: "clock.arrow.circlepath")
.textStyle(.bodyLarge)
.foregroundStyle(scheme.onSurface)
.frame(maxWidth: .infinity, minHeight: 48, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
Button("Clear recent searches") { model.forgetSearches() }
.buttonStyle(.quiet)
.padding(.top, 8)
}
.padding(.horizontal, 16)
}
}
private func longPressed(_ open: () -> Void) {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
open()
@@ -448,6 +564,7 @@ extension LibraryFilter {
case .watching: "Watching"
case .unwatched: "Not started"
case .downloaded: "Downloaded"
case .myList: "My List"
}
}
}
@@ -475,54 +592,168 @@ private struct MetadataOfferCard: View {
}
}
/// The title the banner shows: the one watched last, or with nothing part
/// watched, one of the titles the provider has a backdrop for, turning over
/// once a day — the desktop's `featured`.
private func featuredTitle(_ titles: [TitleRecord], _ resumable: [Resumable]) -> TitleRecord? {
if let first = resumable.first { return first.title }
/// How many titles the banner turns through: the desktop's `FEATURED`.
private let featuredMax = 5
/// How long each title holds the banner: the desktop's `HOLD`.
private let holdSeconds = 8.0
/// One title giving way to the next: the desktop's `TURN`.
private let turn = Animation.easeInOut(duration: 0.6)
/// The titles the banner turns through: the one watched last first, then
/// titles the provider has a backdrop for, starting from a different one each
/// day — the desktop's `featured`.
private func featuredTitles(_ titles: [TitleRecord], _ resumable: [Resumable]) -> [TitleRecord] {
var picked = resumable.first.map { [$0.title] } ?? []
let pictured = titles.filter { $0.backdropUrl != nil }
if pictured.isEmpty { return nil }
if pictured.isEmpty { return picked }
let day = Int(Date().timeIntervalSince1970 + Double(TimeZone.current.secondsFromGMT())) / 86400
return pictured[day % pictured.count]
for offset in pictured.indices {
if picked.count == featuredMax { break }
let title = pictured[(day + offset) % pictured.count]
if !picked.contains(where: { $0.key == title.key }) { picked.append(title) }
}
return picked
}
/// Large art behind the name, rating, genres and three lines of synopsis.
/// The banner at the top of the library: one title at a time, edge to edge
/// under the status bar, turning to the next every `holdSeconds` — a swipe
/// starts the count again. Each title's veil is tinted with its own picture's
/// colour.
private struct FeaturedBanner: View {
let titles: [TitleRecord]
let resumable: [Resumable]
let onPlay: (TitleRecord, Int) -> Void
let onOpen: (TitleRecord) -> Void
@Environment(\.scheme) private var scheme
@State private var shown = ""
var body: some View {
let current = titles.contains { $0.key == shown } ? shown : titles.first?.key ?? ""
TabView(selection: Binding(get: { current }, set: { shown = $0 })) {
ForEach(titles, id: \.key) { title in
FeaturedPage(
title: title,
resume: resumable.first { $0.title.key == title.key },
onPlay: { onPlay(title, $0) },
onOpen: { onOpen(title) }
)
.tag(title.key)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
// Most of a phone's first screen, but never so much of an iPad's that
// nothing under it shows.
.containerRelativeFrame(.vertical) { height, _ in min(max(height * 0.62, 360), 620) }
.overlay(alignment: .bottom) {
if titles.count > 1 {
HStack(spacing: 6) {
ForEach(titles, id: \.key) { title in
let selected = title.key == current
Capsule()
.fill(scheme.onBackground.opacity(selected ? 1 : 0.4))
.frame(width: selected ? 18 : 6, height: 6)
.contentShape(Rectangle().inset(by: -8))
.onTapGesture { withAnimation(turn) { shown = title.key } }
.accessibilityLabel("Show \(title.displayName)")
.accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton)
}
}
.animation(.easeOut(duration: 0.15), value: current)
.padding(.bottom, 10)
}
}
.task(id: current) {
guard titles.count > 1 else { return }
try? await Task.sleep(for: .seconds(holdSeconds))
if Task.isCancelled { return }
let index = titles.firstIndex { $0.key == current } ?? 0
withAnimation(turn) { shown = titles[(index + 1) % titles.count].key }
}
}
}
/// One title on the banner: its art, its veil, and what it is, from the
/// bottom up.
private struct FeaturedPage: View {
let title: TitleRecord
let resume: Resumable?
let onPlay: (Int) -> Void
let onOpen: () -> Void
@Environment(\.scheme) private var scheme
@Environment(AppModel.self) private var model
@State private var mood: UInt32?
var body: some View {
RemoteArtwork(title.backdropUrl ?? title.posterUrl, title.displayName, fallback: title.thumbnailSource, labelled: false)
.aspectRatio(4 / 3, contentMode: .fit)
.overlay {
LinearGradient(
stops: [.init(color: .clear, location: 0.35), .init(color: .black.opacity(0.85), location: 1)],
startPoint: .top,
endPoint: .bottom
)
}
.overlay(alignment: .bottomLeading) {
VStack(alignment: .leading, spacing: 0) {
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(.white).lineLimit(2)
Text(factLine).textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8))
if let overview = title.overview {
Text(overview.trimmingCharacters(in: .whitespacesAndNewlines))
.textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8)).lineLimit(3).padding(.top, 6)
let veil = scheme.veil(mood)
ZStack(alignment: .bottomLeading) {
Backdrop(title: title) { mood = $0 }
LinearGradient(
stops: [
// Dark under the status bar and the bar's white icons…
.init(color: .black.opacity(0.5), location: 0),
.init(color: .clear, location: 0.22),
// …and the picture's own colour under the text, ending in
// the page it hands over to.
.init(color: veil.opacity(0.35), location: 0.45),
.init(color: veil.opacity(0.92), location: 0.78),
.init(color: scheme.background, location: 1),
],
startPoint: .top,
endPoint: .bottom
)
VStack(alignment: .leading, spacing: 0) {
// A logo is lettering made for dark art, white as often as
// not; over a light palette's veil it is gone, so the light
// palette keeps the name.
if scheme.light {
name
} else {
RemoteLogo(title.logoUrl, title.displayName, maxWidth: 260, maxHeight: 88) { name }
.padding(.bottom, 8)
}
Text(factLine).textStyle(.bodySmall).foregroundStyle(scheme.onBackground.opacity(0.8))
if let overview = title.overview {
Text(overview.trimmingCharacters(in: .whitespacesAndNewlines))
.textStyle(.bodySmall).foregroundStyle(scheme.onBackground.opacity(0.8)).lineLimit(2).padding(.top, 6)
}
HStack(spacing: 8) {
Button { onPlay(resume?.index ?? nextUpIndex(title.playlist, continueIndex: title.continueIndex)) } label: {
Label(resume?.episode.resumeAt != nil ? "Resume" : "Play", systemImage: "play.fill")
}
.buttonStyle(.accent)
Button(action: onOpen) { Label("More info", systemImage: "info.circle") }.buttonStyle(.tonal)
// The icon alone: a third labelled button does not fit a
// phone's row.
Button { model.setOnList(title, !title.onList) } label: {
Image(systemName: title.onList ? "checkmark" : "plus")
}
.buttonStyle(.tonal)
.accessibilityLabel(title.onList ? "Remove from My List" : "Add to My List")
}
.padding(.top, 12)
if let episode = resume?.episode, episode.resumeAt != nil, let progress = episode.progress {
HStack(spacing: 8) {
Button { onPlay(resume?.index ?? nextUpIndex(title.playlist, continueIndex: title.continueIndex)) } label: {
Label(resume != nil ? "Resume" : "Play", systemImage: "play.fill")
AccentProgress(progress: progress).frame(width: 120, height: 4)
if let left = episode.timeLeft {
Text(left).textStyle(.bodySmall).foregroundStyle(scheme.onBackground.opacity(0.8))
}
.buttonStyle(.accent)
Button("More info", action: onOpen).buttonStyle(.tonal)
}
.padding(.top, 12)
}
.padding(16)
}
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
.frame(maxWidth: 560, alignment: .leading)
.padding(EdgeInsets(top: 0, leading: 16, bottom: 28, trailing: 16))
}
.animation(Motion.arrive, value: mood)
.clipped()
.contentShape(Rectangle())
.onTapGesture(perform: onOpen)
}
private var name: some View {
Text(title.displayName).textStyle(.displayLarge).foregroundStyle(scheme.onBackground).lineLimit(2).padding(.bottom, 4)
}
private var factLine: String {
@@ -568,8 +799,16 @@ private struct ContinueCard: View {
struct PosterTile: View {
let title: TitleRecord
var folded = 0
/// `New`, `3 new`, or an airing show's countdown, at the top left.
var badge: String?
@Environment(\.scheme) private var scheme
init(title: TitleRecord, folded: Int = 0, badge: String? = nil) {
self.title = title
self.folded = folded
self.badge = badge ?? title.badge
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: title.thumbnailSource)
@@ -587,6 +826,16 @@ struct PosterTile: View {
.accessibilityLabel("and \(folded) more")
}
}
.overlay(alignment: .topLeading) {
if let badge {
Text(badge)
.textStyle(.labelSmall, semibold: true)
.foregroundStyle(scheme.onPrimary)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(scheme.primary, in: RoundedRectangle(cornerRadius: Corner.standard))
.padding(6)
}
}
Text(title.displayName).textStyle(.bodyMedium, semibold: true).foregroundStyle(scheme.onSurface)
.lineLimit(2).padding(.top, 6)
// Two lines, not one: at a large text size "2023 · 7 episodes"
@@ -599,6 +848,12 @@ struct PosterTile: View {
}
extension TitleRecord {
/// `Ep 8 · in 2 d` for a show whose next episode is still to air.
func countdown(now: Int64 = Int64(Date().timeIntervalSince1970)) -> String? {
guard let at = nextAiringAt, at > now, let episode = nextEpisode else { return nil }
return airingCountdown(episode: episode, at: at, now: now)
}
/// "2023 · 11 episodes", or "1988 · Film" — never "1 episodes".
var caption: String {
let count: String
@@ -626,3 +881,53 @@ extension EpisodeRecord {
extension TitleRecord: Identifiable {
public var id: String { key }
}
/// Every title sharing a genre, a studio or a director: what a chip on a title
/// page opens. Android's `BrowseScreen`, the desktop's `library::browse`.
struct BrowseView: View {
let facet: BrowseFacet
let titles: [TitleRecord]
let onBack: () -> Void
let onTitle: (TitleRecord) -> Void
@Environment(\.scheme) private var scheme
var body: some View {
TabPage(facet.label, leading: {
IconButton("chevron.backward", "Back", tint: scheme.onSurface, action: onBack)
}) {
if titles.isEmpty {
// A recrawl or a new match took the last one away.
EmptyState(facet.label, "Nothing in the library has this any more.")
} else {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
Text("\(facetKind(facet: facet)) · \(titles.count == 1 ? "1 title" : "\(titles.count) titles")")
.textStyle(.bodyMedium)
.foregroundStyle(scheme.onSurfaceVariant)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12, alignment: .top)], spacing: 16) {
ForEach(titles, id: \.key) { title in
PosterTile(title: title)
.tapAndHold { onTitle(title) }
}
}
}
.padding(EdgeInsets(top: 8, leading: 16, bottom: 24, trailing: 16))
}
}
}
}
}
extension BrowseFacet {
/// The name it stands for: the chip's text and its page's heading.
var label: String {
switch self {
case .genre(let name), .studio(let name), .person(let name): name
}
}
}
extension SearchExtrasRecord {
/// Whether a search found nothing besides titles.
var isEmpty: Bool { episodes.isEmpty && people.isEmpty && genres.isEmpty }
}
+243 -12
View File
@@ -32,21 +32,33 @@ extension EpisodeRecord {
///
/// `fallback` is used only when there is no provider artwork or it fails to
/// load, which — with metadata off — is the common case rather than the rare
/// one. Until either answers, the box pulses as a skeleton; when neither has
/// one. Until either answers, the box shimmers as a skeleton; when neither has
/// anything, it shows `ArtworkPlaceholder` rather than an empty card — without
/// initials when `labelled` is false, for art with the name already over it.
///
/// Art that arrives fades in over the skeleton, as on Android; art already
/// decoded is drawn in the first frame and has nothing to fade.
struct RemoteArtwork: View {
let url: String?
let description: String
var fallback: ThumbnailSource?
var labelled = true
/// Told the picture's colour once it has arrived — see `pictureMood`.
var onMood: ((UInt32) -> Void)?
@State private var load: ArtworkLoad
init(_ url: String?, _ description: String, fallback: ThumbnailSource? = nil, labelled: Bool = true) {
init(
_ url: String?,
_ description: String,
fallback: ThumbnailSource? = nil,
labelled: Bool = true,
onMood: ((UInt32) -> Void)? = nil
) {
self.url = url
self.description = description
self.fallback = fallback
self.labelled = labelled
self.onMood = onMood
// A poster already decoded draws in the first frame, so scrolling back
// over the grid does not flash a skeleton per tile.
_load = State(initialValue: ArtworkLoader.shared.cached(url: url, fallback: fallback).map(ArtworkLoad.loaded) ?? .pending)
@@ -57,50 +69,254 @@ struct RemoteArtwork: View {
.overlay {
switch load {
case .pending:
Skeleton()
Skeleton().transition(.opacity)
case .missing:
ArtworkPlaceholder(name: description, labelled: labelled)
ArtworkPlaceholder(name: description, labelled: labelled).transition(.opacity)
case let .loaded(image):
Image(uiImage: image).resizable().scaledToFill()
Image(uiImage: image).resizable().scaledToFill().transition(.opacity)
}
}
.clipped()
.accessibilityElement()
.accessibilityLabel(description)
.task(id: Request(url: url, fallback: fallback)) {
if case .loaded = load, ArtworkLoader.shared.cached(url: url, fallback: fallback) != nil { return }
if case let .loaded(image) = load, ArtworkLoader.shared.cached(url: url, fallback: fallback) != nil {
report(image)
return
}
let loaded = await ArtworkLoader.shared.artwork(url: url, fallback: fallback)
if Task.isCancelled { return }
load = loaded.map(ArtworkLoad.loaded) ?? .missing
withAnimation(Motion.arrive) { load = loaded.map(ArtworkLoad.loaded) ?? .missing }
if let loaded { report(loaded) }
}
}
private func report(_ image: UIImage) {
guard let onMood, let mood = moodOf(image) else { return }
onMood(mood)
}
private struct Request: Hashable {
let url: String?
let fallback: ThumbnailSource?
}
}
/// A picture squeezed to this many pixels a side is still its colours, and a
/// few hundred values to average — Android's `MOOD_EDGE`.
private let moodEdge = 24
/// The colour a picture leaves behind, as `0xAARRGGBB`: the shared
/// `appearance::mood`, over the picture squeezed to `moodEdge` pixels a side.
func moodOf(_ image: UIImage) -> UInt32? {
guard let cgImage = image.cgImage else { return nil }
var bytes = [UInt8](repeating: 0, count: moodEdge * moodEdge * 4)
let drawn: Bool = bytes.withUnsafeMutableBytes { buffer in
guard let context = CGContext(
data: buffer.baseAddress,
width: moodEdge,
height: moodEdge,
bitsPerComponent: 8,
bytesPerRow: moodEdge * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return false }
context.interpolationQuality = .medium
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: moodEdge, height: moodEdge))
return true
}
guard drawn else { return nil }
let pixels: [UInt32] = stride(from: 0, to: bytes.count, by: 4).map { index in
let alpha = UInt32(bytes[index + 3])
// Premultiplied as drawn; the average wants the colour itself.
let channel = { (value: UInt8) -> UInt32 in
alpha == 0 ? 0 : min(255, UInt32(value) * 255 / alpha)
}
return alpha << 24 | channel(bytes[index]) << 16 | channel(bytes[index + 1]) << 8 | channel(bytes[index + 2])
}
return pictureMood(pixels: pixels)
}
extension Scheme {
/// The page colour tinted by a picture's `mood`, for the veils over that
/// picture — the shared `appearance::mood_veil`. The page colour itself
/// until the mood is known.
func veil(_ mood: UInt32?) -> Color {
guard let mood else { return background }
return argb(moodVeil(background: backgroundArgb, mood: mood, light: light))
}
}
/// A title's art across the top of a page: its backdrop, or with none, its
/// poster as a wash of its own colours — blurred, so it reads as the poster's
/// light rather than a blown-up slice of it. The desktop's `Fit::Ambient`.
struct Backdrop: View {
let title: TitleRecord
var onMood: ((UInt32) -> Void)?
var body: some View {
let wash = title.backdropUrl == nil && title.posterUrl != nil
RemoteArtwork(
title.backdropUrl ?? title.posterUrl,
title.displayName,
fallback: title.thumbnailSource,
labelled: false,
onMood: onMood
)
.blur(radius: wash ? 24 : 0, opaque: true)
}
}
/// A title's logo — its name as lettering — in place of `fallback`, once it
/// has loaded. `fallback` stands until then, and for good when there is none,
/// so a title without a logo looks as it always did and one with a logo only
/// gains.
///
/// Trimmed to what is not transparent before it is drawn: TVDB pads every
/// clear logo to a square, and the padding would otherwise decide its size
/// and place.
///
/// `maxWidth` and `maxHeight` bound the logo only; the fallback takes the room
/// it always did.
struct RemoteLogo<Fallback: View>: View {
let url: String?
let description: String
let maxWidth: CGFloat
let maxHeight: CGFloat
let fallback: () -> Fallback
@State private var logo: UIImage?
init(
_ url: String?,
_ description: String,
maxWidth: CGFloat,
maxHeight: CGFloat,
@ViewBuilder fallback: @escaping () -> Fallback
) {
self.url = url
self.description = description
self.maxWidth = maxWidth
self.maxHeight = maxHeight
self.fallback = fallback
_logo = State(initialValue: ArtworkLoader.shared.cachedLogo(url))
}
var body: some View {
Group {
if let logo {
Image(uiImage: logo)
.resizable()
.scaledToFit()
.frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .bottomLeading)
.accessibilityLabel(description)
.transition(.opacity)
} else {
fallback().transition(.opacity)
}
}
.task(id: url) {
if logo != nil { return }
let loaded = await ArtworkLoader.shared.logo(url)
if Task.isCancelled { return }
withAnimation(Motion.arrive) { logo = loaded }
}
}
}
/// Inclusive pixel bounds, top-left origin.
struct PixelBounds: Equatable {
let left: Int
let top: Int
let right: Int
let bottom: Int
}
/// The bounds of the pixels in a row-major `alpha` plane of `width` that are
/// not (nearly) transparent; `nil` when none are. Below the threshold is
/// anti-aliasing haze, not lettering.
func opaqueBounds(alpha: [UInt8], width: Int) -> PixelBounds? {
var left = Int.max, top = Int.max, right = -1, bottom = -1
for (index, value) in alpha.enumerated() where value > 8 {
let x = index % width, y = index / width
left = min(left, x)
right = max(right, x)
top = min(top, y)
bottom = max(bottom, y)
}
return right < 0 ? nil : PixelBounds(left: left, top: top, right: right, bottom: bottom)
}
/// `image` cropped to `opaqueBounds`, or itself when there is nothing to crop.
func trimTransparent(_ image: UIImage) -> UIImage {
guard let cgImage = image.cgImage else { return image }
let width = cgImage.width, height = cgImage.height
var pixels = [UInt8](repeating: 0, count: width * height * 4)
let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in
guard let context = CGContext(
data: buffer.baseAddress,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return false }
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
return true
}
guard drawn else { return image }
let alpha = stride(from: 3, to: pixels.count, by: 4).map { pixels[$0] }
guard let bounds = opaqueBounds(alpha: alpha, width: width),
bounds != PixelBounds(left: 0, top: 0, right: width - 1, bottom: height - 1)
else { return image }
let rect = CGRect(
x: bounds.left,
y: bounds.top,
width: bounds.right - bounds.left + 1,
height: bounds.bottom - bounds.top + 1
)
guard let cropped = cgImage.cropping(to: rect) else { return image }
return UIImage(cgImage: cropped, scale: image.scale, orientation: image.imageOrientation)
}
private enum ArtworkLoad {
case pending
case missing
case loaded(UIImage)
}
/// Something being loaded: the surface tint, breathing.
/// How long things take to change: the desktop's `theme::motion`.
enum Motion {
/// Artwork arriving over its skeleton.
static let arrive = Animation.easeOut(duration: 0.24)
/// One pass of a skeleton's highlight.
static let shimmer = Animation.linear(duration: 1.4).repeatForever(autoreverses: false)
}
/// Something being loaded: the surface tint, with a soft highlight sweeping
/// across it on a diagonal.
///
/// A spinner per tile is a grid of spinners; a shape where the poster will be
/// says what is coming and where, and settles the layout before it arrives.
struct Skeleton: View {
@Environment(\.scheme) private var scheme
@State private var dim = false
@State private var sweep: CGFloat = -1
var body: some View {
scheme.surfaceVariant
.opacity(dim ? 0.55 : 1)
.onAppear {
withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: true)) { dim = true }
.overlay {
GeometryReader { geometry in
LinearGradient(
colors: [.clear, mix(scheme.surfaceVariant, scheme.onSurface, 0.07), .clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(width: geometry.size.width * 1.2)
.offset(x: sweep * geometry.size.width)
}
}
.clipped()
.onAppear { withAnimation(Motion.shimmer) { sweep = 2 } }
}
}
@@ -196,6 +412,21 @@ final class ArtworkLoader: @unchecked Sendable {
return await thumbnail(fallback)
}
func cachedLogo(_ url: String?) -> UIImage? {
guard let url else { return nil }
return decoded.object(forKey: "logo:\(url)" as NSString)
}
/// A title's logo, trimmed to its lettering — see `trimTransparent`.
func logo(_ url: String?) async -> UIImage? {
guard let url else { return nil }
if let image = cachedLogo(url) { return image }
guard let image = await remote(url) else { return nil }
let trimmed = trimTransparent(image)
decoded.setObject(trimmed, forKey: "logo:\(url)" as NSString, cost: trimmed.cost)
return trimmed
}
/// Provider artwork, from the disk cache or the network.
func remote(_ requested: String?) async -> UIImage? {
guard let requested else { return nil }
+3 -3
View File
@@ -321,7 +321,7 @@ private struct MetadataSettingsSheet: View {
var body: some View {
DialogSheet("Metadata enrichment") {
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, the matched ids also go to ani.zip for episode names and wide artwork.")
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, a title it has nothing for is then sent to TVmaze, and the matched ids go to ani.zip for episode names and wide artwork.")
Toggle("Enable enrichment", isOn: $enabled).tint(scheme.primary).foregroundStyle(scheme.onSurface).padding(.vertical, 6)
HStack(spacing: 8) {
ForEach([MetadataProvider.aniList, .tmdb], id: \.self) { option in
@@ -329,12 +329,12 @@ private struct MetadataSettingsSheet: View {
.buttonStyle(option == provider ? ProtonButtonStyle(kind: .accent) : ProtonButtonStyle(kind: .edged))
}
}
Text(provider == .aniList ? "Anime; no account or API key required." : "Film and television; requires a free TMDB API key.")
Text(provider == .aniList ? "Anime, and television from TVmaze; no account or API key required. With a TMDB API key, films get TMDB's backdrops and logos, asked for by id alone." : "Film and television; requires a free TMDB API key.")
.textStyle(.bodySmall)
if provider == .tmdb {
Field(label: "Language", text: $language)
Field(label: current.ready ? "TMDB API key (leave blank to keep)" : "TMDB API key", text: $apiKey, secret: true)
}
Field(label: provider == .aniList ? "TMDB API key (optional)" : current.ready ? "TMDB API key (leave blank to keep)" : "TMDB API key", text: $apiKey, secret: true)
if enabled, provider == .tmdb, !current.ready, apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
Text("Enter a TMDB API key to save.").foregroundStyle(scheme.error)
}
+4
View File
@@ -53,6 +53,9 @@ final class AppearanceState {
/// `schemeOf` line for line; `SchemeRolesTests` pins it.
struct Scheme: Equatable {
let light: Bool
/// `background` as the bridge hands it over, for the shared colour work
/// that takes it back — `Scheme.veil`.
let backgroundArgb: UInt32
let primary: Color
let onPrimary: Color
let primaryContainer: Color
@@ -91,6 +94,7 @@ struct Scheme: Equatable {
init(_ palette: PaletteRecord) {
light = palette.light
backgroundArgb = palette.background
primary = argb(palette.accent)
onPrimary = argb(palette.onAccent)
primaryContainer = argb(palette.accentDim)
+21 -7
View File
@@ -3,9 +3,11 @@ import SwiftUI
/// The type ramp, which is the desktop client's type ramp, under Material's
/// names so a view ported from the Android client asks for the same style.
///
/// Inter, Regular and SemiBold only, as on the desktop and on Android: a
/// `Medium` role resolves to Regular. The rungs, largest first:
/// 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11. `TypeRampTests` pins them.
/// Inter, Regular and SemiBold, as on the desktop and on Android: a `Medium`
/// role resolves to Regular. The rungs, largest first:
/// 34 / 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11. The top one is the hero
/// line — the banner's name and the title page's — in Inter Display Bold and
/// set tighter. `TypeRampTests` pins them.
enum TypeRole: CaseIterable {
case displayLarge, displayMedium, displaySmall
case headlineLarge, headlineMedium, headlineSmall
@@ -15,7 +17,8 @@ enum TypeRole: CaseIterable {
var size: CGFloat {
switch self {
case .displayLarge, .displayMedium, .headlineLarge, .headlineMedium: Ramp.display
case .displayLarge: Ramp.hero
case .displayMedium, .headlineLarge, .headlineMedium: Ramp.display
case .displaySmall, .titleLarge: Ramp.title
case .headlineSmall: Ramp.heading
case .titleMedium: Ramp.section
@@ -29,7 +32,7 @@ enum TypeRole: CaseIterable {
var semibold: Bool {
switch self {
case .displayLarge, .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge:
case .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge:
true
default:
false
@@ -39,6 +42,7 @@ enum TypeRole: CaseIterable {
/// The Dynamic Type style each rung scales with.
var relative: Font.TextStyle {
switch size {
case Ramp.hero: .largeTitle
case Ramp.display: .title
case Ramp.title: .title2
case Ramp.heading: .title3
@@ -53,10 +57,19 @@ enum TypeRole: CaseIterable {
/// Line height is derived rather than named per style: 1.35 of the size,
/// as on Android. Inter's own leading is about 1.21, so the rest is spacing.
var lineSpacing: CGFloat { size * (Ramp.lineHeight - 1.21) }
var lineSpacing: CGFloat {
size * ((self == .displayLarge ? Ramp.heroLineHeight : Ramp.lineHeight) - 1.21)
}
/// The PostScript name of the face this style is set in.
func face(semibold: Bool? = nil) -> String {
if self == .displayLarge, semibold == nil { return "InterDisplay-Bold" }
return (semibold ?? self.semibold) ? "Inter-SemiBold" : "Inter-Regular"
}
}
enum Ramp {
static let hero: CGFloat = 34
static let display: CGFloat = 26
static let title: CGFloat = 20
static let heading: CGFloat = 18
@@ -67,11 +80,12 @@ enum Ramp {
static let caption: CGFloat = 12
static let micro: CGFloat = 11
static let lineHeight: CGFloat = 1.35
static let heroLineHeight: CGFloat = 1.1
}
extension Font {
static func inter(_ style: TypeRole, semibold: Bool? = nil) -> Font {
.custom((semibold ?? style.semibold) ? "Inter-SemiBold" : "Inter-Regular", size: style.size, relativeTo: style.relative)
.custom(style.face(semibold: semibold), size: style.size, relativeTo: style.relative)
}
static func inter(size: CGFloat, semibold: Bool = false) -> Font {
+262 -54
View File
@@ -3,6 +3,12 @@ import SwiftUI
/// How wide the page's text and controls run on a window wider than a phone.
private let readableWidth: CGFloat = 720
/// How fast the backdrop moves against the page scrolling over it.
private let parallax: CGFloat = 0.5
/// The bar over the page — Android's `TITLE_BAR`.
private let titleBarHeight: CGFloat = 56
struct TitleView: View {
let title: TitleRecord
let onPlay: ([EpisodeRecord], Int) -> Void
@@ -10,6 +16,10 @@ struct TitleView: View {
/// Every title of its franchise, in release order, this one included.
var franchise: [TitleRecord] = []
var onOpenTitle: (TitleRecord) -> Void = { _ in }
/// The titles most like it, best first.
var moreLike: [TitleRecord] = []
/// Open every title sharing a genre, studio or director.
var onBrowse: (BrowseFacet) -> Void = { _ in }
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
@@ -22,19 +32,27 @@ struct TitleView: View {
// gesture is dragged, and springs back if it is let go of — Android's
// predictive back, so the viewer sees where back goes before committing.
@State private var backProgress: CGFloat = 0
/// How far the backdrop has scrolled up, which is how solid the bar is.
@State private var scrolled: CGFloat = 0
/// The backdrop's colour, once it has arrived — see `pictureMood`.
@State private var mood: UInt32?
init(
title: TitleRecord,
onPlay: @escaping ([EpisodeRecord], Int) -> Void,
onBack: @escaping () -> Void,
franchise: [TitleRecord] = [],
onOpenTitle: @escaping (TitleRecord) -> Void = { _ in }
onOpenTitle: @escaping (TitleRecord) -> Void = { _ in },
moreLike: [TitleRecord] = [],
onBrowse: @escaping (BrowseFacet) -> Void = { _ in }
) {
self.title = title
self.onPlay = onPlay
self.onBack = onBack
self.franchise = franchise
self.onOpenTitle = onOpenTitle
self.moreLike = moreLike
self.onBrowse = onBrowse
// One season on screen at a time, opened on the one Play would land in.
let playlist = title.playlist
let nextUp = nextUpIndex(playlist, continueIndex: title.continueIndex)
@@ -57,11 +75,11 @@ struct TitleView: View {
let backdropHeight = min(geometry.size.width * 9 / 16, geometry.size.height * 0.55)
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
backdrop(height: backdropHeight + geometry.safeAreaInsets.top, top: geometry.safeAreaInsets.top)
backdrop(height: backdropHeight + geometry.safeAreaInsets.top)
heading(playlist: playlist, nextUp: nextUp, upNext: upNext)
.padding(.horizontal, side + 16)
if franchise.count > 1 {
franchiseRow(side: side)
coverRow("In this franchise", franchise, side: side)
}
seasonChips(season: season, side: side)
if let season {
@@ -81,11 +99,23 @@ struct TitleView: View {
.padding(.horizontal, side)
}
}
if !title.cast.isEmpty {
castRow(side: side)
}
// Under the episodes rather than over them: where to go
// once this one is done, not what the page is for.
if !moreLike.isEmpty {
coverRow("More like this", moreLike, side: side)
}
}
.padding(.bottom, 24)
}
.ignoresSafeArea(edges: .top)
.coordinateSpace(name: "title")
.background(scheme.background)
.overlay(alignment: .top) {
titleBar(solid: min(max((scrolled - (backdropHeight - 2 * titleBarHeight)) / titleBarHeight, 0), 1))
}
}
.scaleEffect(1 - 0.1 * backProgress)
.clipShape(RoundedRectangle(cornerRadius: 32 * backProgress))
@@ -110,6 +140,7 @@ struct TitleView: View {
}
.onChange(of: title.key) {
overviewOpen = false
mood = nil
let playlist = title.playlist
let upNext = playlist.indices.contains(nextUpIndex(playlist, continueIndex: title.continueIndex)) ? playlist[nextUpIndex(playlist, continueIndex: title.continueIndex)] : nil
seasonIndex = title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0
@@ -117,37 +148,85 @@ struct TitleView: View {
}
/// Edge to edge, fading into the page: the art is the first thing the page
/// says.
private func backdrop(height: CGFloat, top: CGFloat) -> some View {
RemoteArtwork(title.backdropUrl ?? title.posterUrl, title.displayName, fallback: title.thumbnailSource, labelled: false)
/// says. It moves at half the speed of the page, so the picture stays
/// behind the page rather than being part of it.
private func backdrop(height: CGFloat) -> some View {
Backdrop(title: title) { mood = $0 }
.frame(height: height)
.frame(maxWidth: .infinity)
.visualEffect { content, proxy in
content.offset(y: max(-proxy.frame(in: .named("title")).minY, 0) * parallax)
}
.clipped()
.overlay {
LinearGradient(
stops: [.init(color: .clear, location: 0.45), .init(color: scheme.background, location: 1)],
stops: [
.init(color: .clear, location: 0.4),
// The picture's own colour on the way into the page,
// as on the library's banner.
.init(color: scheme.veil(mood).opacity(0.55), location: 0.75),
.init(color: scheme.background, location: 1),
],
startPoint: .top,
endPoint: .bottom
)
.animation(Motion.arrive, value: mood)
}
.overlay(alignment: .topLeading) {
Button(action: onBack) {
Image(systemName: "chevron.backward")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 40, height: 40)
.background(.black.opacity(0.4), in: Circle())
.frame(width: 48, height: 48)
.background {
GeometryReader { geometry in
Color.clear.onChange(of: geometry.frame(in: .named("title")).minY) { _, minY in
scrolled = -minY
}
}
.accessibilityLabel("Back to library")
.padding(8)
.padding(.top, top)
}
// Scrolled far enough to leave the lazy stack: the bar is long
// since solid.
.onDisappear { scrolled = .greatestFiniteMagnitude }
.onAppear { scrolled = 0 }
}
/// The bar over the page: only the back button while the backdrop is on
/// screen, then a solid bar with the title's name once it has scrolled
/// away, so the page still says whose episodes these are.
private func titleBar(solid: CGFloat) -> some View {
HStack(spacing: 0) {
Button(action: onBack) {
Image(systemName: "chevron.backward")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(solid > 0.5 ? scheme.onSurface : .white)
.frame(width: 40, height: 40)
// A disc behind the arrow over the picture, gone once the
// bar is there to sit on.
.background(.black.opacity(0.4 * (1 - solid)), in: Circle())
.frame(width: 48, height: 48)
}
.accessibilityLabel("Back to library")
.padding(.horizontal, 8)
Text(title.displayName)
.textStyle(.titleLarge)
.foregroundStyle(scheme.onSurface)
.lineLimit(1)
.opacity(solid)
.padding(.trailing, 16)
Spacer(minLength: 0)
}
.frame(height: titleBarHeight)
.background(scheme.surfaceContainer.opacity(solid).ignoresSafeArea(edges: .top))
.animation(.easeOut(duration: 0.15), value: solid > 0.5)
}
@ViewBuilder private func heading(playlist: [EpisodeRecord], nextUp: Int, upNext: EpisodeRecord?) -> some View {
VStack(alignment: .leading, spacing: 0) {
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(scheme.onSurface)
// A light page keeps the name: a clear logo is lettering made for
// dark art, white as often as not.
if scheme.light {
Text(title.displayName).textStyle(.displayLarge).foregroundStyle(scheme.onSurface)
} else {
RemoteLogo(title.logoUrl, title.displayName, maxWidth: 280, maxHeight: 88) {
Text(title.displayName).textStyle(.displayLarge).foregroundStyle(scheme.onSurface)
}
.padding(.bottom, 4)
}
// The other names it goes by: the share's when the shown one is the
// provider's, and the original-language one.
let names: [String?] = [
@@ -160,15 +239,15 @@ struct TitleView: View {
}
Text(factLine)
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 6)
// Who made it, and whether more is coming.
let makers: [String?] = [
title.studios.isEmpty ? nil : title.studios.prefix(2).joined(separator: ", "),
title.directors.first.map { "dir. \($0)" },
airingNote(title),
]
let facts = makers.compactMap { $0 }.joined(separator: " · ")
if !facts.isEmpty {
Text(facts).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 2)
// Where the title stands, then what the files are rather than
// what the title is.
let status = statusText(title)
if !title.quality.isEmpty || status != nil {
HStack(spacing: 4) {
if let status { StatusChip(text: status.text, live: status.live).padding(.trailing, 4) }
ForEach(title.quality, id: \.self) { SpecBadge(text: $0) }
}
.padding(.top, 8)
}
// The one thing the page is for, full width and naming what it will
// play: "Resume" alone left the viewer guessing which episode.
@@ -179,6 +258,10 @@ struct TitleView: View {
.disabled(playlist.isEmpty)
.padding(.top, 16)
HStack(spacing: 0) {
TitleAction(icon: title.onList ? "checkmark" : "plus", label: "My List") {
model.setOnList(title, !title.onList)
}
.accessibilityValue(title.onList ? "On My List" : "Not on My List")
// Only where it would do something different: an unstarted show
// already starts at the beginning.
if upNext?.resumeAt != nil || title.watchedCount > 0 {
@@ -204,9 +287,48 @@ struct TitleView: View {
.padding(.top, 12)
.onTapGesture { overviewOpen.toggle() }
}
if !title.genres.isEmpty || !title.tags.isEmpty {
Text((title.genres + title.tags.prefix(4)).joined(separator: " · "))
.textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 10)
// In the browser: the app itself never requests it.
if let trailer = title.trailerUrl.flatMap(URL.init(string:)) {
Button { openURL(trailer) } label: {
Label("Trailer", systemImage: "film")
}
.buttonStyle(.quiet)
.padding(.top, 4)
}
// Genres as chips, then the provider's tags quieter after them: a
// tag is its community describing it, not what it is filed as.
if !title.genres.isEmpty {
FlowLayout(spacing: 6) {
ForEach(title.genres, id: \.self) { genre in
GenreChip(text: genre) { onBrowse(.genre(name: genre)) }
}
}
.padding(.top, 12)
}
// Who made it, as chips that open everything else by them: the
// desktop's credits row.
let studios = Array(title.studios.prefix(2))
let director = Array(title.directors.prefix(1))
if !studios.isEmpty || !director.isEmpty {
FlowLayout(spacing: 6) {
if !studios.isEmpty {
Text("Studio").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
ForEach(studios, id: \.self) { name in
GenreChip(text: name) { onBrowse(.studio(name: name)) }
}
}
if !director.isEmpty {
Text("Director").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
ForEach(director, id: \.self) { name in
GenreChip(text: name) { onBrowse(.person(name: name)) }
}
}
}
.padding(.top, 8)
}
if !title.tags.isEmpty {
Text(title.tags.prefix(4).joined(separator: " · "))
.textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 8)
}
// Said plainly, because it changes what a re-match will do.
if title.manualMatch {
@@ -219,7 +341,9 @@ struct TitleView: View {
let year: String? = (title.metadataYear ?? title.year).map { String($0) }
let rating: String? = title.rating.map { String(format: "★ %.1f", $0) }
let watched: String? = title.episodeCount > 1 ? "\(title.watchedCount) of \(title.episodeCount) watched" : nil
let parts: [String?] = [title.formatLabel, title.seasonLabel ?? year, rating, watched]
// A series' running time is its episodes', which each row says.
let runtime: String? = title.kind == .film ? title.runtime.map(formatRuntime) : nil
let parts: [String?] = [title.formatLabel, title.seasonLabel ?? year, runtime, rating, watched]
return parts.compactMap { $0 }.joined(separator: " · ")
}
@@ -231,13 +355,48 @@ struct TitleView: View {
return "Play \(upNext.label)"
}
private func franchiseRow(side: CGFloat) -> some View {
/// The characters, each a round portrait over their name and who voices
/// them. A voice actor opens every title in the library they are in.
private func castRow(side: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("In this franchise").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
Text("Cast").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
.padding(.horizontal, side + 16)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: 16) {
// By position: two characters can share a name.
ForEach(Array(title.cast.enumerated()), id: \.offset) { _, member in
VStack(spacing: 2) {
RemoteArtwork(member.characterImage ?? member.actorImage, member.character, labelled: false)
.frame(width: 76, height: 76)
.clipShape(Circle())
Text(member.character).textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
.lineLimit(1).padding(.top, 4)
if let actor = member.actor {
Text(actor).textStyle(.labelSmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
}
}
.frame(width: 88)
.contentShape(Rectangle())
.onTapGesture { if let actor = member.actor { onBrowse(.person(name: actor)) } }
.accessibilityElement(children: .combine)
.accessibilityHint(member.actor.map { "Browse \($0)" } ?? "")
}
}
.padding(.horizontal, side + 16)
}
}
.padding(.top, 16)
}
/// A row of covers: the franchise, with this title marked, or the titles
/// like it.
private func coverRow(_ heading: String, _ members: [TitleRecord], side: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(heading).textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
.padding(.horizontal, side + 16)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: 12) {
ForEach(franchise, id: \.key) { member in
ForEach(members, id: \.key) { member in
FranchiseTile(title: member, here: member.key == title.key)
.tapAndHold { if member.key != title.key { onOpenTitle(member) } }
}
@@ -250,11 +409,34 @@ struct TitleView: View {
private func seasonChips(season: SeasonRecord?, side: CGFloat) -> some View {
HStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
// By position: two seasons can share a label.
ForEach(Array(title.seasons.enumerated()), id: \.offset) { index, entry in
FilterChip(label: entry.label, selected: index == seasonIndex) { seasonIndex = index }
if title.seasons.count > seasonChipsMax {
// A long-running show's row of chips is a scroll of its own;
// past a handful it is a list to pick from.
Menu {
Picker("Season", selection: $seasonIndex) {
ForEach(Array(title.seasons.enumerated()), id: \.offset) { index, entry in
Text(entry.label).tag(index)
}
}
} label: {
HStack(spacing: 4) {
Text(title.seasons.indices.contains(seasonIndex) ? title.seasons[seasonIndex].label : "")
Image(systemName: "chevron.down").font(.system(size: 12, weight: .semibold))
}
.textStyle(.labelLarge)
.foregroundStyle(scheme.onSecondaryContainer)
.padding(.horizontal, 12)
.frame(height: 32)
.background(scheme.secondaryContainer, in: RoundedRectangle(cornerRadius: Corner.standard))
}
Spacer(minLength: 0)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
// By position: two seasons can share a label.
ForEach(Array(title.seasons.enumerated()), id: \.offset) { index, entry in
FilterChip(label: entry.label, selected: index == seasonIndex) { seasonIndex = index }
}
}
}
}
@@ -327,15 +509,20 @@ private struct FranchiseTile: View {
}
}
/// "episode 7 on Sat 11 Oct" while one is due, "airing" while it is airing
/// with no date yet, else nothing.
private func airingNote(_ title: TitleRecord) -> String? {
if let at = title.nextAiringAt, let episode = title.nextEpisode {
let format = DateFormatter()
format.setLocalizedDateFormatFromTemplate("EEE d MMM")
return "episode \(episode) on " + format.string(from: Date(timeIntervalSince1970: TimeInterval(at)))
}
return title.airing ? "airing" : nil
/// The most seasons picked from a row of chips; past it, a list. Android's
/// `SEASON_CHIPS`.
private let seasonChipsMax = 5
/// What the status chip says — "Airing · episode 7 Sat 11 Oct", "Finished" —
/// and whether more is coming. Android's `statusText`.
func statusText(_ title: TitleRecord) -> (text: String, live: Bool)? {
let next = title.nextAiringAt.flatMap { at in title.nextEpisode.map { (episode: $0, at: at) } }
guard let label = title.status ?? (next != nil || title.airing ? "Airing" : nil) else { return nil }
let live = next != nil || title.airing
guard let next else { return (label, live) }
let format = DateFormatter()
format.setLocalizedDateFormatFromTemplate("EEE d MMM")
return ("\(label) · episode \(next.episode) " + format.string(from: Date(timeIntervalSince1970: TimeInterval(next.at))), live)
}
/// One of the title's secondary actions: an icon with its name under it.
@@ -365,10 +552,16 @@ extension MetadataProvider {
switch self {
case .aniList: "AniList"
case .tmdb: "TMDB"
case .tvMaze: "TVmaze"
}
}
}
extension MatchRecord {
/// Unique across one search: the same id can come back from two providers.
var choiceId: String { "\(provider.displayName):\(remoteId)" }
}
extension EpisodeRecord {
/// `S03E38`, `E38`, or the row's place in the season.
///
@@ -404,8 +597,10 @@ private struct EpisodeRow: View {
@State private var holding = false
private var subline: String {
let size: String? = episode.size.map { formatBytes($0) }
let parts: [String?] = [numbering, size, episode.airDate]
// How long it runs says more than how big it is; the size is what
// there is when no provider said.
let length: String? = episode.runtime.map(formatRuntime) ?? episode.size.map { formatBytes($0) }
let parts: [String?] = [numbering, length, episode.airDate]
return parts.compactMap { $0 }.joined(separator: " · ")
}
@@ -420,6 +615,19 @@ private struct EpisodeRow: View {
.overlay(alignment: .bottom) {
if let progress = episode.progress, !episode.watched { AccentProgress(progress: progress) }
}
// Seen, said on the picture as well as by the mark at the
// row's end: a season is scanned by its stills.
.overlay(alignment: .topTrailing) {
if episode.watched {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(scheme.onPrimary)
.frame(width: 18, height: 18)
.background(scheme.primary, in: Circle())
.padding(5)
.accessibilityHidden(true)
}
}
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
VStack(alignment: .leading, spacing: 2) {
// Seen episodes stay legible but stop competing with the
@@ -502,7 +710,7 @@ struct ChangeMatchSheet: View {
var body: some View {
DialogSheet("Change match") {
Text("Search the configured metadata provider. Nothing is stored until you choose an entry.")
Text("Search the configured metadata providers. Nothing is stored until you choose an entry.")
Field(label: "Title", text: $term, submit: .search)
.onSubmit(search)
Button(searching ? "Searching…" : "Search", action: search)
@@ -521,11 +729,11 @@ struct ChangeMatchSheet: View {
if !searching, options.isEmpty, searched {
Text("Nothing found. Try the title's original or English name.").textStyle(.bodySmall)
}
ForEach(options, id: \.remoteId) { option in
ForEach(options, id: \.choiceId) { option in
Button { choose(option) } label: {
VStack(alignment: .leading, spacing: 2) {
Text(option.name).textStyle(.labelLarge, semibold: true)
Text([option.year.map(String.init), option.originalName].compactMap { $0 }.joined(separator: " · "))
Text([option.year.map(String.init), option.originalName, option.provider.displayName].compactMap { $0 }.joined(separator: " · "))
.textStyle(.labelLarge)
}
.frame(maxWidth: .infinity, alignment: .leading)
+67 -3
View File
@@ -171,6 +171,18 @@ final class CommonTests: XCTestCase {
XCTAssertEqual(bare.heading(0), "Episode 4")
}
func testThePlayerCaptionsAnEpisodeWithItsNameAndNeverItsFilename() {
var named = episode("e")
named.providerName = "Dog & Chainsaw"
XCTAssertEqual(named.shownName, "Dog & Chainsaw")
var bare = episode("e")
bare.name = "[Group] Show - S01E01.mkv"
bare.detail = bare.name
XCTAssertNil(bare.shownName)
bare.detail = ""
XCTAssertNil(bare.shownName)
}
func testEpisodesAreNumberedBySeasonThenByPlaceInTheList() {
var numbered = episode("e", number: 38)
numbered.season = 3
@@ -252,10 +264,10 @@ final class ThemeTests: XCTestCase {
XCTAssertEqual(alpha, 128 / 255, accuracy: 0.001)
}
/// Every style on one of the nine rungs, every rung used, and leading
/// Every style on one of the ten rungs, every rung used, and leading
/// derived from the size — Android's `TypeRampTest`.
func testEveryStyleIsOnTheDesktopRamp() {
let rungs: Set<CGFloat> = [26, 20, 18, 17, 15, 14, 13, 12, 11]
let rungs: Set<CGFloat> = [34, 26, 20, 18, 17, 15, 14, 13, 12, 11]
let used = Set(TypeRole.allCases.map(\.size))
XCTAssertEqual(used, rungs)
}
@@ -263,7 +275,59 @@ final class ThemeTests: XCTestCase {
func testOnlyHeadingsAreSemibold() {
XCTAssertEqual(
Set(TypeRole.allCases.filter(\.semibold)),
[.displayLarge, .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge]
[.displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge]
)
}
func testOnlyTheHeroLineIsSetInTheDisplayCut() {
XCTAssertEqual(TypeRole.allCases.filter { $0.face() == "InterDisplay-Bold" }, [.displayLarge])
}
func testAPaddedLogoIsBoundedByItsLetteringAndNotItsHaze() {
let width = 10
var alpha = [UInt8](repeating: 0, count: width * 6)
// Lettering from (2, 1) to (6, 3), and a pixel of haze in the corner.
for y in 1 ... 3 { for x in 2 ... 6 { alpha[y * width + x] = 255 } }
alpha[5 * width + 9] = 3
XCTAssertEqual(opaqueBounds(alpha: alpha, width: width), PixelBounds(left: 2, top: 1, right: 6, bottom: 3))
XCTAssertNil(opaqueBounds(alpha: [UInt8](repeating: 0, count: 16), width: 4))
}
func testARunningTimeReadsInHoursAndMinutes() {
XCTAssertEqual(formatRuntime(24), "24 min")
XCTAssertEqual(formatRuntime(120), "2 h")
XCTAssertEqual(formatRuntime(124), "2 h 4 min")
}
}
final class StatusTextTests: XCTestCase {
private func title(status: String?, airing: Bool = false, next: (UInt32, Int64)? = nil) -> TitleRecord {
TitleRecord(
key: "k", name: "k", year: nil, kind: .series, watchedCount: 0,
episodeCount: 0, canonicalName: nil, originalName: nil, overview: nil,
metadataProvider: nil, metadataId: nil, metadataYear: nil, metadataKind: nil, posterUrl: nil,
backdropUrl: nil, rating: nil, genres: [], providerEpisodeCount: nil, externalUrl: nil,
manualMatch: false, seasons: [],
displayName: "k", wideUrl: nil, formatLabel: nil, seasonLabel: nil, studios: [],
directors: [], tags: [], airing: airing, nextEpisode: next?.0, nextAiringAt: next?.1,
franchise: [], continueIndex: nil, status: status
)
}
func testTheStatusChipNamesTheNextEpisodeWhileOneIsDue() throws {
let status = try XCTUnwrap(statusText(title(status: "Airing", airing: true, next: (8, 1_800_000_000))))
XCTAssertTrue(status.text.hasPrefix("Airing · episode 8 "), status.text)
XCTAssertTrue(status.live)
}
func testAFinishedTitleIsSaidPlainlyAndIsNotLive() throws {
let status = try XCTUnwrap(statusText(title(status: "Finished")))
XCTAssertEqual(status.text, "Finished")
XCTAssertFalse(status.live)
}
func testATitleWithNoStatusAndNothingAiringHasNoChip() {
XCTAssertNil(statusText(title(status: nil)))
XCTAssertEqual(statusText(title(status: nil, airing: true))?.text, "Airing")
}
}
+3
View File
@@ -31,6 +31,8 @@ targets:
buildPhase: resources
- path: ../android/app/src/main/res/font/inter_semibold.ttf
buildPhase: resources
- path: ../android/app/src/main/res/font/inter_display_bold.ttf
buildPhase: resources
- path: ../android/app/src/main/assets/licenses/GPL-3.0.txt
buildPhase: resources
- path: ../android/app/src/main/assets/licenses/Inter-OFL.txt
@@ -74,6 +76,7 @@ targets:
UIAppFonts:
- inter_regular.ttf
- inter_semibold.ttf
- inter_display_bold.ttf
# A share link handed over from elsewhere: protonstream://add?url=…,
# or the link with its scheme swapped (IncomingShareLink.swift).
CFBundleURLTypes:
+1 -1
View File
@@ -14,7 +14,7 @@
# builds from the registry alone.
pkgname=proton-stream
pkgver=1.7.0
pkgver=2.0.1
pkgrel=1
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
arch=('x86_64')