cards-native/Sources/Features/Media/AudioPlayerButton.swift
Till JS 80eb3708b4 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>
2026-05-13 00:35:36 +02:00

73 lines
2.3 KiB
Swift

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
}
}
}