From e1ae6b185eb995cad038f427b3c3954a4f02e092 Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Wed, 21 Jan 2026 13:22:10 -0500 Subject: [PATCH] Show elapsed, remaining, and total time --- lib/utils/formatters.dart | 23 ++++++++++--- .../widgets/video_controls_header.dart | 32 +++++++++++++++---- .../widgets/video_timeline_bar.dart | 13 ++++---- 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/lib/utils/formatters.dart b/lib/utils/formatters.dart index 81983846..bb2a95a9 100644 --- a/lib/utils/formatters.dart +++ b/lib/utils/formatters.dart @@ -121,15 +121,23 @@ String formatDurationWithSeconds(Duration duration) { /// /// Used for: video controls, chapters, episode durations. String formatDurationTimestamp(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); + // Handle negative durations + final isNegative = duration.isNegative; + final absoluteDuration = duration.abs(); + + final hours = absoluteDuration.inHours; + final minutes = absoluteDuration.inMinutes.remainder(60); + final seconds = absoluteDuration.inSeconds.remainder(60); + + final result; if (hours > 0) { - return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + result = '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; } else { - return '$minutes:${seconds.toString().padLeft(2, '0')}'; + result = '$minutes:${seconds.toString().padLeft(2, '0')}'; } + + return isNegative ? '-$result' : result; } /// Formats a sync offset in milliseconds with sign indicator (e.g., "+150ms", "-15.1s"). @@ -170,3 +178,8 @@ DurationLocale _getDurationLocale() { return const EnglishDurationLocale(); } } + +/// Takes a list of strings and returns one long string with each item in the list concatenated by a bullet +String toBulletedString(List parts) { + return parts.join(' · '); +} diff --git a/lib/widgets/video_controls/widgets/video_controls_header.dart b/lib/widgets/video_controls/widgets/video_controls_header.dart index 894b793f..5b09ffee 100644 --- a/lib/widgets/video_controls/widgets/video_controls_header.dart +++ b/lib/widgets/video_controls/widgets/video_controls_header.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:plezy/utils/formatters.dart'; import '../../../models/plex_metadata.dart'; import '../../../i18n/strings.g.dart'; @@ -56,12 +57,19 @@ class VideoControlsHeader extends StatelessWidget { final seriesName = metadata.grandparentTitle ?? metadata.title; final hasEpisodeInfo = metadata.parentIndex != null && metadata.index != null; - final titleText = hasEpisodeInfo - ? '$seriesName · S${metadata.parentIndex} E${metadata.index} · ${metadata.title}' - : seriesName; + List parts = [seriesName]; + + if (hasEpisodeInfo) { + parts.add('S${metadata.parentIndex}'); + parts.add('E${metadata.index}'); + } + + if (metadata.duration != null) { + parts.add(formatDurationTextual(metadata.duration!)); + } return Text( - titleText, + toBulletedString(parts), style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -69,6 +77,18 @@ class VideoControlsHeader extends StatelessWidget { } Widget _buildMultiLineTitle() { + List secondLineParts = []; + + if (metadata.parentIndex != null && metadata.index != null) { + secondLineParts.add('S${metadata.parentIndex}'); + secondLineParts.add('E${metadata.index}'); + secondLineParts.add(metadata.title); + } + + if (metadata.duration != null) { + secondLineParts.add(formatDurationTextual(metadata.duration!)); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -78,9 +98,9 @@ class VideoControlsHeader extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - if (metadata.parentIndex != null && metadata.index != null) + if (secondLineParts.isNotEmpty) Text( - 'S${metadata.parentIndex} · E${metadata.index} · ${metadata.title}', + toBulletedString(secondLineParts), style: const TextStyle(color: Colors.white70, fontSize: 14), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/widgets/video_controls/widgets/video_timeline_bar.dart b/lib/widgets/video_controls/widgets/video_timeline_bar.dart index 84403c31..197f0a70 100644 --- a/lib/widgets/video_controls/widgets/video_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/video_timeline_bar.dart @@ -59,11 +59,12 @@ class VideoTimelineBar extends StatelessWidget { builder: (context, durationSnapshot) { final position = positionSnapshot.data ?? Duration.zero; final duration = durationSnapshot.data ?? Duration.zero; + final remaining = position - duration; // We want this to be negative if (horizontalLayout) { - return _buildHorizontalLayout(position, duration); + return _buildHorizontalLayout(position, duration, remaining); } else { - return _buildVerticalLayout(position, duration); + return _buildVerticalLayout(position, duration, remaining); } }, ); @@ -71,19 +72,19 @@ class VideoTimelineBar extends StatelessWidget { ); } - Widget _buildHorizontalLayout(Duration position, Duration duration) { + Widget _buildHorizontalLayout(Duration position, Duration duration, Duration remaining) { return Row( children: [ _buildTimestamp(position), const SizedBox(width: 12), Expanded(child: _buildSlider(position, duration)), const SizedBox(width: 12), - _buildTimestamp(duration), + _buildTimestamp(remaining), ], ); } - Widget _buildVerticalLayout(Duration position, Duration duration) { + Widget _buildVerticalLayout(Duration position, Duration duration, Duration remaining) { return Column( children: [ _buildSlider(position, duration), @@ -91,7 +92,7 @@ class VideoTimelineBar extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [_buildTimestamp(position), _buildTimestamp(duration)], + children: [_buildTimestamp(position), _buildTimestamp(remaining)], ), ), ],