feat: show end time

close #249
This commit is contained in:
edde746
2026-02-01 14:04:44 +01:00
parent 1e4a054677
commit 6eb78af965
21 changed files with 83 additions and 11 deletions
+1
View File
@@ -301,6 +301,7 @@
"rotationLockButton": "Dreh­sperre",
"timelineSlider": "Video-Zeitleiste",
"volumeSlider": "Lautstärkepegel",
"endsAt": "Endet um ${time}",
"backButton": "Zurück",
"pipFailed": "Bild-in-Bild konnte nicht gestartet werden",
"pipErrors": {
+1
View File
@@ -300,6 +300,7 @@
"rotationLockButton": "Rotation lock",
"timelineSlider": "Video timeline",
"volumeSlider": "Volume level",
"endsAt": "Ends at ${time}",
"backButton": "Back",
"pipFailed": "Picture-in-picture failed to start",
"pipErrors": {
+1
View File
@@ -300,6 +300,7 @@
"rotationLockButton": "Verrouillage de rotation",
"timelineSlider": "Timeline vidéo",
"volumeSlider": "Niveau sonore",
"endsAt": "Fin à ${time}",
"backButton": "Retour",
"pipFailed": "Échec du démarrage du mode image dans l'image",
"pipErrors": {
+1
View File
@@ -301,6 +301,7 @@
"rotationLockButton": "Blocco rotazione",
"timelineSlider": "Timeline video",
"volumeSlider": "Livello volume",
"endsAt": "Finisce alle ${time}",
"backButton": "Indietro",
"pipFailed": "Impossibile avviare la modalità Picture-in-Picture",
"pipErrors": {
+1
View File
@@ -301,6 +301,7 @@
"rotationLockButton": "회전 잠금",
"timelineSlider": "타임라인",
"volumeSlider": "볼륨 조절",
"endsAt": "${time}에 종료",
"backButton": "뒤로 가기",
"pipFailed": "화면 속 화면 모드를 시작할 수 없습니다",
"pipErrors": {
+1
View File
@@ -301,6 +301,7 @@
"rotationLockButton": "Rotatievergrendeling",
"timelineSlider": "Videotijdlijn",
"volumeSlider": "Volumeniveau",
"endsAt": "Eindigt om ${time}",
"backButton": "Terug",
"pipFailed": "Beeld-in-beeld kon niet worden gestart",
"pipErrors": {
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 8
/// Strings: 4540 (567 per locale)
/// Strings: 4548 (568 per locale)
///
/// Built on 2026-01-29 at 06:02 UTC
/// Built on 2026-02-01 at 12:48 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+3 -1
View File
@@ -452,6 +452,7 @@ class _TranslationsVideoControlsDe implements TranslationsVideoControlsEn {
@override String get rotationLockButton => 'Dreh­sperre';
@override String get timelineSlider => 'Video-Zeitleiste';
@override String get volumeSlider => 'Lautstärkepegel';
@override String endsAt({required Object time}) => 'Endet um ${time}';
@override String get backButton => 'Zurück';
@override String get pipFailed => 'Bild-in-Bild konnte nicht gestartet werden';
@override late final _TranslationsVideoControlsPipErrorsDe pipErrors = _TranslationsVideoControlsPipErrorsDe._(_root);
@@ -1258,6 +1259,7 @@ extension on TranslationsDe {
'videoControls.rotationLockButton' => 'Dreh­sperre',
'videoControls.timelineSlider' => 'Video-Zeitleiste',
'videoControls.volumeSlider' => 'Lautstärkepegel',
'videoControls.endsAt' => ({required Object time}) => 'Endet um ${time}',
'videoControls.backButton' => 'Zurück',
'videoControls.pipFailed' => 'Bild-in-Bild konnte nicht gestartet werden',
'videoControls.pipErrors.androidVersion' => 'Erfordert Android 8.0 oder neuer',
@@ -1496,9 +1498,9 @@ extension on TranslationsDe {
'collections.addedToCollection' => 'Zur Sammlung hinzugefügt',
'collections.errorAddingToCollection' => 'Fehler beim Hinzufügen zur Sammlung',
'collections.created' => 'Sammlung erstellt',
'collections.removeFromCollection' => 'Aus Sammlung entfernen',
_ => null,
} ?? switch (path) {
'collections.removeFromCollection' => 'Aus Sammlung entfernen',
'collections.removeFromCollectionConfirm' => ({required Object title}) => '"${title}" aus dieser Sammlung entfernen?',
'collections.removedFromCollection' => 'Aus Sammlung entfernt',
'collections.removeFromCollectionFailed' => 'Entfernen aus Sammlung fehlgeschlagen',
+5 -1
View File
@@ -964,6 +964,9 @@ class TranslationsVideoControlsEn {
/// en: 'Volume level'
String get volumeSlider => 'Volume level';
/// en: 'Ends at ${time}'
String endsAt({required Object time}) => 'Ends at ${time}';
/// en: 'Back'
String get backButton => 'Back';
@@ -2393,6 +2396,7 @@ extension on Translations {
'videoControls.rotationLockButton' => 'Rotation lock',
'videoControls.timelineSlider' => 'Video timeline',
'videoControls.volumeSlider' => 'Volume level',
'videoControls.endsAt' => ({required Object time}) => 'Ends at ${time}',
'videoControls.backButton' => 'Back',
'videoControls.pipFailed' => 'Picture-in-picture failed to start',
'videoControls.pipErrors.androidVersion' => 'Requires Android 8.0 or newer',
@@ -2632,9 +2636,9 @@ extension on Translations {
'watchTogether.hostOnly' => 'Host Only',
'watchTogether.anyone' => 'Anyone',
'watchTogether.hostingSession' => 'Hosting Session',
'watchTogether.inSession' => 'In Session',
_ => null,
} ?? switch (path) {
'watchTogether.inSession' => 'In Session',
'watchTogether.sessionCode' => 'Session Code',
'watchTogether.hostControlsPlayback' => 'Host controls playback',
'watchTogether.anyoneCanControl' => 'Anyone can control playback',
+3 -1
View File
@@ -451,6 +451,7 @@ class _TranslationsVideoControlsFr implements TranslationsVideoControlsEn {
@override String get rotationLockButton => 'Verrouillage de rotation';
@override String get timelineSlider => 'Timeline vidéo';
@override String get volumeSlider => 'Niveau sonore';
@override String endsAt({required Object time}) => 'Fin à ${time}';
@override String get backButton => 'Retour';
@override String get pipFailed => 'Échec du démarrage du mode image dans l\'image';
@override late final _TranslationsVideoControlsPipErrorsFr pipErrors = _TranslationsVideoControlsPipErrorsFr._(_root);
@@ -1255,6 +1256,7 @@ extension on TranslationsFr {
'videoControls.rotationLockButton' => 'Verrouillage de rotation',
'videoControls.timelineSlider' => 'Timeline vidéo',
'videoControls.volumeSlider' => 'Niveau sonore',
'videoControls.endsAt' => ({required Object time}) => 'Fin à ${time}',
'videoControls.backButton' => 'Retour',
'videoControls.pipFailed' => 'Échec du démarrage du mode image dans l\'image',
'videoControls.pipErrors.androidVersion' => 'Nécessite Android 8.0 ou plus récent',
@@ -1494,9 +1496,9 @@ extension on TranslationsFr {
'watchTogether.hostOnly' => 'Hôte uniquement',
'watchTogether.anyone' => 'N\'importe qui',
'watchTogether.hostingSession' => 'Session d\'hébergement',
'watchTogether.inSession' => 'En session',
_ => null,
} ?? switch (path) {
'watchTogether.inSession' => 'En session',
'watchTogether.sessionCode' => 'Code de session',
'watchTogether.hostControlsPlayback' => 'L\'hôte contrôle la lecture',
'watchTogether.anyoneCanControl' => 'Tout le monde peut contrôler la lecture',
+3 -1
View File
@@ -452,6 +452,7 @@ class _TranslationsVideoControlsIt implements TranslationsVideoControlsEn {
@override String get rotationLockButton => 'Blocco rotazione';
@override String get timelineSlider => 'Timeline video';
@override String get volumeSlider => 'Livello volume';
@override String endsAt({required Object time}) => 'Finisce alle ${time}';
@override String get backButton => 'Indietro';
@override String get pipFailed => 'Impossibile avviare la modalità Picture-in-Picture';
@override late final _TranslationsVideoControlsPipErrorsIt pipErrors = _TranslationsVideoControlsPipErrorsIt._(_root);
@@ -1258,6 +1259,7 @@ extension on TranslationsIt {
'videoControls.rotationLockButton' => 'Blocco rotazione',
'videoControls.timelineSlider' => 'Timeline video',
'videoControls.volumeSlider' => 'Livello volume',
'videoControls.endsAt' => ({required Object time}) => 'Finisce alle ${time}',
'videoControls.backButton' => 'Indietro',
'videoControls.pipFailed' => 'Impossibile avviare la modalità Picture-in-Picture',
'videoControls.pipErrors.androidVersion' => 'Richiede Android 8.0 o versioni successive',
@@ -1496,9 +1498,9 @@ extension on TranslationsIt {
'collections.addedToCollection' => 'Aggiunto alla raccolta',
'collections.errorAddingToCollection' => 'Errore nell\'aggiunta alla raccolta',
'collections.created' => 'Raccolta creata',
'collections.removeFromCollection' => 'Rimuovi dalla raccolta',
_ => null,
} ?? switch (path) {
'collections.removeFromCollection' => 'Rimuovi dalla raccolta',
'collections.removeFromCollectionConfirm' => ({required Object title}) => 'Rimuovere "${title}" da questa raccolta?',
'collections.removedFromCollection' => 'Rimosso dalla raccolta',
'collections.removeFromCollectionFailed' => 'Impossibile rimuovere dalla raccolta',
+3 -1
View File
@@ -452,6 +452,7 @@ class _TranslationsVideoControlsKo implements TranslationsVideoControlsEn {
@override String get rotationLockButton => '회전 잠금';
@override String get timelineSlider => '타임라인';
@override String get volumeSlider => '볼륨 조절';
@override String endsAt({required Object time}) => '${time}에 종료';
@override String get backButton => '뒤로 가기';
@override String get pipFailed => '화면 속 화면 모드를 시작할 수 없습니다';
@override late final _TranslationsVideoControlsPipErrorsKo pipErrors = _TranslationsVideoControlsPipErrorsKo._(_root);
@@ -1258,6 +1259,7 @@ extension on TranslationsKo {
'videoControls.rotationLockButton' => '회전 잠금',
'videoControls.timelineSlider' => '타임라인',
'videoControls.volumeSlider' => '볼륨 조절',
'videoControls.endsAt' => ({required Object time}) => '${time}에 종료',
'videoControls.backButton' => '뒤로 가기',
'videoControls.pipFailed' => '화면 속 화면 모드를 시작할 수 없습니다',
'videoControls.pipErrors.androidVersion' => 'Android 8.0 이상이 필요합니다',
@@ -1496,9 +1498,9 @@ extension on TranslationsKo {
'watchTogether.controlMode' => '제어 모드',
'watchTogether.controlModeQuestion' => '누가 재생을 제어할 수 있나요?',
'watchTogether.hostOnly' => '호스트만',
'watchTogether.anyone' => '누구나',
_ => null,
} ?? switch (path) {
'watchTogether.anyone' => '누구나',
'watchTogether.hostingSession' => '세션 호스팅',
'watchTogether.inSession' => '세션 중',
'watchTogether.sessionCode' => '세션 코드',
+3 -1
View File
@@ -452,6 +452,7 @@ class _TranslationsVideoControlsNl implements TranslationsVideoControlsEn {
@override String get rotationLockButton => 'Rotatievergrendeling';
@override String get timelineSlider => 'Videotijdlijn';
@override String get volumeSlider => 'Volumeniveau';
@override String endsAt({required Object time}) => 'Eindigt om ${time}';
@override String get backButton => 'Terug';
@override String get pipFailed => 'Beeld-in-beeld kon niet worden gestart';
@override late final _TranslationsVideoControlsPipErrorsNl pipErrors = _TranslationsVideoControlsPipErrorsNl._(_root);
@@ -1258,6 +1259,7 @@ extension on TranslationsNl {
'videoControls.rotationLockButton' => 'Rotatievergrendeling',
'videoControls.timelineSlider' => 'Videotijdlijn',
'videoControls.volumeSlider' => 'Volumeniveau',
'videoControls.endsAt' => ({required Object time}) => 'Eindigt om ${time}',
'videoControls.backButton' => 'Terug',
'videoControls.pipFailed' => 'Beeld-in-beeld kon niet worden gestart',
'videoControls.pipErrors.androidVersion' => 'Vereist Android 8.0 of nieuwer',
@@ -1496,9 +1498,9 @@ extension on TranslationsNl {
'collections.addedToCollection' => 'Toegevoegd aan collectie',
'collections.errorAddingToCollection' => 'Fout bij toevoegen aan collectie',
'collections.created' => 'Collectie gemaakt',
'collections.removeFromCollection' => 'Verwijderen uit collectie',
_ => null,
} ?? switch (path) {
'collections.removeFromCollection' => 'Verwijderen uit collectie',
'collections.removeFromCollectionConfirm' => ({required Object title}) => '"${title}" uit deze collectie verwijderen?',
'collections.removedFromCollection' => 'Uit collectie verwijderd',
'collections.removeFromCollectionFailed' => 'Verwijderen uit collectie mislukt',
+3 -1
View File
@@ -452,6 +452,7 @@ class _TranslationsVideoControlsSv implements TranslationsVideoControlsEn {
@override String get rotationLockButton => 'Rotationslås';
@override String get timelineSlider => 'Videotidslinje';
@override String get volumeSlider => 'Volymnivå';
@override String endsAt({required Object time}) => 'Slutar ${time}';
@override String get backButton => 'Tillbaka';
@override String get pipFailed => 'Bild-i-bild kunde inte starta';
@override late final _TranslationsVideoControlsPipErrorsSv pipErrors = _TranslationsVideoControlsPipErrorsSv._(_root);
@@ -1258,6 +1259,7 @@ extension on TranslationsSv {
'videoControls.rotationLockButton' => 'Rotationslås',
'videoControls.timelineSlider' => 'Videotidslinje',
'videoControls.volumeSlider' => 'Volymnivå',
'videoControls.endsAt' => ({required Object time}) => 'Slutar ${time}',
'videoControls.backButton' => 'Tillbaka',
'videoControls.pipFailed' => 'Bild-i-bild kunde inte starta',
'videoControls.pipErrors.androidVersion' => 'Kräver Android 8.0 eller nyare',
@@ -1496,9 +1498,9 @@ extension on TranslationsSv {
'collections.addedToCollection' => 'Tillagd i samling',
'collections.errorAddingToCollection' => 'Fel vid tillägg i samling',
'collections.created' => 'Samling skapad',
'collections.removeFromCollection' => 'Ta bort från samling',
_ => null,
} ?? switch (path) {
'collections.removeFromCollection' => 'Ta bort från samling',
'collections.removeFromCollectionConfirm' => ({required Object title}) => 'Ta bort "${title}" från denna samling?',
'collections.removedFromCollection' => 'Borttagen från samling',
'collections.removeFromCollectionFailed' => 'Misslyckades med att ta bort från samling',
+3 -1
View File
@@ -452,6 +452,7 @@ class _TranslationsVideoControlsZh implements TranslationsVideoControlsEn {
@override String get rotationLockButton => '旋转锁定';
@override String get timelineSlider => '视频时间轴';
@override String get volumeSlider => '音量调节';
@override String endsAt({required Object time}) => '${time} 结束';
@override String get backButton => '返回';
@override String get pipFailed => '画中画启动失败';
@override late final _TranslationsVideoControlsPipErrorsZh pipErrors = _TranslationsVideoControlsPipErrorsZh._(_root);
@@ -1258,6 +1259,7 @@ extension on TranslationsZh {
'videoControls.rotationLockButton' => '旋转锁定',
'videoControls.timelineSlider' => '视频时间轴',
'videoControls.volumeSlider' => '音量调节',
'videoControls.endsAt' => ({required Object time}) => '${time} 结束',
'videoControls.backButton' => '返回',
'videoControls.pipFailed' => '画中画启动失败',
'videoControls.pipErrors.androidVersion' => '需要 Android 8.0 或更高版本',
@@ -1496,9 +1498,9 @@ extension on TranslationsZh {
'collections.addedToCollection' => '已添加到合集',
'collections.errorAddingToCollection' => '添加到合集失败',
'collections.created' => '已创建合集',
'collections.removeFromCollection' => '从合集移除',
_ => null,
} ?? switch (path) {
'collections.removeFromCollection' => '从合集移除',
'collections.removeFromCollectionConfirm' => ({required Object title}) => '将“${title}”从此合集移除?',
'collections.removedFromCollection' => '已从合集移除',
'collections.removeFromCollectionFailed' => '从合集移除失败',
+1
View File
@@ -301,6 +301,7 @@
"rotationLockButton": "Rotationslås",
"timelineSlider": "Videotidslinje",
"volumeSlider": "Volymnivå",
"endsAt": "Slutar ${time}",
"backButton": "Tillbaka",
"pipFailed": "Bild-i-bild kunde inte starta",
"pipErrors": {
+1
View File
@@ -301,6 +301,7 @@
"rotationLockButton": "旋转锁定",
"timelineSlider": "视频时间轴",
"volumeSlider": "音量调节",
"endsAt": "${time} 结束",
"backButton": "返回",
"pipFailed": "画中画启动失败",
"pipErrors": {
+8
View File
@@ -180,6 +180,14 @@ DurationLocale _getDurationLocale() {
}
}
/// Formats the clock time at which media will finish playing, given the remaining duration.
/// Returns a localized time string like "6:12 PM" or "18:12" depending on locale.
String formatFinishTime(Duration remaining) {
final finishTime = DateTime.now().add(remaining);
final formatter = DateFormat.jm(LocaleSettings.currentLocale.languageCode);
return formatter.format(finishTime);
}
/// Takes a list of strings and returns one long string with each item in the list concatenated by a bullet
String toBulletedString(List<String> parts) {
return parts.join(' · ');
@@ -12,6 +12,7 @@ import '../../models/plex_media_version.dart';
import '../../models/plex_metadata.dart';
import '../../services/fullscreen_state_manager.dart';
import '../../utils/desktop_window_padding.dart';
import '../../utils/formatters.dart';
import '../../i18n/strings.g.dart';
import '../../focus/focusable_wrapper.dart';
import '../../services/shader_service.dart';
@@ -506,6 +507,30 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
semanticLabel: t.videoControls.nextButton,
),
),
// Finish time
StreamBuilder<Duration>(
stream: widget.player.streams.position,
initialData: widget.player.state.position,
builder: (context, posSnap) {
return StreamBuilder<Duration>(
stream: widget.player.streams.duration,
initialData: widget.player.state.duration,
builder: (context, durSnap) {
final position = posSnap.data ?? Duration.zero;
final duration = durSnap.data ?? Duration.zero;
final remaining = duration - position;
if (remaining.inSeconds <= 0) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
t.videoControls.endsAt(time: formatFinishTime(remaining)),
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
);
},
);
},
),
const Spacer(),
// Volume control
VolumeControl(
@@ -166,6 +166,7 @@ class MobileVideoControls extends StatelessWidget {
onSeekEnd: onSeekEnd,
horizontalLayout: false,
enabled: canControl,
showFinishTime: true,
),
),
);
@@ -33,6 +33,9 @@ class VideoTimelineBar extends StatelessWidget {
/// Whether the timeline is enabled for interaction.
final bool enabled;
/// Whether to show the estimated finish time next to the remaining timestamp (mobile).
final bool showFinishTime;
const VideoTimelineBar({
super.key,
required this.player,
@@ -45,6 +48,7 @@ class VideoTimelineBar extends StatelessWidget {
this.onKeyEvent,
this.onFocusChange,
this.enabled = true,
this.showFinishTime = false,
});
@override
@@ -92,7 +96,7 @@ class VideoTimelineBar extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [_buildTimestamp(position), _buildTimestamp(remaining)],
children: [_buildTimestamp(position), _buildRemainingTimestamp(remaining)],
),
),
],
@@ -103,6 +107,14 @@ class VideoTimelineBar extends StatelessWidget {
return Text(formatDurationTimestamp(time), style: const TextStyle(color: Colors.white, fontSize: 14));
}
Widget _buildRemainingTimestamp(Duration remaining) {
if (!showFinishTime || remaining.inSeconds >= 0) {
return _buildTimestamp(remaining);
}
final text = '${formatDurationTimestamp(remaining)} · ${formatFinishTime(remaining.abs())}';
return Text(text, style: const TextStyle(color: Colors.white, fontSize: 14));
}
Widget _buildSlider(Duration position, Duration duration) {
return TimelineSlider(
position: position,