Live-TV: die Senderliste wird gesucht, nicht geraten — und Serien richtig beschriftet
Vier geratene Adressen, vier 404. Statt ein fünftes Mal zu raten, klopft die App jetzt in einem Durchgang und gleichzeitig ein Dutzend Adressen ab: was /media/providers an Endpunkten angibt, welche Unterverzeichnisse Programmanbieter und Empfänger in ihrer eigenen Antwort führen, die bekannten Formen anderer Serverfassungen, die Senderliste des Tuners selbst — und zuletzt das Programmraster. Dabei zwei Fehlerquellen mitbehoben, die jede einzeln wie „keine Sender" ausgesehen hätten: der Sendername steht je nach Tunertyp in title, name, channelTitle oder callSign, und eine Antwort mit dreißig namenlosen Einträgen sieht aus wie eine Senderliste, ist aber keine. Bleibt es leer, steht jetzt jede abgeklopfte Adresse mit ihrer Antwort in der Ansicht. Das beantwortet „liegt es am Server oder an der App" ohne eine weitere Runde Rückfragen. Dazu drei Sachen bei Serien: - Groß steht, worum es geht — bei einer Folge ihr Titel, bei einer Staffel der Name der Serie. „Staffel 10" als Überschrift sagte nicht, welche Serie gemeint ist. - Die Einordnung steht darunter: Serienname bei Folgen, Staffelnummer bei Staffeln. - Von der Folge führt ein Weg zur Staffel und zur Serie. Wer über „Weiterschauen" bei einer Folge landete, saß dort bisher fest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
591cba73a7
commit
7e36f37506
@@ -442,6 +442,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"detail.toSeason": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Zur Staffel"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Go to season"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"detail.toSeries": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Zur Serie"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Go to series"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"detail.unwatchedCount %lld": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -1760,8 +1792,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"liveTV.channelCount %@": {
|
||||
"comment": "A label displaying the number of channels. The argument is the number of channels.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"liveTV.channelCount %lld": {
|
||||
"comment": "Platzhalter: Anzahl Sender",
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -1778,6 +1815,7 @@
|
||||
}
|
||||
},
|
||||
"liveTV.gap": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -1794,6 +1832,7 @@
|
||||
}
|
||||
},
|
||||
"liveTV.mode": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -1810,6 +1849,7 @@
|
||||
}
|
||||
},
|
||||
"liveTV.mode.guide": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -1826,6 +1866,7 @@
|
||||
}
|
||||
},
|
||||
"liveTV.mode.now": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -1941,6 +1982,7 @@
|
||||
}
|
||||
},
|
||||
"liveTV.now": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -1957,6 +1999,7 @@
|
||||
}
|
||||
},
|
||||
"liveTV.runningNow": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -1990,6 +2033,7 @@
|
||||
}
|
||||
},
|
||||
"liveTV.tune": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -2579,6 +2623,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"season.short %lld": {
|
||||
"comment": "Platzhalter: Staffelnummer",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Staffel %lld"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Season %lld"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"serverPicker.owned": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
|
||||
@@ -60,15 +60,24 @@ extension PlexItem {
|
||||
}
|
||||
|
||||
/// Die Beschriftung unter einer Kachel.
|
||||
///
|
||||
/// Sie ordnet ein, was darüber steht: zu einer Folge gehört die Serie, zu
|
||||
/// einer Staffel ihre Nummer. Die Reihenfolge ist Absicht — wird der
|
||||
/// Platz eng, bleibt der Serienname stehen und die Folgennummer fällt weg.
|
||||
var cardSubtitle: String? {
|
||||
switch kind {
|
||||
case .episode:
|
||||
var code: String?
|
||||
if let season = parentIndex, let episode = index {
|
||||
return String(localized: "episode.short \(season) \(episode)")
|
||||
code = String(localized: "episode.short \(season) \(episode)")
|
||||
}
|
||||
return grandparentTitle
|
||||
case .movie:
|
||||
return year.map(String.init)
|
||||
guard let series = grandparentTitle else { return code }
|
||||
return code.map { "\(series) · \($0)" } ?? series
|
||||
case .season:
|
||||
// Ohne die Serie darüber wäre „Staffel 10" beliebig — deshalb
|
||||
// steht die Nummer hier und der Serienname als Titel.
|
||||
if let index { return String(localized: "season.short \(index)") }
|
||||
return parentTitle == nil ? nil : title
|
||||
default:
|
||||
return year.map(String.init)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// Der Weg nach oben: von der Folge zur Staffel, von der Staffel zur Serie.
|
||||
///
|
||||
/// Bisher gab es ihn nicht. Wer über „Weiterschauen" oder „Zuletzt
|
||||
/// hinzugefügt" bei einer einzelnen Folge landete, saß dort fest — die
|
||||
/// übrigen Folgen derselben Staffel waren nur über den Umweg Bibliothek →
|
||||
/// Serie → Staffel erreichbar. Genau dieser Umweg ist der Grund, dass die
|
||||
/// Zeile existiert.
|
||||
struct ParentLinks: View {
|
||||
let item: PlexItem
|
||||
|
||||
var body: some View {
|
||||
if !destinations.isEmpty {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: FlixySpacing.snug) {
|
||||
ForEach(destinations, id: \.route.ratingKey) { destination in
|
||||
NavigationLink(value: destination.route) {
|
||||
Label {
|
||||
Text(destination.route.title)
|
||||
.lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: destination.symbol)
|
||||
}
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
.padding(.horizontal, FlixySpacing.regular)
|
||||
.padding(.vertical, FlixySpacing.snug)
|
||||
.background(Color.flixyElevated, in: .capsule)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
private struct Destination {
|
||||
let route: MediaRoute
|
||||
let symbol: String
|
||||
}
|
||||
|
||||
/// Die Stationen über diesem Eintrag.
|
||||
///
|
||||
/// Bei einer Folge zwei — Staffel und Serie; bei einer Staffel eine.
|
||||
/// Plex führt beide Kennungen am Eintrag mit, deshalb braucht es dafür
|
||||
/// keine zusätzliche Abfrage.
|
||||
private var destinations: [Destination] {
|
||||
switch item.kind {
|
||||
case .episode:
|
||||
var stops: [Destination] = []
|
||||
if let season = item.parentRatingKey {
|
||||
let title = item.parentIndex.map { String(localized: "season.short \($0)") }
|
||||
?? item.parentTitle
|
||||
?? String(localized: "detail.toSeason")
|
||||
stops.append(Destination(
|
||||
route: MediaRoute(ratingKey: season, title: title),
|
||||
symbol: "rectangle.stack"
|
||||
))
|
||||
}
|
||||
if let show = item.grandparentRatingKey {
|
||||
stops.append(Destination(
|
||||
route: MediaRoute(
|
||||
ratingKey: show,
|
||||
title: item.grandparentTitle ?? String(localized: "detail.toSeries")
|
||||
),
|
||||
symbol: "tv"
|
||||
))
|
||||
}
|
||||
return stops
|
||||
|
||||
case .season:
|
||||
guard let show = item.parentRatingKey else { return [] }
|
||||
return [Destination(
|
||||
route: MediaRoute(
|
||||
ratingKey: show,
|
||||
title: item.parentTitle ?? String(localized: "detail.toSeries")
|
||||
),
|
||||
symbol: "tv"
|
||||
)]
|
||||
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ struct PlayableDetailView: View {
|
||||
VStack(alignment: .leading, spacing: FlixySpacing.section) {
|
||||
header
|
||||
|
||||
ParentLinks(item: item)
|
||||
|
||||
if item.hasMultipleVersions {
|
||||
VersionPicker(media: item.media, selection: $selectedVersion)
|
||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
||||
@@ -104,8 +106,13 @@ struct PlayableDetailView: View {
|
||||
|
||||
private var metadataLine: String? {
|
||||
var pieces: [String] = []
|
||||
if item.kind == .episode, let season = item.parentIndex, let episode = item.index {
|
||||
pieces.append(String(localized: "episode.short \(season) \(episode)"))
|
||||
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 season = item.parentIndex, let episode = item.index {
|
||||
pieces.append(String(localized: "episode.short \(season) \(episode)"))
|
||||
}
|
||||
}
|
||||
if let year = item.year { pieces.append(String(year)) }
|
||||
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
|
||||
|
||||
@@ -17,6 +17,8 @@ struct SeriesDetailView: View {
|
||||
VStack(alignment: .leading, spacing: FlixySpacing.section) {
|
||||
header
|
||||
|
||||
ParentLinks(item: item)
|
||||
|
||||
if let summary = item.summary, !summary.isEmpty {
|
||||
Text(summary)
|
||||
.font(.flixyBody)
|
||||
@@ -38,7 +40,7 @@ struct SeriesDetailView: View {
|
||||
|
||||
private var header: some View {
|
||||
HeroBanner(
|
||||
title: item.title,
|
||||
title: item.rowTitle,
|
||||
metadata: metadataLine,
|
||||
summary: nil,
|
||||
artworkURL: session.server?.artworkURL(
|
||||
@@ -87,6 +89,11 @@ struct SeriesDetailView: View {
|
||||
|
||||
private var metadataLine: String? {
|
||||
var pieces: [String] = []
|
||||
// Bei einer Staffel steht oben der Serienname — welche Staffel es
|
||||
// ist, muss deshalb hierher.
|
||||
if item.kind == .season, let number = item.index {
|
||||
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)"))
|
||||
|
||||
@@ -79,11 +79,19 @@ struct MediaRow: View {
|
||||
extension PlexItem {
|
||||
/// Der Titel auf einer Kachel in einer Reihe.
|
||||
///
|
||||
/// Bei Folgen der Serientitel: in „Weiterschauen" stehen Folgen
|
||||
/// verschiedener Serien nebeneinander, und der Folgentitel allein sagt
|
||||
/// nicht, worum es geht.
|
||||
/// Groß steht, worum es geht: bei einer Folge ihr eigener Titel, bei
|
||||
/// einer Staffel der Name der Serie. Die Einordnung — Serie, Staffel —
|
||||
/// gehört darunter. „Staffel 10" allein als Überschrift sagt nichts
|
||||
/// darüber, welche Serie gemeint ist.
|
||||
var rowTitle: String {
|
||||
kind == .episode ? (grandparentTitle ?? title) : title
|
||||
switch kind {
|
||||
case .episode: title
|
||||
// Die Serie steht je nach Antwort mal unter `parentTitle`, mal unter
|
||||
// `grandparentTitle`. Fehlt beides, bleibt „Staffel 2" — besser als
|
||||
// gar keine Beschriftung.
|
||||
case .season: parentTitle ?? grandparentTitle ?? title
|
||||
default: title
|
||||
}
|
||||
}
|
||||
|
||||
/// Welches Bild auf die Querformat-Kachel gehört.
|
||||
|
||||
@@ -14,6 +14,8 @@ struct LiveTVView: View {
|
||||
@State private var channels: [PlexChannel] = []
|
||||
/// Woher die Senderliste kam — steht in der leeren Ansicht.
|
||||
@State private var channelSource: String?
|
||||
/// Jede abgeklopfte Adresse mit ihrer Antwort.
|
||||
@State private var trace: [ChannelProbe] = []
|
||||
@State private var programs: [String: [PlexProgram]] = [:]
|
||||
@State private var isTuning: String?
|
||||
/// Raster oder Senderliste.
|
||||
@@ -55,19 +57,12 @@ struct LiveTVView: View {
|
||||
ContentUnavailableView {
|
||||
Label("liveTV.noChannels.title", systemImage: "tv")
|
||||
} description: {
|
||||
VStack(spacing: FlixySpacing.snug) {
|
||||
Text("liveTV.noChannels.description")
|
||||
if let channelSource {
|
||||
// Ohne diese Angabe ist „keine Sender" eine
|
||||
// Sackgasse: es sagt nicht, ob der Server nichts
|
||||
// hergibt oder ob die App am falschen Ort sucht.
|
||||
Text(channelSource)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
}
|
||||
}
|
||||
Text("liveTV.noChannels.description")
|
||||
} actions: {
|
||||
Button("action.retry") { Task { await load() } }
|
||||
VStack(spacing: FlixySpacing.regular) {
|
||||
Button("action.retry") { Task { await load() } }
|
||||
searchTrace
|
||||
}
|
||||
}
|
||||
|
||||
case .available:
|
||||
@@ -102,6 +97,50 @@ struct LiveTVView: View {
|
||||
.refreshable { await load() }
|
||||
}
|
||||
|
||||
/// Wo überall gesucht wurde, und was dort stand.
|
||||
///
|
||||
/// Unbequem, aber nützlich: die Live-TV-Schnittstelle von Plex hat keine
|
||||
/// Beschreibung, und „keine Sender" allein ist eine Sackgasse. Hier steht
|
||||
/// jede abgeklopfte Adresse mit ihrer Antwort — das beantwortet die Frage
|
||||
/// „liegt es am Server oder an der App" ohne eine weitere Runde.
|
||||
@ViewBuilder
|
||||
private var searchTrace: some View {
|
||||
if !trace.isEmpty {
|
||||
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
|
||||
if let channelSource {
|
||||
Text(channelSource)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
ForEach(trace) { probe in
|
||||
Text("\(probe.path) → \(probe.result)")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
.lineLimit(2)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 200)
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = trace
|
||||
.map { "\($0.path) → \($0.result)" }
|
||||
.joined(separator: "\n")
|
||||
} label: {
|
||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||
.font(.flixyCaption)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
||||
}
|
||||
}
|
||||
|
||||
private var channelList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
@@ -138,6 +177,7 @@ struct LiveTVView: View {
|
||||
channels = guide.channels
|
||||
programs = guide.programs
|
||||
channelSource = guide.source
|
||||
trace = guide.trace
|
||||
}
|
||||
|
||||
private func tune(_ channel: PlexChannel) async {
|
||||
|
||||
Reference in New Issue
Block a user