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:
co-authored by
Claude Opus 5
parent
effdcd4532
commit
fe2d730c00
@@ -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()
|
||||
}
|
||||
|
||||
@@ -3900,7 +3900,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"sort.lastViewed": {
|
||||
"sort.lastPlayed": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: " · ")
|
||||
|
||||
@@ -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)")]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user