Eine Sekunde heisst nicht „Weiterschauen"
Die Schwelle aus den Einstellungen wirkt serverseitig, also sofort überall: `/home/continue` liest die gespeicherte Spalte. Eine Stelle blieb trotzdem widersprüchlich — die **Beschriftung**. Web und Apps entschieden „Weiterschauen" gegen „Abspielen" selbst, allein daran, ob eine Stelle über null steht. Nach einer Sekunde stand der Titel damit nicht mehr in der Reihe, hiess aber weiter „Weiterschauen", trug einen Fortschrittsbalken unter der Kachel und bot „Aus Weiterschauen entfernen" an. Der Server hatte diese Frage längst beantwortet. `ItemStateResponse` bringt sie jetzt mit; `MediaItem.isResumable` und `.resumeProgress` sind die einzigen Stellen, die sie noch stellen. Im Simulator nachgeprüft: nach einer Sekunde heisst der Knopf „Abspielen", „Von vorn" fehlt, und der Balken unter der Kachel ist weg. Kennt ein Server das Feld nicht — Jellyfin, oder ein älterer Flaro —, gilt wie früher die blosse Stelle. **Und das Profilbild steht in den Apps.** `FlaroAccount` führt es mit, `/auth/me` bringt es bei jedem Start frisch, und der Buchstabenkreis tritt zurück, sobald eines da ist. Jellyfin bleibt beim Buchstaben — dort gibt es kein Profilbild, und das ist keine Lücke, sondern der Stand der Dinge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
579a49b73b
commit
e30158b84c
@@ -130,6 +130,16 @@ final class FlaroSession {
|
|||||||
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
|
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
|
||||||
var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName }
|
var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName }
|
||||||
|
|
||||||
|
/// Das Profilbild des angemeldeten Kontos, als Adresse.
|
||||||
|
///
|
||||||
|
/// **Nur Flaro hat eines.** Jellyfin gibt kein Profilbild heraus; dort
|
||||||
|
/// bleibt es beim Buchstaben, und das ist keine Lücke, sondern der Stand
|
||||||
|
/// der Dinge.
|
||||||
|
var accountAvatarURL: URL? {
|
||||||
|
guard let schluessel = flaroAccount?.avatarImage else { return nil }
|
||||||
|
return backend?.rawImageURL(path: schluessel)
|
||||||
|
}
|
||||||
|
|
||||||
/// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos
|
/// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos
|
||||||
/// erscheinen gar nicht erst.
|
/// erscheinen gar nicht erst.
|
||||||
private(set) var libraries: [MediaLibrary] = []
|
private(set) var libraries: [MediaLibrary] = []
|
||||||
|
|||||||
@@ -155,8 +155,8 @@ final class HomeFeed {
|
|||||||
case .continueFirst:
|
case .continueFirst:
|
||||||
// Innerhalb der Gruppen ziehen, nicht über sie hinweg:
|
// Innerhalb der Gruppen ziehen, nicht über sie hinweg:
|
||||||
// Angefangenes bleibt vorn, wechselt aber untereinander.
|
// Angefangenes bleibt vorn, wechselt aber untereinander.
|
||||||
let ordered = HeroSelection.ordered(unique.filter { $0.watchProgress != nil })
|
let ordered = HeroSelection.ordered(unique.filter(\.isResumable))
|
||||||
+ HeroSelection.ordered(unique.filter { $0.watchProgress == nil })
|
+ HeroSelection.ordered(unique.filter { !$0.isResumable })
|
||||||
return Array(ordered.prefix(layout.heroCount))
|
return Array(ordered.prefix(layout.heroCount))
|
||||||
case .latestOnly:
|
case .latestOnly:
|
||||||
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
|
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ enum TopShelfWriter {
|
|||||||
width: posterWidth,
|
width: posterWidth,
|
||||||
height: posterHeight
|
height: posterHeight
|
||||||
),
|
),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ struct ItemActionEntries: View {
|
|||||||
// **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle
|
// **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle
|
||||||
// stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den
|
// stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den
|
||||||
// Fehler bei sich.
|
// Fehler bei sich.
|
||||||
if item.watchProgress != nil {
|
if item.isResumable {
|
||||||
Button {
|
Button {
|
||||||
run { try await $0.removeFromContinueWatching(item) }
|
run { try await $0.removeFromContinueWatching(item) }
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ struct PlayableDetailView: View {
|
|||||||
playback.play(item, mediaIndex: selectedVersion)
|
playback.play(item, mediaIndex: selectedVersion)
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Label(
|
||||||
item.watchProgress == nil ? "hero.play" : "hero.resume",
|
item.isResumable ? "hero.resume" : "hero.play",
|
||||||
systemImage: "play.fill"
|
systemImage: "play.fill"
|
||||||
)
|
)
|
||||||
.font(.flaroCardTitle)
|
.font(.flaroCardTitle)
|
||||||
@@ -117,7 +117,9 @@ struct PlayableDetailView: View {
|
|||||||
.tint(accent)
|
.tint(accent)
|
||||||
.disabled(currentMedia?.files.first == nil)
|
.disabled(currentMedia?.files.first == nil)
|
||||||
|
|
||||||
if let progress = item.watchProgress, progress > 0 {
|
// „Von vorn" nur, wenn es ein „weiter" gibt — dieselbe
|
||||||
|
// Entscheidung wie beim Knopf daneben.
|
||||||
|
if item.isResumable {
|
||||||
Button {
|
Button {
|
||||||
playback.play(item, mediaIndex: selectedVersion, resuming: false)
|
playback.play(item, mediaIndex: selectedVersion, resuming: false)
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ private struct ChildRow: View {
|
|||||||
.frame(width: item.kind == .season ? 48 : 96)
|
.frame(width: item.kind == .season ? 48 : 96)
|
||||||
.clipShape(.rect(cornerRadius: 4))
|
.clipShape(.rect(cornerRadius: 4))
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
if let progress = item.watchProgress {
|
if let progress = item.resumeProgress {
|
||||||
WatchProgressBar(fraction: progress)
|
WatchProgressBar(fraction: progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ struct HeroSection: View {
|
|||||||
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
|
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
|
||||||
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
|
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
|
||||||
private var playLabel: LocalizedStringKey {
|
private var playLabel: LocalizedStringKey {
|
||||||
item.watchProgress == nil ? "hero.play" : "hero.resume"
|
item.isResumable ? "hero.resume" : "hero.play"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var metadata: String? { Self.metadata(for: item) }
|
private var metadata: String? { Self.metadata(for: item) }
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ struct MediaRow: View {
|
|||||||
width: 440,
|
width: 440,
|
||||||
height: 248
|
height: 248
|
||||||
),
|
),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
)
|
)
|
||||||
.frame(width: landscapeWidth)
|
.frame(width: landscapeWidth)
|
||||||
} else {
|
} else {
|
||||||
@@ -73,7 +73,7 @@ struct MediaRow: View {
|
|||||||
width: 300,
|
width: 300,
|
||||||
height: 450
|
height: 450
|
||||||
),
|
),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
)
|
)
|
||||||
.frame(width: posterWidth)
|
.frame(width: posterWidth)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ struct LibraryView: View {
|
|||||||
title: item.title,
|
title: item.title,
|
||||||
subtitle: item.cardSubtitle,
|
subtitle: item.cardSubtitle,
|
||||||
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
|
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.task { await loadMoreIfNeeded(reaching: item) }
|
.task { await loadMoreIfNeeded(reaching: item) }
|
||||||
|
|||||||
@@ -1,24 +1,46 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
|
||||||
/// Der erste Buchstabe auf einer Scheibe.
|
/// Das eigene Bild auf einer Scheibe — oder der erste Buchstabe.
|
||||||
///
|
///
|
||||||
/// **Kein Platzhalterbild.** Jellyfin gibt kein Profilbild heraus, ohne dass
|
/// **Der Buchstabe ist keine Notlösung.** Jellyfin gibt kein Profilbild
|
||||||
/// eines hinterlegt ist, und am Konto führt die App heute keines mit. Ein
|
/// heraus, und auch an einem Flaro-Konto muss keines hängen. Ein Buchstabe
|
||||||
/// Buchstabe ist ehrlicher als eine graue Silhouette, die bei jedem gleich
|
/// ist ehrlicher als eine graue Silhouette, die bei jedem gleich aussieht —
|
||||||
/// aussieht — und man erkennt an ihm wenigstens, mit welchem Konto man
|
/// und man erkennt an ihm wenigstens, mit welchem Konto man angemeldet ist.
|
||||||
/// angemeldet ist.
|
|
||||||
///
|
///
|
||||||
/// Stand bis eben in `AccountBar` und wird jetzt zweimal gebraucht: dort in
|
/// Steht ein Bild im Konto, tritt es an seine Stelle. Lädt es nicht, kommt
|
||||||
/// der Seitenleiste und auf dem iPhone oben rechts, wo seit dem Wegfall des
|
/// der Buchstabe zurück: eine leere Scheibe sagt weniger als ein P.
|
||||||
/// Einstellungen-Tabs der Weg dorthin liegt.
|
///
|
||||||
|
/// Stand einmal in `AccountBar` und wird inzwischen dreimal gebraucht: dort in
|
||||||
|
/// der Seitenleiste, auf dem iPhone oben rechts und in der Werkzeugleiste.
|
||||||
struct AccountAvatar: View {
|
struct AccountAvatar: View {
|
||||||
let name: String
|
let name: String
|
||||||
var diameter: CGFloat = 26
|
var diameter: CGFloat = 26
|
||||||
|
/// Das Bild des Kontos, falls eines hinterlegt ist.
|
||||||
|
var image: URL?
|
||||||
|
|
||||||
@Environment(\.flaroAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
buchstabe
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.background(accent, in: .circle)
|
||||||
|
.overlay {
|
||||||
|
if let image {
|
||||||
|
AsyncImage(url: image) { bild in
|
||||||
|
bild.resizable().scaledToFill()
|
||||||
|
} placeholder: {
|
||||||
|
// Nichts: darunter liegt der Buchstabe und füllt die
|
||||||
|
// Lücke, bis das Bild da ist.
|
||||||
|
Color.clear
|
||||||
|
}
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.clipShape(.circle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buchstabe: some View {
|
||||||
Text(name.prefix(1).uppercased())
|
Text(name.prefix(1).uppercased())
|
||||||
// Mit der Scheibe wachsen, nicht fest: oben rechts auf dem
|
// Mit der Scheibe wachsen, nicht fest: oben rechts auf dem
|
||||||
// Telefon ist er grösser als in der Leiste, und ein Buchstabe in
|
// Telefon ist er grösser als in der Leiste, und ein Buchstabe in
|
||||||
@@ -26,8 +48,6 @@ struct AccountAvatar: View {
|
|||||||
// Fehler.
|
// Fehler.
|
||||||
.font(.system(size: diameter * 0.46, weight: .semibold))
|
.font(.system(size: diameter * 0.46, weight: .semibold))
|
||||||
.foregroundStyle(Color.flaroCanvas)
|
.foregroundStyle(Color.flaroCanvas)
|
||||||
.frame(width: diameter, height: diameter)
|
|
||||||
.background(accent, in: .circle)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +73,8 @@ struct AccountToolbarButton: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: openSettings) {
|
Button(action: openSettings) {
|
||||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
|
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
|
||||||
|
image: session.accountAvatarURL)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(Text("sidebar.settings"))
|
.accessibilityLabel(Text("sidebar.settings"))
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ struct AccountBar: View {
|
|||||||
isExpanded.toggle()
|
isExpanded.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: FlaroSpacing.snug) {
|
HStack(spacing: FlaroSpacing.snug) {
|
||||||
AccountAvatar(name: title)
|
AccountAvatar(name: title, image: session.accountAvatarURL)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text(title)
|
Text(title)
|
||||||
|
|||||||
@@ -514,7 +514,8 @@ struct RootView: View {
|
|||||||
Button {
|
Button {
|
||||||
showsSettings = true
|
showsSettings = true
|
||||||
} label: {
|
} label: {
|
||||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
|
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
|
||||||
|
image: session.accountAvatarURL)
|
||||||
.padding(FlaroSpacing.snug)
|
.padding(FlaroSpacing.snug)
|
||||||
// Damit er auch über einem hellen Aufmacher steht.
|
// Damit er auch über einem hellen Aufmacher steht.
|
||||||
.background(.black.opacity(0.28), in: .circle)
|
.background(.black.opacity(0.28), in: .circle)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ struct TVHeroSection: View {
|
|||||||
|
|
||||||
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde.
|
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde.
|
||||||
private var abspielTitel: LocalizedStringKey {
|
private var abspielTitel: LocalizedStringKey {
|
||||||
item.watchProgress == nil ? "hero.play" : "hero.resume"
|
item.isResumable ? "hero.resume" : "hero.play"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var metadaten: String? {
|
private var metadaten: String? {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ struct TVMediaRow: View {
|
|||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: session.backend?.artworkURL(
|
||||||
path: item.rowArtworkPath, width: 880, height: 496
|
path: item.rowArtworkPath, width: 880, height: 496
|
||||||
),
|
),
|
||||||
progress: item.watchProgress,
|
progress: item.resumeProgress,
|
||||||
onFocusChange: { if $0 { onFocus(item) } }
|
onFocusChange: { if $0 { onFocus(item) } }
|
||||||
) { onSelect(item) }
|
) { onSelect(item) }
|
||||||
} else {
|
} else {
|
||||||
@@ -65,7 +65,7 @@ struct TVMediaRow: View {
|
|||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: session.backend?.artworkURL(
|
||||||
path: item.thumb, width: 560, height: 840
|
path: item.thumb, width: 560, height: 840
|
||||||
),
|
),
|
||||||
progress: item.watchProgress,
|
progress: item.resumeProgress,
|
||||||
onFocusChange: { if $0 { onFocus(item) } }
|
onFocusChange: { if $0 { onFocus(item) } }
|
||||||
) { onSelect(item) }
|
) { onSelect(item) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ struct TVLibraryView: View {
|
|||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: session.backend?.artworkURL(
|
||||||
path: item.posterArtworkPath, width: 560, height: 840
|
path: item.posterArtworkPath, width: 560, height: 840
|
||||||
),
|
),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
) {
|
) {
|
||||||
// **Über QuickPlay, nicht unmittelbar.** Steht im
|
// **Über QuickPlay, nicht unmittelbar.** Steht im
|
||||||
// Raster eine Serie, hat die keine Fassungen; der
|
// Raster eine Serie, hat die keine Fassungen; der
|
||||||
|
|||||||
@@ -443,17 +443,26 @@ public struct FlaroAccount: Sendable, Equatable {
|
|||||||
public let userName: String
|
public let userName: String
|
||||||
public let isAdministrator: Bool
|
public let isAdministrator: Bool
|
||||||
|
|
||||||
|
/// Das eigene Bild, als Schlüssel in die Bildablage — `nil`: der Buchstabe.
|
||||||
|
///
|
||||||
|
/// **Mit Vorgabewert, damit Jellyfin unberührt bleibt.** Dort gibt es
|
||||||
|
/// kein Profilbild, und ein Pflichtfeld hier zwänge jeden Aufrufer, sich
|
||||||
|
/// dazu zu äussern.
|
||||||
|
public let avatarImage: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
baseURL: URL,
|
baseURL: URL,
|
||||||
serverName: String,
|
serverName: String,
|
||||||
userID: String,
|
userID: String,
|
||||||
userName: String,
|
userName: String,
|
||||||
isAdministrator: Bool
|
isAdministrator: Bool,
|
||||||
|
avatarImage: String? = nil
|
||||||
) {
|
) {
|
||||||
self.baseURL = baseURL
|
self.baseURL = baseURL
|
||||||
self.serverName = serverName
|
self.serverName = serverName
|
||||||
self.userID = userID
|
self.userID = userID
|
||||||
self.userName = userName
|
self.userName = userName
|
||||||
self.isAdministrator = isAdministrator
|
self.isAdministrator = isAdministrator
|
||||||
|
self.avatarImage = avatarImage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ enum Wire {
|
|||||||
let id: String
|
let id: String
|
||||||
let userName: String
|
let userName: String
|
||||||
let isAdministrator: Bool
|
let isAdministrator: Bool
|
||||||
|
/// Das Profilbild, als Schlüssel in die Bildablage des Servers.
|
||||||
|
let avatarImage: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Library: Decodable, Sendable {
|
struct Library: Decodable, Sendable {
|
||||||
@@ -78,6 +80,12 @@ enum Wire {
|
|||||||
let rating: Int?
|
let rating: Int?
|
||||||
let dismissed: Bool
|
let dismissed: Bool
|
||||||
let lastPlayedAt: Date?
|
let lastPlayedAt: Date?
|
||||||
|
/// Ob der Server den Titel in „Weiterschauen" führt.
|
||||||
|
///
|
||||||
|
/// **Nicht dasselbe wie „Stelle über null".** Seit es eine
|
||||||
|
/// einstellbare Untergrenze gibt, sind das zwei Fragen: eine Sekunde
|
||||||
|
/// wird gemerkt, zählt aber nicht als angefangen.
|
||||||
|
let inProgress: Bool?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Movie: Decodable, Sendable {
|
struct Movie: Decodable, Sendable {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ extension Wire.Movie {
|
|||||||
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||||
lastPlayedAt: state?.lastPlayedAt,
|
lastPlayedAt: state?.lastPlayedAt,
|
||||||
isFavourite: state?.favourite ?? false,
|
isFavourite: state?.favourite ?? false,
|
||||||
|
isInProgress: state?.inProgress,
|
||||||
availableFrom: publishAt,
|
availableFrom: publishAt,
|
||||||
versions: [Wire.fassung(id: id, files: files)]
|
versions: [Wire.fassung(id: id, files: files)]
|
||||||
)
|
)
|
||||||
@@ -69,6 +70,7 @@ extension Wire.Episode {
|
|||||||
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||||
lastPlayedAt: state?.lastPlayedAt,
|
lastPlayedAt: state?.lastPlayedAt,
|
||||||
isFavourite: state?.favourite ?? false,
|
isFavourite: state?.favourite ?? false,
|
||||||
|
isInProgress: state?.inProgress,
|
||||||
availableFrom: publishAt,
|
availableFrom: publishAt,
|
||||||
versions: [Wire.fassung(id: id, files: files)]
|
versions: [Wire.fassung(id: id, files: files)]
|
||||||
)
|
)
|
||||||
@@ -104,6 +106,7 @@ extension Wire.Started {
|
|||||||
resumeMilliseconds: Int(state.positionSeconds * 1000),
|
resumeMilliseconds: Int(state.positionSeconds * 1000),
|
||||||
lastPlayedAt: state.lastPlayedAt,
|
lastPlayedAt: state.lastPlayedAt,
|
||||||
isFavourite: state.favourite,
|
isFavourite: state.favourite,
|
||||||
|
isInProgress: state.inProgress,
|
||||||
availableFrom: publishAt
|
availableFrom: publishAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ public enum FlaroSignIn {
|
|||||||
serverName: gruss.serverName,
|
serverName: gruss.serverName,
|
||||||
userID: antwort.user.id,
|
userID: antwort.user.id,
|
||||||
userName: antwort.user.userName,
|
userName: antwort.user.userName,
|
||||||
isAdministrator: antwort.user.isAdministrator), client)
|
isAdministrator: antwort.user.isAdministrator,
|
||||||
|
avatarImage: antwort.user.avatarImage), client)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nimmt ein gespeichertes Token wieder auf.
|
/// Nimmt ein gespeichertes Token wieder auf.
|
||||||
@@ -111,7 +112,8 @@ public enum FlaroSignIn {
|
|||||||
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
||||||
userID: wer.id,
|
userID: wer.id,
|
||||||
userName: wer.userName,
|
userName: wer.userName,
|
||||||
isAdministrator: wer.isAdministrator), client)
|
isAdministrator: wer.isAdministrator,
|
||||||
|
avatarImage: wer.avatarImage), client)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Der Weg über einen Code
|
// MARK: - Der Weg über einen Code
|
||||||
@@ -179,7 +181,8 @@ public enum FlaroSignIn {
|
|||||||
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
||||||
userID: anmeldung.user.id,
|
userID: anmeldung.user.id,
|
||||||
userName: anmeldung.user.userName,
|
userName: anmeldung.user.userName,
|
||||||
isAdministrator: anmeldung.user.isAdministrator)), client)
|
isAdministrator: anmeldung.user.isAdministrator,
|
||||||
|
avatarImage: anmeldung.user.avatarImage)), client)
|
||||||
case "expired":
|
case "expired":
|
||||||
return (.expired, nil)
|
return (.expired, nil)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -71,6 +71,18 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
/// eine Fähigkeit, die es nur auf einem gibt.
|
/// eine Fähigkeit, die es nur auf einem gibt.
|
||||||
public let isFavourite: Bool
|
public let isFavourite: Bool
|
||||||
|
|
||||||
|
/// Ob der Server den Titel als angefangen führt.
|
||||||
|
///
|
||||||
|
/// **Die Entscheidung gehört dem Server, nicht der Beschriftung.** „Hat
|
||||||
|
/// eine Stelle über null" und „ist angefangen" waren dasselbe, bis es eine
|
||||||
|
/// einstellbare Untergrenze gab. Seitdem heisst ein Titel, in den jemand
|
||||||
|
/// eine Sekunde hineingeschaut hat, nicht mehr „Weiterschauen" — er steht
|
||||||
|
/// ja auch nicht mehr in der Reihe.
|
||||||
|
///
|
||||||
|
/// `nil` heisst: der Server sagt nichts dazu (Jellyfin, oder ein älterer
|
||||||
|
/// Flaro). Dann gilt wie früher die blosse Stelle.
|
||||||
|
public let isInProgress: Bool?
|
||||||
|
|
||||||
/// Ab wann es den Titel gibt — `nil` heisst: jetzt.
|
/// Ab wann es den Titel gibt — `nil` heisst: jetzt.
|
||||||
///
|
///
|
||||||
/// **Ein geplanter Titel steht in der Mediathek, läuft aber nicht.** Das
|
/// **Ein geplanter Titel steht in der Mediathek, läuft aber nicht.** Das
|
||||||
@@ -125,6 +137,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
lastPlayedAt: Date? = nil,
|
lastPlayedAt: Date? = nil,
|
||||||
addedAt: Date? = nil,
|
addedAt: Date? = nil,
|
||||||
isFavourite: Bool = false,
|
isFavourite: Bool = false,
|
||||||
|
isInProgress: Bool? = nil,
|
||||||
availableFrom: Date? = nil,
|
availableFrom: Date? = nil,
|
||||||
episodeCount: Int? = nil,
|
episodeCount: Int? = nil,
|
||||||
playedEpisodeCount: Int? = nil,
|
playedEpisodeCount: Int? = nil,
|
||||||
@@ -164,6 +177,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
self.lastPlayedAt = lastPlayedAt
|
self.lastPlayedAt = lastPlayedAt
|
||||||
self.addedAt = addedAt
|
self.addedAt = addedAt
|
||||||
self.isFavourite = isFavourite
|
self.isFavourite = isFavourite
|
||||||
|
self.isInProgress = isInProgress
|
||||||
self.availableFrom = availableFrom
|
self.availableFrom = availableFrom
|
||||||
self.episodeCount = episodeCount
|
self.episodeCount = episodeCount
|
||||||
self.playedEpisodeCount = playedEpisodeCount
|
self.playedEpisodeCount = playedEpisodeCount
|
||||||
@@ -235,6 +249,20 @@ extension MediaItem {
|
|||||||
|
|
||||||
public var isWatched: Bool { (playCount ?? 0) > 0 }
|
public var isWatched: Bool { (playCount ?? 0) > 0 }
|
||||||
|
|
||||||
|
/// Ob die Beschriftung „Weiterschauen" heissen soll.
|
||||||
|
///
|
||||||
|
/// Eine Stelle, die beide Fälle kennt: was der Server sagt, und was ohne
|
||||||
|
/// seine Auskunft gilt. Stünde der Zweifel an jedem Knopf einzeln, hiesse
|
||||||
|
/// derselbe Titel auf der Startseite anders als auf seiner Seite.
|
||||||
|
public var isResumable: Bool { isInProgress ?? (watchProgress != nil) }
|
||||||
|
|
||||||
|
/// Der Balken unter der Kachel — nur, wenn der Titel wirklich angefangen ist.
|
||||||
|
///
|
||||||
|
/// **Sonst sagt die Kachel etwas anderes als der Knopf.** Ein Strich unter
|
||||||
|
/// dem Plakat heisst „du bist mittendrin". Für eine Sekunde, in die jemand
|
||||||
|
/// hineingeschaut hat, ist das keine Auskunft, sondern ein Fussel.
|
||||||
|
public var resumeProgress: Double? { isResumable ? watchProgress : nil }
|
||||||
|
|
||||||
/// Ob der Titel erst noch kommt.
|
/// Ob der Titel erst noch kommt.
|
||||||
///
|
///
|
||||||
/// Gefragt wird an einer Stelle, beantwortet an einer: sonst steht an
|
/// Gefragt wird an einer Stelle, beantwortet an einer: sonst steht an
|
||||||
|
|||||||
@@ -634,7 +634,21 @@ public sealed record RatingRequest(int? Rating);
|
|||||||
public sealed record ItemStateResponse(
|
public sealed record ItemStateResponse(
|
||||||
double PositionSeconds, double? RuntimeSeconds,
|
double PositionSeconds, double? RuntimeSeconds,
|
||||||
bool Watched, int PlayCount, bool Favourite, int? Rating, bool Dismissed,
|
bool Watched, int PlayCount, bool Favourite, int? Rating, bool Dismissed,
|
||||||
DateTimeOffset? LastPlayedAt);
|
DateTimeOffset? LastPlayedAt,
|
||||||
|
|
||||||
|
/// <summary>Ob der Titel in „Weiterschauen" steht.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Damit die Beschriftung dasselbe sagt wie die Reihe.</b> Web und Apps
|
||||||
|
/// entschieden „Weiterschauen" gegen „Abspielen" selbst, allein daran, ob
|
||||||
|
/// eine Stelle über null steht. Seit es eine einstellbare Untergrenze gibt,
|
||||||
|
/// lief das auseinander: ein Titel, in den man eine Sekunde hineingeschaut
|
||||||
|
/// hat, stand nicht mehr in der Reihe — hiess aber weiter „Weiterschauen".
|
||||||
|
///
|
||||||
|
/// Der Server hat diese Frage ohnehin beantwortet; sie noch einmal zu
|
||||||
|
/// stellen hiesse, dieselbe Regel an vier Stellen zu pflegen.
|
||||||
|
/// </remarks>
|
||||||
|
bool InProgress = false);
|
||||||
|
|
||||||
/// <summary>Eine Zeile in „Weiterschauen".</summary>
|
/// <summary>Eine Zeile in „Weiterschauen".</summary>
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -781,5 +781,6 @@ public static class PlaybackEndpoints
|
|||||||
|
|
||||||
internal static ItemStateResponse Abbild(UserItemState z) => new(
|
internal static ItemStateResponse Abbild(UserItemState z) => new(
|
||||||
z.Position.TotalSeconds, z.Runtime?.TotalSeconds,
|
z.Position.TotalSeconds, z.Runtime?.TotalSeconds,
|
||||||
z.Watched, z.PlayCount, z.Favourite, z.Rating, z.Dismissed, z.LastPlayedAt);
|
z.Watched, z.PlayCount, z.Favourite, z.Rating, z.Dismissed, z.LastPlayedAt,
|
||||||
|
z.InProgress);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ export type Stand = {
|
|||||||
watched: boolean; playCount: number;
|
watched: boolean; playCount: number;
|
||||||
favourite: boolean; rating: number | null; dismissed: boolean;
|
favourite: boolean; rating: number | null; dismissed: boolean;
|
||||||
lastPlayedAt: string | null;
|
lastPlayedAt: string | null;
|
||||||
|
/** Ob der Titel in „Fortsetzen" steht — die Entscheidung des Servers.
|
||||||
|
*
|
||||||
|
* **Nicht `positionSeconds > 0`.** Seit es eine einstellbare Untergrenze
|
||||||
|
* gibt, sind das zwei verschiedene Fragen: eine Sekunde wird gemerkt, zählt
|
||||||
|
* aber nicht als angefangen. Wer hier selbst rechnet, beschriftet den Knopf
|
||||||
|
* anders, als die Startseite entscheidet. */
|
||||||
|
inProgress?: boolean;
|
||||||
};
|
};
|
||||||
export type Mitwirkender = {
|
export type Mitwirkender = {
|
||||||
id: string; name: string;
|
id: string; name: string;
|
||||||
|
|||||||
+6
-1
@@ -25,6 +25,8 @@ export type Heroeintrag = {
|
|||||||
laenge: number | null;
|
laenge: number | null;
|
||||||
/** Wo man stehengeblieben ist. */
|
/** Wo man stehengeblieben ist. */
|
||||||
stelle: number;
|
stelle: number;
|
||||||
|
/** Ob der Server den Titel als angefangen führt — die Schwelle ist einstellbar. */
|
||||||
|
angefangen: boolean;
|
||||||
/** Wem der Fortschritt gehört: bei einer Folge der Folge, nicht der Serie. */
|
/** Wem der Fortschritt gehört: bei einer Folge der Folge, nicht der Serie. */
|
||||||
standId: string;
|
standId: string;
|
||||||
};
|
};
|
||||||
@@ -227,7 +229,7 @@ export function hero(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const beschriftung = e.dateiId === null ? 'Ansehen'
|
const beschriftung = e.dateiId === null ? 'Ansehen'
|
||||||
: e.stelle > 0 ? `Fortsetzen ab ${dauer(e.stelle)}`
|
: e.angefangen ? `Fortsetzen ab ${dauer(e.stelle)}`
|
||||||
: 'Abspielen';
|
: 'Abspielen';
|
||||||
spielen.textContent = beschriftung;
|
spielen.textContent = beschriftung;
|
||||||
spielen.prepend(spielIcon);
|
spielen.prepend(spielIcon);
|
||||||
@@ -332,6 +334,7 @@ export function alsHero(eintrag: Film | Serie, art: 'filme' | 'serien'): Heroein
|
|||||||
dateiId: datei?.id ?? null,
|
dateiId: datei?.id ?? null,
|
||||||
laenge: datei?.durationSeconds ?? null,
|
laenge: datei?.durationSeconds ?? null,
|
||||||
stelle: eintrag.state?.positionSeconds ?? 0,
|
stelle: eintrag.state?.positionSeconds ?? 0,
|
||||||
|
angefangen: eintrag.state?.inProgress ?? (eintrag.state?.positionSeconds ?? 0) > 0,
|
||||||
standId: eintrag.id,
|
standId: eintrag.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -366,6 +369,8 @@ export function angefangenAlsHero(eintrag: Angefangen): Heroeintrag {
|
|||||||
dateiId: eintrag.fileId ?? null,
|
dateiId: eintrag.fileId ?? null,
|
||||||
laenge: eintrag.durationSeconds ?? eintrag.state.runtimeSeconds,
|
laenge: eintrag.durationSeconds ?? eintrag.state.runtimeSeconds,
|
||||||
stelle: eintrag.state.positionSeconds,
|
stelle: eintrag.state.positionSeconds,
|
||||||
|
// Was in „Weiterschauen" steht, ist per Definition angefangen.
|
||||||
|
angefangen: eintrag.state.inProgress ?? eintrag.state.positionSeconds > 0,
|
||||||
standId: eintrag.id,
|
standId: eintrag.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,12 +268,16 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
|||||||
|
|
||||||
if (datei && !nochNicht) {
|
if (datei && !nochNicht) {
|
||||||
const stelle = stand?.positionSeconds ?? 0;
|
const stelle = stand?.positionSeconds ?? 0;
|
||||||
const los = knopf(stelle > 0 ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
|
// **Der Server entscheidet, was „angefangen" heisst**, nicht diese
|
||||||
|
// Zeile: die Schwelle steht in den Einstellungen. Ältere Server kennen
|
||||||
|
// das Feld nicht — dann gilt wie früher die blosse Stelle.
|
||||||
|
const angefangen = stand?.inProgress ?? stelle > 0;
|
||||||
|
const los = knopf(angefangen ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
|
||||||
los.prepend(icon('abspielen'));
|
los.prepend(icon('abspielen'));
|
||||||
los.onclick = () => starten(datei, eintrag.id, titelzeile(eintrag), stelle);
|
los.onclick = () => starten(datei, eintrag.id, titelzeile(eintrag), angefangen ? stelle : 0);
|
||||||
reihe.appendChild(los);
|
reihe.appendChild(los);
|
||||||
|
|
||||||
if (stelle > 0) {
|
if (angefangen) {
|
||||||
const vonVorn = knopf('Von vorn', 'btn');
|
const vonVorn = knopf('Von vorn', 'btn');
|
||||||
vonVorn.onclick = () => starten(datei, eintrag.id, titelzeile(eintrag), 0);
|
vonVorn.onclick = () => starten(datei, eintrag.id, titelzeile(eintrag), 0);
|
||||||
reihe.appendChild(vonVorn);
|
reihe.appendChild(vonVorn);
|
||||||
|
|||||||
Reference in New Issue
Block a user