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)
}
}
@@ -0,0 +1,72 @@
import Foundation
/// Etwas, das der Nutzer ausblenden und verschieben kann.
public protocol Arrangeable {
/// Eine Kennung, die über Abrufe hinweg gleich bleibt.
///
/// **Nicht** der angezeigte Titel: benennt der Server eine Reihe oder eine
/// Mediathek um, wäre sonst alles Gespeicherte hinfällig.
var arrangementID: String { get }
var arrangementTitle: String { get }
}
/// Was ausgeblendet ist und in welcher Reihenfolge der Rest steht.
///
/// War bis eben an die Startseiten-Reihen von Plex gebunden. Die Rechnung ist
/// dieselbe für jede Liste, die jemand selbst zusammenstellt — Reihen,
/// Seitenleisteneinträge, Sender. Sie ein zweites Mal zu schreiben hieße, sie
/// ein zweites Mal falsch zu machen.
public struct Arrangement: Sendable, Equatable {
public var hidden: Set<String>
public var order: [String]
public init(hidden: Set<String> = [], order: [String] = []) {
self.hidden = hidden
self.order = order
}
/// Wendet Auswahl und Reihenfolge an.
///
/// Unbekannte Einträge landen am Ende statt zu verschwinden. Sie zu
/// verwerfen wäre die einfachere Regel — und der Grund dafür, dass ein
/// neu angelegtes Regal auf dem Server in der App nie aufgetaucht wäre.
public func apply<Element: Arrangeable>(to elements: [Element]) -> [Element] {
let visible = elements.filter { !hidden.contains($0.arrangementID) }
guard !order.isEmpty else { return visible }
let positions = Dictionary(
order.enumerated().map { ($1, $0) },
// Ein doppelter Eintrag in der gespeicherten Reihenfolge — etwa
// nach einem fehlgeschlagenen Schreibvorgang — soll nicht
// abstürzen. Die erste Position gewinnt.
uniquingKeysWith: { first, _ in first }
)
return visible.enumerated().sorted { left, right in
let leftPosition = positions[left.element.arrangementID] ?? Int.max
let rightPosition = positions[right.element.arrangementID] ?? Int.max
// Bei gleicher Position — beide unbekannt — bleibt die Reihenfolge
// des Servers erhalten. Ohne diese Rückfalllinie wäre die
// Anordnung neuer Einträge zufällig.
if leftPosition == rightPosition { return left.offset < right.offset }
return leftPosition < rightPosition
}.map(\.element)
}
/// Die Liste für den Einstellungsbildschirm: gespeicherte Reihenfolge
/// zuerst, danach alles Übrige.
///
/// Gespeicherte Einträge, die es nicht mehr gibt, fallen heraus — sie
/// anzuzeigen hieße, eine Reihe anzubieten, die niemand mehr hat.
public func arrangedIdentifiers<Element: Arrangeable>(from elements: [Element]) -> [String] {
let available = elements.map(\.arrangementID)
let known = Set(available)
let stored = order.filter(known.contains)
return stored + available.filter { !stored.contains($0) }
}
}
extension MediaRowGroup: Arrangeable {
public var arrangementID: String { id }
public var arrangementTitle: String { title }
}
@@ -43,6 +43,22 @@ public protocol MediaBackend: Sendable {
nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL?
nonisolated func rawImageURL(path: String?) -> URL?
// MARK: - Herunterladen
/// Die Adresse, unter der eine Datei unverändert zu holen ist.
nonisolated func downloadURL(for file: MediaFile) -> URL?
/// Die Adresse für eine verkleinerte Fassung.
///
/// `nil`, wenn der Server das nicht anbietet — dann bleibt nur das
/// Original, und die Oberfläche soll die Wahl gar nicht erst zeigen.
nonisolated func compressedDownloadURL(
itemID: String,
versionIndex: Int,
maximumBitrate: Int,
resolution: String?
) -> URL?
// MARK: - Zustand melden
func markWatched(id: String) async throws(MediaBackendError)
@@ -67,16 +83,27 @@ public enum BackendCapability: String, Sendable, Hashable {
case transcoding
}
/// Eine Seite aus einer Mediathek.
/// Ein Ausschnitt aus einer Mediathek.
///
/// „Seite" ist hier eine technische Portion, **keine Seite in der Oberfläche**.
/// Die Mediathek ist eine einzige lange Liste, die nachlädt, sobald man sich
/// dem Ende nähert — Blättern mit „weiter zu Seite 2" gehört nicht in eine
/// Mediathek, in der man stöbert.
public struct MediaPage: Sendable, Equatable {
public let items: [MediaItem]
/// Ab welcher Stelle dieser Ausschnitt beginnt.
public let offset: Int
/// Wie viele es insgesamt gibt — Grundlage fürs Nachladen.
public let totalCount: Int?
public let totalCount: Int
public init(items: [MediaItem], totalCount: Int? = nil) {
public init(items: [MediaItem], offset: Int = 0, totalCount: Int) {
self.items = items
self.offset = offset
self.totalCount = totalCount
}
public var hasMore: Bool { offset + items.count < totalCount }
public var nextOffset: Int { offset + items.count }
}
/// Ein möglicher Wert für einen Filter.
@@ -62,6 +62,31 @@ public struct MediaVersion: Sendable, Equatable, Identifiable {
}
}
/// Die Auflösung als Wort — „4K", „1080p".
///
/// Aus der Bildhöhe, nicht aus dem Namen des Servers: Plex sagt „4k",
/// Jellyfin gibt Zahlen. Gemeint ist dasselbe.
public var resolutionLabel: String? {
guard let pixelHeight else { return videoResolution?.uppercased() }
switch pixelHeight {
case 1_600...: return "4K"
case 1_000..<1_600: return "1080p"
case 700..<1_000: return "720p"
case 1..<700: return "SD"
default: return nil
}
}
/// Eine kurze Beschreibung für die Versionsauswahl, etwa
/// „4K · HEVC · 38 Mbit/s".
public var qualityLabel: String {
var pieces: [String] = []
if let label = resolutionLabel { pieces.append(label) }
if let videoCodec { pieces.append(videoCodec.uppercased()) }
if let bitrate { pieces.append("\(bitrate / 1000) Mbit/s") }
return pieces.joined(separator: " · ")
}
/// Was die Wiedergabe-Entscheidung über diese Fassung wissen muss.
public func profile(file: MediaFile?) -> MediaProfile {
MediaProfile(
@@ -1,67 +0,0 @@
import Foundation
/// Wie die Reihen der Startseite angeordnet werden.
///
/// Reine Rechnung ohne Speicher und ohne Oberfläche — die Stelle, an der die
/// zwei unangenehmen Fälle entschieden werden: was mit Reihen geschieht, die
/// der Server neu liefert, und was mit gespeicherten, die es nicht mehr gibt.
public struct HubArrangement: Sendable, Equatable {
/// Ausdrücklich ausgeblendete Reihen.
public var hidden: Set<String>
/// Die gewünschte Reihenfolge. Reihen, die hier fehlen, landen am Ende.
public var order: [String]
public init(hidden: Set<String> = [], order: [String] = []) {
self.hidden = hidden
self.order = order
}
/// Wendet Auswahl und Reihenfolge an.
///
/// Unbekannte Reihen landen am Ende statt zu verschwinden. Sie zu
/// verwerfen wäre die einfachere Regel — und der Grund dafür, dass ein
/// neu angelegtes Regal auf dem Server in der App nie aufgetaucht wäre.
public func apply(to hubs: [PlexHub]) -> [PlexHub] {
let visible = hubs.filter { !hidden.contains($0.hubIdentifier) }
guard !order.isEmpty else { return visible }
let positions = Dictionary(
order.enumerated().map { ($1, $0) },
// Ein doppelter Eintrag in der gespeicherten Reihenfolge — etwa
// nach einem fehlgeschlagenen Schreibvorgang — soll nicht
// abstürzen. Die erste Position gewinnt.
uniquingKeysWith: { first, _ in first }
)
return visible.enumerated().sorted { left, right in
let leftPosition = positions[left.element.hubIdentifier] ?? Int.max
let rightPosition = positions[right.element.hubIdentifier] ?? Int.max
// Bei gleicher Position — beide unbekannt — bleibt die Reihenfolge
// des Servers erhalten. Ohne diese Rückfalllinie wäre die
// Anordnung neuer Reihen zufällig.
if leftPosition == rightPosition { return left.offset < right.offset }
return leftPosition < rightPosition
}.map(\.element)
}
/// Die Liste für den Einstellungsbildschirm: gespeicherte Reihenfolge
/// zuerst, danach alles, was der Server sonst noch liefert.
///
/// Gespeicherte Reihen, die es nicht mehr gibt, fallen dabei heraus — sie
/// anzuzeigen hieße, eine Reihe anzubieten, die nie erscheinen kann.
public func arrangedIdentifiers(from hubs: [PlexHub]) -> [String] {
var result: [String] = []
var seen = Set<String>()
for identifier in order
where !seen.contains(identifier) && hubs.contains(where: { $0.hubIdentifier == identifier }) {
result.append(identifier)
seen.insert(identifier)
}
for hub in hubs where !seen.contains(hub.hubIdentifier) {
result.append(hub.hubIdentifier)
seen.insert(hub.hubIdentifier)
}
return result
}
}
@@ -49,7 +49,11 @@ public struct PlexBackend: MediaBackend {
offset: offset,
limit: limit
)
return MediaPage(items: page.items.map(\.asMediaItem), totalCount: page.totalSize)
return MediaPage(
items: page.items.map(\.asMediaItem),
offset: page.offset,
totalCount: page.totalSize
)
}
}
@@ -95,6 +99,29 @@ public struct PlexBackend: MediaBackend {
server.rawImageURL(path: path)
}
// MARK: - Herunterladen
public nonisolated func downloadURL(for file: MediaFile) -> URL? {
guard let key = file.key else { return nil }
// Nur der Schlüssel wird gebraucht; alles andere steckt schon in der
// Adresse oder ist für den Abruf ohne Belang.
return server.downloadURL(part: PlexPart(id: 0, key: key))
}
public nonisolated func compressedDownloadURL(
itemID: String,
versionIndex: Int,
maximumBitrate: Int,
resolution: String?
) -> URL? {
server.transcodedDownloadURL(
ratingKey: itemID,
mediaIndex: versionIndex,
maximumBitrate: maximumBitrate,
resolution: resolution
)
}
// MARK: - Zustand melden
public func markWatched(id: String) async throws(MediaBackendError) {
@@ -1,33 +1,33 @@
import Foundation
import Testing
@testable import PlexKit
@testable import MediaKit
private func hub(_ identifier: String) -> PlexHub {
PlexHub(hubIdentifier: identifier, title: identifier.capitalized)
private func hub(_ identifier: String) -> MediaRowGroup {
MediaRowGroup(id: identifier, title: identifier.capitalized)
}
@Suite("Anordnung der Startseiten-Reihen")
struct HubArrangementTests {
struct ArrangementTests {
private let serverHubs = [hub("continue"), hub("recent"), hub("watchlist"), hub("collections")]
@Test("Ohne Einstellungen bleibt die Reihenfolge des Servers")
func defaultKeepsServerOrder() {
let result = HubArrangement().apply(to: serverHubs)
#expect(result.map(\.hubIdentifier) == ["continue", "recent", "watchlist", "collections"])
let result = Arrangement().apply(to: serverHubs)
#expect(result.map(\.id) == ["continue", "recent", "watchlist", "collections"])
}
@Test("Ausgeblendete Reihen verschwinden")
func hiddenRowsAreRemoved() {
let arrangement = HubArrangement(hidden: ["watchlist", "collections"])
let arrangement = Arrangement(hidden: ["watchlist", "collections"])
let result = arrangement.apply(to: serverHubs)
#expect(result.map(\.hubIdentifier) == ["continue", "recent"])
#expect(result.map(\.id) == ["continue", "recent"])
}
@Test("Die gespeicherte Reihenfolge wird angewendet")
func storedOrderIsApplied() {
let arrangement = HubArrangement(order: ["watchlist", "continue", "recent", "collections"])
let arrangement = Arrangement(order: ["watchlist", "continue", "recent", "collections"])
let result = arrangement.apply(to: serverHubs)
#expect(result.map(\.hubIdentifier) == ["watchlist", "continue", "recent", "collections"])
#expect(result.map(\.id) == ["watchlist", "continue", "recent", "collections"])
}
@Test("Neue Reihen vom Server landen am Ende statt zu verschwinden")
@@ -35,53 +35,53 @@ struct HubArrangementTests {
// Der Fall, der die einfachere Regel — nur Bekanntes anzeigen — zur
// Falle macht: ein neu angelegtes Regal auf dem Server wäre in der App
// nie aufgetaucht, ohne dass jemand den Grund gesehen hätte.
let arrangement = HubArrangement(order: ["recent", "continue"])
let arrangement = Arrangement(order: ["recent", "continue"])
let result = arrangement.apply(to: serverHubs)
#expect(result.map(\.hubIdentifier) == ["recent", "continue", "watchlist", "collections"])
#expect(result.map(\.id) == ["recent", "continue", "watchlist", "collections"])
}
@Test("Mehrere neue Reihen behalten die Reihenfolge des Servers")
func multipleUnknownRowsKeepServerOrder() {
// Ohne stabile Rückfalllinie wäre ihre Anordnung zufällig — und
// änderte sich bei jedem Start.
let arrangement = HubArrangement(order: ["collections"])
let arrangement = Arrangement(order: ["collections"])
let result = arrangement.apply(to: serverHubs)
#expect(result.map(\.hubIdentifier) == ["collections", "continue", "recent", "watchlist"])
#expect(result.map(\.id) == ["collections", "continue", "recent", "watchlist"])
}
@Test("Gespeicherte Reihen, die es nicht mehr gibt, stören nicht")
func staleOrderEntriesAreIgnored() {
// Entsteht beim Löschen einer Mediathek auf dem Server.
let arrangement = HubArrangement(order: ["geloescht", "recent", "continue"])
let arrangement = Arrangement(order: ["geloescht", "recent", "continue"])
let result = arrangement.apply(to: serverHubs)
#expect(result.map(\.hubIdentifier) == ["recent", "continue", "watchlist", "collections"])
#expect(result.map(\.id) == ["recent", "continue", "watchlist", "collections"])
}
@Test("Ein doppelter Eintrag in der Reihenfolge stürzt nicht ab")
func duplicateOrderEntriesAreTolerated() {
// Kann nach einem abgebrochenen Schreibvorgang entstehen. Ein
// Dictionary aus doppelten Schlüsseln wäre ein harter Absturz.
let arrangement = HubArrangement(order: ["recent", "recent", "continue"])
let arrangement = Arrangement(order: ["recent", "recent", "continue"])
let result = arrangement.apply(to: serverHubs)
#expect(result.first?.hubIdentifier == "recent")
#expect(result.first?.id == "recent")
#expect(result.count == 4)
}
@Test("Ausblenden und Sortieren greifen zusammen")
func hidingAndOrderingCombine() {
let arrangement = HubArrangement(
let arrangement = Arrangement(
hidden: ["recent"],
order: ["collections", "recent", "continue"]
)
let result = arrangement.apply(to: serverHubs)
#expect(result.map(\.hubIdentifier) == ["collections", "continue", "watchlist"])
#expect(result.map(\.id) == ["collections", "continue", "watchlist"])
}
// MARK: - Die Liste für die Einstellungen
@Test("Der Einstellungsbildschirm zeigt Gespeichertes zuerst")
func settingsListPutsStoredFirst() {
let arrangement = HubArrangement(order: ["watchlist", "continue"])
let arrangement = Arrangement(order: ["watchlist", "continue"])
let identifiers = arrangement.arrangedIdentifiers(from: serverHubs)
#expect(identifiers == ["watchlist", "continue", "recent", "collections"])
}
@@ -90,14 +90,14 @@ struct HubArrangementTests {
func settingsListIncludesHidden() {
// Sonst ließe sich eine einmal ausgeblendete Reihe nie wieder
// einschalten — eine Einbahnstraße.
let arrangement = HubArrangement(hidden: ["recent"])
let arrangement = Arrangement(hidden: ["recent"])
let identifiers = arrangement.arrangedIdentifiers(from: serverHubs)
#expect(identifiers.contains("recent"))
}
@Test("Verschwundene Reihen erscheinen nicht in den Einstellungen")
func settingsListDropsStaleEntries() {
let arrangement = HubArrangement(order: ["geloescht", "recent"])
let arrangement = Arrangement(order: ["geloescht", "recent"])
let identifiers = arrangement.arrangedIdentifiers(from: serverHubs)
#expect(!identifiers.contains("geloescht"))
}