Drei Gründe, warum der Mac anders aussah, und keiner davon war Geschmack. **Die Schrift.** macOS rendert dieselben Textstile deutlich kleiner als iOS: `largeTitle` misst dort 26 Punkt statt 34, Fließtext 13 statt 17. Eine Oberfläche, die auf dem iPad stimmt, wirkt damit zusammengeschrumpft. Auf dem Mac stehen jetzt feste Größen — die von iOS. Das kostet dort Dynamic Type; vertretbar, weil macOS keine systemweite Textgröße kennt und man ein Fenster stattdessen größer ziehen kann. **Der Aufmacher wächst mit.** Das war der zweite, größere Teil: das Bild ist so breit wie das Fenster, die Schrift darauf war es nicht. Bei 1280 Punkt Breite wirkt ein 34-Punkt-Titel verloren, bei 834 auf dem iPad genau richtig. Titel, Schriftzug, Angaben und Abstände richten sich jetzt nach der Breite des Aufmachers — mit dem iPad als Bezug und einem Deckel, damit ein Fenster über die ganze Wand nicht in Plakatschrift endet. Die Beschreibung bekommt außerdem eine Zeilenbreite: ein Satz über 1280 Punkt ist unlesbar, das Auge verliert die Zeile. **Die Seitenleiste.** Sie richtet sich nach ihrem Inhalt, und bei Wörtern wie „Start" fällt sie auf eine Breite zusammen, in der schon der Kontoname darunter nicht mehr Platz hatte — daher das „S…". Die Beschriftungen haben jetzt eine Mindestbreite. **Die Formulare.** Auf dem Mac nahm SwiftUI den Spalten-Stil: Beschriftungen rechts ausgerichtet, keine abgesetzten Abschnitte. Jetzt derselbe gruppierte Stil wie auf iOS. Dazu: Scrollbalken sind weg. Der graue Streifen unter dem Aufmacher kam daher, dass in den Systemeinstellungen „Scrollbalken immer einblenden" steht — dagegen reicht `.hidden` nicht, `.never` schon. Und das Fenster öffnet mit 1280 × 820 statt in Notizzettelgröße, ohne graue Titelleiste. 263 Tests grün, iOS baut unverändert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
216 lines
7.8 KiB
Swift
216 lines
7.8 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import MediaKit
|
|
|
|
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
|
|
/// einzelne Folge.
|
|
struct PlayableDetailView: View {
|
|
let item: MediaItem
|
|
|
|
@Environment(FlixySession.self) private var session
|
|
@Environment(PlaybackCoordinator.self) private var playback
|
|
@Environment(\.flixyAccent) private var accent
|
|
|
|
@State private var selectedVersion = 0
|
|
@State private var showsTechnicalDetails = false
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: FlixySpacing.section) {
|
|
header
|
|
|
|
ParentLinks(item: item)
|
|
|
|
if item.hasMultipleVersions {
|
|
VersionPicker(media: item.versions, selection: $selectedVersion)
|
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
|
}
|
|
|
|
if let summary = item.summary, !summary.isEmpty {
|
|
Text(summary)
|
|
.font(.flixyBody)
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
|
}
|
|
|
|
if !item.genres.isEmpty {
|
|
TagStrip(tags: item.genres)
|
|
}
|
|
|
|
if !item.cast.isEmpty {
|
|
CastRow(cast: item.cast)
|
|
}
|
|
|
|
if let media = currentMedia, let part = media.files.first {
|
|
TechnicalDetails(
|
|
media: media,
|
|
part: part,
|
|
isExpanded: $showsTechnicalDetails
|
|
)
|
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
|
}
|
|
}
|
|
.padding(.bottom, FlixySpacing.section)
|
|
}
|
|
.scrollIndicators(.never)
|
|
}
|
|
|
|
private var currentMedia: MediaVersion? {
|
|
guard item.versions.indices.contains(selectedVersion) else { return item.versions.first }
|
|
return item.versions[selectedVersion]
|
|
}
|
|
|
|
private var header: some View {
|
|
HeroBanner(
|
|
title: item.rowTitle,
|
|
metadata: metadataLine,
|
|
summary: nil,
|
|
artworkURL: session.backend?.artworkURL(
|
|
path: item.art ?? item.seriesArt ?? item.thumb,
|
|
width: 900,
|
|
height: 1200
|
|
),
|
|
logoURL: session.backend?.rawImageURL(path: item.clearLogoPath)
|
|
) {
|
|
Button {
|
|
playback.play(item, mediaIndex: selectedVersion)
|
|
} label: {
|
|
Label(
|
|
item.watchProgress == nil ? "hero.play" : "hero.resume",
|
|
systemImage: "play.fill"
|
|
)
|
|
.font(.flixyCardTitle)
|
|
.padding(.horizontal, FlixySpacing.loose)
|
|
.padding(.vertical, FlixySpacing.snug)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(accent)
|
|
.disabled(currentMedia?.files.first == nil)
|
|
|
|
if let progress = item.watchProgress, progress > 0 {
|
|
Button {
|
|
playback.play(item, mediaIndex: selectedVersion, resuming: false)
|
|
} label: {
|
|
Label("detail.playFromStart", systemImage: "gobackward")
|
|
.labelStyle(.iconOnly)
|
|
.font(.flixyCardTitle)
|
|
.padding(FlixySpacing.snug)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(Color.flixyTextSecondary)
|
|
}
|
|
|
|
DownloadButton(item: item, mediaIndex: selectedVersion)
|
|
}
|
|
}
|
|
|
|
private var metadataLine: String? {
|
|
var pieces: [String] = []
|
|
if item.kind == .episode {
|
|
// Der Folgentitel steht jetzt groß darüber; ohne die Serie hier
|
|
// wüsste man auf dieser Seite nicht, wozu die Folge gehört.
|
|
if let series = item.seriesTitle { pieces.append(series) }
|
|
if let season = item.parentIndex, let episode = item.index {
|
|
pieces.append(String(localized: "episode.short \(season) \(episode)"))
|
|
}
|
|
}
|
|
if let year = item.year { pieces.append(String(year)) }
|
|
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
|
|
if let rating = item.contentRating { pieces.append(rating) }
|
|
if let resolution = currentMedia?.videoResolution { pieces.append(resolution.uppercased()) }
|
|
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
|
}
|
|
}
|
|
|
|
/// Auswahl zwischen mehreren Fassungen desselben Titels.
|
|
struct VersionPicker: View {
|
|
let media: [MediaVersion]
|
|
@Binding var selection: Int
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
|
|
Text("detail.versions")
|
|
.font(.flixyRowHeader)
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
|
|
Picker("detail.versions", selection: $selection) {
|
|
ForEach(Array(media.enumerated()), id: \.offset) { index, version in
|
|
Text(version.qualityLabel).tag(index)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Genres als Streifen.
|
|
struct TagStrip: View {
|
|
let tags: [String]
|
|
|
|
var body: some View {
|
|
ScrollView(.horizontal) {
|
|
HStack(spacing: FlixySpacing.snug) {
|
|
ForEach(tags, id: \.self) { tag in
|
|
Text(tag)
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
.padding(.horizontal, FlixySpacing.regular)
|
|
.padding(.vertical, FlixySpacing.tight)
|
|
.background(Color.flixyElevated, in: .capsule)
|
|
}
|
|
}
|
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
|
}
|
|
.scrollIndicators(.never)
|
|
}
|
|
}
|
|
|
|
/// Die Besetzung mit Porträts.
|
|
struct CastRow: View {
|
|
let cast: [MediaPerson]
|
|
@Environment(FlixySession.self) private var session
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
|
Text("detail.cast")
|
|
.font(.flixyRowHeader)
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
|
|
|
ScrollView(.horizontal) {
|
|
HStack(alignment: .top, spacing: FlixySpacing.regular) {
|
|
ForEach(cast.prefix(20)) { role in
|
|
VStack(spacing: FlixySpacing.snug) {
|
|
FlixyArtwork(
|
|
url: session.backend?.artworkURL(path: role.thumb, width: 160, height: 160),
|
|
aspectRatio: 1,
|
|
fallbackSymbol: "person"
|
|
)
|
|
.frame(width: 72, height: 72)
|
|
.clipShape(.circle)
|
|
|
|
Text(role.name)
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.center)
|
|
|
|
if let character = role.role {
|
|
Text(character)
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.frame(width: 90)
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
|
}
|
|
.scrollIndicators(.never)
|
|
}
|
|
}
|
|
}
|