feat(core,app,android,ios,cli): look up new titles after every crawl, show presses and work on iOS

Metadata was only fetched after adding a share (mobile) or from the
Settings buttons (desktop), so a show or episode that turned up in an
existing share stayed bare. Every crawl is now followed by a match run
when enrichment is on; `plan` makes no requests for a library that is
already matched. A stored episode guide is fetched again when a file
newer than it has no named entry, and daily for two weeks while an
aired episode is still unnamed.

Removing a share now carries its watch state to any other share holding
the same file, `pstr remove` drops the share's catalog rows, and the app
forgets catalog rows of shares no longer configured — an older `pstr
remove` left them, and every episode showed twice.

iOS, for a slow iPad:
- swipe back on a settings page uses its own edge strip; on the whole
  page it also pressed the row under the finger and opened the metadata
  sheet
- "Change match" from the tile menu opens once the menu has dismissed,
  not after a guessed 350 ms that a slow device missed
- the activity banner tracks each running line by itself (also Android),
  so overlapping work no longer leaves it spinning forever
- buttons, tiles and rows draw their press; marking watched shows at
  once; remove/repair share, sign out and deletions show the banner;
  the player says "Opening…" while the stream opens
- posters are read into a reserved buffer instead of a byte at a time
This commit is contained in:
nvrl committed 2026-10-12 18:36:33 +02:00
1 parent 2f3be1d193
commit 73973841c2
19 files changed
+692 -113

No files matched your search

+1 -1
View File
@@ -383,7 +383,7 @@ private struct DriveRow: View {
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
.disabled(open == nil)
}
}
+1 -1
View File
@@ -69,7 +69,7 @@ struct IconButton: View {
.frame(width: 48, height: 48)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.pressableIcon)
.foregroundStyle(enabled ? tint ?? scheme.onSurfaceVariant : scheme.onSurface.opacity(0.38))
.disabled(!enabled)
.accessibilityLabel(label)
+2 -2
View File
@@ -57,7 +57,7 @@ struct LanguageSetting: View {
Button { choosing = true } label: {
ListRow(headline: headline, supporting: languageLabel(value))
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
.sheet(isPresented: $choosing) {
LanguageSheet(headline: headline, value: value) { tag in
choosing = false
@@ -114,7 +114,7 @@ private struct LanguageSheet: View {
.padding(.vertical, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}
+19 -14
View File
@@ -55,6 +55,9 @@ struct LibraryView: View {
@State private var menuFor: TitleRecord?
@State private var menuResume: Resumable?
@State private var matching: TitleRecord?
/// "Change match" picked in the tile menu, waiting for that menu to finish
/// leaving: a sheet presented while another is still dismissing is dropped.
@State private var pendingMatch: TitleRecord?
// Search is an icon until it is wanted: a permanent field above the
// library spent a band of every visit on something used in few of them.
@SceneStorage("library.searching") private var searching = false
@@ -90,7 +93,15 @@ struct LibraryView: View {
content(resumable)
}
.background(scheme.background)
.sheet(item: $menuFor) { title in
.sheet(item: $menuFor, onDismiss: {
// After the dismissal rather than a guessed delay: on a slow iPad
// the menu was still leaving after 350 ms, the search sheet was
// dropped, and it turned up later on some unrelated redraw.
if let title = pendingMatch {
pendingMatch = nil
matching = title
}
}) { title in
let playlist = title.playlist
let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount
TileMenu(heading: title.displayName, caption: title.caption) {
@@ -104,12 +115,7 @@ struct LibraryView: View {
}
// The same search the title page offers, for the tile that is
// plainly wrong — the wrong poster is what the grid shows.
MenuRow("pencil", "Change match") {
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(350))
matching = title
}
}
MenuRow("pencil", "Change match") { pendingMatch = title }
}
}
.sheet(item: $menuResume) { entry in
@@ -183,8 +189,9 @@ struct LibraryView: View {
LazyHStack(alignment: .top, spacing: 12) {
ForEach(resumable) { entry in
ContinueCard(entry: entry)
.onTapGesture { onResume(entry.title, entry.index) }
.onLongPressGesture { longPressed { menuResume = entry } }
.tapAndHold { onResume(entry.title, entry.index) } onHold: {
longPressed { menuResume = entry }
}
}
}
}
@@ -199,8 +206,7 @@ struct LibraryView: View {
ForEach(shelf.titles, id: \.key) { title in
PosterTile(title: title)
.frame(width: 112)
.onTapGesture { onTitle(title) }
.onLongPressGesture { longPressed { menuFor = title } }
.tapAndHold { onTitle(title) } onHold: { longPressed { menuFor = title } }
}
}
}
@@ -214,8 +220,7 @@ struct LibraryView: View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12, alignment: .top)], spacing: 16) {
ForEach(tiles) { tile in
PosterTile(title: tile.title, folded: tile.folded)
.onTapGesture { onTitle(tile.title) }
.onLongPressGesture { longPressed { menuFor = tile.title } }
.tapAndHold { onTitle(tile.title) } onHold: { longPressed { menuFor = tile.title } }
}
}
}
@@ -357,7 +362,7 @@ struct MenuRow: View {
} label: {
ListRow(icon: icon, headline: label)
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
.padding(.horizontal, 8)
}
}
+7 -2
View File
@@ -259,7 +259,12 @@ final class ArtworkLoader: @unchecked Sendable {
// A redirect must not leave HTTPS.
http.url?.scheme?.lowercased() == "https"
else { return nil }
var bytes = Data()
// Refused before reading when the server says up front it is too big.
if response.expectedContentLength > Int64(Self.maxBytes) { return nil }
// An array sized once, not a `Data` grown a byte at a time: on an old
// iPad, a screen of posters read that way was seconds of CPU.
var bytes = [UInt8]()
bytes.reserveCapacity(Int(min(max(response.expectedContentLength, 0), Int64(Self.maxBytes))))
do {
for try await byte in stream {
bytes.append(byte)
@@ -268,7 +273,7 @@ final class ArtworkLoader: @unchecked Sendable {
} catch {
return nil
}
return bytes
return Data(bytes)
}
/// Decode within a bound the display can actually use: the compressed size
+25 -9
View File
@@ -42,6 +42,8 @@ struct SettingsView: View {
@State private var prefs: PlaybackPrefsRecord?
@State private var confirmDelete = false
@State private var showMetadata = false
/// How far an edge swipe back has gone, 0 to 1.
@State private var backProgress: CGFloat = 0
@State private var legalDocument: LegalDocument?
var body: some View {
@@ -56,7 +58,7 @@ struct SettingsView: View {
Button { page = entry } label: {
ListRow(icon: entry.icon, headline: entry.title, supporting: subtitle(entry))
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
}
}
}
@@ -115,13 +117,27 @@ struct SettingsView: View {
.padding(.bottom, 24)
}
}
// Back by an edge swipe, as from a title page.
.gesture(
DragGesture(minimumDistance: 20)
.onEnded { drag in
if drag.startLocation.x < 24, drag.translation.width > 80 { self.page = nil }
}
)
.scaleEffect(1 - 0.1 * backProgress)
.clipShape(RoundedRectangle(cornerRadius: 32 * backProgress))
// Back by an edge swipe, as from a title page — from a strip of its
// own, as there. On the whole page the swipe began on whichever row
// was under the edge, and lifting the finger pressed it: the way back
// from Metadata opened the enrichment sheet.
.overlay(alignment: .leading) {
Color.clear
.frame(width: 20)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 10)
.onChanged { drag in backProgress = min(max(drag.translation.width / 300, 0), 1) }
.onEnded { drag in
if drag.translation.width > 100 || drag.predictedEndTranslation.width > 250 {
self.page = nil
}
withAnimation(.spring) { backProgress = 0 }
}
)
}
}
@ViewBuilder private var playback: some View {
@@ -268,7 +284,7 @@ private struct SettingAction: View {
var body: some View {
Button(action: action) { ListRow(headline: headline, supporting: supporting, enabled: enabled) }
.buttonStyle(.plain)
.buttonStyle(.pressable)
.disabled(!enabled)
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ struct SharesView: View {
.background(scheme.primary, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.3), radius: 6, y: 3)
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
.padding(16)
}
}
+59
View File
@@ -67,6 +67,65 @@ extension ButtonStyle where Self == ProtonButtonStyle {
static var quiet: ProtonButtonStyle { ProtonButtonStyle(kind: .quiet) }
}
/// A button with no chrome of its own — an icon, a row, a chip — that still
/// shows it was pressed: a little smaller, with a wash behind it.
///
/// `.plain` shows nothing at all, and on a slow device, where what the press
/// does can take a second to appear, a press that shows nothing reads as one
/// that missed — and gets pressed again.
struct PressableStyle: ButtonStyle {
var circle = false
/// The wash's colour; the scheme's text colour when nil. White over video.
var wash: Color?
@Environment(\.scheme) private var scheme
func makeBody(configuration: Configuration) -> some View {
let pressed = configuration.isPressed
let fill = (wash ?? scheme.onSurface).opacity(pressed ? 0.12 : 0)
configuration.label
.background {
if circle {
Circle().fill(fill)
} else {
RoundedRectangle(cornerRadius: Corner.standard).fill(fill)
}
}
.scaleEffect(pressed ? 0.96 : 1)
.animation(.easeOut(duration: 0.1), value: pressed)
}
}
extension ButtonStyle where Self == PressableStyle {
static var pressable: PressableStyle { PressableStyle() }
static var pressableIcon: PressableStyle { PressableStyle(circle: true) }
}
/// A tap and a long press on something that is not a button — a poster, a
/// card, an episode row — showing the press the moment the finger lands, as
/// `PressableStyle` does for a button.
private struct TapAndHold: ViewModifier {
let onTap: () -> Void
let onHold: (() -> Void)?
@State private var pressed = false
func body(content: Content) -> some View {
content
.scaleEffect(pressed ? 0.96 : 1)
.opacity(pressed ? 0.8 : 1)
.animation(.easeOut(duration: 0.1), value: pressed)
.onTapGesture(perform: onTap)
.onLongPressGesture(perform: { onHold?() }, onPressingChanged: { pressed = $0 })
}
}
extension View {
/// `onTapGesture`, plus `onLongPressGesture` when `onHold` is given, with
/// the press drawn.
func tapAndHold(_ onTap: @escaping () -> Void, onHold: (() -> Void)? = nil) -> some View {
modifier(TapAndHold(onTap: onTap, onHold: onHold))
}
}
/// How far along something is, drawn in the accent.
///
/// `solid` is for the one bar that is deliberately not the accent: a download in
+13 -6
View File
@@ -35,7 +35,9 @@ struct TitleView: View {
self.franchise = franchise
self.onOpenTitle = onOpenTitle
// One season on screen at a time, opened on the one Play would land in.
let upNext = title.playlist.indices.contains(nextUpIndex(title.playlist)) ? title.playlist[nextUpIndex(title.playlist)] : nil
let playlist = title.playlist
let nextUp = nextUpIndex(playlist)
let upNext = playlist.indices.contains(nextUp) ? playlist[nextUp] : nil
_seasonIndex = State(initialValue: max(title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0, 0))
}
@@ -231,7 +233,7 @@ struct TitleView: View {
LazyHStack(alignment: .top, spacing: 12) {
ForEach(franchise, id: \.key) { member in
FranchiseTile(title: member, here: member.key == title.key)
.onTapGesture { if member.key != title.key { onOpenTitle(member) } }
.tapAndHold { if member.key != title.key { onOpenTitle(member) } }
}
}
.padding(.horizontal, side + 16)
@@ -282,7 +284,7 @@ struct FilterChip: View {
if !selected { RoundedRectangle(cornerRadius: Corner.standard).strokeBorder(scheme.outlineVariant) }
}
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
}
}
@@ -341,7 +343,7 @@ private struct TitleAction: View {
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.pressable)
}
}
@@ -449,7 +451,7 @@ private struct EpisodeRow: View {
.padding(.horizontal, 16)
.padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture(perform: onPlay)
.tapAndHold(onPlay)
}
}
@@ -468,6 +470,7 @@ struct ChangeMatchSheet: View {
/// Said here rather than in the snackbar, which this sheet covers.
@State private var failure: String?
@State private var searched = false
@State private var forgetting = false
init(title: TitleRecord, onDone: @escaping () -> Void) {
self.title = title
@@ -510,7 +513,9 @@ struct ChangeMatchSheet: View {
.disabled(choosing)
}
} buttons: {
Button("Forget match") {
Button(forgetting ? "Forgetting…" : "Forget match") {
forgetting = true
failure = nil
Task {
do {
try await model.run { try $0.forgetMatch(titleKey: title.key) }
@@ -518,9 +523,11 @@ struct ChangeMatchSheet: View {
} catch {
failure = errorMessage(error)
}
forgetting = false
}
}
.buttonStyle(.quiet)
.disabled(forgetting || choosing)
Button("Close", action: onDone).buttonStyle(.tonal)
}
}