Skip to content
Open
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: 1 addition & 1 deletion Sources/Kaset/Services/AI/FoundationModelsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ final class FoundationModelsService {

for try await snapshot in stream {
partial = snapshot.content
await onPartial(snapshot.content)
onPartial(snapshot.content)
}
Comment on lines 244 to 247

guard let final = partial,
Expand Down
3 changes: 3 additions & 0 deletions Sources/Kaset/Services/Player/PlayerService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ final class PlayerService: NSObject, PlayerServiceProtocol {
}
}

/// Whether the full now-playing lyrics view is visible.
var showNowPlayingLyrics: Bool = false

/// Display mode for the queue panel (popup vs side panel).
var queueDisplayMode: QueueDisplayMode = .popup

Expand Down
30 changes: 19 additions & 11 deletions Sources/Kaset/Utilities/ImageCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ actor ImageCache {
/// Maximum disk cache size in bytes (200MB).
private static let maxDiskCacheSize: Int64 = 200 * 1024 * 1024

private let memoryCache = NSCache<NSURL, NSImage>()
private var inFlight: [URL: Task<NSImage?, Never>] = [:]
private let memoryCache = NSCache<NSString, NSImage>()
private var inFlight: [String: Task<NSImage?, Never>] = [:]
private let fileManager = FileManager.default
private let diskCacheURL: URL

Expand Down Expand Up @@ -63,19 +63,21 @@ actor ImageCache {
/// - targetSize: Optional target size for downsampling. If provided, the image will be
/// downsampled to fit this size, significantly reducing memory usage.
func image(for url: URL, targetSize: CGSize? = nil) async -> NSImage? {
let cacheKey = self.cacheKey(for: url, targetSize: targetSize)

// Check memory cache
if let cached = memoryCache.object(forKey: url as NSURL) {
if let cached = self.memoryCache.object(forKey: cacheKey as NSString) {
return cached
}

// Check disk cache
if let diskImage = loadFromDisk(url: url, targetSize: targetSize) {
self.memoryCache.setObject(diskImage, forKey: url as NSURL)
self.memoryCache.setObject(diskImage, forKey: cacheKey as NSString)
return diskImage
}

// Check if already fetching
if let existing = inFlight[url] {
if let existing = self.inFlight[cacheKey] {
return await existing.value
}

Expand All @@ -86,17 +88,17 @@ actor ImageCache {
guard Self.isSuccessfulResponse(response) else { return nil }
guard let image = Self.createImage(from: data, targetSize: targetSize) else { return nil }
let cost = targetSize != nil ? Int(image.size.width * image.size.height * 4) : data.count
self.memoryCache.setObject(image, forKey: url as NSURL, cost: cost)
self.memoryCache.setObject(image, forKey: cacheKey as NSString, cost: cost)
self.saveToDisk(url: url, data: data)
return image
} catch {
return nil
}
}

self.inFlight[url] = task
self.inFlight[cacheKey] = task
let result = await task.value
self.inFlight.removeValue(forKey: url)
self.inFlight.removeValue(forKey: cacheKey)
return result
}

Expand All @@ -121,7 +123,7 @@ actor ImageCache {
guard !Task.isCancelled else { break }

// Skip if already in memory cache
if self.memoryCache.object(forKey: url as NSURL) != nil {
if self.memoryCache.object(forKey: self.cacheKey(for: url, targetSize: targetSize) as NSString) != nil {
continue
}

Expand Down Expand Up @@ -210,8 +212,14 @@ actor ImageCache {

// MARK: - Disk Cache Helpers

private func cacheKey(for url: URL) -> String {
let data = Data(url.absoluteString.utf8)
private func cacheKey(for url: URL, targetSize: CGSize? = nil) -> String {
let targetComponent = if let targetSize {
"\(Int(targetSize.width.rounded()))x\(Int(targetSize.height.rounded()))"
} else {
"original"
}

let data = Data("\(url.absoluteString)|\(targetComponent)".utf8)
Comment on lines +215 to +222
let hash = SHA256.hash(data: data)
return hash.compactMap { String(format: "%02x", $0) }.joined()
}
Comment on lines +215 to 225
Comment on lines +215 to 225
Expand Down
20 changes: 13 additions & 7 deletions Sources/Kaset/Views/CachedAsyncImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {

@State private var image: NSImage?
@State private var isLoaded = false
@State private var loadingURL: URL?

/// Whether to animate the image appearance.
private var shouldAnimate: Bool {
Expand All @@ -32,20 +33,25 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
self.placeholder()
}
}
.onChange(of: self.url) { _, _ in
// Reset state when URL changes for proper UX
self.image = nil
self.isLoaded = false
.onChange(of: self.url) { _, newURL in
if newURL == nil {
self.loadingURL = nil
self.image = nil
self.isLoaded = false
}
}
Comment on lines +36 to 42
.task(id: self.url) {
guard let url else { return }
self.loadingURL = url
let loadedImage = await ImageCache.shared.image(for: url, targetSize: self.targetSize)
guard !Task.isCancelled else { return }
guard self.loadingURL == url else { return }

guard let loadedImage else {
self.image = nil
self.isLoaded = false
self.onFailure?()
if self.image == nil {
self.isLoaded = false
self.onFailure?()
}
Comment on lines +53 to +54
return
}

Expand Down
35 changes: 26 additions & 9 deletions Sources/Kaset/Views/LyricsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,17 +129,34 @@ struct LyricsView: View {
private var contentView: some View {
if self.playerService.currentTrack == nil {
self.noTrackPlayingView
} else if self.syncedLyricsService.isLoading || self.isLoadingFallback {
} else if self.shouldShowFullLoadingView {
self.loadingView
} else {
switch self.syncedLyricsService.currentLyrics {
case let .synced(synced):
self.syncedLyricsContentView(synced)
case let .plain(plain):
self.plainLyricsContentView(plain)
case .unavailable:
self.noLyricsView
}
self.lyricsResultView
.overlay(alignment: .topTrailing) {
if self.syncedLyricsService.isLoading || self.isLoadingFallback {
ProgressView()
.controlSize(.small)
.padding(10)
}
}
}
}

private var shouldShowFullLoadingView: Bool {
guard self.syncedLyricsService.isLoading || self.isLoadingFallback else { return false }
return !self.syncedLyricsService.currentLyrics.isAvailable
}

@ViewBuilder
private var lyricsResultView: some View {
switch self.syncedLyricsService.currentLyrics {
case let .synced(synced):
self.syncedLyricsContentView(synced)
case let .plain(plain):
self.plainLyricsContentView(plain)
case .unavailable:
self.noLyricsView
}
}

Expand Down
15 changes: 15 additions & 0 deletions Sources/Kaset/Views/MainWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ struct MainWindow: View {
OnboardingView()
}
}
.allowsHitTesting(!self.playerService.showNowPlayingLyrics)
.onAppear {
DiagnosticsLogger.app.info("MainWindow: UI appeared")
}
Expand Down Expand Up @@ -171,6 +172,20 @@ struct MainWindow: View {
AccountErrorToast()
.padding(.top, 60)
}
.overlay {
if self.playerService.showNowPlayingLyrics {
ZStack(alignment: .topLeading) {
Rectangle()
.fill(.clear)
.ignoresSafeArea()

NowPlayingLyricsView(client: self.client)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.ignoresSafeArea()
}
}
.onChange(of: self.showCommandBar.wrappedValue) { _, newValue in
if newValue {
self.isCommandBarPresented = true
Expand Down
Loading
Loading