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
2 changes: 2 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ Presenters subscribe to Interactors via Combine. Interactors access UseCases via

**FetchState\<T\>**: Generic enum (`.idle`, `.loading`, `.revealing(T)`, `.success(T)`, `.failure`) drives both data flow and UI animation. The `.revealing` → `.success` transition is timed by Presenters using `DecodeEffectState`. Use `FetchState<T>` only when the payload `T` is genuinely consumed downstream (e.g. `LyricsPresenter.lyricsState`, whose content feeds `columns(in:)` and `updateActiveLineTick()`). When a Presenter only needs the animation lifecycle and the View already renders the text from a separate `display…` property, expose the payload-less `RevealPhase` (`.idle` / `.revealing` / `.revealed`) instead and keep the decode target in a private field — `HeaderPresenter` does this for `titlePhase` / `artistPhase` so the public surface never duplicates `displayTitle` / `displayArtist` (#275).

**AI processing indicator (#57)**: While the AI (LLM) extractor resolves title/artist on a cache miss, the header scrambles in a configurable color so the user sees that work is happening. `TrackInteractorImpl.resolveTrack` emits an extra `TrackUpdate(aiResolving: true)` after the debounce only when an `[ai]` endpoint is configured **and** `MetadataUseCase.isAIMetadataCached(track:)` returns `false` (an LLM cache hit means no API round-trip, so no indicator). `HeaderPresenter` maps `aiResolving` to `DecodeEffectState.startLoading` (the indefinite scramble, distinct from `decode`'s settle) and swaps `titleColor` / `artistColor` to `DecodeEffect.processingColor` (default green `#4ADE80FF`, config key `text.decode_effect.processing_color`, solid or gradient). The resolved (non-`aiResolving`) update settles the scramble and restores the normal color. `HeaderView` reads the effective `titleColor` / `artistColor` (`@Published`) rather than the static `titleStyle.color`.

**Entity types**: `AppStyle`, `TextLayout`, `TextAppearance`, `ArtworkStyle`, `RippleStyle`, `WallpaperStyle`, `WallpaperItem`, `WallpaperPlaybackMode`, `DecodeEffect`, `AIEndpoint`, `ColorStyle`, `HealthCheckResult`, `ConfigValidationResult`, `MusicBrainzMetadata`, `MediaRemotePollResult`, `LocalWallpaper`, `RemoteWallpaper`, `YouTubeWallpaper`, `TrackUpdate`, `TrackLyricsState`, `WallpaperState`, `ResolvedWallpaperItem`, `ScreenLayout`, `WallpaperConfig`, `WallpaperItemConfig`, `NowPlayingInfo`, `LyricLine`, `LyricsContent`, `RevealPhase`. Config flows through Interactors, not via global `AppStyleKey`.

**No AppStyleKey**: `@Dependency(\.appStyle)` was removed. All config access goes through the owning Interactor's computed properties (e.g., `trackInteractor.textLayout`, `wallpaperInteractor.rippleConfig`). This enforces the VIPER dependency rule.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ Controls the matrix-style text reveal animation.
|---|---|---|---|
| `duration` | number | `0.8` | Animation duration in seconds |
| `charset` | string / array | all | Character sets for scramble: `"latin"`, `"cyrillic"`, `"greek"`, `"symbols"`, `"cjk"`. Single string or array |
| `processing_color` | string / array | `"#4ADE80FF"` (green) | Title/artist color while the AI extractor is resolving (LLM cache miss). The header scrambles in this color until the API responds, then settles to the resolved text in its normal color. Solid hex or gradient array. Only applies when an `[ai]` endpoint is configured |

### `[artwork]`

Expand Down
3 changes: 2 additions & 1 deletion Sources/ConfigRepository/ConfigRepositoryImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ extension ConfigRepositoryImpl: ConfigRepository {
highlight: config.text.highlight.toTextAppearance(),
decodeEffect: DecodeEffect(
duration: config.text.decodeEffect.duration.value,
charsets: config.text.decodeEffect.charset
charsets: config.text.decodeEffect.charset,
processingColor: config.text.decodeEffect.processingColor
)
),
artwork: ArtworkStyle(size: config.artwork.size.value, opacity: config.artwork.opacity.value),
Expand Down
5 changes: 5 additions & 0 deletions Sources/Domain/Repository/MetadataRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import Dependencies

public protocol MetadataRepository: Sendable {
func resolve(track: Track) async -> [Track]
/// Whether the AI (LLM) extractor already has a cached result for this raw
/// track. A `false` here with an AI endpoint configured means `resolve` will
/// make a live API call, which presenters surface as a processing indicator (#57).
func isAIMetadataCached(track: Track) async -> Bool
}

public enum MetadataRepositoryKey: TestDependencyKey {
Expand All @@ -17,4 +21,5 @@ extension DependencyValues {

private struct UnimplementedMetadataRepository: MetadataRepository {
func resolve(track: Track) async -> [Track] { [] }
func isAIMetadataCached(track: Track) async -> Bool { false }
}
5 changes: 5 additions & 0 deletions Sources/Domain/UseCase/MetadataUseCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import Dependencies
public protocol MetadataUseCase: Sendable {
func resolve(track: Track) async -> Track?
func resolveCandidates(track: Track) async -> [Track]
/// Whether the AI (LLM) extractor already has a cached result for this raw
/// track. Used to decide whether `resolveCandidates` will make a live API
/// call worth showing a processing indicator for (#57).
func isAIMetadataCached(track: Track) async -> Bool
}

public enum MetadataUseCaseKey: TestDependencyKey {
Expand All @@ -19,4 +23,5 @@ extension DependencyValues {
private struct UnimplementedMetadataUseCase: MetadataUseCase {
func resolve(track: Track) async -> Track? { nil }
func resolveCandidates(track: Track) async -> [Track] { [] }
func isAIMetadataCached(track: Track) async -> Bool { false }
}
11 changes: 10 additions & 1 deletion Sources/Entity/Config/DecodeEffectConfig.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
public struct DecodeEffectConfig {
public let duration: FlexibleDouble
public let charset: Set<CharsetName>
public let processingColor: ColorStyle
}

extension DecodeEffectConfig: Sendable {}

extension DecodeEffectConfig {
static let defaults = DecodeEffectConfig(duration: 0.8, charset: Set(CharsetName.allCases))
static let defaults = DecodeEffectConfig(
duration: 0.8, charset: Set(CharsetName.allCases), processingColor: .solid("#4ADE80FF"))
}

extension DecodeEffectConfig: Codable {
enum CodingKeys: String, CodingKey {
case duration, charset
case processingColor = "processing_color"
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
duration = try container.decodeIfPresent(FlexibleDouble.self, forKey: .duration) ?? Self.defaults.duration
processingColor =
try container.decodeIfPresent(ColorStyle.self, forKey: .processingColor) ?? Self.defaults.processingColor
switch (
try? container.decodeIfPresent([CharsetName].self, forKey: .charset), try? container.decodeIfPresent(CharsetName.self, forKey: .charset)
) {
Expand Down
8 changes: 7 additions & 1 deletion Sources/Entity/Style/DecodeEffect.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
public struct DecodeEffect {
public let duration: Double
public let charsets: Set<CharsetName>
/// Text color used while the AI extractor is resolving title/artist
/// (cache miss): the header scrambles in this color until the API responds,
/// then settles to the resolved text in its normal color (#57).
public let processingColor: ColorStyle

public init(
duration: Double = 0.8,
charsets: Set<CharsetName> = Set(CharsetName.allCases)
charsets: Set<CharsetName> = Set(CharsetName.allCases),
processingColor: ColorStyle = .solid("#4ADE80FF")
) {
self.duration = duration
self.charsets = charsets
self.processingColor = processingColor
}
}

Expand Down
9 changes: 8 additions & 1 deletion Sources/Entity/TrackUpdate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,24 @@ public struct TrackUpdate {
public let artist: String?
public let lyrics: LyricsContent?
public let lyricsState: TrackLyricsState
/// `true` while the title/artist are being resolved by the AI extractor
/// (LLM cache miss with an AI endpoint configured). Presenters use it to
/// show a "processing" indicator during the API round-trip; it is `false`
/// for the raw, cache-hit, and resolved updates (#57).
public let aiResolving: Bool

public init(
title: String? = nil,
artist: String? = nil,
lyrics: LyricsContent? = nil,
lyricsState: TrackLyricsState = .idle
lyricsState: TrackLyricsState = .idle,
aiResolving: Bool = false
) {
self.title = title
self.artist = artist
self.lyrics = lyrics
self.lyricsState = lyricsState
self.aiResolving = aiResolving
}
}

Expand Down
4 changes: 4 additions & 0 deletions Sources/MetadataRepository/MetadataRepositoryImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,8 @@ extension MetadataRepositoryImpl: MetadataRepository {
Track(title: $0.title, artist: $0.artist, duration: track.duration)
}
}

public func isAIMetadataCached(track: Track) async -> Bool {
await llmDataStore.read(title: track.title, artist: track.artist) != nil
}
}
4 changes: 4 additions & 0 deletions Sources/MetadataUseCase/MetadataUseCaseImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@ extension MetadataUseCaseImpl: MetadataUseCase {
public func resolveCandidates(track: Track) async -> [Track] {
await repository.resolve(track: track)
}

public func isAIMetadataCached(track: Track) async -> Bool {
await repository.isAIMetadataCached(track: track)
}
}
53 changes: 51 additions & 2 deletions Sources/Presenters/Track/HeaderPresenter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ public final class HeaderPresenter: ObservableObject {
// state (#275).
@Published public private(set) var titlePhase: RevealPhase = .idle
@Published public private(set) var artistPhase: RevealPhase = .idle
// Effective foreground colors. They equal the configured title/artist
// colors except while the AI extractor is resolving (cache miss), when both
// switch to `decodeEffect.processingColor` and the header scrambles in it,
// then settle back to the normal color on the resolved text (#57).
@Published public private(set) var titleColor: ColorStyle = .solid("#FFFFFFD9")
@Published public private(set) var artistColor: ColorStyle = .solid("#FFFFFFD9")

public private(set) var titleStyle: TextAppearance = .init()
public private(set) var artistStyle: TextAppearance = .init()
Expand All @@ -27,6 +33,7 @@ public final class HeaderPresenter: ObservableObject {
private var titleTarget: String?
private var artistTarget: String?
private var artworkData: Data?
private var processingColor: ColorStyle = .solid("#4ADE80FF")
private var cancellables: Set<AnyCancellable> = []

@Dependency(\.trackInteractor) private var interactor
Expand All @@ -38,6 +45,9 @@ public final class HeaderPresenter: ObservableObject {
let style = interactor.textLayout
titleStyle = style.title
artistStyle = style.artist
titleColor = style.title.color
artistColor = style.artist.color
processingColor = config.processingColor
artworkSize = interactor.artworkStyle.size
artworkOpacity = interactor.artworkStyle.opacity
titleEffect = DecodeEffectState(config: config)
Expand Down Expand Up @@ -67,8 +77,12 @@ public final class HeaderPresenter: ObservableObject {

extension HeaderPresenter {
private func receive(_ update: TrackUpdate) {
revealTitle(update.title)
revealArtist(update.artist)
guard update.aiResolving else {
revealTitle(update.title)
revealArtist(update.artist)
return
}
startProcessing(title: update.title, artist: update.artist)
}

private func receiveArtwork(_ data: Data?) {
Expand All @@ -78,6 +92,7 @@ extension HeaderPresenter {
}

private func revealTitle(_ text: String?) {
titleColor = titleStyle.color
guard let text else {
titleTarget = nil
titlePhase = .idle
Expand All @@ -97,6 +112,7 @@ extension HeaderPresenter {
}

private func revealArtist(_ text: String?) {
artistColor = artistStyle.color
guard let text else {
artistTarget = nil
artistPhase = .idle
Expand All @@ -115,3 +131,36 @@ extension HeaderPresenter {
}
}
}

extension HeaderPresenter {
/// Switches the header into the AI-processing state: both fields scramble
/// indefinitely in `processingColor` until the resolved update arrives and
/// `revealTitle` / `revealArtist` settle them. `*Target` is cleared so the
/// settle is never deduped away even when the AI result equals the raw text.
private func startProcessing(title: String?, artist: String?) {
startProcessingTitle(title)
startProcessingArtist(artist)
}

private func startProcessingTitle(_ text: String?) {
guard let effect = titleEffect, let text, !text.isEmpty else { return }
titleTarget = nil
titleColor = processingColor
titlePhase = .revealing
effect.onUpdate = { [weak self] displayText in
self?.displayTitle = displayText
}
effect.startLoading(placeholderLength: text.count)
}

private func startProcessingArtist(_ text: String?) {
guard let effect = artistEffect, let text, !text.isEmpty else { return }
artistTarget = nil
artistColor = processingColor
artistPhase = .revealing
effect.onUpdate = { [weak self] displayText in
self?.displayArtist = displayText
}
effect.startLoading(placeholderLength: text.count)
}
}
20 changes: 20 additions & 0 deletions Sources/TrackInteractor/TrackInteractorImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ extension TrackInteractorImpl {
let metadata = metadataService
let lyrics = lyricsService
let clock = self.clock
let aiConfigured = configService.appStyle.ai != nil

return Just(loading)
.append(
Expand All @@ -159,6 +160,25 @@ extension TrackInteractorImpl {
return
}

// AI cache miss with an endpoint configured means
// `resolveCandidates` will make a live API call — signal the
// header to show its processing indicator for the round-trip (#57).
if aiConfigured,
await metadata.isAIMetadataCached(track: rawTrack) == false
{
guard !Task.isCancelled else {
unsafeSubject.send(completion: .finished)
return
}
unsafeSubject.send(
TrackUpdate(
title: title,
artist: artist,
lyricsState: .loading,
aiResolving: true
))
}

let candidates = await metadata.resolveCandidates(track: rawTrack)
guard !Task.isCancelled else {
unsafeSubject.send(completion: .finished)
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.14.2
2.15.0
4 changes: 2 additions & 2 deletions Sources/Views/Header/HeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public struct HeaderView: View {
VStack(alignment: .leading, spacing: presenter.titleStyle.spacing) {
Text(presenter.displayTitle)
.font(resolver.font(from: presenter.titleStyle))
.foregroundStyle(resolver.shapeStyle(from: presenter.titleStyle.color))
.foregroundStyle(resolver.shapeStyle(from: presenter.titleColor))
.shadow(
color: resolver.solidColor(from: presenter.titleStyle.shadow),
radius: 5, x: 0, y: 1
Expand All @@ -43,7 +43,7 @@ public struct HeaderView: View {
.accessibilityIdentifier("header-title")
Text(presenter.displayArtist)
.font(resolver.font(from: presenter.artistStyle))
.foregroundStyle(resolver.shapeStyle(from: presenter.artistStyle.color))
.foregroundStyle(resolver.shapeStyle(from: presenter.artistColor))
.shadow(
color: resolver.solidColor(from: presenter.artistStyle.shadow),
radius: 5, x: 0, y: 1
Expand Down
5 changes: 4 additions & 1 deletion Tests/ConfigDataSourceTests/ConfigTemplateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ spacing = 6.0
[text.decode_effect]
charset = \(charsetPlaceholder)
duration = 0.8
processing_color = '#4ADE80'

[text.default]
color = '#FFFFFFD9'
Expand Down Expand Up @@ -176,7 +177,8 @@ spacing = 6.0
},
"decode_effect" : {
"charset" : "\(charsetPlaceholder)",
"duration" : 0.8
"duration" : 0.8,
"processing_color" : "#4ADE80"
},
"default" : {
"color" : "#FFFFFFD9",
Expand Down Expand Up @@ -242,6 +244,7 @@ spacing = 6.0
decoded.text.highlight.color
== .gradient(["#B8942DFF", "#EDCF73FF", "#FFEB99FF", "#CCA64DFF", "#A68038FF"]))
#expect(decoded.text.decodeEffect.charset == Set(CharsetName.allCases))
#expect(decoded.text.decodeEffect.processingColor == .solid("#4ADE80FF"))
#expect(decoded.ai == nil)
#expect(decoded.wallpaper == nil)
}
Expand Down
32 changes: 31 additions & 1 deletion Tests/ConfigRepositoryTests/ConfigRepositoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,34 @@ struct ConfigRepositoryTests {
}
}

@Test("decode_effect processing_color flows through to DecodeEffect")
func decodeEffectProcessingColor() {
let config = makeAppConfig(text: ["decode_effect": ["processing_color": "#FF00FF"]])
let result = ConfigLoadResult(config: config, configDir: "/tmp")

withDependencies {
$0.configDataSource = StubConfigDataSource(loadResult: result)
} operation: {
let repo = ConfigRepositoryImpl()
let style = repo.loadAppStyle()
#expect(style.text.decodeEffect.processingColor == .solid("#FF00FFFF"))
}
}

@Test("decode_effect processing_color defaults to green when absent")
func decodeEffectProcessingColorDefault() {
let config = makeAppConfig()
let result = ConfigLoadResult(config: config, configDir: "/tmp")

withDependencies {
$0.configDataSource = StubConfigDataSource(loadResult: result)
} operation: {
let repo = ConfigRepositoryImpl()
let style = repo.loadAppStyle()
#expect(style.text.decodeEffect.processingColor == .solid("#4ADE80FF"))
}
}

@Test("ripple shape defaults to circle when [ripple] is absent")
func rippleShapeDefaultsToCircle() {
let config = makeAppConfig()
Expand Down Expand Up @@ -301,12 +329,14 @@ private enum StubError: Error, LocalizedError {
private func makeAppConfig(
wallpaper: Any? = nil,
ai: AIConfig? = nil,
ripple: [String: Any]? = nil
ripple: [String: Any]? = nil,
text: [String: Any]? = nil
) -> AppConfig {
var fields = [String: Any]()
wallpaper.map { fields["wallpaper"] = $0 }
ai.map { fields["ai"] = ["endpoint": $0.endpoint, "model": $0.model, "api_key": $0.apiKey] }
ripple.map { fields["ripple"] = $0 }
text.map { fields["text"] = $0 }
let data = try! JSONSerialization.data(withJSONObject: fields)
return try! JSONDecoder().decode(AppConfig.self, from: data)
}
Expand Down
Loading
Loading