Der Knopf war wirkungslos, und zwar aus zwei Gründen, die beide auf
dasselbe hinauslaufen: er war nie geprüft worden.
**Der Weg war falsch.** Die Methode las erst ein Benutzerdatenblatt über
`GET /Users/{id}/Items/{id}/UserData`, änderte es und schrieb es zurück.
Diese Stelle war die **einzige im ganzen Projekt**, die ein solches Blatt
eigens liest — überall sonst kommt es am Titel mit. Es gab also kein
zweites Beispiel, an dem der Weg je funktioniert hätte, und ein 404
beendete die Sache, bevor überhaupt etwas geschrieben wurde.
Jetzt wird das Blatt **gebaut**, nicht erfragt: „gesehen", „Favorit" und
die Zählung stehen am Titel, den der Aufrufer ohnehin in der Hand hat —
deshalb nimmt `removeFromContinueWatching` jetzt den Titel statt nur
seiner Kennung. Gelesen wird trotzdem noch, aber unverbindlich: gelingt
es, bleiben auch Felder erhalten, die diese App gar nicht kennt. Und
geschrieben wird über beide Wege — erst der gebündelte
`/UserItems/{id}/UserData`, bei einem 404 der ältere. Welcher gilt, hängt
an der Serverfassung.
**Und man konnte es nicht sehen.** Die Attrappe kannte die Route nicht;
ihr Auffangzweig antwortete auf jeden Schreibversuch mit Status 200, und
die Weiterschauen-Reihe war fest verdrahtet. Der Aufruf gelang also immer
und änderte nie etwas — genau das Bild eines kaputten Knopfes, nur aus
einem anderen Grund. Genau deshalb ist der Fehler durchgerutscht.
Sie führt jetzt echte Benutzerdatenblätter, beantwortet beide Wege und
liefert unter „Weiterschauen" nur noch, was eine gemerkte Stelle grösser
null hat. Unter `/__userdata` steht, was geschrieben wurde und wohin.
Gemessen im Simulator, nach einem Tipp auf den Eintrag:
/UserItems/m2/UserData
{"Played":false,"IsFavorite":false,"PlaybackPositionTicks":0,…}
Danach ist „Weiterschauen" leer und die Reihe verschwindet von der
Startseite.
**Zwei Dinge, die danach trotzdem auffallen können**, und beide sind
keine Fehler: Jellyfins „Als Nächstes" führt angefangene Folgen mit, eine
entfernte Folge kann dort wieder auftauchen. Und bei zusammengelegten
Reihen stehen auch Folgen ohne gemerkte Stelle — dort gibt es nichts zu
entfernen, und der Eintrag fehlt.
Plex bleibt unberührt: der Weg dort war in Ordnung und bekommt nur die
neue Form.
iOS und tvOS bauen, 469 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
104 lines
4.2 KiB
Swift
104 lines
4.2 KiB
Swift
import SwiftUI
|
|
import MediaKit
|
|
|
|
/// Die Einträge, die am Server etwas ändern — ohne Hülle darum.
|
|
///
|
|
/// **Gesehen, Favorit, Sterne gehören nicht nur auf die Detailseite.** Wer in
|
|
/// einer Reihe steht und weiss, dass er die Folge schon kennt, will sie dort
|
|
/// abhaken und nicht erst eine Seite tiefer gehen. Dieselben Einträge, zwei
|
|
/// Orte — und deshalb an einer Stelle beschrieben.
|
|
///
|
|
/// **Warum sie keinen eigenen Zustand tragen.** Der Inhalt eines
|
|
/// Kontextmenüs lebt nur, solange das Menü offen ist: SwiftUI baut ihn beim
|
|
/// Öffnen und wirft ihn beim Schliessen weg. Ein `@State` darin könnte also
|
|
/// nichts anzeigen, was **nach** dem Tippen passiert — weder Kreisel noch
|
|
/// Fehlermeldung, denn da ist das Menü längst zu. Ausgeführt wird deshalb vom
|
|
/// Aufrufer, der länger lebt als das Menü.
|
|
///
|
|
/// Aus demselben Grund kommen `capabilities` als Wert herein, statt aus der
|
|
/// Umgebung gelesen zu werden: was in ein Kontextmenü durchgereicht wird, ist
|
|
/// von SwiftUI-Fassung zu SwiftUI-Fassung verschieden — ein Wert, den der
|
|
/// Aufrufer übergibt, ist es nicht.
|
|
struct ItemActionEntries: View {
|
|
let item: MediaItem
|
|
let capabilities: Set<BackendCapability>
|
|
/// Führt die Serveraktion aus und räumt hinterher auf.
|
|
let run: (@escaping (any MediaBackend) async throws -> Void) -> Void
|
|
|
|
var body: some View {
|
|
Button {
|
|
run { backend in
|
|
if item.isWatched {
|
|
try await backend.markUnwatched(id: item.id)
|
|
} else {
|
|
try await backend.markWatched(id: item.id)
|
|
}
|
|
}
|
|
} label: {
|
|
Label(
|
|
item.isWatched ? "action.markUnwatched" : "action.markWatched",
|
|
systemImage: item.isWatched ? "eye.slash" : "checkmark.circle"
|
|
)
|
|
}
|
|
|
|
// **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle
|
|
// stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den
|
|
// Fehler bei sich.
|
|
if item.watchProgress != nil {
|
|
Button {
|
|
run { try await $0.removeFromContinueWatching(item) }
|
|
} label: {
|
|
Label("action.removeFromContinue", systemImage: "minus.circle")
|
|
}
|
|
}
|
|
|
|
if capabilities.contains(.favourites) {
|
|
Button {
|
|
run { try await $0.setFavourite(!item.isFavourite, id: item.id) }
|
|
} label: {
|
|
Label(
|
|
item.isFavourite ? "action.removeFavourite" : "action.addFavourite",
|
|
systemImage: item.isFavourite ? "heart.slash" : "heart"
|
|
)
|
|
}
|
|
}
|
|
|
|
if capabilities.contains(.ratings) {
|
|
Menu {
|
|
ForEach((1...5).reversed(), id: \.self) { stars in
|
|
Button {
|
|
// In Zehnteln: fünf Sterne sind eine 10.
|
|
run { try await $0.rate(id: item.id, rating: stars * 2) }
|
|
} label: {
|
|
Label(String(repeating: "★", count: stars), systemImage: "star")
|
|
}
|
|
}
|
|
if item.rating != nil {
|
|
Divider()
|
|
Button(role: .destructive) {
|
|
run { try await $0.rate(id: item.id, rating: 0) }
|
|
} label: {
|
|
Label("action.clearRating", systemImage: "star.slash")
|
|
}
|
|
}
|
|
} label: {
|
|
Label("action.rate", systemImage: "star")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
/// Was zu tun ist, wenn ein Titel am Server verändert wurde.
|
|
///
|
|
/// **Über die Umgebung, weil der Weg sonst lang ist.** Eine Kachel steckt
|
|
/// in einer Reihe, die in der Startseite steckt — und nur die Startseite
|
|
/// weiss, wie man ihre Reihen neu lädt. Diesen Rückweg durch jede
|
|
/// Zwischenebene zu reichen hiesse, drei Ansichten um ein Argument zu
|
|
/// erweitern, das sie nur weitergeben.
|
|
///
|
|
/// Die Voreinstellung tut nichts: wo niemand etwas hinterlegt, gibt es
|
|
/// auch nichts neu zu laden.
|
|
@Entry var flixyItemChanged: () async -> Void = {}
|
|
}
|