mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
2f3be1d193
commit
73973841c2
19 files changed
+692
-113
No files matched your search
@@ -64,6 +64,9 @@ final class AppModel {
|
||||
@ObservationIgnored private var started = false
|
||||
/// Launch and the first `.active` both ask; one crawl answers.
|
||||
@ObservationIgnored private var catchingUp = false
|
||||
/// Every banner line whose work is still running, oldest first.
|
||||
@ObservationIgnored private var activities: [(token: Int, line: String)] = []
|
||||
@ObservationIgnored private var nextActivity = 0
|
||||
|
||||
/// Five minutes, as on Android: cheap when nothing changed — one folder
|
||||
/// listing, and no upload.
|
||||
@@ -142,7 +145,19 @@ final class AppModel {
|
||||
message = errorMessage(error)
|
||||
}
|
||||
}
|
||||
reload()
|
||||
await reloadNow()
|
||||
// Not awaited: pull to refresh lets go once the crawl is in, and the
|
||||
// banner carries the lookup.
|
||||
Task { await matchAfterCrawl() }
|
||||
}
|
||||
|
||||
/// Look up whatever a crawl brought that has no answer yet: a new show, or
|
||||
/// a new episode its stored guide cannot name. When everything already
|
||||
/// has one the bridge asks nothing, so this is cheap after every crawl.
|
||||
private func matchAfterCrawl() async {
|
||||
if metadataSettings.enabled, metadataSettings.ready {
|
||||
await match(force: false, quiet: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Run enrichment again. With `force` every title is looked up afresh,
|
||||
@@ -151,11 +166,16 @@ final class AppModel {
|
||||
Task { await match(force: force) }
|
||||
}
|
||||
|
||||
private func match(force: Bool) async {
|
||||
/// With `quiet`, a run nobody asked for says nothing when it found
|
||||
/// nothing to do.
|
||||
private func match(force: Bool, quiet: Bool = false) async {
|
||||
await busy("Looking up titles…") {
|
||||
do {
|
||||
let summary = try await NativeRuntime.engine().matchTitles(force: force)
|
||||
message = Self.describe(summary)
|
||||
let found = summary.matched + summary.unmatched + summary.failed + summary.episodes > 0
|
||||
if !quiet || found {
|
||||
message = Self.describe(summary)
|
||||
}
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
}
|
||||
@@ -163,15 +183,25 @@ final class AppModel {
|
||||
reload()
|
||||
}
|
||||
|
||||
/// Say `what` in the activity banner while `work` runs. Nested work keeps
|
||||
/// the outer line once the inner one is done.
|
||||
/// Say `what` in the activity banner while `work` runs.
|
||||
///
|
||||
/// The banner shows the newest line still running. Work overlaps — a sync
|
||||
/// starts during a crawl and ends after it — so each line is removed by
|
||||
/// itself rather than by putting back whatever was showing before, which
|
||||
/// once brought back a finished crawl's line and spun it forever.
|
||||
private func busy(_ what: String, _ work: () async -> Void) async {
|
||||
let outer = activity
|
||||
activity = what
|
||||
refreshing = true
|
||||
let token = nextActivity
|
||||
nextActivity += 1
|
||||
activities.append((token, what))
|
||||
showActivity()
|
||||
await work()
|
||||
activity = outer
|
||||
refreshing = outer != nil
|
||||
activities.removeAll { $0.token == token }
|
||||
showActivity()
|
||||
}
|
||||
|
||||
private func showActivity() {
|
||||
activity = activities.last?.line
|
||||
refreshing = activity != nil
|
||||
}
|
||||
|
||||
// MARK: - Shares
|
||||
@@ -216,9 +246,7 @@ final class AppModel {
|
||||
message = failed.joined(separator: "\n")
|
||||
return
|
||||
}
|
||||
if metadataSettings.enabled {
|
||||
await match(force: false)
|
||||
}
|
||||
await matchAfterCrawl()
|
||||
if announce {
|
||||
let added = Set(titles.map(\.key)).subtracting(before).count
|
||||
message = added == 1 ? "1 title added" : "\(added) titles added"
|
||||
@@ -230,34 +258,41 @@ final class AppModel {
|
||||
/// offline files alone.
|
||||
func repairShare(id: String, url: String, password: String?) {
|
||||
Task {
|
||||
do {
|
||||
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
||||
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
|
||||
try await NativeRuntime.engine().crawl(shareId: id)
|
||||
} catch {
|
||||
reportError(error)
|
||||
let name = shares.first { $0.id == id }?.name ?? "the share"
|
||||
await busy("Reading \(name) again…") {
|
||||
do {
|
||||
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
||||
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
|
||||
try await NativeRuntime.engine().crawl(shareId: id)
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
await reloadNow()
|
||||
}
|
||||
reload()
|
||||
await matchAfterCrawl()
|
||||
}
|
||||
}
|
||||
|
||||
func removeShare(_ id: String) {
|
||||
Task {
|
||||
do {
|
||||
// Wait for the transfers to stop before Rust removes catalog
|
||||
// and files. Rust also rejects any late publication.
|
||||
await DownloadCoordinator.shared.cancelShare(id)
|
||||
let store = DownloadStateStore.shared
|
||||
let engine = try await NativeRuntime.engine()
|
||||
for download in store.records() where download.shareId == id {
|
||||
try await engine.removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId)
|
||||
let name = shares.first { $0.id == id }?.name ?? "the share"
|
||||
await busy("Removing \(name)…") {
|
||||
do {
|
||||
// Wait for the transfers to stop before Rust removes catalog
|
||||
// and files. Rust also rejects any late publication.
|
||||
await DownloadCoordinator.shared.cancelShare(id)
|
||||
let store = DownloadStateStore.shared
|
||||
let engine = try await NativeRuntime.engine()
|
||||
for download in store.records() where download.shareId == id {
|
||||
try await engine.removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId)
|
||||
}
|
||||
store.removeShare(id)
|
||||
try await run { try $0.removeShare(shareId: id) }
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
}
|
||||
store.removeShare(id)
|
||||
try await run { try $0.removeShare(shareId: id) }
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
await reloadNow()
|
||||
}
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,13 +361,15 @@ final class AppModel {
|
||||
|
||||
func signOut() {
|
||||
Task {
|
||||
do {
|
||||
try await NativeRuntime.engine().signOut()
|
||||
} catch {
|
||||
reportError(error)
|
||||
await busy("Signing out…") {
|
||||
do {
|
||||
try await NativeRuntime.engine().signOut()
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
account = AccountUiState(state: .signedOut)
|
||||
await reloadNow()
|
||||
}
|
||||
account = AccountUiState(state: .signedOut)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +478,7 @@ final class AppModel {
|
||||
/// title's positions are what this throws away, and a mis-tap on a tile's
|
||||
/// menu should not cost them.
|
||||
func setTitleWatched(_ title: TitleRecord, _ watched: Bool) {
|
||||
showWatched(title.playlist, watched)
|
||||
changeWatch(title.playlist, "\(title.displayName) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in
|
||||
let duration = before?.durationSecs
|
||||
try engine.saveWatchState(
|
||||
@@ -571,12 +609,14 @@ final class AppModel {
|
||||
|
||||
func removeOffline(_ file: OfflineRecord) {
|
||||
Task {
|
||||
do {
|
||||
try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
await busy("Deleting the download…") {
|
||||
do {
|
||||
try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
}
|
||||
await reloadNow()
|
||||
}
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,14 +639,16 @@ final class AppModel {
|
||||
|
||||
func removeAllOffline() {
|
||||
Task {
|
||||
await DownloadCoordinator.shared.cancelAll()
|
||||
do {
|
||||
try await NativeRuntime.engine().removeAllOffline()
|
||||
DownloadStateStore.shared.clear()
|
||||
} catch {
|
||||
reportError(error)
|
||||
await busy("Deleting every download…") {
|
||||
await DownloadCoordinator.shared.cancelAll()
|
||||
do {
|
||||
try await NativeRuntime.engine().removeAllOffline()
|
||||
DownloadStateStore.shared.clear()
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
await reloadNow()
|
||||
}
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,6 +693,7 @@ final class AppModel {
|
||||
/// the position at its duration where one is known, so the progress bar
|
||||
/// agrees with the tick.
|
||||
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) {
|
||||
showWatched([episode], watched)
|
||||
Task {
|
||||
do {
|
||||
try await run { engine in
|
||||
@@ -670,6 +713,34 @@ final class AppModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a watched mark the moment it is tapped, before the bridge has
|
||||
/// stored it. The reload that shows the stored state is a second away on
|
||||
/// an old iPad, and a tick that does not appear gets tapped again. That
|
||||
/// reload still follows, and puts back whatever was really stored.
|
||||
private func showWatched(_ targets: [EpisodeRecord], _ watched: Bool) {
|
||||
let keys = Set(targets.map { "\($0.shareId)/\($0.linkId)" })
|
||||
titles = titles.map { title in
|
||||
guard title.playlist.contains(where: { keys.contains("\($0.shareId)/\($0.linkId)") }) else { return title }
|
||||
var title = title
|
||||
title.seasons = title.seasons.map { season in
|
||||
var season = season
|
||||
season.episodes = season.episodes.map { episode in
|
||||
guard keys.contains("\(episode.shareId)/\(episode.linkId)") else { return episode }
|
||||
var episode = episode
|
||||
episode.watched = watched
|
||||
if !watched {
|
||||
episode.progress = nil
|
||||
episode.resumeAt = nil
|
||||
}
|
||||
return episode
|
||||
}
|
||||
return season
|
||||
}
|
||||
title.watchedCount = UInt64(title.playlist.filter(\.watched).count)
|
||||
return title
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reload
|
||||
|
||||
func reload() {
|
||||
|
||||
@@ -262,7 +262,7 @@ private struct NavigationBar: View {
|
||||
.padding(.bottom, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
.accessibilityLabel(item.label)
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
@@ -295,7 +295,7 @@ private struct NavigationRail: View {
|
||||
.frame(width: 80)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
.accessibilityLabel(item.label)
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
@@ -350,7 +350,7 @@ private struct Snackbar: View {
|
||||
Button("Undo", action: model.performUndo)
|
||||
.textStyle(.labelLarge)
|
||||
.foregroundStyle(scheme.inversePrimary)
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@ final class PlayerHost {
|
||||
private(set) var chapters = ChapterPlan(entries: [], creditsStart: nil)
|
||||
/// What stopped this episode, until the viewer dismisses it.
|
||||
var playbackError: String?
|
||||
/// The stream is being opened: the share session, the revision and its
|
||||
/// block list, all before mpv has a byte. On an old iPad that is seconds
|
||||
/// of a black screen unless something says so.
|
||||
private(set) var opening = false
|
||||
/// Read once per episode: a preference the viewer changes mid-episode
|
||||
/// applies to the next one.
|
||||
private(set) var autoSkip = false
|
||||
@@ -177,6 +181,7 @@ final class PlayerHost {
|
||||
index = 0
|
||||
minimized = false
|
||||
playbackError = nil
|
||||
opening = false
|
||||
chapters = ChapterPlan(entries: [], creditsStart: nil)
|
||||
OrientationLock.set(false)
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
@@ -210,6 +215,7 @@ final class PlayerHost {
|
||||
return
|
||||
}
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
opening = true
|
||||
loadTask = Task {
|
||||
do {
|
||||
try await session.open()
|
||||
@@ -244,6 +250,8 @@ final class PlayerHost {
|
||||
hardwareDecoding: SettingsStore().hardwareDecoding
|
||||
)
|
||||
guard loaded else { throw PlaybackFailure("libmpv rejected the stream") }
|
||||
// mpv's own buffering takes over the spinner from here.
|
||||
opening = false
|
||||
session.transferToPlayer()
|
||||
// After the load, because mpv keeps none of the three across one.
|
||||
player.setVolume(global.volume)
|
||||
@@ -256,6 +264,7 @@ final class PlayerHost {
|
||||
abandon(session)
|
||||
return
|
||||
}
|
||||
opening = false
|
||||
playbackError = errorMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,10 +73,12 @@ struct PlayerView: View {
|
||||
|
||||
// Only while nothing else explains the still picture: an error
|
||||
// is the more useful thing to say.
|
||||
if player.stalled && host.playbackError == nil {
|
||||
if (host.opening || player.stalled) && host.playbackError == nil {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView().controlSize(.large).tint(scheme.primary)
|
||||
if player.buffering {
|
||||
if host.opening {
|
||||
Text("Opening…").textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
|
||||
} else if player.buffering {
|
||||
Text(player.cachePercent > 0 ? "Buffering… \(Int(player.cachePercent.rounded()))%" : "Buffering…")
|
||||
.textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
|
||||
}
|
||||
@@ -295,7 +297,7 @@ private struct CompactIcon: View {
|
||||
.frame(width: 40, height: 40)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(PressableStyle(circle: true, wash: .white))
|
||||
.disabled(!enabled)
|
||||
.accessibilityLabel(label)
|
||||
}
|
||||
@@ -342,7 +344,7 @@ private struct UpNextCard: View {
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(PressableStyle(wash: .white))
|
||||
.accessibilityLabel("Play now")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Up next in \(remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||
@@ -515,7 +517,7 @@ private struct EpisodeSheet: View {
|
||||
.background(current ? scheme.secondaryContainer : .clear)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -383,7 +383,7 @@ private struct DriveRow: View {
|
||||
.padding(.vertical, 12)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
.disabled(open == nil)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 : [])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user