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