diff --git a/Sources/Presenters/Track/HeaderPresenter.swift b/Sources/Presenters/Track/HeaderPresenter.swift index 0daa3814..6ba3b990 100644 --- a/Sources/Presenters/Track/HeaderPresenter.swift +++ b/Sources/Presenters/Track/HeaderPresenter.swift @@ -1,3 +1,4 @@ +import AppKit import Combine import Dependencies import Domain @@ -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 = .idle @Published public private(set) var artistState: FetchState = .idle @@ -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 = [] @Dependency(\.trackInteractor) private var interactor @@ -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) } @@ -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 diff --git a/Sources/VersionHandler/Resources/version.txt b/Sources/VersionHandler/Resources/version.txt index 6ad4f1e4..3caff04a 100644 --- a/Sources/VersionHandler/Resources/version.txt +++ b/Sources/VersionHandler/Resources/version.txt @@ -1 +1 @@ -2.13.9 +2.13.10 diff --git a/Sources/Views/Header/HeaderView.swift b/Sources/Views/Header/HeaderView.swift index c9353c16..41d87803 100644 --- a/Sources/Views/Header/HeaderView.swift +++ b/Sources/Views/Header/HeaderView.swift @@ -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) diff --git a/Tests/PresentersTests/HeaderPresenterDuplicateTests.swift b/Tests/PresentersTests/HeaderPresenterDuplicateTests.swift index 12deaa74..85e1af45 100644 --- a/Tests/PresentersTests/HeaderPresenterDuplicateTests.swift +++ b/Tests/PresentersTests/HeaderPresenterDuplicateTests.swift @@ -1,3 +1,4 @@ +import AppKit @preconcurrency import Combine import Dependencies import Domain @@ -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 { @@ -89,7 +99,7 @@ struct HeaderPresenterDuplicateTests { let artworkSubject = PassthroughSubject() let update = TrackUpdate(title: "Song", artist: "Band") - await withDependencies { + try await withDependencies { $0.trackInteractor = StubTrackInteractor( trackChangePublisher: trackSubject.eraseToAnyPublisher(), artworkPublisher: artworkSubject.eraseToAnyPublisher(), @@ -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) } } @@ -126,7 +141,7 @@ struct HeaderPresenterDuplicateTests { let trackSubject = PassthroughSubject() let artworkSubject = PassthroughSubject() - await withDependencies { + try await withDependencies { $0.trackInteractor = StubTrackInteractor( trackChangePublisher: trackSubject.eraseToAnyPublisher(), artworkPublisher: artworkSubject.eraseToAnyPublisher(), @@ -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")) } diff --git a/Tests/PresentersTests/HeaderPresenterTests.swift b/Tests/PresentersTests/HeaderPresenterTests.swift index 15fafeb0..087529ba 100644 --- a/Tests/PresentersTests/HeaderPresenterTests.swift +++ b/Tests/PresentersTests/HeaderPresenterTests.swift @@ -142,7 +142,7 @@ struct HeaderPresenterTests { #expect(presenter.artistState.isIdle) #expect(presenter.displayTitle == " ") #expect(presenter.displayArtist == " ") - #expect(presenter.artworkData == nil) + #expect(presenter.artworkImage == nil) } } } diff --git a/Tests/ViewsTests/ViewRenderingTests.swift b/Tests/ViewsTests/ViewRenderingTests.swift index 8d4efa2d..1f86bc13 100644 --- a/Tests/ViewsTests/ViewRenderingTests.swift +++ b/Tests/ViewsTests/ViewRenderingTests.swift @@ -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( @@ -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)) } }