Skip to content

Commit

Permalink
Show time remaining estimate during conversion (#65)
Browse files Browse the repository at this point in the history
Fixes #52
  • Loading branch information
allewun authored and sindresorhus committed Mar 25, 2019
1 parent bb54fbb commit 57cc4cd
Show file tree
Hide file tree
Showing 3 changed files with 166 additions and 0 deletions.
4 changes: 4 additions & 0 deletions Gifski.xcodeproj/project.pbxproj
Expand Up @@ -10,6 +10,7 @@
5A7524BA20D0862200F12C99 /* libgifski.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A7524B520D085FB00F12C99 /* libgifski.a */; };
6D86841721FD283B0044F6FE /* ConversionCompletedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D86841121FD283B0044F6FE /* ConversionCompletedView.swift */; };
6D86841821FD283B0044F6FE /* DraggableFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D86841621FD283B0044F6FE /* DraggableFile.swift */; };
9F3340A322431CC3006EF9B5 /* TimeEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3340A222431CC3006EF9B5 /* TimeEstimator.swift */; };
B576D25422294F9900A9B75C /* CircularProgress+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = B576D24F22294F9900A9B75C /* CircularProgress+Util.swift */; };
C2040B8920435871004EE259 /* GifskiWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2040B8820435871004EE259 /* GifskiWrapper.swift */; };
C2AFA91D204FFEFD00FC5A7F /* MainWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AFA91B204FFEFD00FC5A7F /* MainWindowController.swift */; };
Expand Down Expand Up @@ -72,6 +73,7 @@
5A7524AD20D085FB00F12C99 /* gifski.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = gifski.xcodeproj; path = "gifski-api/gifski.xcodeproj"; sourceTree = "<group>"; };
6D86841121FD283B0044F6FE /* ConversionCompletedView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = ConversionCompletedView.swift; sourceTree = "<group>"; usesTabs = 1; };
6D86841621FD283B0044F6FE /* DraggableFile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = DraggableFile.swift; sourceTree = "<group>"; usesTabs = 1; };
9F3340A222431CC3006EF9B5 /* TimeEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeEstimator.swift; sourceTree = "<group>"; };
B576D24F22294F9900A9B75C /* CircularProgress+Util.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = "CircularProgress+Util.swift"; sourceTree = "<group>"; usesTabs = 1; };
C2040B8820435871004EE259 /* GifskiWrapper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = GifskiWrapper.swift; sourceTree = "<group>"; usesTabs = 1; };
C2AFA91B204FFEFD00FC5A7F /* MainWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = MainWindowController.swift; sourceTree = "<group>"; usesTabs = 1; };
Expand Down Expand Up @@ -189,6 +191,7 @@
C2040B8820435871004EE259 /* GifskiWrapper.swift */,
E3A940112182DCE5006981D5 /* CustomButton.swift */,
E3D08F6D1E5D7BFD00F465DF /* util.swift */,
9F3340A222431CC3006EF9B5 /* TimeEstimator.swift */,
E3AE628A1E5CD2F300035A2F /* MainMenu.xib */,
E3AE62881E5CD2F300035A2F /* Assets.xcassets */,
E356A15D21028942000148AD /* Other */,
Expand Down Expand Up @@ -362,6 +365,7 @@
C2AFA91D204FFEFD00FC5A7F /* MainWindowController.swift in Sources */,
E3AE62871E5CD2F300035A2F /* AppDelegate.swift in Sources */,
6D86841821FD283B0044F6FE /* DraggableFile.swift in Sources */,
9F3340A322431CC3006EF9B5 /* TimeEstimator.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
25 changes: 25 additions & 0 deletions Gifski/MainWindowController.swift
Expand Up @@ -17,6 +17,12 @@ final class MainWindowController: NSWindowController {
}
}

private lazy var timeRemainingLabel = with(Label()) {
$0.isHidden = true
$0.textColor = NSColor.secondaryLabelColor
$0.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular)
}

private lazy var conversionCompletedView = with(ConversionCompletedView()) {
$0.isHidden = true
}
Expand Down Expand Up @@ -74,9 +80,12 @@ final class MainWindowController: NSWindowController {
}

view?.addSubview(circularProgress)
view?.addSubview(timeRemainingLabel)
view?.addSubview(videoDropView, positioned: .above, relativeTo: nil)
view?.addSubview(conversionCompletedView, positioned: .above, relativeTo: nil)

setupTimeRemainingLabel()

window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: false)

Expand Down Expand Up @@ -129,6 +138,7 @@ final class MainWindowController: NSWindowController {
}

private var progress: Progress?
private lazy var timeEstimator = TimeEstimator(label: timeRemainingLabel)

func startConversion(inputUrl: URL, outputUrl: URL) {
guard !isRunning else {
Expand All @@ -142,6 +152,8 @@ final class MainWindowController: NSWindowController {
progress = Progress(totalUnitCount: 1)
circularProgress.progressInstance = progress
DockProgress.progress = progress
timeEstimator.progress = progress
timeEstimator.start()

progress?.performAsCurrent(withPendingUnitCount: 1) {
let conversion = Gifski.Conversion(
Expand Down Expand Up @@ -186,6 +198,19 @@ final class MainWindowController: NSWindowController {
}
}
}

private func setupTimeRemainingLabel() {
guard let view = view else {
return
}

timeRemainingLabel.translatesAutoresizingMaskIntoConstraints = false

NSLayoutConstraint.activate([
timeRemainingLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
timeRemainingLabel.topAnchor.constraint(equalTo: circularProgress.bottomAnchor)
])
}
}

extension MainWindowController: NSMenuItemValidation {
Expand Down
137 changes: 137 additions & 0 deletions Gifski/TimeEstimator.swift
@@ -0,0 +1,137 @@
import Foundation

final class TimeEstimator {
/// The delay before revealing the estimated time remaining, allowing the estimation to stabilize.
let bufferDuration: TimeInterval = 3.0

/// Don't show the estimate at all if the total time estimate (after it stabilizes) is less than this amount.
let skipThreshold: TimeInterval = 10.0

/// Begin fade out when remaining time reaches this amount.
let fadeOutThreshold: TimeInterval = 1.0

var progress: Progress? {
didSet {
progressObserver = progress?.observe(\.fractionCompleted) { sender, _ in
self.percentComplete = sender.fractionCompleted
}

cancelObserver = progress?.observe(\.isCancelled) { sender, _ in
if sender.isCancelled {
self.state = .done
}
}
}
}

init(label: Label) {
self.label = label
}

func start() {
state = .buffering
startTime = Date()
}

// MARK: - Private

private enum State {
case buffering
case running
case done
}

private var state: State = .buffering {
didSet {
guard state != oldValue else {
return
}

switch state {
case .buffering:
break
case .running:
fadeInLabel()
case .done:
fadeOutLabel()
}
}
}

private var nextState: State {
switch state {
case .buffering:
if finishedBuffering {
return shouldShowEstimation ? .running : .done
} else {
return .buffering
}
case .running:
return secondsRemaining < fadeOutThreshold ? .done : .running
case .done:
return .done
}
}

private var finishedBuffering: Bool {
return secondsElapsed > bufferDuration
}

private var shouldShowEstimation: Bool {
return secondsRemaining > skipThreshold
}

private var secondsElapsed: TimeInterval {
return Date().timeIntervalSince(startTime)
}

private var secondsRemaining: TimeInterval {
return (secondsElapsed / percentComplete) * (1 - percentComplete)
}

private var label: Label
private var startTime = Date()
private var progressObserver: NSKeyValueObservation?
private var cancelObserver: NSKeyValueObservation?

private lazy var elapsedTimeFormatter = with(DateComponentsFormatter()) {
$0.unitsStyle = .full
$0.includesApproximationPhrase = true
$0.includesTimeRemainingPhrase = true
}

private var formattedTimeRemaining: String? {
let seconds = secondsRemaining
elapsedTimeFormatter.allowedUnits = seconds < 60 ? .second : [.hour, .minute]
return elapsedTimeFormatter.string(from: seconds)
}

private var percentComplete: Double = 0.001 {
didSet {
state = nextState
updateLabel()
}
}

private func fadeInLabel() {
DispatchQueue.main.async {
if self.label.isHidden {
self.label.fadeIn()
}
}
}

private func fadeOutLabel() {
DispatchQueue.main.async {
if !self.label.isHidden {
self.label.fadeOut()
}
}
}

private func updateLabel() {
DispatchQueue.main.async {
self.label.text = self.formattedTimeRemaining ?? ""
}
}
}

0 comments on commit 57cc4cd

Please sign in to comment.