mirror of
https://github.com/yattee/yattee.git
synced 2026-04-09 17:16:57 +00:00
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:
16
Yattee/Extensions/TimeInterval+Formatting.swift
Normal file
16
Yattee/Extensions/TimeInterval+Formatting.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1118,7 +1118,6 @@ struct PlayerControlsView: View {
|
||||
VStack {
|
||||
GestureSeekPreviewView(
|
||||
storyboard: playerState.preferredStoryboard,
|
||||
currentTime: seekGestureStartTime,
|
||||
seekTime: seekGesturePreviewTime,
|
||||
duration: playerState.duration,
|
||||
storyboardService: StoryboardService.shared,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user