v0.5.0 — Phase β-4 Media + Advanced Card-Types

Alle 7 Card-Types werden gerendert und können erstellt werden.
image-occlusion mit Touch-Drag-Mask-Editor (kein PencilKit — Server-
Schema erlaubt nur Rechtecke), audio-front mit AVAudioPlayer und
File-Picker.

- MediaUploadResponse-DTO, MaskRegion-Codable mit 0..1-Coordinates
- MaskRegions.parse/encode (1:1-Port aus cards-domain, Sortierung
  nach ID lexikographisch)
- CardFieldsBuilder.imageOcclusion mit stringified-JSON-mask_regions
  + audioFront
- CardsAPI.uploadMedia (Multipart, 25 MiB) + fetchMedia (streamed)
- MediaCache actor mit LRU 200 MB (contentModificationDate-Eviction)
- mediaCache Environment-Key
- RemoteImage + AudioPlayerButton SwiftUI-Views
- CardRenderer: imageOcclusion (Mask-Overlay über RemoteImage) +
  audioFront (AudioPlayerButton + back-Text auf Flip)
- MaskEditorView: Touch-Drag-Rechteck, Label-Edit, Delete
- CardEditorView erweitert: PhotosPicker für Image, fileImporter
  für Audio, Magic-Byte-MIME-Detection
- 6 neue Tests für MaskRegions (30 Total grün)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-13 00:35:36 +02:00
parent cf1160b270
commit 80eb3708b4
12 changed files with 923 additions and 44 deletions

View file

@ -6,6 +6,7 @@ import SwiftUI
struct CardsNativeApp: App {
let container: ModelContainer
@State private var auth: AuthClient
private let mediaCache: MediaCache
init() {
do {
@ -16,6 +17,7 @@ struct CardsNativeApp: App {
let auth = AuthClient(config: AppConfig.manaAppConfig)
auth.bootstrap()
_auth = State(initialValue: auth)
mediaCache = MediaCache(api: CardsAPI(auth: auth))
Log.app.info("Cards starting — auth status: \(String(describing: auth.status), privacy: .public)")
}
@ -23,6 +25,7 @@ struct CardsNativeApp: App {
WindowGroup {
RootView()
.environment(auth)
.environment(\.mediaCache, mediaCache)
.tint(CardsTheme.primary)
}
.modelContainer(container)

View file

@ -54,6 +54,44 @@ actor CardsAPI {
return try decoder.decode(DueReviewsResponse.self, from: data).total
}
// MARK: - Media
/// `POST /api/v1/media/upload` Multipart-Upload. Max 25 MiB.
/// Erlaubte MIMEs: image/*, audio/*, video/*.
func uploadMedia(data: Data, filename: String, mimeType: String) async throws -> MediaUploadResponse {
let boundary = "cards-native-\(UUID().uuidString)"
let body = makeMultipartBody(
file: data,
filename: filename,
mimeType: mimeType,
boundary: boundary
)
let (response, http) = try await transport.request(
path: "/api/v1/media/upload",
method: "POST",
body: body,
contentType: "multipart/form-data; boundary=\(boundary)"
)
try ensureOK(http, data: response)
return try decoder.decode(MediaUploadResponse.self, from: response)
}
/// `GET /api/v1/media/:id` streamt das Media-File. Antwortet mit
/// raw bytes (kein JSON), Caller schreibt das auf Disk via MediaCache.
func fetchMedia(id: String) async throws -> Data {
let (data, http) = try await transport.request(path: "/api/v1/media/\(id)")
guard (200 ..< 300).contains(http.statusCode) else {
throw AuthError.serverError(status: http.statusCode, message: "media fetch failed")
}
return data
}
/// `DELETE /api/v1/media/:id` Soft-Forget. (Endpoint heute nicht
/// implementiert serverseitig; Stub bleibt für späteren Use.)
func deleteMedia(id _: String) async throws {
throw AuthError.serverError(status: 501, message: "media delete not implemented on server")
}
// MARK: - Deck-Mutations
/// `POST /api/v1/decks` Deck anlegen.
@ -172,6 +210,28 @@ actor CardsAPI {
return try encoder.encode(value)
}
// MARK: - Multipart
private func makeMultipartBody(
file: Data,
filename: String,
mimeType: String,
boundary: String
) -> Data {
var body = Data()
let lineBreak = "\r\n"
let header = """
--\(boundary)\(lineBreak)\
Content-Disposition: form-data; name="file"; filename="\(filename)"\(lineBreak)\
Content-Type: \(mimeType)\(lineBreak)\(lineBreak)
"""
body.append(header.data(using: .utf8) ?? Data())
body.append(file)
body.append(lineBreak.data(using: .utf8) ?? Data())
body.append("--\(boundary)--\(lineBreak)".data(using: .utf8) ?? Data())
return body
}
// MARK: - Helpers
private func ensureOK(_ http: HTTPURLResponse, data: Data) throws {

View file

@ -0,0 +1,103 @@
import Foundation
/// Response von `POST /api/v1/media/upload`.
struct MediaUploadResponse: Decodable, Sendable {
let id: String
let url: String
let mimeType: String
let kind: MediaKind
let sizeBytes: Int
let originalFilename: String?
enum CodingKeys: String, CodingKey {
case id
case url
case mimeType = "mime_type"
case kind
case sizeBytes = "size_bytes"
case originalFilename = "original_filename"
}
}
enum MediaKind: String, Codable, Sendable {
case image
case audio
case video
case other
}
/// Image-Occlusion-Mask-Region.
/// `mask_regions`-Feld ist ein JSON-Array-**String** in `fields`,
/// nicht ein Object Server-Schema-Constraint (`fields: Record<string,string>`).
struct MaskRegion: Codable, Hashable, Sendable, Identifiable {
let id: String
let x: Double // 0..1 relativ
let y: Double
let w: Double
let h: Double
let label: String?
init(id: String, x: Double, y: Double, w: Double, h: Double, label: String? = nil) {
self.id = id
self.x = x
self.y = y
self.w = w
self.h = h
self.label = label
}
}
/// Helpers zum Parsen/Serialisieren von `mask_regions` als JSON-String.
enum MaskRegions {
/// 1:1-Port aus `cards-domain/image-occlusion.ts:parseMaskRegions`.
/// Bei Parse- oder Schema-Fehler: leere Liste. Sortiert nach ID
/// (lexikographisch, gleich wie Server-Sortierung).
static func parse(_ json: String) -> [MaskRegion] {
guard let data = json.data(using: .utf8) else { return [] }
guard let regions = try? JSONDecoder().decode([MaskRegion].self, from: data) else { return [] }
return regions.sorted { $0.id < $1.id }
}
/// Sub-Index Region (Sortier-Reihenfolge).
static func region(for json: String, subIndex: Int) -> MaskRegion? {
let all = parse(json)
return all.indices.contains(subIndex) ? all[subIndex] : nil
}
/// Anzahl Regionen Anzahl Sub-Index-Reviews.
static func count(_ json: String) -> Int {
parse(json).count
}
/// Serialisiert eine Liste zu einem JSON-Array-String fürs `fields`-Feld.
static func encode(_ regions: [MaskRegion]) -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
guard let data = try? encoder.encode(regions) else { return "[]" }
return String(decoding: data, as: UTF8.self)
}
}
extension CardFieldsBuilder {
/// `image-occlusion`-Fields: `image_ref` (media_id) +
/// `mask_regions` (stringified JSON-Array) + optional `note`.
static func imageOcclusion(
imageRef: String,
regions: [MaskRegion],
note: String? = nil
) -> [String: String] {
var fields: [String: String] = [
"image_ref": imageRef,
"mask_regions": MaskRegions.encode(regions),
]
if let note, !note.isEmpty {
fields["note"] = note
}
return fields
}
/// `audio-front`-Fields: `audio_ref` (media_id) + `back` (Antwort-Text).
static func audioFront(audioRef: String, back: String) -> [String: String] {
["audio_ref": audioRef, "back": back]
}
}

View file

@ -0,0 +1,73 @@
import Foundation
import ManaCore
/// Persistenter Disk-Cache für Cards-Media-Files. Bilder/Audio werden
/// einmal vom Server geladen und danach lokal serviert der Server
/// setzt `Cache-Control: private, immutable`, das honorieren wir hier.
///
/// LRU-Verdrängung mit Soft-Limit (Default 200 MB).
actor MediaCache {
private let root: URL
private let api: CardsAPI
private let maxBytes: Int
init(api: CardsAPI, maxBytes: Int = 200 * 1024 * 1024) {
self.api = api
self.maxBytes = maxBytes
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
root = caches.appendingPathComponent("cards-media", isDirectory: true)
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
/// Liefert die lokale URL eines Media-Files. Lädt vom Server, falls
/// nicht im Cache. Wirft `AuthError`, wenn der Download scheitert.
func localURL(for mediaId: String) async throws -> URL {
let target = root.appendingPathComponent(mediaId)
if FileManager.default.fileExists(atPath: target.path) {
try? FileManager.default.setAttributes([.modificationDate: Date.now], ofItemAtPath: target.path)
return target
}
let data = try await api.fetchMedia(id: mediaId)
try data.write(to: target, options: .atomic)
try? await pruneIfNeeded()
return target
}
/// Direktes Lesen für UI-Komponenten, die `Data` brauchen (z.B. AVAudioPlayer).
func data(for mediaId: String) async throws -> Data {
try Data(contentsOf: try await localURL(for: mediaId))
}
/// LRU-Eviction: bei Überschreitung des Limits ältesten zuerst löschen.
private func pruneIfNeeded() async throws {
let resourceKeys: Set<URLResourceKey> = [.fileSizeKey, .contentModificationDateKey]
guard let items = try? FileManager.default.contentsOfDirectory(
at: root,
includingPropertiesForKeys: Array(resourceKeys)
) else { return }
let withMeta = items.compactMap { url -> (url: URL, size: Int, date: Date)? in
let values = try? url.resourceValues(forKeys: resourceKeys)
guard let size = values?.fileSize, let date = values?.contentModificationDate else { return nil }
return (url, size, date)
}
let totalBytes = withMeta.reduce(0) { $0 + $1.size }
guard totalBytes > maxBytes else { return }
let sortedOldestFirst = withMeta.sorted { $0.date < $1.date }
var remaining = totalBytes
for item in sortedOldestFirst {
if remaining <= maxBytes { break }
try? FileManager.default.removeItem(at: item.url)
remaining -= item.size
Log.sync.info("MediaCache evicted \(item.url.lastPathComponent, privacy: .public) (\(item.size, privacy: .public)B)")
}
}
/// Wipe für Sign-out o.ä.
func clear() {
try? FileManager.default.removeItem(at: root)
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
}

View file

@ -0,0 +1,15 @@
import SwiftUI
/// Environment-Key, der den shared `MediaCache` durch die View-Hierarchie
/// reicht. App-Entrypoint setzt den Wert; Views lesen via
/// `@Environment(\.mediaCache)`.
private struct MediaCacheKey: EnvironmentKey {
static let defaultValue: MediaCache? = nil
}
extension EnvironmentValues {
var mediaCache: MediaCache? {
get { self[MediaCacheKey.self] }
set { self[MediaCacheKey.self] = newValue }
}
}

View file

@ -1,9 +1,13 @@
import ManaCore
import PhotosUI
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
/// Card-Create-View. Type-Picker oben, type-spezifische Felder unten.
/// Deckt basic, basic-reverse, cloze, typing, multiple-choice ab
/// image-occlusion und audio-front kommen in β-4 (brauchen Media).
/// Deckt alle 7 Card-Types ab.
struct CardEditorView: View {
let deckId: String
let onCreated: (Card) -> Void
@ -20,9 +24,21 @@ struct CardEditorView: View {
@State private var isSubmitting = false
@State private var errorMessage: String?
/// β-3-Card-Types (β-4 ergänzt image-occlusion + audio-front).
// Image-Occlusion-State
@State private var imagePickerItem: PhotosPickerItem?
@State private var occlusionImage: PlatformImage?
@State private var occlusionImageData: Data?
@State private var occlusionMimeType: String = "image/jpeg"
@State private var occlusionRegions: [MaskRegion] = []
@State private var occlusionNote: String = ""
// Audio-Front-State
@State private var audioFileURL: URL?
@State private var showAudioPicker = false
private static let supportedTypes: [CardType] = [
.basic, .basicReverse, .cloze, .typing, .multipleChoice,
.imageOcclusion, .audioFront,
]
var body: some View {
@ -129,11 +145,123 @@ struct CardEditorView: View {
.foregroundStyle(CardsTheme.mutedForeground)
}
case .imageOcclusion, .audioFront:
Section {
Label("Dieser Typ kommt in Phase β-4 (Media)", systemImage: "clock")
.foregroundStyle(CardsTheme.mutedForeground)
case .imageOcclusion:
imageOcclusionFields
case .audioFront:
audioFrontFields
}
}
@ViewBuilder
private var imageOcclusionFields: some View {
Section("Bild") {
PhotosPicker(selection: $imagePickerItem, matching: .images) {
if occlusionImage == nil {
Label("Bild auswählen", systemImage: "photo")
} else {
Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath")
}
}
.onChange(of: imagePickerItem) { _, newItem in
Task { await loadPickedImage(newItem) }
}
}
if let image = occlusionImage {
Section("Masken") {
MaskEditorView(image: image, regions: $occlusionRegions)
}
}
Section("Hinweis (optional)") {
TextField("z.B. Kurz-Erklärung", text: $occlusionNote, axis: .vertical)
.lineLimit(1 ... 3)
}
Section {
if occlusionImage == nil {
Label("Erst Bild wählen", systemImage: "info.circle")
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
} else if occlusionRegions.isEmpty {
Label("Mindestens eine Maske nötig", systemImage: "exclamationmark.circle")
.font(.caption)
.foregroundStyle(CardsTheme.warning)
} else {
Label("\(occlusionRegions.count) Masken → \(occlusionRegions.count) Reviews",
systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(CardsTheme.success)
}
}
}
@ViewBuilder
private var audioFrontFields: some View {
Section("Audio-Datei") {
Button {
showAudioPicker = true
} label: {
if let audioFileURL {
Label(audioFileURL.lastPathComponent, systemImage: "waveform")
} else {
Label("Audio auswählen", systemImage: "waveform.badge.plus")
}
}
.fileImporter(
isPresented: $showAudioPicker,
allowedContentTypes: [.audio, .mp3, .wav, .mpeg4Audio],
allowsMultipleSelection: false
) { result in
if case let .success(urls) = result, let first = urls.first {
audioFileURL = first
}
}
}
Section("Antwort") {
TextField("Was zu hören ist", text: $back, axis: .vertical)
.lineLimit(2 ... 4)
}
}
private func loadPickedImage(_ item: PhotosPickerItem?) async {
guard let item else { return }
do {
guard let data = try await item.loadTransferable(type: Data.self) else { return }
occlusionImageData = data
occlusionMimeType = inferMimeType(from: data)
if let img = PlatformImage(data: data) {
occlusionImage = img
occlusionRegions = [] // neue Bildauswahl resetet Masken
}
} catch {
errorMessage = "Bild konnte nicht geladen werden: \(error.localizedDescription)"
}
}
private func inferMimeType(from data: Data) -> String {
// Schneller Magic-Byte-Check für die häufigsten Formate
guard data.count > 4 else { return "image/jpeg" }
let bytes = Array(data.prefix(8))
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" }
if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" }
if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" }
// WebP: starts with "RIFF" + 4 bytes size + "WEBP"
if bytes.count >= 8,
bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] {
return "image/webp"
}
return "image/jpeg"
}
private func audioMimeType(for url: URL) -> String {
switch url.pathExtension.lowercased() {
case "mp3": "audio/mpeg"
case "wav": "audio/wav"
case "m4a", "mp4": "audio/mp4"
case "ogg", "oga": "audio/ogg"
default: "audio/mpeg"
}
}
@ -147,8 +275,10 @@ struct CardEditorView: View {
!front.trimmed.isEmpty && !typingAnswer.trimmed.isEmpty
case .multipleChoice:
!front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty
case .imageOcclusion, .audioFront:
false
case .imageOcclusion:
occlusionImageData != nil && !occlusionRegions.isEmpty
case .audioFront:
audioFileURL != nil && !back.trimmed.isEmpty
}
}
@ -158,22 +288,46 @@ struct CardEditorView: View {
defer { isSubmitting = false }
let api = CardsAPI(auth: auth)
let fields: [String: String]
switch type {
case .basic, .basicReverse:
fields = CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed)
case .cloze:
fields = CardFieldsBuilder.cloze(text: clozeText.trimmed)
case .typing:
fields = CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed)
case .multipleChoice:
fields = CardFieldsBuilder.multipleChoice(front: front.trimmed, answer: multipleChoiceAnswer.trimmed)
case .imageOcclusion, .audioFront:
return // disabled
}
let body = CardCreateBody(deckId: deckId, type: type, fields: fields, mediaRefs: nil)
do {
let fields: [String: String]
var mediaRefs: [String]? = nil
switch type {
case .basic, .basicReverse:
fields = CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed)
case .cloze:
fields = CardFieldsBuilder.cloze(text: clozeText.trimmed)
case .typing:
fields = CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed)
case .multipleChoice:
fields = CardFieldsBuilder.multipleChoice(front: front.trimmed, answer: multipleChoiceAnswer.trimmed)
case .imageOcclusion:
guard let data = occlusionImageData else { return }
let media = try await api.uploadMedia(
data: data,
filename: "occlusion.\(occlusionMimeType.contains("png") ? "png" : "jpg")",
mimeType: occlusionMimeType
)
fields = CardFieldsBuilder.imageOcclusion(
imageRef: media.id,
regions: occlusionRegions,
note: occlusionNote.trimmed.isEmpty ? nil : occlusionNote.trimmed
)
mediaRefs = [media.id]
case .audioFront:
guard let url = audioFileURL else { return }
let didStart = url.startAccessingSecurityScopedResource()
defer { if didStart { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
let media = try await api.uploadMedia(
data: data,
filename: url.lastPathComponent,
mimeType: audioMimeType(for: url)
)
fields = CardFieldsBuilder.audioFront(audioRef: media.id, back: back.trimmed)
mediaRefs = [media.id]
}
let body = CardCreateBody(deckId: deckId, type: type, fields: fields, mediaRefs: mediaRefs)
let card = try await api.createCard(body)
onCreated(card)
dismiss()

View file

@ -0,0 +1,147 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
/// Mask-Editor: Bild anzeigen, mit Drag-Gesten Rechtecke zeichnen, jede
/// Region mit Label versehen. Coordinaten 0..1 relativ zur Bild-Größe.
///
/// Output binding ist `regions`. Caller serialisiert via `MaskRegions.encode()`.
struct MaskEditorView: View {
let image: PlatformImage
@Binding var regions: [MaskRegion]
@State private var dragStart: CGPoint?
@State private var dragEnd: CGPoint?
@State private var nextIdCounter: Int = 0
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Tippe und ziehe auf das Bild, um eine Maske zu erstellen.")
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
imageCanvas
.aspectRatio(image.size.width / max(image.size.height, 1), contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 8))
if regions.isEmpty {
Text("Noch keine Maske")
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
} else {
ForEach(regions) { region in
maskRow(region: region)
}
}
}
}
@ViewBuilder
private var imageCanvas: some View {
GeometryReader { geo in
ZStack(alignment: .topLeading) {
#if canImport(UIKit)
Image(uiImage: image).resizable().aspectRatio(contentMode: .fit)
#else
Image(nsImage: image).resizable().aspectRatio(contentMode: .fit)
#endif
ForEach(regions) { region in
overlayRect(for: region, in: geo.size)
}
if let dragStart, let dragEnd {
let rect = normalizedRect(from: dragStart, to: dragEnd)
Rectangle()
.stroke(CardsTheme.warning, lineWidth: 2)
.background(Rectangle().fill(CardsTheme.warning.opacity(0.2)))
.frame(width: rect.width, height: rect.height)
.offset(x: rect.minX, y: rect.minY)
}
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 4)
.onChanged { value in
if dragStart == nil { dragStart = value.startLocation }
dragEnd = value.location
}
.onEnded { value in
commitDrag(start: value.startLocation, end: value.location, in: geo.size)
}
)
}
}
private func overlayRect(for region: MaskRegion, in size: CGSize) -> some View {
Rectangle()
.fill(CardsTheme.primary.opacity(0.6))
.frame(width: region.w * size.width, height: region.h * size.height)
.offset(x: region.x * size.width, y: region.y * size.height)
.overlay(
Text(region.label?.isEmpty == false ? region.label! : region.id)
.font(.caption2.weight(.bold))
.foregroundStyle(CardsTheme.primaryForeground)
.padding(2)
.offset(x: region.x * size.width + 2, y: region.y * size.height + 2),
alignment: .topLeading
)
}
private func maskRow(region: MaskRegion) -> some View {
HStack(spacing: 8) {
Image(systemName: "square.dashed")
.foregroundStyle(CardsTheme.primary)
TextField("Label (optional)", text: Binding(
get: { region.label ?? "" },
set: { newValue in updateLabel(for: region.id, to: newValue) }
))
.textFieldStyle(.roundedBorder)
Button(role: .destructive) {
regions.removeAll { $0.id == region.id }
} label: {
Image(systemName: "trash")
.foregroundStyle(CardsTheme.error)
}
.buttonStyle(.plain)
}
}
private func updateLabel(for id: String, to value: String) {
guard let idx = regions.firstIndex(where: { $0.id == id }) else { return }
let old = regions[idx]
regions[idx] = MaskRegion(id: old.id, x: old.x, y: old.y, w: old.w, h: old.h, label: value)
}
private func normalizedRect(from start: CGPoint, to end: CGPoint) -> CGRect {
let x = min(start.x, end.x)
let y = min(start.y, end.y)
let w = abs(end.x - start.x)
let h = abs(end.y - start.y)
return CGRect(x: x, y: y, width: w, height: h)
}
private func commitDrag(start: CGPoint, end: CGPoint, in size: CGSize) {
defer {
dragStart = nil
dragEnd = nil
}
let rect = normalizedRect(from: start, to: end)
// Mindestgröße 1% der Bildkante Tap-Klicks ignorieren
guard rect.width > size.width * 0.01, rect.height > size.height * 0.01 else { return }
nextIdCounter += 1
let id = String(format: "m%03d", nextIdCounter)
let normalized = MaskRegion(
id: id,
x: rect.minX / size.width,
y: rect.minY / size.height,
w: rect.width / size.width,
h: rect.height / size.height,
label: nil
)
regions.append(normalized)
}
}

View file

@ -0,0 +1,73 @@
import AVFoundation
import SwiftUI
/// Audio-Wiedergabe-Button für `audio-front`-Karten. Lädt das File einmal
/// per MediaCache, spielt mit AVAudioPlayer ab.
struct AudioPlayerButton: View {
let mediaId: String
@Environment(\.mediaCache) private var mediaCache
@State private var player: AVAudioPlayer?
@State private var isPlaying = false
@State private var failed = false
var body: some View {
Button {
togglePlayback()
} label: {
HStack(spacing: 12) {
Image(systemName: failed
? "speaker.slash.fill"
: (isPlaying ? "pause.circle.fill" : "play.circle.fill"))
.font(.system(size: 48))
.foregroundStyle(failed ? CardsTheme.error : CardsTheme.primary)
Text(failed ? "Audio nicht verfügbar" : (isPlaying ? "Wiedergabe läuft" : "Anhören"))
.font(.headline)
.foregroundStyle(CardsTheme.foreground)
}
.frame(maxWidth: .infinity)
.padding(20)
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(CardsTheme.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
.disabled(failed)
.task(id: mediaId) {
await load()
}
.onDisappear {
player?.stop()
isPlaying = false
}
}
private func load() async {
guard let cache = mediaCache else { failed = true; return }
do {
let data = try await cache.data(for: mediaId)
#if canImport(UIKit)
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
try AVAudioSession.sharedInstance().setActive(true)
#endif
player = try AVAudioPlayer(data: data)
player?.prepareToPlay()
} catch {
failed = true
}
}
private func togglePlayback() {
guard let player else { return }
if player.isPlaying {
player.pause()
isPlaying = false
} else {
player.currentTime = 0
player.play()
isPlaying = true
}
}
}

View file

@ -0,0 +1,70 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
/// Lädt ein authentifiziertes Image vom Cardecky-Media-Endpoint und
/// rendert es. Streamt erst beim ersten Mal, danach aus dem
/// MediaCache (LRU 200 MB).
struct RemoteImage: View {
let mediaId: String
let contentMode: ContentMode
@Environment(\.mediaCache) private var mediaCache
@State private var image: PlatformImage?
@State private var failed = false
init(mediaId: String, contentMode: ContentMode = .fit) {
self.mediaId = mediaId
self.contentMode = contentMode
}
var body: some View {
Group {
if let image {
imageView(image)
} else if failed {
ContentUnavailableView("Bild konnte nicht geladen werden", systemImage: "photo.badge.exclamationmark")
.foregroundStyle(CardsTheme.mutedForeground)
} else {
ProgressView()
.tint(CardsTheme.primary)
}
}
.task(id: mediaId) {
await load()
}
}
@ViewBuilder
private func imageView(_ image: PlatformImage) -> some View {
#if canImport(UIKit)
Image(uiImage: image).resizable().aspectRatio(contentMode: contentMode)
#elseif canImport(AppKit)
Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode)
#endif
}
private func load() async {
guard let cache = mediaCache else { failed = true; return }
do {
let data = try await cache.data(for: mediaId)
if let img = PlatformImage(data: data) {
image = img
} else {
failed = true
}
} catch {
failed = true
}
}
}
#if canImport(UIKit)
typealias PlatformImage = UIImage
#elseif canImport(AppKit)
typealias PlatformImage = NSImage
#endif

View file

@ -24,7 +24,11 @@ struct CardRenderer: View {
}
case .cloze:
clozeView
case .imageOcclusion, .audioFront, .typing, .multipleChoice:
case .imageOcclusion:
imageOcclusionView
case .audioFront:
audioFrontView
case .typing, .multipleChoice:
placeholderView
}
}
@ -61,6 +65,70 @@ struct CardRenderer: View {
}
}
@ViewBuilder
private var imageOcclusionView: some View {
let imageRef = card.fields["image_ref"] ?? ""
let maskJSON = card.fields["mask_regions"] ?? "[]"
let regions = MaskRegions.parse(maskJSON)
let activeRegion = regions.indices.contains(subIndex) ? regions[subIndex] : nil
VStack(spacing: 12) {
GeometryReader { geo in
ZStack(alignment: .topLeading) {
RemoteImage(mediaId: imageRef, contentMode: .fit)
.frame(width: geo.size.width, height: geo.size.height)
ForEach(regions) { region in
let isActive = region.id == activeRegion?.id
// Front: aktive Maske opak, andere transparent.
// Back: alle Masken transparent (Bild komplett sichtbar).
if !isFlipped, isActive {
Rectangle()
.fill(CardsTheme.primary.opacity(0.92))
.frame(
width: region.w * geo.size.width,
height: region.h * geo.size.height
)
.offset(x: region.x * geo.size.width, y: region.y * geo.size.height)
.overlay(
Text(region.label?.isEmpty == false ? region.label! : "?")
.font(.caption.weight(.bold))
.foregroundStyle(CardsTheme.primaryForeground)
.offset(x: region.x * geo.size.width, y: region.y * geo.size.height),
alignment: .topLeading
)
}
}
}
}
.aspectRatio(4 / 3, contentMode: .fit)
if isFlipped, let label = activeRegion?.label, !label.isEmpty {
Text(label)
.font(.title3.weight(.semibold))
.foregroundStyle(CardsTheme.primary)
}
if let note = card.fields["note"], !note.isEmpty {
Text(note)
.font(.caption)
.foregroundStyle(CardsTheme.mutedForeground)
}
}
}
@ViewBuilder
private var audioFrontView: some View {
let audioRef = card.fields["audio_ref"] ?? ""
VStack(spacing: 16) {
AudioPlayerButton(mediaId: audioRef)
if isFlipped {
Divider().background(CardsTheme.border)
text(card.fields["back"] ?? "")
.font(.title3)
.foregroundStyle(CardsTheme.foreground)
}
}
}
@ViewBuilder
private var placeholderView: some View {
VStack(spacing: 8) {