Legt das Fundament für den Plex-Client an: - XcodeGen-Projekt nach dem Muster von Onyx (com.scarriffleservices, strict concurrency, Projektdatei nicht eingecheckt) - FlixyKit mit vier Modulen: PlexKit, FlixyDesign, FlixyPlayback, FlixyDownloads. macOS steht als Plattform mit drin, damit die Logik-Tests ohne Simulator laufen. - FlixyDesign: dunkle Farb-Tokens, Schriftskala, Poster- und Querformat-Kacheln, Aufmacher-Bild. Die Akzentfarbe fließt über die Umgebung und wird per WCAG-Rechnung lesbar gehalten. - PlexKit: Kopfzeilen-Identität, Transport-Protokoll, Fehlerarten und die Medienmodelle samt nachsichtigem Decoder. - FlixyPlayback: VideoPlayerEngine-Protokoll und PlaybackDecider — die eine Funktion, an der Phase 2 das Transcoding andockt. - Ausfahrbare Seitenleiste auf dem iPhone, Split-View auf dem iPad. - 45 Tests, alle grün. VLCKit 4.0.0-a24 ist vorab geprüft: baut über SPM unter Swift 6 mit strict concurrency. Die iOS-Slice ist mit 239 MB allerdings gewichtig, deshalb bekommt sie in Phase 1 ein eigenes Target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
90 lines
3.4 KiB
Swift
90 lines
3.4 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
|
|
/// Die ausfahrbare Seitenleiste auf dem iPhone.
|
|
///
|
|
/// Sie legt sich über den Inhalt und dunkelt ihn ab, statt ihn zur Seite zu
|
|
/// schieben — dadurch bleibt die Position im Inhalt erhalten und das Zumachen
|
|
/// fühlt sich an wie das Wegwischen eines Blattes.
|
|
struct DrawerContainer<Sidebar: View, Content: View>: View {
|
|
@Binding var isOpen: Bool
|
|
|
|
@ViewBuilder let sidebar: Sidebar
|
|
@ViewBuilder let content: Content
|
|
|
|
/// Breit genug für lange Mediatheksnamen, schmal genug, dass vom Inhalt
|
|
/// dahinter noch etwas zu sehen bleibt.
|
|
private let width: CGFloat = 280
|
|
/// Ab wie weit gezogen die Leiste offen bleibt, statt zurückzuschnappen.
|
|
private let commitThreshold: CGFloat = 0.4
|
|
|
|
@GestureState private var drag: CGFloat = 0
|
|
@Environment(\.layoutDirection) private var layoutDirection
|
|
|
|
/// 0 = zu, 1 = offen. Fasst Zustand und laufende Geste zusammen, damit
|
|
/// Abdunklung und Position immer zueinander passen.
|
|
private var openness: CGFloat {
|
|
let base = isOpen ? width : 0
|
|
return ((base + drag) / width).clamped()
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .leading) {
|
|
content
|
|
.accessibilityHidden(isOpen)
|
|
|
|
if openness > 0 {
|
|
Color.flixyScrim
|
|
.opacity(openness)
|
|
.ignoresSafeArea()
|
|
.onTapGesture { setOpen(false) }
|
|
// Ohne diese Beschriftung ist die Fläche für VoiceOver ein
|
|
// stummer Block, der den Inhalt verdeckt.
|
|
.accessibilityLabel(Text("sidebar.close"))
|
|
.accessibilityAddTraits(.isButton)
|
|
.accessibilityAction { setOpen(false) }
|
|
}
|
|
|
|
sidebar
|
|
.frame(width: width)
|
|
.frame(maxHeight: .infinity)
|
|
.background(Color.flixySurface)
|
|
.offset(x: (openness - 1) * width)
|
|
.accessibilityHidden(!isOpen)
|
|
}
|
|
.gesture(dragGesture)
|
|
.animation(.snappy(duration: 0.28), value: isOpen)
|
|
}
|
|
|
|
private var dragGesture: some Gesture {
|
|
DragGesture(minimumDistance: 12, coordinateSpace: .global)
|
|
.updating($drag) { value, state, _ in
|
|
// Bei geschlossener Leiste zählt nur ein Zug, der am linken
|
|
// Rand beginnt. Sonst würde jedes waagerechte Wischen in einer
|
|
// Reihe die Seitenleiste hervorziehen.
|
|
if !isOpen {
|
|
guard value.startLocation.x < 24 else { return }
|
|
state = max(0, value.translation.width)
|
|
} else {
|
|
state = min(0, value.translation.width)
|
|
}
|
|
}
|
|
.onEnded { value in
|
|
guard isOpen || value.startLocation.x < 24 else { return }
|
|
let projected = openness + (value.predictedEndTranslation.width - value.translation.width) / width
|
|
setOpen(projected > commitThreshold)
|
|
}
|
|
}
|
|
|
|
private func setOpen(_ open: Bool) {
|
|
guard open != isOpen else { return }
|
|
withAnimation(.snappy(duration: 0.28)) { isOpen = open }
|
|
}
|
|
}
|
|
|
|
extension CGFloat {
|
|
fileprivate func clamped(to range: ClosedRange<CGFloat> = 0...1) -> CGFloat {
|
|
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
|
}
|
|
}
|