Deduplicate time formatting and clean up unused code

Extract shared TimeInterval.formattedAsTimestamp replacing 8 identical
formatTime/formattedTime implementations across player views. Remove
unused currentTime parameter from GestureSeekPreviewView. Consolidate
duplicated geometry math in MacOSControlBar into seekPreviewPosition().
This commit is contained in:
Arkadiusz Fal
2026-03-28 14:09:25 +01:00
parent e50817c043
commit 44f3cbb9f3
12 changed files with 68 additions and 172 deletions

View File

@@ -0,0 +1,16 @@
import Foundation
extension TimeInterval {
/// Formats as "M:SS" or "H:MM:SS" when hours > 0.
var formattedAsTimestamp: String {
let totalSeconds = Int(max(0, self))
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%d:%02d", minutes, seconds)
}
}

View File

@@ -254,7 +254,7 @@ final class PlayerState {
if isLive {
return "LIVE"
}
return formatTime(currentTime)
return currentTime.formattedAsTimestamp
}
/// Formatted duration string.
@@ -263,12 +263,12 @@ final class PlayerState {
if isLive {
return "LIVE"
}
return formatTime(duration)
return duration.formattedAsTimestamp
}
/// Formatted remaining time string.
var formattedRemainingTime: String {
"-" + formatTime(max(0, duration - currentTime))
"-" + max(0, duration - currentTime).formattedAsTimestamp
}
// MARK: - Playback Settings
@@ -625,20 +625,6 @@ final class PlayerState {
queue.first
}
// MARK: - Private
private func formatTime(_ time: TimeInterval) -> String {
let totalSeconds = Int(time)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%d:%02d", minutes, seconds)
}
}
}
/// Represents a chapter in a video.

View File

@@ -687,20 +687,7 @@ struct ControlsSectionRenderer: View {
private var formattedRemainingTime: String {
let remaining = max(0, actions.playerState.duration - actions.playerState.currentTime)
return formatTime(remaining)
}
private func formatTime(_ time: TimeInterval) -> String {
let totalSeconds = Int(time)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%d:%02d", minutes, seconds)
}
return remaining.formattedAsTimestamp
}
// MARK: - Volume Controls

View File

@@ -12,7 +12,6 @@ import SwiftUI
/// Shows only the storyboard thumbnail with timestamp overlay.
struct GestureSeekPreviewView: View {
let storyboard: Storyboard?
let currentTime: TimeInterval
let seekTime: TimeInterval
let duration: TimeInterval
let storyboardService: StoryboardService
@@ -70,7 +69,6 @@ struct GestureSeekPreviewView: View {
GestureSeekPreviewView(
storyboard: nil,
currentTime: 120,
seekTime: 180,
duration: 600,
storyboardService: StoryboardService(),

View File

@@ -1118,7 +1118,6 @@ struct PlayerControlsView: View {
VStack {
GestureSeekPreviewView(
storyboard: playerState.preferredStoryboard,
currentTime: seekGestureStartTime,
seekTime: seekGesturePreviewTime,
duration: playerState.duration,
storyboardService: StoryboardService.shared,

View File

@@ -55,19 +55,6 @@ struct SeekPreviewView: View {
@State private var thumbnail: PlatformImage?
@State private var loadTask: Task<Void, Never>?
private var formattedTime: String {
let totalSeconds = Int(seekTime)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%d:%02d", minutes, seconds)
}
}
private let thumbnailWidth: CGFloat = 160
var body: some View {
@@ -95,7 +82,7 @@ struct SeekPreviewView: View {
.clipped()
// Timestamp overlaid at bottom center
Text(formattedTime)
Text(seekTime.formattedAsTimestamp)
.font(.caption)
.fontWeight(.medium)
.monospacedDigit()

View File

@@ -13,21 +13,8 @@ struct SeekTimePreviewView: View {
let buttonBackground: ButtonBackgroundStyle
let theme: ControlsTheme
private var formattedTime: String {
let totalSeconds = Int(seekTime)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%d:%02d", minutes, seconds)
}
}
var body: some View {
Text(formattedTime)
Text(seekTime.formattedAsTimestamp)
.font(.system(size: 16, weight: .medium))
.monospacedDigit()
.foregroundStyle(.white)

View File

@@ -95,61 +95,34 @@ struct MacOSControlBar: View {
.shadow(color: .black.opacity(0.3), radius: 8, y: 4)
// Seek preview overlay - positioned above the control bar
.overlay(alignment: .bottom) {
if let storyboard = playerState.preferredStoryboard,
(isDragging || isHoveringProgress),
!playerState.isLive {
if (isDragging || isHoveringProgress), !playerState.isLive {
GeometryReader { geometry in
let previewProgress = isDragging ? dragProgress : hoverProgress
// Progress bar spans full width minus padding and time labels
// Time labels ~50px each, spacing ~16px = ~116px total for labels
let horizontalPadding: CGFloat = 16
let timeLabelWidth: CGFloat = 50
let spacing: CGFloat = 8
let progressBarOffset: CGFloat = horizontalPadding + timeLabelWidth + spacing
let progressBarWidth: CGFloat = geometry.size.width - (2 * horizontalPadding) - (2 * timeLabelWidth) - (2 * spacing)
let previewWidth: CGFloat = 176 // 160 + 16 padding
let xOffset = progressBarOffset + (progressBarWidth * previewProgress) - (previewWidth / 2)
let clampedX = max(0, min(geometry.size.width - previewWidth, xOffset))
let pos = seekPreviewPosition(geometry: geometry, previewWidth: playerState.preferredStoryboard != nil ? 176 : 80)
let storyboardCenterX = clampedX + previewWidth / 2
if let storyboard = playerState.preferredStoryboard {
seekPreviewView(storyboard: storyboard)
.offset(x: pos.clampedX, y: -150)
seekPreviewView(storyboard: storyboard)
.offset(x: clampedX, y: -150)
if showChapters, let chapter = playerState.chapters.last(where: { $0.startTime <= pos.progress * playerState.duration }) {
ChapterCapsuleView(title: chapter.title, buttonBackground: .none)
.positioned(xTarget: pos.centerX, availableWidth: geometry.size.width)
.offset(y: -176)
}
} else {
SeekTimePreviewView(
seekTime: pos.progress * playerState.duration,
buttonBackground: .none,
theme: .dark
)
.offset(x: pos.clampedX, y: -60)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging || isHoveringProgress)
if showChapters, let chapter = playerState.chapters.last(where: { $0.startTime <= previewProgress * playerState.duration }) {
ChapterCapsuleView(title: chapter.title, buttonBackground: .none)
.positioned(xTarget: storyboardCenterX, availableWidth: geometry.size.width)
.offset(y: -176)
}
}
} else if (isDragging || isHoveringProgress),
!playerState.isLive {
GeometryReader { geometry in
let previewProgress = isDragging ? dragProgress : hoverProgress
let horizontalPadding: CGFloat = 16
let timeLabelWidth: CGFloat = 50
let spacing: CGFloat = 8
let progressBarOffset: CGFloat = horizontalPadding + timeLabelWidth + spacing
let progressBarWidth: CGFloat = geometry.size.width - (2 * horizontalPadding) - (2 * timeLabelWidth) - (2 * spacing)
let previewWidth: CGFloat = 80
let xOffset = progressBarOffset + (progressBarWidth * previewProgress) - (previewWidth / 2)
let clampedX = max(0, min(geometry.size.width - previewWidth, xOffset))
let timeCenterX = clampedX + previewWidth / 2
SeekTimePreviewView(
seekTime: previewProgress * playerState.duration,
buttonBackground: .none,
theme: .dark
)
.offset(x: clampedX, y: -60)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging || isHoveringProgress)
if showChapters, let chapter = playerState.chapters.last(where: { $0.startTime <= previewProgress * playerState.duration }) {
ChapterCapsuleView(title: chapter.title, buttonBackground: .none)
.positioned(xTarget: timeCenterX, availableWidth: geometry.size.width)
.offset(y: -86)
if showChapters, let chapter = playerState.chapters.last(where: { $0.startTime <= pos.progress * playerState.duration }) {
ChapterCapsuleView(title: chapter.title, buttonBackground: .none)
.positioned(xTarget: pos.centerX, availableWidth: geometry.size.width)
.offset(y: -86)
}
}
}
}
@@ -336,6 +309,21 @@ struct MacOSControlBar: View {
.frame(height: 20)
}
private func seekPreviewPosition(
geometry: GeometryProxy,
previewWidth: CGFloat
) -> (progress: Double, clampedX: CGFloat, centerX: CGFloat) {
let previewProgress = isDragging ? dragProgress : hoverProgress
let horizontalPadding: CGFloat = 16
let timeLabelWidth: CGFloat = 50
let spacing: CGFloat = 8
let progressBarOffset = horizontalPadding + timeLabelWidth + spacing
let progressBarWidth = geometry.size.width - (2 * horizontalPadding) - (2 * timeLabelWidth) - (2 * spacing)
let xOffset = progressBarOffset + (progressBarWidth * previewProgress) - (previewWidth / 2)
let clampedX = max(0, min(geometry.size.width - previewWidth, xOffset))
return (previewProgress, clampedX, clampedX + previewWidth / 2)
}
@ViewBuilder
private func seekPreviewView(storyboard: Storyboard) -> some View {
let previewProgress = isDragging ? dragProgress : hoverProgress

View File

@@ -160,7 +160,7 @@ struct TVPlayerProgressBar: View {
.foregroundStyle(.red)
}
} else {
Text(formatTime(displayTime))
Text(displayTime.formattedAsTimestamp)
.monospacedDigit()
.font(.subheadline)
.fontWeight(isScrubbing ? .semibold : .regular)
@@ -184,7 +184,7 @@ struct TVPlayerProgressBar: View {
// Remaining time (only for non-live)
if !isLive {
Text("-\(formatTime(max(0, duration - displayTime)))")
Text("-\(max(0, duration - displayTime).formattedAsTimestamp)")
.monospacedDigit()
.font(.subheadline)
.foregroundStyle(.white.opacity(0.7))
@@ -202,7 +202,7 @@ struct TVPlayerProgressBar: View {
.transition(.scale.combined(with: .opacity))
} else {
// Fallback when no storyboard available
Text(formatTime(scrubTime ?? currentTime))
Text((scrubTime ?? currentTime).formattedAsTimestamp)
.font(.system(size: 48, weight: .medium))
.monospacedDigit()
.foregroundStyle(.white)
@@ -329,19 +329,6 @@ struct TVPlayerProgressBar: View {
}
}
// MARK: - Formatting
private func formatTime(_ time: TimeInterval) -> String {
let totalSeconds = Int(max(0, time))
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%d:%02d", minutes, seconds)
}
}
// MARK: - Pan Gesture View

View File

@@ -23,19 +23,6 @@ struct TVSeekPreviewView: View {
chapters.last { $0.startTime <= seekTime }
}
private var formattedTime: String {
let totalSeconds = Int(seekTime)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%d:%02d", minutes, seconds)
}
}
private let thumbnailWidth: CGFloat = 320
var body: some View {
@@ -69,7 +56,7 @@ struct TVSeekPreviewView: View {
}
// Timestamp overlaid at bottom center (larger for TV)
Text(formattedTime)
Text(seekTime.formattedAsTimestamp)
.font(.system(size: 36, weight: .medium))
.monospacedDigit()
.foregroundStyle(.white)

View File

@@ -427,9 +427,9 @@ struct RemoteControlView: View {
#endif
HStack {
Text(formatTime(displayCurrentTime))
Text(displayCurrentTime.formattedAsTimestamp)
Spacer()
Text("-" + formatTime(remoteState.duration - displayCurrentTime))
Text("-" + (remoteState.duration - displayCurrentTime).formattedAsTimestamp)
}
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
@@ -683,19 +683,6 @@ struct RemoteControlView: View {
isConnected = false
}
private func formatTime(_ time: TimeInterval) -> String {
let totalSeconds = Int(time)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%d:%02d", minutes, seconds)
}
}
/// Returns the previous playback rate, or nil if at minimum.
private func previousRate() -> PlaybackRate? {
let allRates = PlaybackRate.allCases

View File

@@ -238,24 +238,11 @@ struct VideoInfoView: View {
let resumeAction = appEnvironment?.settingsManager.resumeAction ?? .continueWatching
switch resumeAction {
case .continueWatching, .ask:
return String(localized: "resume.action.continueAt \(formatTime(savedProgress))")
return String(localized: "resume.action.continueAt \(savedProgress.formattedAsTimestamp)")
case .startFromBeginning:
return String(localized: "video.context.play")
}
}
/// Formats a time interval as MM:SS or H:MM:SS.
private func formatTime(_ time: TimeInterval) -> String {
let hours = Int(time) / 3600
let minutes = (Int(time) % 3600) / 60
let seconds = Int(time) % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%d:%02d", minutes, seconds)
}
}
var body: some View {
Group {