mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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
234 lines
9.0 KiB
Swift
234 lines
9.0 KiB
Swift
import SwiftUI
|
|
|
|
/// The four buttons the Android client draws, at the shared corner radius.
|
|
///
|
|
/// The primary button wears the accent ramp; a disabled one keeps a flat
|
|
/// disabled fill, because a greyed control that still wears the one strong
|
|
/// colour in the window reads as pressable.
|
|
enum ButtonKind {
|
|
case accent, tonal, edged, quiet
|
|
}
|
|
|
|
struct ProtonButtonStyle: ButtonStyle {
|
|
let kind: ButtonKind
|
|
var expand = false
|
|
@Environment(\.scheme) private var scheme
|
|
@Environment(\.isEnabled) private var enabled
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.textStyle(.labelLarge)
|
|
.lineLimit(1)
|
|
.padding(.horizontal, kind == .quiet ? 12 : 24)
|
|
.frame(maxWidth: expand ? .infinity : nil)
|
|
// 40pt drawn, inside the 48pt a finger is given.
|
|
.frame(minHeight: 40)
|
|
.foregroundStyle(foreground)
|
|
.background { fill }
|
|
.overlay {
|
|
if kind == .edged {
|
|
RoundedRectangle(cornerRadius: Corner.standard)
|
|
.strokeBorder(enabled ? scheme.outline : scheme.onSurface.opacity(0.12))
|
|
}
|
|
}
|
|
.opacity(configuration.isPressed ? 0.8 : 1)
|
|
.contentShape(Rectangle())
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private var foreground: Color {
|
|
guard enabled else { return scheme.onSurface.opacity(0.38) }
|
|
switch kind {
|
|
case .accent: return scheme.onPrimary
|
|
case .tonal: return scheme.onSecondaryContainer
|
|
case .edged, .quiet: return scheme.primary
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private var fill: some View {
|
|
let shape = RoundedRectangle(cornerRadius: Corner.standard)
|
|
switch kind {
|
|
case .accent:
|
|
if enabled { shape.fill(scheme.accentGradient) } else { shape.fill(scheme.onSurface.opacity(0.12)) }
|
|
case .tonal:
|
|
shape.fill(enabled ? scheme.secondaryContainer : scheme.onSurface.opacity(0.12))
|
|
case .edged, .quiet:
|
|
Color.clear
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ButtonStyle where Self == ProtonButtonStyle {
|
|
static var accent: ProtonButtonStyle { ProtonButtonStyle(kind: .accent) }
|
|
static var accentWide: ProtonButtonStyle { ProtonButtonStyle(kind: .accent, expand: true) }
|
|
static var tonal: ProtonButtonStyle { ProtonButtonStyle(kind: .tonal) }
|
|
static var edged: ProtonButtonStyle { ProtonButtonStyle(kind: .edged) }
|
|
static var edgedWide: ProtonButtonStyle { ProtonButtonStyle(kind: .edged, expand: true) }
|
|
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
|
|
/// flight sits directly under the watch-progress bar of the same row, and two
|
|
/// ramps in the same colour stacked two points apart say nothing.
|
|
struct AccentProgress: View {
|
|
let progress: Double
|
|
var solid: Color?
|
|
@Environment(\.scheme) private var scheme
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let height = geometry.size.height
|
|
let done = geometry.size.width * min(max(progress, 0), 1)
|
|
ZStack(alignment: .leading) {
|
|
Capsule().fill(scheme.surfaceContainerHighest)
|
|
// Below one bar's width there is nothing to round.
|
|
if done >= height {
|
|
if let solid {
|
|
Capsule().fill(solid).frame(width: done)
|
|
} else {
|
|
Capsule().fill(scheme.accentGradient).frame(width: done)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 4)
|
|
}
|
|
}
|
|
|
|
/// The seek bar, in the accent ramp, with what is already read ahead shaded
|
|
/// under the played part: a jump that lands in it costs no fetch.
|
|
///
|
|
/// `onCommit` fires once, when the finger lifts — a seek per drag step is a
|
|
/// block fetch per drag step.
|
|
struct AccentTrack: View {
|
|
@Binding var value: Double
|
|
let range: ClosedRange<Double>
|
|
var buffered: Double = 0
|
|
var ticks: [Double] = []
|
|
var onEditing: (Bool) -> Void = { _ in }
|
|
@Environment(\.scheme) private var scheme
|
|
@State private var dragging = false
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let width = geometry.size.width
|
|
let span = max(range.upperBound - range.lowerBound, 0.001)
|
|
let fraction = min(max((value - range.lowerBound) / span, 0), 1)
|
|
ZStack(alignment: .leading) {
|
|
Capsule().fill(Color.white.opacity(0.30)).frame(height: 6)
|
|
if width * buffered >= 6 {
|
|
Capsule().fill(Color.white.opacity(0.55)).frame(width: width * min(max(buffered, 0), 1), height: 6)
|
|
}
|
|
if width * fraction >= 6 {
|
|
Capsule().fill(scheme.accentGradient).frame(width: width * fraction, height: 6)
|
|
}
|
|
ForEach(ticks.indices, id: \.self) { index in
|
|
let tick = min(max(ticks[index], 0), 1)
|
|
Rectangle().fill(Color.black.opacity(0.6)).frame(width: 2, height: 6)
|
|
.offset(x: width * tick - 1)
|
|
}
|
|
Circle().fill(scheme.primary)
|
|
.frame(width: dragging ? 22 : 16, height: dragging ? 22 : 16)
|
|
.offset(x: width * fraction - (dragging ? 11 : 8))
|
|
}
|
|
.frame(maxHeight: .infinity)
|
|
.contentShape(Rectangle())
|
|
.gesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { drag in
|
|
if !dragging {
|
|
dragging = true
|
|
onEditing(true)
|
|
}
|
|
let x = min(max(drag.location.x / max(width, 1), 0), 1)
|
|
value = range.lowerBound + x * span
|
|
}
|
|
.onEnded { _ in
|
|
dragging = false
|
|
onEditing(false)
|
|
}
|
|
)
|
|
}
|
|
.frame(height: 32)
|
|
.accessibilityElement()
|
|
.accessibilityValue(Text(clock(value)))
|
|
.accessibilityAdjustableAction { direction in
|
|
onEditing(true)
|
|
value = min(max(value + (direction == .increment ? 10 : -10), range.lowerBound), range.upperBound)
|
|
onEditing(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Seconds as `m:ss` or `h:mm:ss`.
|
|
func clock(_ seconds: Double) -> String {
|
|
let total = Int(max(seconds, 0).rounded(.down))
|
|
let hours = total / 3600
|
|
let minutes = (total % 3600) / 60
|
|
let rest = total % 60
|
|
return hours > 0
|
|
? String(format: "%d:%02d:%02d", hours, minutes, rest)
|
|
: String(format: "%d:%02d", minutes, rest)
|
|
}
|