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);