Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions Sources/Presenters/Track/HeaderPresenter.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import Combine
import Dependencies
import Domain
Expand All @@ -7,7 +8,7 @@ import Foundation
public final class HeaderPresenter: ObservableObject {
@Published public private(set) var displayTitle: String = " "
@Published public private(set) var displayArtist: String = " "
@Published public private(set) var artworkData: Data?
@Published public private(set) var artworkImage: NSImage?
@Published public private(set) var titleState: FetchState<String> = .idle
@Published public private(set) var artistState: FetchState<String> = .idle

Expand All @@ -18,6 +19,7 @@ public final class HeaderPresenter: ObservableObject {

private var titleEffect: DecodeEffectState?
private var artistEffect: DecodeEffectState?
private var artworkData: Data?
private var cancellables: Set<AnyCancellable> = []

@Dependency(\.trackInteractor) private var interactor
Expand All @@ -44,8 +46,7 @@ public final class HeaderPresenter: ObservableObject {
interactor.artwork
.receive(on: DispatchQueue.main)
.sink { [weak self] data in
guard data != self?.artworkData else { return }
self?.artworkData = data
self?.receiveArtwork(data)
}
.store(in: &cancellables)
}
Expand All @@ -63,6 +64,12 @@ extension HeaderPresenter {
revealArtist(update.artist)
}

private func receiveArtwork(_ data: Data?) {
guard data != artworkData else { return }
artworkData = data
artworkImage = data.flatMap(NSImage.init(data:))
}

private func revealTitle(_ text: String?) {
guard let text else {
titleState = .idle
Expand Down
2 changes: 1 addition & 1 deletion Sources/VersionHandler/Resources/version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.13.9
2.13.10
2 changes: 1 addition & 1 deletion Sources/Views/Header/HeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public struct HeaderView: View {
if presenter.titleState != .idle {
HStack(spacing: presenter.artworkOpacity > 0 ? 24 : 0) {
if presenter.artworkOpacity > 0 {
if let artworkData = presenter.artworkData, let image = NSImage(data: artworkData) {
if let image = presenter.artworkImage {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
Expand Down
38 changes: 27 additions & 11 deletions Tests/PresentersTests/HeaderPresenterDuplicateTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
@preconcurrency import Combine
import Dependencies
import Domain
Expand Down Expand Up @@ -32,6 +33,15 @@ private func waitForTitleSuccess(_ presenter: HeaderPresenter, timeout: Duration
}
}

@MainActor
private func fixtureArtworkData(color: NSColor = .red) throws -> Data {
let image = NSImage(size: NSSize(width: 1, height: 1))
image.lockFocus()
color.drawSwatch(in: NSRect(x: 0, y: 0, width: 1, height: 1))
image.unlockFocus()
return try #require(image.tiffRepresentation)
}

extension FetchState {
fileprivate var isSuccess: Bool {
switch self {
Expand Down Expand Up @@ -89,7 +99,7 @@ struct HeaderPresenterDuplicateTests {
let artworkSubject = PassthroughSubject<Data?, Never>()
let update = TrackUpdate(title: "Song", artist: "Band")

await withDependencies {
try await withDependencies {
$0.trackInteractor = StubTrackInteractor(
trackChangePublisher: trackSubject.eraseToAnyPublisher(),
artworkPublisher: artworkSubject.eraseToAnyPublisher(),
Expand All @@ -103,20 +113,25 @@ struct HeaderPresenterDuplicateTests {
trackSubject.send(update)
await waitForTitleSuccess(presenter)
#expect(presenter.titleState == .success("Song"))
#expect(presenter.artworkData == nil)
#expect(presenter.artworkImage == nil)

// Send artwork
let imageData = Data([0xFF, 0xD8, 0xFF])
let imageData = try fixtureArtworkData()
artworkSubject.send(imageData)
let artDeadline = ContinuousClock.now + .seconds(3)
while presenter.artworkData != imageData, ContinuousClock.now < artDeadline {
while presenter.artworkImage == nil, ContinuousClock.now < artDeadline {
try? await Task.sleep(for: .milliseconds(10))
}

#expect(presenter.artworkData == imageData)
let cachedImage = try #require(presenter.artworkImage)
// Title state must remain unchanged
#expect(presenter.titleState == .success("Song"))
#expect(presenter.artistState == .success("Band"))

artworkSubject.send(imageData)
try? await Task.sleep(for: .milliseconds(200))

#expect(presenter.artworkImage === cachedImage)
}
}

Expand All @@ -126,7 +141,7 @@ struct HeaderPresenterDuplicateTests {
let trackSubject = PassthroughSubject<TrackUpdate, Never>()
let artworkSubject = PassthroughSubject<Data?, Never>()

await withDependencies {
try await withDependencies {
$0.trackInteractor = StubTrackInteractor(
trackChangePublisher: trackSubject.eraseToAnyPublisher(),
artworkPublisher: artworkSubject.eraseToAnyPublisher(),
Expand All @@ -138,24 +153,25 @@ struct HeaderPresenterDuplicateTests {

// Send track and artwork nearly simultaneously
trackSubject.send(TrackUpdate(title: "New Song", artist: "New Artist"))
let artData = Data([0x89, 0x50, 0x4E, 0x47])
let artData = try fixtureArtworkData()
artworkSubject.send(artData)
await waitForTitleSuccess(presenter)

// Both should have settled correctly
#expect(presenter.artworkData == artData)
let cachedImage = try #require(presenter.artworkImage)
#expect(presenter.titleState == .success("New Song"))
#expect(presenter.artistState == .success("New Artist"))

// Now change artwork again
let newArtData = Data([0x00, 0x01])
let newArtData = try fixtureArtworkData(color: .blue)
artworkSubject.send(newArtData)
let newArtDeadline = ContinuousClock.now + .seconds(3)
while presenter.artworkData != newArtData, ContinuousClock.now < newArtDeadline {
while presenter.artworkImage === cachedImage, ContinuousClock.now < newArtDeadline {
try? await Task.sleep(for: .milliseconds(10))
}

#expect(presenter.artworkData == newArtData)
#expect(presenter.artworkImage != nil)
#expect(presenter.artworkImage !== cachedImage)
// Title state still untouched
#expect(presenter.titleState == .success("New Song"))
}
Expand Down
2 changes: 1 addition & 1 deletion Tests/PresentersTests/HeaderPresenterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ struct HeaderPresenterTests {
#expect(presenter.artistState.isIdle)
#expect(presenter.displayTitle == " ")
#expect(presenter.displayArtist == " ")
#expect(presenter.artworkData == nil)
#expect(presenter.artworkImage == nil)
}
}
}
Expand Down
10 changes: 5 additions & 5 deletions Tests/ViewsTests/ViewRenderingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,17 +136,17 @@ struct HeaderViewRenderingTests {
await waitUntil { presenter.displayTitle == "Song" }

#expect(presenter.artworkOpacity > 0)
#expect(presenter.artworkData == nil)
#expect(presenter.artworkImage == nil)
render(HeaderView(presenter: presenter), size: CGSize(width: 600, height: 120))
}

@Test("artwork image rendered with valid data")
func artworkWithImage() async {
func artworkWithImage() async throws {
let image = NSImage(size: NSSize(width: 1, height: 1))
image.lockFocus()
NSColor.red.drawSwatch(in: NSRect(x: 0, y: 0, width: 1, height: 1))
image.unlockFocus()
let pngData = image.tiffRepresentation!
let pngData = try #require(image.tiffRepresentation)

let presenter = withDependencies {
$0.trackInteractor = FixtureTrackInteractor(
Expand All @@ -157,9 +157,9 @@ struct HeaderViewRenderingTests {
}
presenter.start()
defer { presenter.stop() }
await waitUntil { presenter.artworkData != nil && presenter.displayTitle == "Song" }
await waitUntil { presenter.artworkImage != nil && presenter.displayTitle == "Song" }

#expect(presenter.artworkData != nil)
#expect(presenter.artworkImage != nil)
render(HeaderView(presenter: presenter), size: CGSize(width: 600, height: 120))
}
}
Expand Down
Loading