diff --git a/Flaro/Session/FlaroSession.swift b/Flaro/Session/FlaroSession.swift
index 0946fb9..74fae24 100644
--- a/Flaro/Session/FlaroSession.swift
+++ b/Flaro/Session/FlaroSession.swift
@@ -130,6 +130,16 @@ final class FlaroSession {
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
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
/// erscheinen gar nicht erst.
private(set) var libraries: [MediaLibrary] = []
diff --git a/Flaro/Session/HomeFeed.swift b/Flaro/Session/HomeFeed.swift
index 3c3a12d..4f08b9f 100644
--- a/Flaro/Session/HomeFeed.swift
+++ b/Flaro/Session/HomeFeed.swift
@@ -155,8 +155,8 @@ final class HomeFeed {
case .continueFirst:
// Innerhalb der Gruppen ziehen, nicht über sie hinweg:
// Angefangenes bleibt vorn, wechselt aber untereinander.
- let ordered = HeroSelection.ordered(unique.filter { $0.watchProgress != nil })
- + HeroSelection.ordered(unique.filter { $0.watchProgress == nil })
+ let ordered = HeroSelection.ordered(unique.filter(\.isResumable))
+ + HeroSelection.ordered(unique.filter { !$0.isResumable })
return Array(ordered.prefix(layout.heroCount))
case .latestOnly:
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
diff --git a/Flaro/Session/TopShelfWriter.swift b/Flaro/Session/TopShelfWriter.swift
index 78d158a..a8a13bf 100644
--- a/Flaro/Session/TopShelfWriter.swift
+++ b/Flaro/Session/TopShelfWriter.swift
@@ -61,7 +61,7 @@ enum TopShelfWriter {
width: posterWidth,
height: posterHeight
),
- progress: item.watchProgress
+ progress: item.resumeProgress
)
}
)
diff --git a/Flaro/Views/Components/ItemActionEntries.swift b/Flaro/Views/Components/ItemActionEntries.swift
index 973851c..2c0030c 100644
--- a/Flaro/Views/Components/ItemActionEntries.swift
+++ b/Flaro/Views/Components/ItemActionEntries.swift
@@ -44,7 +44,7 @@ struct ItemActionEntries: View {
// **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle
// stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den
// Fehler bei sich.
- if item.watchProgress != nil {
+ if item.isResumable {
Button {
run { try await $0.removeFromContinueWatching(item) }
} label: {
diff --git a/Flaro/Views/Detail/PlayableDetailView.swift b/Flaro/Views/Detail/PlayableDetailView.swift
index 712872c..ec38065 100644
--- a/Flaro/Views/Detail/PlayableDetailView.swift
+++ b/Flaro/Views/Detail/PlayableDetailView.swift
@@ -106,7 +106,7 @@ struct PlayableDetailView: View {
playback.play(item, mediaIndex: selectedVersion)
} label: {
Label(
- item.watchProgress == nil ? "hero.play" : "hero.resume",
+ item.isResumable ? "hero.resume" : "hero.play",
systemImage: "play.fill"
)
.font(.flaroCardTitle)
@@ -117,7 +117,9 @@ struct PlayableDetailView: View {
.tint(accent)
.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 {
playback.play(item, mediaIndex: selectedVersion, resuming: false)
} label: {
diff --git a/Flaro/Views/Detail/SeriesDetailView.swift b/Flaro/Views/Detail/SeriesDetailView.swift
index e3a777c..7dd8b8c 100644
--- a/Flaro/Views/Detail/SeriesDetailView.swift
+++ b/Flaro/Views/Detail/SeriesDetailView.swift
@@ -403,7 +403,7 @@ private struct ChildRow: View {
.frame(width: item.kind == .season ? 48 : 96)
.clipShape(.rect(cornerRadius: 4))
.overlay(alignment: .bottom) {
- if let progress = item.watchProgress {
+ if let progress = item.resumeProgress {
WatchProgressBar(fraction: progress)
}
}
diff --git a/Flaro/Views/Home/HeroSection.swift b/Flaro/Views/Home/HeroSection.swift
index bb3f49d..600498e 100644
--- a/Flaro/Views/Home/HeroSection.swift
+++ b/Flaro/Views/Home/HeroSection.swift
@@ -84,7 +84,7 @@ struct HeroSection: View {
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
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) }
diff --git a/Flaro/Views/Home/MediaRow.swift b/Flaro/Views/Home/MediaRow.swift
index e9cdffc..cc503f8 100644
--- a/Flaro/Views/Home/MediaRow.swift
+++ b/Flaro/Views/Home/MediaRow.swift
@@ -61,7 +61,7 @@ struct MediaRow: View {
width: 440,
height: 248
),
- progress: item.watchProgress
+ progress: item.resumeProgress
)
.frame(width: landscapeWidth)
} else {
@@ -73,7 +73,7 @@ struct MediaRow: View {
width: 300,
height: 450
),
- progress: item.watchProgress
+ progress: item.resumeProgress
)
.frame(width: posterWidth)
}
diff --git a/Flaro/Views/Library/LibraryView.swift b/Flaro/Views/Library/LibraryView.swift
index 64f629f..0af0893 100644
--- a/Flaro/Views/Library/LibraryView.swift
+++ b/Flaro/Views/Library/LibraryView.swift
@@ -106,7 +106,7 @@ struct LibraryView: View {
title: item.title,
subtitle: item.cardSubtitle,
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
- progress: item.watchProgress
+ progress: item.resumeProgress
)
}
.task { await loadMoreIfNeeded(reaching: item) }
diff --git a/Flaro/Views/Navigation/AccountAvatar.swift b/Flaro/Views/Navigation/AccountAvatar.swift
index bab908d..f314694 100644
--- a/Flaro/Views/Navigation/AccountAvatar.swift
+++ b/Flaro/Views/Navigation/AccountAvatar.swift
@@ -1,24 +1,46 @@
import SwiftUI
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
-/// eines hinterlegt ist, und am Konto führt die App heute keines mit. Ein
-/// Buchstabe ist ehrlicher als eine graue Silhouette, die bei jedem gleich
-/// aussieht — und man erkennt an ihm wenigstens, mit welchem Konto man
-/// angemeldet ist.
+/// **Der Buchstabe ist keine Notlösung.** Jellyfin gibt kein Profilbild
+/// heraus, und auch an einem Flaro-Konto muss keines hängen. Ein Buchstabe
+/// ist ehrlicher als eine graue Silhouette, die bei jedem gleich aussieht —
+/// und man erkennt an ihm wenigstens, mit welchem Konto man angemeldet ist.
///
-/// Stand bis eben in `AccountBar` und wird jetzt zweimal gebraucht: dort in
-/// der Seitenleiste und auf dem iPhone oben rechts, wo seit dem Wegfall des
-/// Einstellungen-Tabs der Weg dorthin liegt.
+/// Steht ein Bild im Konto, tritt es an seine Stelle. Lädt es nicht, kommt
+/// der Buchstabe zurück: eine leere Scheibe sagt weniger als ein P.
+///
+/// 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 {
let name: String
var diameter: CGFloat = 26
+ /// Das Bild des Kontos, falls eines hinterlegt ist.
+ var image: URL?
@Environment(\.flaroAccent) private var accent
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())
// Mit der Scheibe wachsen, nicht fest: oben rechts auf dem
// Telefon ist er grösser als in der Leiste, und ein Buchstabe in
@@ -26,8 +48,6 @@ struct AccountAvatar: View {
// Fehler.
.font(.system(size: diameter * 0.46, weight: .semibold))
.foregroundStyle(Color.flaroCanvas)
- .frame(width: diameter, height: diameter)
- .background(accent, in: .circle)
}
}
@@ -53,7 +73,8 @@ struct AccountToolbarButton: View {
var body: some View {
Button(action: openSettings) {
- AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
+ AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
+ image: session.accountAvatarURL)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("sidebar.settings"))
diff --git a/Flaro/Views/Navigation/AccountBar.swift b/Flaro/Views/Navigation/AccountBar.swift
index 45f7a74..8a26e89 100644
--- a/Flaro/Views/Navigation/AccountBar.swift
+++ b/Flaro/Views/Navigation/AccountBar.swift
@@ -78,7 +78,7 @@ struct AccountBar: View {
isExpanded.toggle()
} label: {
HStack(spacing: FlaroSpacing.snug) {
- AccountAvatar(name: title)
+ AccountAvatar(name: title, image: session.accountAvatarURL)
VStack(alignment: .leading, spacing: 0) {
Text(title)
diff --git a/Flaro/Views/Navigation/RootView.swift b/Flaro/Views/Navigation/RootView.swift
index a30f8fe..0a29af8 100644
--- a/Flaro/Views/Navigation/RootView.swift
+++ b/Flaro/Views/Navigation/RootView.swift
@@ -514,7 +514,8 @@ struct RootView: View {
Button {
showsSettings = true
} label: {
- AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
+ AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
+ image: session.accountAvatarURL)
.padding(FlaroSpacing.snug)
// Damit er auch über einem hellen Aufmacher steht.
.background(.black.opacity(0.28), in: .circle)
diff --git a/FlaroTV/Views/Home/TVHeroSection.swift b/FlaroTV/Views/Home/TVHeroSection.swift
index 95e58cc..9c47880 100644
--- a/FlaroTV/Views/Home/TVHeroSection.swift
+++ b/FlaroTV/Views/Home/TVHeroSection.swift
@@ -44,7 +44,7 @@ struct TVHeroSection: View {
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde.
private var abspielTitel: LocalizedStringKey {
- item.watchProgress == nil ? "hero.play" : "hero.resume"
+ item.isResumable ? "hero.resume" : "hero.play"
}
private var metadaten: String? {
diff --git a/FlaroTV/Views/Home/TVMediaRow.swift b/FlaroTV/Views/Home/TVMediaRow.swift
index a17c993..5f5ac78 100644
--- a/FlaroTV/Views/Home/TVMediaRow.swift
+++ b/FlaroTV/Views/Home/TVMediaRow.swift
@@ -55,7 +55,7 @@ struct TVMediaRow: View {
artworkURL: session.backend?.artworkURL(
path: item.rowArtworkPath, width: 880, height: 496
),
- progress: item.watchProgress,
+ progress: item.resumeProgress,
onFocusChange: { if $0 { onFocus(item) } }
) { onSelect(item) }
} else {
@@ -65,7 +65,7 @@ struct TVMediaRow: View {
artworkURL: session.backend?.artworkURL(
path: item.thumb, width: 560, height: 840
),
- progress: item.watchProgress,
+ progress: item.resumeProgress,
onFocusChange: { if $0 { onFocus(item) } }
) { onSelect(item) }
}
diff --git a/FlaroTV/Views/Library/TVLibraryView.swift b/FlaroTV/Views/Library/TVLibraryView.swift
index a4c4d07..3803421 100644
--- a/FlaroTV/Views/Library/TVLibraryView.swift
+++ b/FlaroTV/Views/Library/TVLibraryView.swift
@@ -78,7 +78,7 @@ struct TVLibraryView: View {
artworkURL: session.backend?.artworkURL(
path: item.posterArtworkPath, width: 560, height: 840
),
- progress: item.watchProgress
+ progress: item.resumeProgress
) {
// **Über QuickPlay, nicht unmittelbar.** Steht im
// Raster eine Serie, hat die keine Fassungen; der
diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift
index 33d278d..852a432 100644
--- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift
+++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift
@@ -443,17 +443,26 @@ public struct FlaroAccount: Sendable, Equatable {
public let userName: String
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(
baseURL: URL,
serverName: String,
userID: String,
userName: String,
- isAdministrator: Bool
+ isAdministrator: Bool,
+ avatarImage: String? = nil
) {
self.baseURL = baseURL
self.serverName = serverName
self.userID = userID
self.userName = userName
self.isAdministrator = isAdministrator
+ self.avatarImage = avatarImage
}
}
diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift
index d2940bd..9ae6151 100644
--- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift
+++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift
@@ -38,6 +38,8 @@ enum Wire {
let id: String
let userName: String
let isAdministrator: Bool
+ /// Das Profilbild, als Schlüssel in die Bildablage des Servers.
+ let avatarImage: String?
}
struct Library: Decodable, Sendable {
@@ -78,6 +80,12 @@ enum Wire {
let rating: Int?
let dismissed: Bool
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 {
diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift
index 4d79bf9..b94dab1 100644
--- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift
+++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift
@@ -20,6 +20,7 @@ extension Wire.Movie {
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
lastPlayedAt: state?.lastPlayedAt,
isFavourite: state?.favourite ?? false,
+ isInProgress: state?.inProgress,
availableFrom: publishAt,
versions: [Wire.fassung(id: id, files: files)]
)
@@ -69,6 +70,7 @@ extension Wire.Episode {
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
lastPlayedAt: state?.lastPlayedAt,
isFavourite: state?.favourite ?? false,
+ isInProgress: state?.inProgress,
availableFrom: publishAt,
versions: [Wire.fassung(id: id, files: files)]
)
@@ -104,6 +106,7 @@ extension Wire.Started {
resumeMilliseconds: Int(state.positionSeconds * 1000),
lastPlayedAt: state.lastPlayedAt,
isFavourite: state.favourite,
+ isInProgress: state.inProgress,
availableFrom: publishAt
)
}
diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroSignIn.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroSignIn.swift
index 8faf06b..93f61bf 100644
--- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroSignIn.swift
+++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroSignIn.swift
@@ -84,7 +84,8 @@ public enum FlaroSignIn {
serverName: gruss.serverName,
userID: antwort.user.id,
userName: antwort.user.userName,
- isAdministrator: antwort.user.isAdministrator), client)
+ isAdministrator: antwort.user.isAdministrator,
+ avatarImage: antwort.user.avatarImage), client)
}
/// Nimmt ein gespeichertes Token wieder auf.
@@ -111,7 +112,8 @@ public enum FlaroSignIn {
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
userID: wer.id,
userName: wer.userName,
- isAdministrator: wer.isAdministrator), client)
+ isAdministrator: wer.isAdministrator,
+ avatarImage: wer.avatarImage), client)
}
// MARK: - Der Weg über einen Code
@@ -179,7 +181,8 @@ public enum FlaroSignIn {
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
userID: anmeldung.user.id,
userName: anmeldung.user.userName,
- isAdministrator: anmeldung.user.isAdministrator)), client)
+ isAdministrator: anmeldung.user.isAdministrator,
+ avatarImage: anmeldung.user.avatarImage)), client)
case "expired":
return (.expired, nil)
default:
diff --git a/Packages/FlaroKit/Sources/MediaKit/MediaItem.swift b/Packages/FlaroKit/Sources/MediaKit/MediaItem.swift
index b7760e4..29981f0 100644
--- a/Packages/FlaroKit/Sources/MediaKit/MediaItem.swift
+++ b/Packages/FlaroKit/Sources/MediaKit/MediaItem.swift
@@ -71,6 +71,18 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
/// eine Fähigkeit, die es nur auf einem gibt.
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.
///
/// **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,
addedAt: Date? = nil,
isFavourite: Bool = false,
+ isInProgress: Bool? = nil,
availableFrom: Date? = nil,
episodeCount: Int? = nil,
playedEpisodeCount: Int? = nil,
@@ -164,6 +177,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
self.lastPlayedAt = lastPlayedAt
self.addedAt = addedAt
self.isFavourite = isFavourite
+ self.isInProgress = isInProgress
self.availableFrom = availableFrom
self.episodeCount = episodeCount
self.playedEpisodeCount = playedEpisodeCount
@@ -235,6 +249,20 @@ extension MediaItem {
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.
///
/// Gefragt wird an einer Stelle, beantwortet an einer: sonst steht an
diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs
index bc44708..4812ab7 100644
--- a/server/src/Flaro.Api/Endpoints/Contracts.cs
+++ b/server/src/Flaro.Api/Endpoints/Contracts.cs
@@ -634,7 +634,21 @@ public sealed record RatingRequest(int? Rating);
public sealed record ItemStateResponse(
double PositionSeconds, double? RuntimeSeconds,
bool Watched, int PlayCount, bool Favourite, int? Rating, bool Dismissed,
- DateTimeOffset? LastPlayedAt);
+ DateTimeOffset? LastPlayedAt,
+
+ /// Ob der Titel in „Weiterschauen" steht.
+ ///
+ ///
+ /// Damit die Beschriftung dasselbe sagt wie die Reihe. 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.
+ ///
+ bool InProgress = false);
/// Eine Zeile in „Weiterschauen".
///
diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs
index 045f5c6..a5b1536 100644
--- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs
@@ -781,5 +781,6 @@ public static class PlaybackEndpoints
internal static ItemStateResponse Abbild(UserItemState z) => new(
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);
}
diff --git a/web/app/src/api.ts b/web/app/src/api.ts
index 91b5ca1..5ed375a 100644
--- a/web/app/src/api.ts
+++ b/web/app/src/api.ts
@@ -34,6 +34,13 @@ export type Stand = {
watched: boolean; playCount: number;
favourite: boolean; rating: number | null; dismissed: boolean;
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 = {
id: string; name: string;
diff --git a/web/app/src/hero.ts b/web/app/src/hero.ts
index 7471db8..d385c3c 100644
--- a/web/app/src/hero.ts
+++ b/web/app/src/hero.ts
@@ -25,6 +25,8 @@ export type Heroeintrag = {
laenge: number | null;
/** Wo man stehengeblieben ist. */
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. */
standId: string;
};
@@ -227,7 +229,7 @@ export function hero(
}
const beschriftung = e.dateiId === null ? 'Ansehen'
- : e.stelle > 0 ? `Fortsetzen ab ${dauer(e.stelle)}`
+ : e.angefangen ? `Fortsetzen ab ${dauer(e.stelle)}`
: 'Abspielen';
spielen.textContent = beschriftung;
spielen.prepend(spielIcon);
@@ -332,6 +334,7 @@ export function alsHero(eintrag: Film | Serie, art: 'filme' | 'serien'): Heroein
dateiId: datei?.id ?? null,
laenge: datei?.durationSeconds ?? null,
stelle: eintrag.state?.positionSeconds ?? 0,
+ angefangen: eintrag.state?.inProgress ?? (eintrag.state?.positionSeconds ?? 0) > 0,
standId: eintrag.id,
};
}
@@ -366,6 +369,8 @@ export function angefangenAlsHero(eintrag: Angefangen): Heroeintrag {
dateiId: eintrag.fileId ?? null,
laenge: eintrag.durationSeconds ?? eintrag.state.runtimeSeconds,
stelle: eintrag.state.positionSeconds,
+ // Was in „Weiterschauen" steht, ist per Definition angefangen.
+ angefangen: eintrag.state.inProgress ?? eintrag.state.positionSeconds > 0,
standId: eintrag.id,
};
}
diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts
index cefcf61..d9c4bbf 100644
--- a/web/app/src/views/detail.ts
+++ b/web/app/src/views/detail.ts
@@ -268,12 +268,16 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
if (datei && !nochNicht) {
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.onclick = () => starten(datei, eintrag.id, titelzeile(eintrag), stelle);
+ los.onclick = () => starten(datei, eintrag.id, titelzeile(eintrag), angefangen ? stelle : 0);
reihe.appendChild(los);
- if (stelle > 0) {
+ if (angefangen) {
const vonVorn = knopf('Von vorn', 'btn');
vonVorn.onclick = () => starten(datei, eintrag.id, titelzeile(eintrag), 0);
reihe.appendChild(vonVorn);