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
235 lines
10 KiB
Swift
235 lines
10 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
|
|
struct SharesView: View {
|
|
@Environment(AppModel.self) private var model
|
|
@Environment(\.scheme) private var scheme
|
|
/// A link opened in this app: the Add form opens with it filled in.
|
|
@Binding var incomingLink: String?
|
|
|
|
@State private var showAdd = false
|
|
@State private var signingIn = false
|
|
@State private var browsing = false
|
|
@State private var repairing: ShareRecord?
|
|
@State private var removing: ShareRecord?
|
|
|
|
var body: some View {
|
|
TabPage("Shares") {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
if model.shares.isEmpty && model.account.state == nil {
|
|
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
AccountCard(onSignIn: { signingIn = true }, onBrowse: { browsing = true })
|
|
if model.shares.isEmpty {
|
|
Text("No shares yet. Add a public link, or a folder of your Drive once signed in.")
|
|
.textStyle(.bodyMedium)
|
|
.foregroundStyle(scheme.onSurfaceVariant)
|
|
.padding(16)
|
|
}
|
|
ForEach(model.shares, id: \.id) { share in
|
|
ShareRow(
|
|
share: share,
|
|
onRefresh: { model.refreshShare(share.id) },
|
|
onRepair: { repairing = share },
|
|
onRemove: { removing = share }
|
|
)
|
|
}
|
|
}
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 96)
|
|
}
|
|
}
|
|
// Where a thumb is, and the one thing this page is for.
|
|
Button { showAdd = true } label: {
|
|
Label("Add share", systemImage: "plus")
|
|
.textStyle(.labelLarge)
|
|
.padding(.horizontal, 20)
|
|
.frame(height: 56)
|
|
.foregroundStyle(scheme.onPrimary)
|
|
.background(scheme.primary, in: RoundedRectangle(cornerRadius: 16))
|
|
.shadow(color: .black.opacity(0.3), radius: 6, y: 3)
|
|
}
|
|
.buttonStyle(.pressable)
|
|
.padding(16)
|
|
}
|
|
}
|
|
.sheet(isPresented: Binding(get: { showAdd || incomingLink != nil }, set: { if !$0 { showAdd = false; incomingLink = nil } })) {
|
|
AddShareSheet(initialUrl: incomingLink ?? "") { name, url, password in
|
|
await model.addShare(name: name, url: url, password: password)
|
|
}
|
|
}
|
|
.sheet(isPresented: Binding(
|
|
get: { signingIn || model.account.state == .secondFactor || model.account.state == .mailboxPassword },
|
|
set: { shown in
|
|
if shown { return }
|
|
signingIn = false
|
|
// Swiped away mid sign-in: the half-made session is spent.
|
|
if model.account.state == .secondFactor || model.account.state == .mailboxPassword { model.cancelSignIn() }
|
|
}
|
|
)) {
|
|
SignInSheet { signingIn = false }
|
|
}
|
|
.fullScreenCover(isPresented: $browsing) {
|
|
DriveBrowser()
|
|
}
|
|
.sheet(item: $repairing) { share in
|
|
RepairShareSheet(share: share) { url, password in
|
|
model.repairShare(id: share.id, url: url, password: password)
|
|
}
|
|
}
|
|
// Asked, because it cannot be undone: the link's secret and its
|
|
// offline files go. Watch positions stay (`Catalog::remove_share`).
|
|
.alert(
|
|
removing.map { "Remove \($0.name)?" } ?? "",
|
|
isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
|
|
presenting: removing
|
|
) { share in
|
|
Button("Remove", role: .destructive) { model.removeShare(share.id) }
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: { _ in
|
|
Text("Its titles leave the library and its offline episodes are deleted. Watch progress is kept, so adding the link again picks up where you left off.")
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ShareRecord: Identifiable {}
|
|
|
|
/// One share: its name, how it is unlocked, and its actions behind a menu.
|
|
private struct ShareRow: View {
|
|
let share: ShareRecord
|
|
let onRefresh: () -> Void
|
|
let onRepair: () -> Void
|
|
let onRemove: () -> Void
|
|
@Environment(\.scheme) private var scheme
|
|
|
|
var body: some View {
|
|
HStack(spacing: 16) {
|
|
Image(systemName: "folder.badge.person.crop").font(.system(size: 20)).foregroundStyle(scheme.primary).frame(width: 24)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(share.name).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface)
|
|
Text(share.fromAccount ? "Folder of your Drive"
|
|
: share.hasCustomPassword ? "Link and custom password, stored securely" : "Public link")
|
|
.textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
|
}
|
|
Spacer(minLength: 0)
|
|
Menu {
|
|
// One share, not the library: a link that has just had files
|
|
// added should not cost a walk of every other one.
|
|
Button(action: onRefresh) { Label("Refresh", systemImage: "arrow.clockwise") }
|
|
// Not a remove: a share whose secret has become unreadable
|
|
// cannot be removed either.
|
|
if !share.fromAccount {
|
|
Button(action: onRepair) { Label("Re-enter link", systemImage: "key") }
|
|
}
|
|
Button(role: .destructive, action: onRemove) { Label("Remove", systemImage: "trash") }
|
|
} label: {
|
|
Image(systemName: "ellipsis").font(.system(size: 20)).frame(width: 48, height: 48)
|
|
.foregroundStyle(scheme.onSurfaceVariant)
|
|
}
|
|
.accessibilityLabel("Actions for \(share.name)")
|
|
}
|
|
.padding(.leading, 16)
|
|
.padding(.trailing, 4)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
|
|
/// Re-enter the link for a share the app can no longer decrypt its secret for.
|
|
///
|
|
/// It has to be the *same* link — a different token is a different share.
|
|
/// Rust enforces that; this only says so.
|
|
private struct RepairShareSheet: View {
|
|
let share: ShareRecord
|
|
let onRepair: (String, String?) -> Void
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var url = ""
|
|
@State private var password = ""
|
|
|
|
var body: some View {
|
|
DialogSheet("Re-enter link for \(share.name)") {
|
|
Text("The stored credentials for this share cannot be read — usually after a device restore or a Keychain reset. Entering the same link again restores access without losing the library or anything downloaded.")
|
|
.textStyle(.bodySmall)
|
|
Field(label: "Public share URL", text: $url, secret: true)
|
|
Field(label: "Custom password (optional)", text: $password, secret: true)
|
|
} buttons: {
|
|
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
|
|
Button("Restore") {
|
|
onRepair(url.trimmingCharacters(in: .whitespaces), password)
|
|
dismiss()
|
|
}
|
|
.buttonStyle(.accent)
|
|
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
}
|
|
.secureContent()
|
|
}
|
|
}
|
|
|
|
/// Add a link. The form stays up while the link is opened, and with the
|
|
/// reason under it when the link is refused, so fixing a typo is a retry
|
|
/// rather than starting over.
|
|
private struct AddShareSheet: View {
|
|
/// Resolves to why the link was refused, or nil once it is added.
|
|
let onAdd: (String, String, String?) async -> String?
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.scheme) private var scheme
|
|
@State private var name = ""
|
|
@State private var url: String
|
|
@State private var password = ""
|
|
@State private var adding = false
|
|
@State private var refusal: String?
|
|
|
|
init(initialUrl: String, onAdd: @escaping (String, String, String?) async -> String?) {
|
|
self.onAdd = onAdd
|
|
_url = State(initialValue: initialUrl)
|
|
}
|
|
|
|
private var ready: Bool {
|
|
!name.trimmingCharacters(in: .whitespaces).isEmpty && !url.trimmingCharacters(in: .whitespaces).isEmpty
|
|
}
|
|
|
|
var body: some View {
|
|
DialogSheet("Add Proton Drive share") {
|
|
Field(label: "Library name", text: $name)
|
|
// The URL fragment is a secret, so the field is a secure one: no
|
|
// keyboard learning, no suggestions, and paste still works.
|
|
Field(label: "Public share URL", text: $url, secret: true)
|
|
// There is no share sheet into a sideloaded app, so a link copied
|
|
// elsewhere comes in from the clipboard.
|
|
if url.isEmpty {
|
|
Button {
|
|
if let text = UIPasteboard.general.string, let link = shareLink(in: text) { url = link }
|
|
} label: {
|
|
Label("Paste link", systemImage: "doc.on.clipboard")
|
|
}
|
|
.buttonStyle(.quiet)
|
|
}
|
|
Field(label: "Custom password (optional)", text: $password, secret: true)
|
|
if adding {
|
|
HStack(spacing: 12) {
|
|
ProgressView().controlSize(.small).tint(scheme.primary)
|
|
Text("Opening the link…")
|
|
}
|
|
} else if let refusal {
|
|
Text(refusal).foregroundStyle(scheme.error)
|
|
}
|
|
} buttons: {
|
|
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
|
|
Button(adding ? "Adding…" : "Add") {
|
|
adding = true
|
|
refusal = nil
|
|
Task {
|
|
refusal = await onAdd(name.trimmingCharacters(in: .whitespaces), url.trimmingCharacters(in: .whitespaces), password)
|
|
adding = false
|
|
if refusal == nil { dismiss() }
|
|
}
|
|
}
|
|
.buttonStyle(.accent)
|
|
.disabled(!ready || adding)
|
|
}
|
|
.interactiveDismissDisabled(adding)
|
|
.secureContent()
|
|
}
|
|
}
|