Die Ansichten sprechen nicht mehr mit Plex

Start, Suche, Mediathek, Detailseiten und Downloads arbeiten jetzt mit
`MediaItem` und dem `MediaBackend`-Vertrag. Damit sind sie fertig für
einen zweiten Server, ohne dass an ihnen noch etwas zu tun wäre.

Dabei sind ein paar Namen ehrlicher geworden: aus `grandparentTitle`
wurde `seriesTitle`, aus `viewedLeafCount` `playedEpisodeCount`. Und
`TagStrip` nahm bisher Plex-Marken mit Kennung und Rolle entgegen, um
davon nur den Namen anzuzeigen — Genres sind jetzt schlicht Zeichenketten.

`Arrangement` ist aus PlexKit heraus und generisch: dieselbe Rechnung
gilt für jede Liste, die jemand selbst zusammenstellt. Phase 3 baut die
konfigurierbare Seitenleiste darauf, statt sie ein zweites Mal — und ein
zweites Mal falsch — zu schreiben.

Die Fehlerübersetzung gibt es nur noch einmal. Es waren zwei, und sie
waren bereits auseinandergelaufen: derselbe Fehler bekam je nach
Bildschirm eine andere Erklärung.

Das Nachladen der Mediathek bleibt unangetastet: eine lange Liste, die
fünf Einträge vor dem Ende nachlädt. `MediaPage` heißt zwar Seite, ist
aber eine technische Portion — Blättern mit „weiter zu Seite 2" gehört
nicht in eine Mediathek, in der man stöbert. Der Kommentar dort sagt das
jetzt ausdrücklich, damit es niemand später „aufräumt".

Player, Live-TV und die Download-Engine sprechen noch unmittelbar mit
Plex. Sie folgen als Nächstes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 12:46:52 +02:00
co-authored by Claude Opus 5
parent effdcd4532
commit fe2d730c00
30 changed files with 432 additions and 318 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ struct FlixyApp: App {
// Beim Start einmal prüfen: während die App weg war,
// können Folgen gesehen oder gelöscht worden sein.
await DownloadRefiller(
server: session.server,
backend: session.backend,
coordinator: downloads
).refill()
}
+1 -1
View File
@@ -3900,7 +3900,7 @@
}
}
},
"sort.lastViewed": {
"sort.lastPlayed": {
"extractionState": "stale",
"localizations": {
"de": {
+12 -11
View File
@@ -1,5 +1,6 @@
import Foundation
import FlixyDownloads
import MediaKit
import PlexKit
/// Füllt den Vorrat an ungesehenen Folgen wieder auf.
@@ -9,7 +10,7 @@ import PlexKit
/// Server und ist deshalb prüfbar, das Auswählen braucht ihn zwangsläufig.
@MainActor
struct DownloadRefiller {
let server: PlexServer?
let backend: PlexBackend?
let coordinator: DownloadCoordinator
/// Lädt nach, was den Regeln nach fehlt.
@@ -19,26 +20,26 @@ struct DownloadRefiller {
/// wäre eine unangenehme Überraschung auf der Mobilfunkrechnung.
@discardableResult
func refill() async -> Int {
guard let server, coordinator.rules.keepUnwatchedEpisodes != nil else { return 0 }
guard let backend, coordinator.rules.keepUnwatchedEpisodes != nil else { return 0 }
let needed = coordinator.rules.seriesNeedingRefill(in: coordinator.items)
guard !needed.isEmpty else { return 0 }
var added = 0
let service = DownloadService(server: server, coordinator: coordinator)
let service = DownloadService(backend: backend, coordinator: coordinator)
for (seriesKey, count) in needed {
let candidates = await nextUnwatchedEpisodes(
inSeries: seriesKey,
count: count,
server: server
backend: backend
)
for episode in candidates {
// Die Kinderliste bringt keine Dateiangaben mit — ohne sie
// ließe sich weder das Speicherlimit prüfen noch ein
// Fortschritt anzeigen.
let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode
let detailed = (try? await backend.item(id: episode.id)) ?? episode
// Folgen ohne abrufbare Datei still überspringen: das Nachladen
// läuft im Hintergrund, da hilft keine Fehlermeldung.
guard let plan = try? service.plan(for: detailed) else { continue }
@@ -55,21 +56,21 @@ struct DownloadRefiller {
private func nextUnwatchedEpisodes(
inSeries seriesKey: String,
count: Int,
server: PlexServer
) async -> [PlexItem] {
guard let seasons = try? await server.children(of: seriesKey) else { return [] }
backend: PlexBackend
) async -> [MediaItem] {
guard let seasons = try? await backend.children(of: seriesKey) else { return [] }
let offlineKeys = Set(coordinator.items.map(\.ratingKey))
var result: [PlexItem] = []
var result: [MediaItem] = []
for season in seasons.sorted(by: { ($0.index ?? 0) < ($1.index ?? 0) }) {
// Specials führt Plex als Staffel 0. Sie gehören nicht in den
// Vorrat für „was kommt als Nächstes".
guard (season.index ?? 0) > 0 else { continue }
guard let episodes = try? await server.children(of: season.ratingKey) else { continue }
guard let episodes = try? await backend.children(of: season.id) else { continue }
for episode in episodes.sorted(by: { ($0.index ?? 0) < ($1.index ?? 0) }) {
guard !episode.isWatched, !offlineKeys.contains(episode.ratingKey) else { continue }
guard !episode.isWatched, !offlineKeys.contains(episode.id) else { continue }
result.append(episode)
if result.count >= count { return result }
}
+36 -35
View File
@@ -1,5 +1,6 @@
import Foundation
import FlixyDownloads
import MediaKit
import PlexKit
/// Übersetzt Titel vom Server in Download-Aufträge.
@@ -79,7 +80,7 @@ enum DownloadQuality: String, CaseIterable, Identifiable {
@MainActor
struct DownloadService {
let server: PlexServer?
let backend: PlexBackend?
let coordinator: DownloadCoordinator
/// Baut den Auftrag für einen einzelnen Titel.
@@ -89,33 +90,33 @@ struct DownloadService {
/// nichts, obwohl der Grund bekannt war — etwa ein Titel, den der Server
/// noch nicht analysiert hat.
func plan(
for item: PlexItem,
for item: MediaItem,
mediaIndex: Int = 0,
quality: DownloadQuality = .original
) throws(DownloadRefusal) -> DownloadPlan {
guard let server else {
guard let backend else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer"))
}
let media = item.media.indices.contains(mediaIndex)
? item.media[mediaIndex]
: item.preferredMedia
let media = item.versions.indices.contains(mediaIndex)
? item.versions[mediaIndex]
: item.preferredVersion
guard let media else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noMedia"))
}
guard let part = media.parts.first else {
guard let part = media.files.first else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart"))
}
let url: URL?
switch quality {
case .original:
url = server.downloadURL(part: part)
url = backend.downloadURL(for: part)
case .fullHD, .hd, .low:
// Über den Universal-Transcoder. NOCH NICHT AM SERVER GEPRÜFT:
// offen ist, ob Plex eine Content-Length mitschickt — ohne sie
// gibt es keinen Fortschrittsbalken und kein Fortsetzen.
url = server.transcodedDownloadURL(
ratingKey: item.ratingKey,
mediaIndex: mediaIndex,
url = backend.compressedDownloadURL(
itemID: item.id,
versionIndex: mediaIndex,
maximumBitrate: quality.maximumBitrate ?? 8_000,
resolution: quality.resolution
)
@@ -125,13 +126,13 @@ struct DownloadService {
}
return DownloadPlan(
ratingKey: item.ratingKey,
ratingKey: item.id,
title: item.rowTitle,
subtitle: item.downloadSubtitle,
sourceURL: url,
seriesKey: item.grandparentRatingKey,
seriesTitle: item.grandparentTitle,
seasonKey: item.parentRatingKey,
seriesKey: item.seriesID,
seriesTitle: item.seriesTitle,
seasonKey: item.parentID,
seasonIndex: item.parentIndex,
episodeIndex: item.index,
durationMilliseconds: item.duration,
@@ -146,7 +147,7 @@ struct DownloadService {
/// Lädt einen einzelnen Titel.
@discardableResult
func download(
_ item: PlexItem,
_ item: MediaItem,
mediaIndex: Int = 0,
quality: DownloadQuality = .original
) throws(DownloadRefusal) -> DownloadItem {
@@ -159,30 +160,30 @@ struct DownloadService {
/// keine Dateiangaben, und ohne Dateigröße ließe sich weder das
/// Speicherlimit prüfen noch ein Fortschrittsbalken zeigen.
func downloadAll(
in container: PlexItem
) async -> (accepted: Int, refused: [(PlexItem, DownloadRefusal)], unavailable: Int) {
guard let server else { return (0, [], 0) }
in container: MediaItem
) async -> (accepted: Int, refused: [(MediaItem, DownloadRefusal)], unavailable: Int) {
guard let backend else { return (0, [], 0) }
let episodes: [PlexItem]
let episodes: [MediaItem]
do {
episodes = try await allEpisodes(in: container, server: server)
episodes = try await allEpisodes(in: container, backend: backend)
} catch {
return (0, [], 0)
}
var accepted = 0
var refused: [(PlexItem, DownloadRefusal)] = []
var refused: [(MediaItem, DownloadRefusal)] = []
var unavailable = 0
for episode in episodes {
// Folgen, die schon offline sind, werden übersprungen statt
// abgelehnt — bei einer Serie, von der die Hälfte geladen ist,
// wäre eine Liste aus zwanzig Fehlern keine Hilfe.
if coordinator.items.contains(where: { $0.ratingKey == episode.ratingKey }) {
if coordinator.items.contains(where: { $0.ratingKey == episode.id }) {
continue
}
let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode
let detailed = (try? await backend.item(id: episode.id)) ?? episode
do {
_ = try coordinator.enqueue(try plan(for: detailed))
@@ -191,7 +192,7 @@ struct DownloadService {
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
// werden am Ende nur gezählt.
unavailable += 1
} catch {
} catch let error as DownloadRefusal {
refused.append((episode, error))
// Beim Speicherlimit hat es keinen Sinn, die übrigen Folgen
// noch einzeln durchzuprobieren — sie scheitern alle gleich.
@@ -203,14 +204,14 @@ struct DownloadService {
return (accepted, refused, unavailable)
}
private func allEpisodes(in container: PlexItem, server: PlexServer) async throws -> [PlexItem] {
private func allEpisodes(in container: MediaItem, backend: PlexBackend) async throws -> [MediaItem] {
switch container.kind {
case .season:
return try await server.children(of: container.ratingKey)
return try await backend.children(of: container.id)
case .show:
var episodes: [PlexItem] = []
for season in try await server.children(of: container.ratingKey) {
episodes.append(contentsOf: try await server.children(of: season.ratingKey))
var episodes: [MediaItem] = []
for season in try await backend.children(of: container.id) {
episodes.append(contentsOf: try await backend.children(of: season.id))
}
return episodes
default:
@@ -219,7 +220,7 @@ struct DownloadService {
}
/// Der Abzug der Serverangaben, den der Player offline braucht.
private func offlineMedia(for item: PlexItem, media: PlexMedia, part: PlexPart) -> OfflineMedia {
private func offlineMedia(for item: MediaItem, media: MediaVersion, part: MediaFile) -> OfflineMedia {
OfflineMedia(
container: part.container ?? media.container,
videoCodec: media.videoCodec,
@@ -230,16 +231,16 @@ struct DownloadService {
subtitleLanguages: part.subtitleStreams.compactMap(\.language),
markers: item.markers.map {
OfflineMarker(
type: $0.type,
startMilliseconds: $0.startTimeOffset,
endMilliseconds: $0.endTimeOffset
type: $0.kind.rawValue,
startMilliseconds: $0.startMilliseconds,
endMilliseconds: $0.endMilliseconds
)
}
)
}
}
extension PlexItem {
extension MediaItem {
/// Die Zeile unter dem Titel in der Downloads-Ansicht.
var downloadSubtitle: String? {
if kind == .episode, let season = parentIndex, let episode = index {
+10
View File
@@ -1,4 +1,5 @@
import Foundation
import MediaKit
import Observation
import PlexKit
@@ -69,6 +70,15 @@ final class FlixySession {
private(set) var phase: Phase = .launching
private(set) var server: PlexServer?
/// Derselbe Server, aber hinter dem neutralen Vertrag.
///
/// Die Ansichten sprechen hierüber, nicht mehr mit Plex unmittelbar. Der
/// Weg darunter bleibt offen, solange Player, Downloads und Live-TV noch
/// nicht umgestellt sind — die drei folgen als Nächstes.
var backend: PlexBackend? { server.map(PlexBackend.init) }
/// Die Mediatheken in neutraler Form.
var mediaLibraries: [MediaLibrary] { libraries.map(\.asMediaLibrary) }
/// Nur die Mediatheken, die Flixy darstellen kann — Musik und Fotos
/// erscheinen gar nicht erst.
private(set) var libraries: [PlexLibrary] = []
+10 -9
View File
@@ -1,11 +1,12 @@
import Foundation
import Observation
import MediaKit
import PlexKit
/// Welche Reihen die Startseite zeigt und in welcher Reihenfolge.
///
/// Hält nur die Einstellung und ihre Speicherung. Das Anordnen selbst macht
/// `HubArrangement` in PlexKit — dort ist es reine Rechnung und ohne Simulator
/// `Arrangement` in MediaKit — dort ist es reine Rechnung und ohne Simulator
/// prüfbar, und genau dort liegen die kniffligen Fälle: neue Reihen vom
/// Server, verschwundene aus der gespeicherten Liste.
@MainActor
@@ -17,11 +18,11 @@ final class HomeLayoutSettings {
}
private let defaults: UserDefaults
private(set) var arrangement: HubArrangement
private(set) var arrangement: Arrangement
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.arrangement = HubArrangement(
self.arrangement = Arrangement(
hidden: Set(defaults.stringArray(forKey: Key.hidden) ?? []),
order: defaults.stringArray(forKey: Key.order) ?? []
)
@@ -45,15 +46,15 @@ final class HomeLayoutSettings {
defaults.set(identifiers, forKey: Key.order)
}
func apply(to hubs: [PlexHub]) -> [PlexHub] {
arrangement.apply(to: hubs)
func apply(to rows: [MediaRowGroup]) -> [MediaRowGroup] {
arrangement.apply(to: rows)
}
/// Die Zeilen für den Einstellungsbildschirm.
func rows(from hubs: [PlexHub]) -> [HomeRow] {
arrangement.arrangedIdentifiers(from: hubs).compactMap { identifier in
guard let hub = hubs.first(where: { $0.hubIdentifier == identifier }) else { return nil }
return HomeRow(id: identifier, title: hub.title, isHidden: isHidden(identifier))
func rows(from rows: [MediaRowGroup]) -> [HomeRow] {
arrangement.arrangedIdentifiers(from: rows).compactMap { identifier in
guard let row = rows.first(where: { $0.id == identifier }) else { return nil }
return HomeRow(id: identifier, title: row.title, isHidden: isHidden(identifier))
}
}
}
+6 -12
View File
@@ -1,6 +1,6 @@
import Foundation
import Observation
import PlexKit
import MediaKit
/// Was abgespielt werden soll.
struct PlaybackRequest: Identifiable, Hashable {
@@ -63,9 +63,9 @@ final class PlaybackCoordinator {
private(set) var request: PlaybackRequest?
private(set) var presentation: Presentation = .hidden
func play(_ item: PlexItem, mediaIndex: Int = 0, resuming: Bool = true) {
func play(_ item: MediaItem, mediaIndex: Int = 0, resuming: Bool = true) {
play(PlaybackRequest(
ratingKey: item.ratingKey,
ratingKey: item.id,
title: item.playbackTitle,
mediaIndex: mediaIndex,
startTime: resuming ? item.resumeTime : .zero
@@ -127,24 +127,18 @@ final class PlaybackCoordinator {
}
}
extension PlexItem {
extension MediaItem {
/// Wie der Titel im Player heißt.
///
/// Bei Folgen ist der eigene Titel allein nutzlos — „Die erste Folge"
/// sagt nichts darüber, welche Serie läuft.
var playbackTitle: String {
if kind == .episode, let series = grandparentTitle {
if kind == .episode, let series = seriesTitle {
return "\(series) · \(title)"
}
return title
}
/// Ab welcher Stelle fortgesetzt wird.
var resumeTime: Duration {
guard let viewOffset, viewOffset > 0 else { return .zero }
return .milliseconds(viewOffset)
}
/// Die Beschriftung unter einer Kachel.
///
/// Sie ordnet ein, was darüber steht: zu einer Folge gehört die Serie, zu
@@ -157,7 +151,7 @@ extension PlexItem {
if let season = parentIndex, let episode = index {
code = String(localized: "episode.short \(season) \(episode)")
}
guard let series = grandparentTitle else { return code }
guard let series = seriesTitle else { return code }
return code.map { "\(series) · \($0)" } ?? series
case .season:
// Ohne die Serie darüber wäre „Staffel 10" beliebig — deshalb
+10 -9
View File
@@ -1,5 +1,6 @@
import SwiftUI
import FlixyDesign
import MediaKit
import PlexKit
/// Das Aktionsmenü auf einer Detailseite.
@@ -8,7 +9,7 @@ import PlexKit
/// einzelne Knöpfe. Das hält die Kopfzeile frei für das, was man ständig
/// braucht: Abspielen und Herunterladen.
struct ItemActionsMenu: View {
let item: PlexItem
let item: MediaItem
/// Wird gerufen, wenn sich am Server etwas geändert hat und die
/// Detailseite neu geladen werden sollte.
let onChange: () async -> Void
@@ -22,9 +23,9 @@ struct ItemActionsMenu: View {
Button {
run { server in
if item.isWatched {
try await server.markUnwatched(ratingKey: item.ratingKey)
try await server.markUnwatched(id: item.id)
} else {
try await server.markWatched(ratingKey: item.ratingKey)
try await server.markWatched(id: item.id)
}
}
} label: {
@@ -38,7 +39,7 @@ struct ItemActionsMenu: View {
ForEach((1...5).reversed(), id: \.self) { stars in
Button {
// Plex rechnet in Zehnteln: fünf Sterne sind eine 10.
run { try await $0.rate(ratingKey: item.ratingKey, rating: stars * 2) }
run { try await $0.rate(id: item.id, rating: stars * 2) }
} label: {
Label(String(repeating: "★", count: stars), systemImage: "star")
}
@@ -46,7 +47,7 @@ struct ItemActionsMenu: View {
if item.rating != nil {
Divider()
Button(role: .destructive) {
run { try await $0.rate(ratingKey: item.ratingKey, rating: 0) }
run { try await $0.rate(id: item.id, rating: 0) }
} label: {
Label("action.clearRating", systemImage: "star.slash")
}
@@ -77,17 +78,17 @@ struct ItemActionsMenu: View {
/// 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 (PlexServer) async throws -> Void) {
guard let server = session.server else { return }
private func run(_ action: @escaping (PlexBackend) async throws -> Void) {
guard let backend = session.backend else { return }
isWorking = true
Task {
defer { isWorking = false }
do {
try await action(server)
try await action(backend)
await onChange()
} catch {
errorMessage = PlexErrorText.describe(error)
errorMessage = ErrorText.describe(error)
}
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
import SwiftUI
import FlixyDesign
import PlexKit
import MediaKit
/// Die Detailseite eines Titels.
///
@@ -11,7 +11,7 @@ struct MediaDetailView: View {
let route: MediaRoute
@Environment(FlixySession.self) private var session
@State private var state: LoadState<PlexItem> = .loading
@State private var state: LoadState<MediaItem> = .loading
var body: some View {
Group {
@@ -51,9 +51,9 @@ struct MediaDetailView: View {
}
private func load() async {
guard let server = session.server else { return }
guard let server = session.backend else { return }
state = await LoadState.load {
try await server.metadata(ratingKey: route.ratingKey)
try await server.item(id: route.ratingKey)
}
}
}
+6 -6
View File
@@ -1,6 +1,6 @@
import SwiftUI
import FlixyDesign
import PlexKit
import MediaKit
/// Der Weg nach oben: von der Folge zur Staffel, von der Staffel zur Serie.
///
@@ -10,7 +10,7 @@ import PlexKit
/// Serie → Staffel erreichbar. Genau dieser Umweg ist der Grund, dass die
/// Zeile existiert.
struct ParentLinks: View {
let item: PlexItem
let item: MediaItem
var body: some View {
if !destinations.isEmpty {
@@ -53,7 +53,7 @@ struct ParentLinks: View {
switch item.kind {
case .episode:
var stops: [Destination] = []
if let season = item.parentRatingKey {
if let season = item.parentID {
let title = item.parentIndex.map { String(localized: "season.short \($0)") }
?? item.parentTitle
?? String(localized: "detail.toSeason")
@@ -62,11 +62,11 @@ struct ParentLinks: View {
symbol: "rectangle.stack"
))
}
if let show = item.grandparentRatingKey {
if let show = item.seriesID {
stops.append(Destination(
route: MediaRoute(
ratingKey: show,
title: item.grandparentTitle ?? String(localized: "detail.toSeries")
title: item.seriesTitle ?? String(localized: "detail.toSeries")
),
symbol: "tv"
))
@@ -74,7 +74,7 @@ struct ParentLinks: View {
return stops
case .season:
guard let show = item.parentRatingKey else { return [] }
guard let show = item.parentID else { return [] }
return [Destination(
route: MediaRoute(
ratingKey: show,
+22 -22
View File
@@ -1,11 +1,11 @@
import SwiftUI
import FlixyDesign
import PlexKit
import MediaKit
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
/// einzelne Folge.
struct PlayableDetailView: View {
let item: PlexItem
let item: MediaItem
@Environment(FlixySession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@@ -22,7 +22,7 @@ struct PlayableDetailView: View {
ParentLinks(item: item)
if item.hasMultipleVersions {
VersionPicker(media: item.media, selection: $selectedVersion)
VersionPicker(media: item.versions, selection: $selectedVersion)
.padding(.horizontal, FlixySpacing.screenMargin)
}
@@ -37,11 +37,11 @@ struct PlayableDetailView: View {
TagStrip(tags: item.genres)
}
if !item.roles.isEmpty {
CastRow(roles: item.roles)
if !item.cast.isEmpty {
CastRow(cast: item.cast)
}
if let media = currentMedia, let part = media.parts.first {
if let media = currentMedia, let part = media.files.first {
TechnicalDetails(
media: media,
part: part,
@@ -55,9 +55,9 @@ struct PlayableDetailView: View {
.scrollIndicators(.hidden)
}
private var currentMedia: PlexMedia? {
guard item.media.indices.contains(selectedVersion) else { return item.media.first }
return item.media[selectedVersion]
private var currentMedia: MediaVersion? {
guard item.versions.indices.contains(selectedVersion) else { return item.versions.first }
return item.versions[selectedVersion]
}
private var header: some View {
@@ -65,12 +65,12 @@ struct PlayableDetailView: View {
title: item.rowTitle,
metadata: metadataLine,
summary: nil,
artworkURL: session.server?.artworkURL(
path: item.art ?? item.grandparentArt ?? item.thumb,
artworkURL: session.backend?.artworkURL(
path: item.art ?? item.seriesArt ?? item.thumb,
width: 900,
height: 1200
),
logoURL: session.server?.rawImageURL(path: item.clearLogoPath)
logoURL: session.backend?.rawImageURL(path: item.clearLogoPath)
) {
Button {
playback.play(item, mediaIndex: selectedVersion)
@@ -85,7 +85,7 @@ struct PlayableDetailView: View {
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(currentMedia?.parts.first == nil)
.disabled(currentMedia?.files.first == nil)
if let progress = item.watchProgress, progress > 0 {
Button {
@@ -109,7 +109,7 @@ struct PlayableDetailView: View {
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.grandparentTitle { pieces.append(series) }
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)"))
}
@@ -124,7 +124,7 @@ struct PlayableDetailView: View {
/// Auswahl zwischen mehreren Fassungen desselben Titels.
struct VersionPicker: View {
let media: [PlexMedia]
let media: [MediaVersion]
@Binding var selection: Int
var body: some View {
@@ -146,13 +146,13 @@ struct VersionPicker: View {
/// Genres als Streifen.
struct TagStrip: View {
let tags: [PlexTag]
let tags: [String]
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: FlixySpacing.snug) {
ForEach(tags) { tag in
Text(tag.tag)
ForEach(tags, id: \.self) { tag in
Text(tag)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.padding(.horizontal, FlixySpacing.regular)
@@ -168,7 +168,7 @@ struct TagStrip: View {
/// Die Besetzung mit Porträts.
struct CastRow: View {
let roles: [PlexTag]
let cast: [MediaPerson]
@Environment(FlixySession.self) private var session
var body: some View {
@@ -180,17 +180,17 @@ struct CastRow: View {
ScrollView(.horizontal) {
HStack(alignment: .top, spacing: FlixySpacing.regular) {
ForEach(roles.prefix(20)) { role in
ForEach(cast.prefix(20)) { role in
VStack(spacing: FlixySpacing.snug) {
FlixyArtwork(
url: session.server?.artworkURL(path: role.thumb, width: 160, height: 160),
url: session.backend?.artworkURL(path: role.thumb, width: 160, height: 160),
aspectRatio: 1,
fallbackSymbol: "person"
)
.frame(width: 72, height: 72)
.clipShape(.circle)
Text(role.tag)
Text(role.name)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(2)
+16 -16
View File
@@ -1,16 +1,16 @@
import SwiftUI
import FlixyDesign
import PlexKit
import MediaKit
/// Die Detailseite einer Serie oder Staffel.
///
/// Beide nutzen dieselbe Ansicht, weil sie sich nur im Inhalt der Liste
/// unterscheiden: eine Serie führt zu Staffeln, eine Staffel zu Folgen.
struct SeriesDetailView: View {
let item: PlexItem
let item: MediaItem
@Environment(FlixySession.self) private var session
@State private var children: LoadState<[PlexItem]> = .loading
@State private var children: LoadState<[MediaItem]> = .loading
var body: some View {
ScrollView {
@@ -30,12 +30,12 @@ struct SeriesDetailView: View {
childList
if !item.roles.isEmpty { CastRow(roles: item.roles) }
if !item.cast.isEmpty { CastRow(cast: item.cast) }
}
.padding(.bottom, FlixySpacing.section)
}
.scrollIndicators(.hidden)
.task(id: item.ratingKey) { await loadChildren() }
.task(id: item.id) { await loadChildren() }
}
private var header: some View {
@@ -43,12 +43,12 @@ struct SeriesDetailView: View {
title: item.rowTitle,
metadata: metadataLine,
summary: nil,
artworkURL: session.server?.artworkURL(
artworkURL: session.backend?.artworkURL(
path: item.art ?? item.thumb,
width: 900,
height: 1200
),
logoURL: session.server?.rawImageURL(path: item.clearLogoPath)
logoURL: session.backend?.rawImageURL(path: item.clearLogoPath)
) {
DownloadAllButton(container: item)
}
@@ -75,7 +75,7 @@ struct SeriesDetailView: View {
LazyVStack(spacing: 0) {
ForEach(entries) { entry in
NavigationLink(value: MediaRoute(ratingKey: entry.ratingKey, title: entry.title)) {
NavigationLink(value: MediaRoute(ratingKey: entry.id, title: entry.title)) {
ChildRow(item: entry)
}
.buttonStyle(.plain)
@@ -95,10 +95,10 @@ struct SeriesDetailView: View {
pieces.append(String(localized: "season.short \(number)"))
}
if let year = item.year { pieces.append(String(year)) }
if let leafCount = item.leafCount {
pieces.append(String(localized: "detail.episodeCount \(leafCount)"))
if let episodeCount = item.episodeCount {
pieces.append(String(localized: "detail.episodeCount \(episodeCount)"))
}
if let viewed = item.viewedLeafCount, let total = item.leafCount, viewed < total {
if let viewed = item.playedEpisodeCount, let total = item.episodeCount, viewed < total {
pieces.append(String(localized: "detail.unwatchedCount \(total - viewed)"))
}
if let rating = item.contentRating { pieces.append(rating) }
@@ -106,22 +106,22 @@ struct SeriesDetailView: View {
}
private func loadChildren() async {
guard let server = session.server else { return }
guard let server = session.backend else { return }
children = await LoadState.load {
try await server.children(of: item.ratingKey)
try await server.children(of: item.id)
}
}
}
/// Eine Staffel- oder Folgenzeile.
private struct ChildRow: View {
let item: PlexItem
let item: MediaItem
@Environment(FlixySession.self) private var session
var body: some View {
HStack(spacing: FlixySpacing.regular) {
FlixyArtwork(
url: session.server?.artworkURL(
url: session.backend?.artworkURL(
path: item.thumb,
width: item.kind == .season ? 120 : 240,
height: item.kind == .season ? 180 : 135
@@ -176,7 +176,7 @@ private struct ChildRow: View {
private var subtitle: String? {
switch item.kind {
case .season:
item.leafCount.map { String(localized: "detail.episodeCount \($0)") }
item.episodeCount.map { String(localized: "detail.episodeCount \($0)") }
case .episode:
item.duration.map { Duration.milliseconds($0).runtimeLabel }
default:
+5 -5
View File
@@ -1,7 +1,7 @@
import SwiftUI
import FlixyDesign
import FlixyPlayback
import PlexKit
import MediaKit
/// Die aufklappbaren technischen Angaben zu einer Datei.
///
@@ -10,12 +10,12 @@ import PlexKit
/// erste Stelle zum Nachsehen — und in Phase 1, wo es noch kein Transcoding
/// gibt, erklärt es, warum bei manchen Dateien kein Bild-in-Bild angeboten wird.
struct TechnicalDetails: View {
let media: PlexMedia
let part: PlexPart
let media: MediaVersion
let part: MediaFile
@Binding var isExpanded: Bool
private var decision: PlaybackDecision {
PlaybackDecider().decide(profile: media.profile(part: part), policy: .directPlayOnly)
PlaybackDecider().decide(profile: media.profile(file: part), policy: .directPlayOnly)
}
var body: some View {
@@ -88,7 +88,7 @@ struct TechnicalDetails: View {
}
}
private func trackLabel(_ stream: PlexStream) -> String {
private func trackLabel(_ stream: MediaStream) -> String {
stream.displayTitle ?? [stream.language, stream.codec?.uppercased()]
.compactMap(\.self)
.joined(separator: " · ")
+9 -8
View File
@@ -1,6 +1,7 @@
import SwiftUI
import FlixyDesign
import FlixyDownloads
import MediaKit
import PlexKit
/// Der Download-Knopf auf einer Detailseite.
@@ -9,7 +10,7 @@ import PlexKit
/// verfügbar, gescheitert. Ein getrennter Knopf je Zustand würde die
/// Kopfzeile überfüllen — und der Zustand ist ohnehin immer genau einer.
struct DownloadButton: View {
let item: PlexItem
let item: MediaItem
var mediaIndex: Int = 0
@Environment(FlixySession.self) private var session
@@ -20,7 +21,7 @@ struct DownloadButton: View {
@State private var showsQualityChoice = false
private var existing: DownloadItem? {
downloads.items.first { $0.ratingKey == item.ratingKey }
downloads.items.first { $0.ratingKey == item.id }
}
var body: some View {
@@ -100,7 +101,7 @@ struct DownloadButton: View {
}
private func enqueue(quality: DownloadQuality) {
let service = DownloadService(server: session.server, coordinator: downloads)
let service = DownloadService(backend: session.backend, coordinator: downloads)
do {
_ = try service.download(item, mediaIndex: mediaIndex, quality: quality)
} catch {
@@ -108,8 +109,8 @@ struct DownloadButton: View {
}
}
private var currentMedia: PlexMedia? {
item.media.indices.contains(mediaIndex) ? item.media[mediaIndex] : item.preferredMedia
private var currentMedia: MediaVersion? {
item.versions.indices.contains(mediaIndex) ? item.versions[mediaIndex] : item.preferredVersion
}
/// Nur Stufen, die unter der Auflösung der Datei liegen.
@@ -123,7 +124,7 @@ struct DownloadButton: View {
/// Ohne diese Angabe ist die Wahl blind — man weiß nicht, ob man gerade
/// herunterrechnet oder nicht.
private func qualityLabel(_ quality: DownloadQuality) -> String {
let originalBytes = currentMedia?.parts.first?.size ?? 0
let originalBytes = currentMedia?.files.first?.size ?? 0
let bytes = quality.estimatedBytes(
durationMilliseconds: item.duration,
originalBytes: originalBytes
@@ -156,7 +157,7 @@ extension DownloadQuality {
/// Der Knopf für eine ganze Staffel oder Serie.
struct DownloadAllButton: View {
let container: PlexItem
let container: MediaItem
@Environment(FlixySession.self) private var session
@Environment(DownloadCoordinator.self) private var downloads
@@ -195,7 +196,7 @@ struct DownloadAllButton: View {
isWorking = true
defer { isWorking = false }
let service = DownloadService(server: session.server, coordinator: downloads)
let service = DownloadService(backend: session.backend, coordinator: downloads)
let result = await service.downloadAll(in: container)
var lines = [String(localized: "downloads.batch.queued \(result.accepted)")]
+6 -6
View File
@@ -1,10 +1,10 @@
import SwiftUI
import FlixyDesign
import PlexKit
import MediaKit
/// Der Aufmacher oben auf der Startseite.
struct HeroSection: View {
let item: PlexItem
let item: MediaItem
@Environment(FlixySession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@@ -15,12 +15,12 @@ struct HeroSection: View {
title: item.rowTitle,
metadata: metadata,
summary: item.summary,
artworkURL: session.server?.artworkURL(
path: item.art ?? item.grandparentArt ?? item.thumb,
artworkURL: session.backend?.artworkURL(
path: item.art ?? item.seriesArt ?? item.thumb,
width: 900,
height: 1200
),
logoURL: session.server?.rawImageURL(path: item.clearLogoPath)
logoURL: session.backend?.rawImageURL(path: item.clearLogoPath)
) {
Button {
playback.play(item)
@@ -33,7 +33,7 @@ struct HeroSection: View {
.buttonStyle(.borderedProminent)
.tint(accent)
NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) {
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
Label("hero.details", systemImage: "info.circle")
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
+10 -10
View File
@@ -1,12 +1,12 @@
import SwiftUI
import FlixyDesign
import PlexKit
import MediaKit
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
struct HomeView: View {
@Environment(FlixySession.self) private var session
@Environment(HomeLayoutSettings.self) private var layout
@State private var state: LoadState<[PlexHub]> = .loading
@State private var state: LoadState<[MediaRowGroup]> = .loading
var body: some View {
Group {
@@ -23,8 +23,8 @@ struct HomeView: View {
Button("action.retry") { Task { await load() } }
}
case .loaded(let allHubs):
let hubs = layout.apply(to: allHubs)
case .loaded(let allRows):
let hubs = layout.apply(to: allRows)
if hubs.isEmpty {
ContentUnavailableView {
Label("home.empty.title", systemImage: "film.stack")
@@ -55,7 +55,7 @@ struct HomeView: View {
.refreshable { await load() }
}
private func content(hubs: [PlexHub]) -> some View {
private func content(hubs: [MediaRowGroup]) -> some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
if let featured = heroItem(in: hubs) {
@@ -63,7 +63,7 @@ struct HomeView: View {
}
ForEach(hubs) { hub in
MediaRow(hub: hub)
MediaRow(row: hub)
}
}
.padding(.bottom, FlixySpacing.section)
@@ -76,15 +76,15 @@ struct HomeView: View {
/// Erste Wahl ist etwas Angefangenes — wer die App öffnet, will meistens
/// weiterschauen. Erst wenn es nichts Angefangenes gibt, tritt der
/// Neuzugang an die Stelle.
private func heroItem(in hubs: [PlexHub]) -> PlexItem? {
let continuing = hubs.first { $0.hubIdentifier.contains("continue") }
private func heroItem(in hubs: [MediaRowGroup]) -> MediaItem? {
let continuing = hubs.first(where: \.isContinueWatching)
return continuing?.items.first ?? hubs.first?.items.first
}
private func load() async {
guard let server = session.server else { return }
guard let backend = session.backend else { return }
state = await LoadState.load(keepingPrevious: state) {
try await server.homeHubs()
try await backend.homeRows()
}
}
}
+14 -14
View File
@@ -1,10 +1,10 @@
import SwiftUI
import FlixyDesign
import PlexKit
import MediaKit
/// Eine waagerechte Reihe von Titeln.
struct MediaRow: View {
let hub: PlexHub
let row: MediaRowGroup
@Environment(FlixySession.self) private var session
@@ -17,15 +17,15 @@ struct MediaRow: View {
var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
Text(hub.title)
Text(row.title)
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.screenMargin)
ScrollView(.horizontal) {
LazyHStack(spacing: FlixySpacing.regular) {
ForEach(hub.items) { item in
NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) {
ForEach(row.items) { item in
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
card(for: item)
}
.buttonStyle(.plain)
@@ -47,12 +47,12 @@ struct MediaRow: View {
/// dafür ist das Querformat richtig. Wer dagegen aussucht, was er als
/// Nächstes sehen will, sucht nach Plakaten.
@ViewBuilder
private func card(for item: PlexItem) -> some View {
if hub.isContinueWatching {
private func card(for item: MediaItem) -> some View {
if row.isContinueWatching {
LandscapeCard(
title: item.rowTitle,
subtitle: item.cardSubtitle,
artworkURL: session.server?.artworkURL(
artworkURL: session.backend?.artworkURL(
path: item.rowArtworkPath,
width: 440,
height: 248
@@ -64,7 +64,7 @@ struct MediaRow: View {
PosterCard(
title: item.rowTitle,
subtitle: item.cardSubtitle,
artworkURL: session.server?.artworkURL(
artworkURL: session.backend?.artworkURL(
path: item.posterArtworkPath,
width: 300,
height: 450
@@ -76,7 +76,7 @@ struct MediaRow: View {
}
}
extension PlexItem {
extension MediaItem {
/// Der Titel auf einer Kachel in einer Reihe.
///
/// Groß steht, worum es geht: bei einer Folge ihr eigener Titel, bei
@@ -87,9 +87,9 @@ extension PlexItem {
switch kind {
case .episode: title
// Die Serie steht je nach Antwort mal unter `parentTitle`, mal unter
// `grandparentTitle`. Fehlt beides, bleibt „Staffel 2" — besser als
// `seriesTitle`. Fehlt beides, bleibt „Staffel 2" — besser als
// gar keine Beschriftung.
case .season: parentTitle ?? grandparentTitle ?? title
case .season: parentTitle ?? seriesTitle ?? title
default: title
}
}
@@ -101,7 +101,7 @@ extension PlexItem {
/// würde; dort ist `art` (das Hintergrundbild) die bessere Wahl.
var rowArtworkPath: String? {
switch kind {
case .episode: thumb ?? grandparentThumb
case .episode: thumb ?? seriesThumb
default: art ?? thumb
}
}
@@ -113,7 +113,7 @@ extension PlexItem {
/// Wiedererkennen wertlos.
var posterArtworkPath: String? {
switch kind {
case .episode: grandparentThumb ?? parentThumb ?? thumb
case .episode: seriesThumb ?? parentThumb ?? thumb
default: thumb
}
}
+14 -14
View File
@@ -1,24 +1,24 @@
import SwiftUI
import FlixyDesign
import PlexKit
import MediaKit
/// Filter und Sortierung einer Mediathek.
struct LibraryFilterSheet: View {
@Binding var query: LibraryQuery
let libraryKind: PlexLibrary.Kind?
@Binding var query: MediaQuery
let libraryKind: MediaLibrary.Kind?
@Environment(\.dismiss) private var dismiss
@Environment(\.flixyAccent) private var accent
@Environment(FlixySession.self) private var session
@State private var genres: [PlexFilterOption] = []
@State private var genres: [MediaFilterOption] = []
/// Ein Arbeitsstand, der erst beim Bestätigen übernommen wird.
///
/// Sonst löste jeder Tipp im Blatt sofort eine neue Serverabfrage aus —
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
@State private var draft: LibraryQuery
@State private var draft: MediaQuery
init(query: Binding<LibraryQuery>, libraryKind: PlexLibrary.Kind?) {
init(query: Binding<MediaQuery>, libraryKind: MediaLibrary.Kind?) {
self._query = query
self.libraryKind = libraryKind
self._draft = State(initialValue: query.wrappedValue)
@@ -29,7 +29,7 @@ struct LibraryFilterSheet: View {
Form {
Section("filter.sort") {
Picker("filter.sort", selection: $draft.sort) {
ForEach(LibraryQuery.Sort.allCases, id: \.self) { sort in
ForEach(MediaQuery.Sort.allCases, id: \.self) { sort in
Text(sort.label).tag(sort)
}
}
@@ -43,7 +43,7 @@ struct LibraryFilterSheet: View {
Picker("filter.genre", selection: $draft.genre) {
Text("filter.any").tag(String?.none)
ForEach(genres) { genre in
Text(genre.title).tag(String?.some(genre.key))
Text(genre.title).tag(String?.some(genre.id))
}
}
@@ -58,7 +58,7 @@ struct LibraryFilterSheet: View {
Section {
Button("filter.reset", role: .destructive) {
draft = LibraryQuery(sort: draft.sort)
draft = MediaQuery(sort: draft.sort)
}
.disabled(!draft.isFiltering)
}
@@ -91,14 +91,14 @@ struct LibraryFilterSheet: View {
}
private func loadGenres() async {
guard let server = session.server,
let library = session.libraries.first(where: { $0.kind == libraryKind })
guard let server = session.backend,
let library = session.mediaLibraries.first(where: { $0.kind == libraryKind })
else { return }
genres = (try? await server.filterOptions(inLibrary: library, field: "genre")) ?? []
genres = (try? await server.filterOptions(inLibrary: library, field: .genre)) ?? []
}
}
extension LibraryQuery.Sort {
extension MediaQuery.Sort {
var label: LocalizedStringKey {
switch self {
case .titleAscending: "sort.titleAscending"
@@ -106,7 +106,7 @@ extension LibraryQuery.Sort {
case .recentlyAdded: "sort.recentlyAdded"
case .releaseDate: "sort.releaseDate"
case .rating: "sort.rating"
case .lastViewed: "sort.lastViewed"
case .lastPlayed: "sort.lastPlayed"
}
}
}
+23 -22
View File
@@ -1,18 +1,18 @@
import SwiftUI
import FlixyDesign
import PlexKit
import MediaKit
/// Eine Mediathek zum Durchblättern.
struct LibraryView: View {
let library: PlexLibrary
let library: MediaLibrary
@Environment(FlixySession.self) private var session
@AppStorage("flixy.library.layout") private var layout: LibraryLayout = .grid
@State private var items: [PlexItem] = []
@State private var items: [MediaItem] = []
@State private var state: LoadState<Void> = .loading
@State private var query = LibraryQuery()
@State private var page: PlexPage<PlexItem>?
@State private var query = MediaQuery()
@State private var page: MediaPage?
@State private var isLoadingMore = false
@State private var showsFilters = false
@State private var isScanning = false
@@ -41,7 +41,7 @@ struct LibraryView: View {
} actions: {
if query.isFiltering {
Button("library.clearFilters") {
query = LibraryQuery(sort: query.sort)
query = MediaQuery(sort: query.sort)
}
}
}
@@ -58,7 +58,7 @@ struct LibraryView: View {
LibraryFilterSheet(query: $query, libraryKind: library.kind)
}
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
.task(id: library.key) { await reload() }
.task(id: library.id) { await reload() }
// Jede Änderung an Filter oder Sortierung verwirft die bisherige
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
// verschiedenen Abfragen vermischen.
@@ -84,11 +84,11 @@ struct LibraryView: View {
spacing: FlixySpacing.loose
) {
ForEach(items) { item in
NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) {
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
PosterCard(
title: item.title,
subtitle: item.cardSubtitle,
artworkURL: session.server?.artworkURL(path: item.thumb, width: 300, height: 450),
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
progress: item.watchProgress
)
}
@@ -106,7 +106,7 @@ struct LibraryView: View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(items) { item in
NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) {
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
LibraryListRow(item: item)
}
.buttonStyle(.plain)
@@ -164,7 +164,7 @@ struct LibraryView: View {
/// nach kurzer Wartezeit neu geholt. Wer gerade Dateien abgelegt hat,
/// sieht sie damit meistens schon.
private func scan() async {
guard let server = session.server, !isScanning else { return }
guard let server = session.backend, !isScanning else { return }
isScanning = true
defer { isScanning = false }
@@ -176,30 +176,30 @@ struct LibraryView: View {
// MARK: - Laden
private func reload() async {
guard let server = session.server else { return }
guard let server = session.backend else { return }
state = .loading
do {
let first = try await server.items(inLibrary: library, query: query)
let first = try await server.items(inLibrary: library, query: query, offset: 0, limit: 60)
items = first.items
page = first
state = .loaded(())
} catch is CancellationError {
return
} catch {
state = .failed(message: PlexErrorText.describe(error))
state = .failed(message: ErrorText.describe(error))
}
}
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
private func loadMoreIfNeeded(reaching item: PlexItem) async {
guard let server = session.server,
private func loadMoreIfNeeded(reaching item: MediaItem) async {
guard let server = session.backend,
let page, page.hasMore,
!isLoadingMore,
// Fünf Einträge vor Schluss: früh genug, dass der Nachschub da
// ist, bevor jemand am Ende ankommt, und spät genug, dass nicht
// jedes Scrollen eine Anfrage auslöst.
items.suffix(5).contains(where: { $0.ratingKey == item.ratingKey })
items.suffix(5).contains(where: { $0.id == item.id })
else { return }
isLoadingMore = true
@@ -209,13 +209,14 @@ struct LibraryView: View {
let next = try await server.items(
inLibrary: library,
query: query,
offset: page.nextOffset
offset: page.nextOffset,
limit: 60
)
// Plex kann bei gleichzeitigem Serverscan denselben Titel auf zwei
// Seiten liefern. Ohne diese Prüfung stünde er doppelt im Raster —
// und SwiftUI beschwert sich über doppelte Kennungen.
let known = Set(items.map(\.ratingKey))
items.append(contentsOf: next.items.filter { !known.contains($0.ratingKey) })
let known = Set(items.map(\.id))
items.append(contentsOf: next.items.filter { !known.contains($0.id) })
self.page = next
} catch {
// Stillschweigend: die Liste steht bereits, ein Fehlerblatt mitten
@@ -231,13 +232,13 @@ enum LibraryLayout: String, CaseIterable {
/// Eine Zeile in der Listenansicht.
private struct LibraryListRow: View {
let item: PlexItem
let item: MediaItem
@Environment(FlixySession.self) private var session
var body: some View {
HStack(spacing: FlixySpacing.regular) {
FlixyArtwork(
url: session.server?.artworkURL(path: item.thumb, width: 120, height: 180),
url: session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
aspectRatio: PosterCard.aspectRatio
)
.frame(width: 44)
+21 -4
View File
@@ -1,4 +1,5 @@
import Foundation
import MediaKit
import PlexKit
/// Der Ladezustand eines Bildschirms.
@@ -41,16 +42,32 @@ extension LoadState {
// interessiert.
return previous ?? .loading
} catch {
return .failed(message: PlexErrorText.describe(error))
return .failed(message: ErrorText.describe(error))
}
}
}
/// Übersetzt Fehler in Sätze, die man einem Menschen zeigen kann.
enum PlexErrorText {
///
/// **Eine** Stelle. Es gab davon zwei — hier und in `FlixySession` —, und sie
/// waren bereits auseinandergelaufen: derselbe Fehler bekam je nach
/// Bildschirm eine andere Erklärung.
enum ErrorText {
static func describe(_ error: any Error) -> String {
guard let plexError = error as? PlexError else { return error.localizedDescription }
return switch plexError {
// Die neutrale Form zuerst, weil die Ansichten nur noch sie sehen.
// Plex' eigener Fehler kommt nur noch dort an, wo unmittelbar mit ihm
// gesprochen wird — Player, Downloads, Live-TV.
if let backendError = error as? MediaBackendError {
return describe(backendError)
}
if let plexError = error as? PlexError {
return describe(plexError.asMediaBackendError)
}
return error.localizedDescription
}
static func describe(_ error: MediaBackendError) -> String {
switch error {
case .unauthorized: String(localized: "error.unauthorized")
case .notFound: String(localized: "error.notFound")
case .noReachableConnection: String(localized: "error.serverUnreachable")
+2 -2
View File
@@ -146,7 +146,7 @@ struct RootView: View {
}
private var sidebarDestinations: [SidebarDestination] {
session.libraries.map { .library(key: $0.key, title: $0.title) }
session.mediaLibraries.map { .library(key: $0.id, title: $0.title) }
}
/// iPad: die Seitenleiste ist Teil des Fensters.
@@ -196,7 +196,7 @@ struct RootView: View {
case .home:
HomeView()
case .library(let key, let title):
if let library = session.libraries.first(where: { $0.key == key }) {
if let library = session.mediaLibraries.first(where: { $0.id == key }) {
LibraryView(library: library)
} else {
PlaceholderScreen(destination: .library(key: key, title: title))
+8 -5
View File
@@ -168,13 +168,13 @@ final class PlayerModel {
item = try await server.metadata(ratingKey: request.ratingKey)
diagnostics.note("Metadaten da")
} catch {
diagnostics.finish(outcome: "Metadaten fehlgeschlagen: \(PlexErrorText.describe(error))")
phase = .failed(message: PlexErrorText.describe(error))
diagnostics.finish(outcome: "Metadaten fehlgeschlagen: \(ErrorText.describe(error))")
phase = .failed(message: ErrorText.describe(error))
return
}
self.item = item
title = item.rowTitle
title = item.asMediaItem.rowTitle
subtitle = playerSubtitle(for: item)
guard let media = version(of: item), let part = media.parts.first else {
@@ -591,7 +591,7 @@ final class PlayerModel {
guard let nextEpisode else { return nil }
return PlaybackRequest(
ratingKey: nextEpisode.ratingKey,
title: nextEpisode.playbackTitle,
title: nextEpisode.asMediaItem.playbackTitle,
mediaIndex: 0,
startTime: .zero
)
@@ -689,7 +689,10 @@ final class PlayerModel {
// um eine Folge kleiner.
downloads.markWatched(ratingKey: request.ratingKey)
Task { [server, downloads] in
await DownloadRefiller(server: server, coordinator: downloads).refill()
await DownloadRefiller(
backend: server.map(PlexBackend.init),
coordinator: downloads
).refill()
}
case .tracksResolved(let audio, let subtitle):
audioTracks = audio
+5 -5
View File
@@ -1,6 +1,6 @@
import SwiftUI
import FlixyDesign
import PlexKit
import MediaKit
/// Der Knopf, der zur Suche führt. Steht in mehreren Werkzeugleisten.
struct SearchToolbarItem: ToolbarContent {
@@ -21,7 +21,7 @@ struct SearchView: View {
@Environment(FlixySession.self) private var session
@State private var text = ""
@State private var hubs: [PlexHub] = []
@State private var hubs: [MediaRowGroup] = []
@State private var isSearching = false
@State private var recent: [String] = UserDefaults.standard.stringArray(forKey: recentKey) ?? []
@@ -53,7 +53,7 @@ struct SearchView: View {
ScrollView {
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
ForEach(hubs) { hub in
MediaRow(hub: hub)
MediaRow(row: hub)
}
}
.padding(.vertical, FlixySpacing.loose)
@@ -94,7 +94,7 @@ struct SearchView: View {
private func search() async {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let server = session.server, trimmed.count >= 2 else {
guard let backend = session.backend, trimmed.count >= 2 else {
hubs = []
return
}
@@ -111,7 +111,7 @@ struct SearchView: View {
defer { isSearching = false }
do {
hubs = try await server.search(trimmed)
hubs = try await backend.search(trimmed, limit: 30)
if !hubs.isEmpty { remember(trimmed) }
} catch is CancellationError {
return
+3 -3
View File
@@ -1,6 +1,6 @@
import SwiftUI
import FlixyDesign
import PlexKit
import MediaKit
/// Reihen der Startseite ein- und ausblenden und umsortieren.
struct HomeLayoutView: View {
@@ -68,11 +68,11 @@ struct HomeLayoutView: View {
private func load() async {
defer { isLoading = false }
guard let server = session.server else { return }
guard let backend = session.backend else { return }
// Die Reihen kommen vom Server — es gibt keine feste Liste, die sich
// vorweg anzeigen ließe. Was hier steht, hängt davon ab, welche
// Mediatheken und Sammlungen angelegt sind.
let hubs = (try? await server.homeHubs()) ?? []
let hubs = (try? await backend.homeRows()) ?? []
rows = layout.rows(from: hubs)
}
}