import SwiftUI import FlixyDesign import MediaKit /// Das Aktionsmenü auf einer Detailseite. /// /// Alles, was den Server verändert, liegt hier zusammen — statt verstreut als /// einzelne Knöpfe. Das hält die Kopfzeile frei für das, was man ständig /// braucht: Abspielen und Herunterladen. /// /// Welche Einträge erscheinen, entscheidet der Server: Plex kennt Sterne, aber /// keine Favoriten, Jellyfin Favoriten, aber keine Sterne. Ein Eintrag, der /// nichts bewirkt, ist schlimmer als keiner. struct ItemActionsMenu: View { let item: MediaItem /// Wird gerufen, wenn sich am Server etwas geändert hat und die /// Detailseite neu geladen werden sollte. let onChange: () async -> Void @Environment(FlixySession.self) private var session @State private var isWorking = false @State private var errorMessage: String? private var capabilities: Set { session.backend?.capabilities ?? [] } var body: some View { Menu { 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" ) } 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") } } } label: { if isWorking { ProgressView().controlSize(.small) } else { Image(systemName: "ellipsis.circle") } } .disabled(isWorking) .alert( Text("error.title"), isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } }) ) { Button("action.close") { errorMessage = nil } } message: { if let errorMessage { Text(errorMessage) } } } /// Führt eine Serveraktion aus und lädt danach neu. /// /// Bewusst ohne vorweggenommene Anzeige: den Haken sofort zu setzen und /// bei einem Fehler wieder zurückzunehmen sieht aus wie ein Wackler. Die /// Aktionen dauern Millisekunden. private func run(_ action: @escaping (any MediaBackend) async throws -> Void) { guard let backend = session.backend else { return } isWorking = true Task { defer { isWorking = false } do { try await action(backend) await onChange() } catch { errorMessage = ErrorText.describe(error) } } } }