Merge origin/main into bug/terminal-bugs

This commit is contained in:
Hariharan Mudaliar 2026-04-27 10:35:39 +05:30
commit cdf0785e29
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
22 changed files with 1363 additions and 353 deletions

View file

@ -26,6 +26,8 @@ import AVFoundation
enum LockScreenAnimationTimings {
static let lockExpand: TimeInterval = 0.45
static let unlockCollapse: TimeInterval = 0.82
static let postUnlockMusicHUDPause: TimeInterval = 1.0
static let postUnlockMusicHUDReveal: TimeInterval = 0.34
}
@MainActor
@ -39,11 +41,13 @@ class LockScreenManager: ObservableObject {
// MARK: - Published Properties
@Published var isLocked: Bool = false
@Published var isLockIdle: Bool = true
@Published var shouldDelayPostUnlockMusicHUD: Bool = false
@Published var lastUpdated: Date = .distantPast
// MARK: - Private Properties
private var debounceIdleTask: Task<Void, Never>?
private var collapseTask: Task<Void, Never>?
private var postUnlockMusicHUDTask: Task<Void, Never>?
// MARK: - Helpers
@ -63,6 +67,7 @@ class LockScreenManager: ObservableObject {
DistributedNotificationCenter.default().removeObserver(self)
debounceIdleTask?.cancel()
collapseTask?.cancel()
postUnlockMusicHUDTask?.cancel()
}
// MARK: - Setup
@ -102,6 +107,8 @@ class LockScreenManager: ObservableObject {
// Update state SYNCHRONOUSLY without Task/await to avoid any delay
lastUpdated = Date()
updateIdleState(locked: true)
postUnlockMusicHUDTask?.cancel()
shouldDelayPostUnlockMusicHUD = false
// Set locked state immediately without animation wrapper
isLocked = true
@ -149,6 +156,33 @@ class LockScreenManager: ObservableObject {
lastUpdated = Date()
updateIdleState(locked: false)
isLocked = false
postUnlockMusicHUDTask?.cancel()
shouldDelayPostUnlockMusicHUD = Defaults[.enableLockScreenLiveActivity]
if shouldDelayPostUnlockMusicHUD {
postUnlockMusicHUDTask = Task { [weak self] in
try? await Task.sleep(
for: .seconds(
LockScreenAnimationTimings.unlockCollapse
+ LockScreenAnimationTimings.postUnlockMusicHUDPause
)
)
guard let self = self, !Task.isCancelled else { return }
await MainActor.run {
if !self.isLocked {
withAnimation(
.spring(
response: LockScreenAnimationTimings.postUnlockMusicHUDReveal,
dampingFraction: 0.88,
blendDuration: 0.08
)
) {
self.shouldDelayPostUnlockMusicHUD = false
}
}
}
}
}
// Hide panel window immediately and synchronously
print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window")
@ -182,8 +216,9 @@ class LockScreenManager: ObservableObject {
} else {
debounceIdleTask?.cancel()
debounceIdleTask = Task { [weak self] in
let configuredInterval = max(Defaults[.waitInterval], 0)
let idleDelay = min(max(configuredInterval, 0.2), LockScreenAnimationTimings.unlockCollapse)
// Keep the lock live activity mounted until the collapse animation finishes,
// otherwise the content disappears before the island fully closes.
let idleDelay = LockScreenAnimationTimings.unlockCollapse
try? await Task.sleep(for: .seconds(idleDelay))
guard let self = self, !Task.isCancelled else { return }
await MainActor.run {

View file

@ -43,6 +43,354 @@ let defaultImage: NSImage = .init(
accessibilityDescription: "Album Art"
)!
private struct ITunesExplicitnessSearchResponse: Decodable {
let results: [ITunesExplicitnessTrack]
}
private struct ITunesExplicitnessTrack: Decodable {
let trackName: String?
let artistName: String?
let collectionName: String?
let trackExplicitness: String?
}
private actor MusicExplicitnessResolver {
struct LookupKey: Hashable, Sendable {
let title: String
let artist: String
let album: String
init(title: String, artist: String, album: String) {
self.title = MusicExplicitnessResolver.normalize(title)
self.artist = MusicExplicitnessResolver.normalize(artist)
self.album = MusicExplicitnessResolver.normalize(album)
}
var canResolve: Bool {
!title.isEmpty && !artist.isEmpty
}
}
static let shared = MusicExplicitnessResolver()
private let session = URLSession(configuration: .ephemeral)
private var cache: [LookupKey: Bool] = [:]
private var inFlightTasks: [LookupKey: Task<Bool, Never>] = [:]
func resolve(title: String, artist: String, album: String) async -> Bool {
let key = LookupKey(title: title, artist: artist, album: album)
guard key.canResolve else { return false }
if let cached = cache[key] {
return cached
}
if let inFlightTask = inFlightTasks[key] {
return await inFlightTask.value
}
let task = Task<Bool, Never> { [session] in
await Self.fetchExplicitness(for: key, using: session)
}
inFlightTasks[key] = task
let result = await task.value
cache[key] = result
inFlightTasks[key] = nil
return result
}
private static func fetchExplicitness(for key: LookupKey, using session: URLSession) async -> Bool {
let query = "\(key.title) \(key.artist)"
guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(
string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=song&limit=15")
else {
return false
}
do {
let (data, _) = try await session.data(from: url)
let response = try JSONDecoder().decode(ITunesExplicitnessSearchResponse.self, from: data)
let bestMatch = response.results
.map { track in (track, matchScore(for: track, key: key)) }
.max { lhs, rhs in lhs.1 < rhs.1 }
guard let bestMatch,
bestMatch.1 >= 8
else {
return false
}
return bestMatch.0.trackExplicitness?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
== "explicit"
} catch {
return false
}
}
private static func matchScore(for track: ITunesExplicitnessTrack, key: LookupKey) -> Int {
let trackTitle = canonicalTitle(track.trackName ?? "")
guard !trackTitle.isEmpty else { return Int.min }
let keyTitle = canonicalTitle(key.title)
let trackArtist = normalize(track.artistName ?? "")
let trackAlbum = normalize(track.collectionName ?? "")
var score = 0
if trackTitle == keyTitle {
score += 6
} else if trackTitle.contains(keyTitle) || keyTitle.contains(trackTitle) {
score += 4
} else {
return Int.min
}
if !key.artist.isEmpty {
if trackArtist == key.artist {
score += 4
} else if trackArtist.contains(key.artist) || key.artist.contains(trackArtist) {
score += 2
}
}
if !key.album.isEmpty {
if trackAlbum == key.album {
score += 2
} else if !trackAlbum.isEmpty && (trackAlbum.contains(key.album) || key.album.contains(trackAlbum)) {
score += 1
}
}
return score
}
private static func canonicalTitle(_ value: String) -> String {
var title = value.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
title = title.replacingOccurrences(
of: #"\([^)]*\)|\[[^\]]*\]"#,
with: " ",
options: .regularExpression
)
title = title.replacingOccurrences(
of: #"\s-\s(?:\d{4}\s)?(?:remaster(?:ed)?|live|edit|mix|version|mono|stereo).*$"#,
with: " ",
options: .regularExpression
)
return normalize(title)
}
private static func normalize(_ value: String) -> String {
let folded = value.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
let stripped = folded.replacingOccurrences(
of: #"[^a-z0-9]+"#,
with: " ",
options: .regularExpression
)
return stripped
.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
private actor SpotifyExplicitnessResolver {
struct LookupKey: Hashable, Sendable {
let trackID: String
init?(contentIdentifier: String?, contentURL: String?) {
guard let trackID = SpotifyExplicitnessResolver.extractTrackID(from: contentIdentifier)
?? SpotifyExplicitnessResolver.extractTrackID(from: contentURL)
else {
return nil
}
self.trackID = trackID
}
}
static let shared = SpotifyExplicitnessResolver()
private let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.httpAdditionalHeaders = [
"User-Agent": "Mozilla/5.0",
"Accept-Language": "en-US,en;q=0.9"
]
return URLSession(configuration: configuration)
}()
private var cache: [LookupKey: Bool] = [:]
private var inFlightTasks: [LookupKey: Task<Bool?, Never>] = [:]
func resolve(key: LookupKey) async -> Bool? {
if let cached = cache[key] {
return cached
}
if let inFlightTask = inFlightTasks[key] {
return await inFlightTask.value
}
let task = Task<Bool?, Never> { [session] in
await Self.fetchExplicitness(for: key, using: session)
}
inFlightTasks[key] = task
let result = await task.value
if let result {
cache[key] = result
}
inFlightTasks[key] = nil
return result
}
private static func fetchExplicitness(for key: LookupKey, using session: URLSession) async -> Bool? {
guard let url = URL(string: "https://open.spotify.com/embed/track/\(key.trackID)") else {
return nil
}
do {
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let html = String(data: data, encoding: .utf8)
else {
return nil
}
if let isExplicit = parseIsExplicitDirectly(from: html) {
return isExplicit
}
if let nextDataJSON = extractNextDataJSON(from: html),
let isExplicit = parseIsExplicit(from: nextDataJSON) {
return isExplicit
}
return nil
} catch {
return nil
}
}
private static func extractNextDataJSON(from html: String) -> String? {
let pattern = #"<script[^>]*id="__NEXT_DATA__"[^>]*>(.*?)</script>"#
guard let regex = try? NSRegularExpression(
pattern: pattern,
options: [.dotMatchesLineSeparators, .caseInsensitive]
) else {
return nil
}
let range = NSRange(html.startIndex..<html.endIndex, in: html)
guard let match = regex.firstMatch(in: html, options: [], range: range),
match.numberOfRanges > 1,
let jsonRange = Range(match.range(at: 1), in: html)
else {
return nil
}
return String(html[jsonRange])
}
private static func parseIsExplicitDirectly(from html: String) -> Bool? {
if let value = captureFirstMatch(
in: html,
pattern: #""isExplicit"\s*:\s*(true|false)"#
) {
return value == "true"
}
if let label = captureFirstMatch(
in: html,
pattern: #""label"\s*:\s*"([A-Z_]+)""#
) {
switch label {
case "EXPLICIT":
return true
case "NON_EXPLICIT":
return false
default:
break
}
}
return nil
}
private static func captureFirstMatch(in source: String, pattern: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
return nil
}
let range = NSRange(source.startIndex..<source.endIndex, in: source)
guard let match = regex.firstMatch(in: source, options: [], range: range),
match.numberOfRanges > 1,
let valueRange = Range(match.range(at: 1), in: source)
else {
return nil
}
return String(source[valueRange]).lowercased()
}
private static func parseIsExplicit(from nextDataJSON: String) -> Bool? {
guard let data = nextDataJSON.data(using: .utf8),
let rootObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let props = rootObject["props"] as? [String: Any],
let pageProps = props["pageProps"] as? [String: Any],
let state = pageProps["state"] as? [String: Any],
let dataObject = state["data"] as? [String: Any],
let entity = dataObject["entity"] as? [String: Any]
else {
return nil
}
if let isExplicit = entity["isExplicit"] as? Bool {
return isExplicit
}
if let contentRating = entity["contentRating"] as? [String: Any],
let label = contentRating["label"] as? String {
return label.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() == "EXPLICIT"
}
return nil
}
private static func extractTrackID(from rawValue: String?) -> String? {
guard let rawValue else { return nil }
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("spotify:track:") {
return validatedTrackID(String(trimmed.split(separator: ":").last ?? ""))
}
if let url = URL(string: trimmed) {
let pathComponents = url.pathComponents.filter { $0 != "/" && !$0.isEmpty }
if let trackIndex = pathComponents.firstIndex(of: "track"),
trackIndex + 1 < pathComponents.count {
return validatedTrackID(pathComponents[trackIndex + 1])
}
}
return validatedTrackID(trimmed)
}
private static func validatedTrackID<S: StringProtocol>(_ candidate: S) -> String? {
let value = String(candidate).trimmingCharacters(in: .whitespacesAndNewlines)
guard value.range(of: #"^[A-Za-z0-9]{22}$"#, options: .regularExpression) != nil else {
return nil
}
return value
}
}
class MusicManager: ObservableObject {
enum SkipDirection: Equatable {
case backward
@ -82,6 +430,7 @@ class MusicManager: ObservableObject {
@Published var isPlaying = false
@Published var album: String = "Self Love"
@Published var isPlayerIdle: Bool = true
@Published var isCurrentTrackExplicit: Bool = false
/// Whether there is an active music session with real metadata.
/// Returns `false` only when the metadata is still placeholder/fallback defaults
@ -126,6 +475,8 @@ class MusicManager: ObservableObject {
// Task used to periodically sync displayed lyric with playback position
private var lyricSyncTask: Task<Void, Never>?
private var explicitLookupTask: Task<Void, Never>?
private var explicitLookupKey: String?
private var artworkData: Data? = nil
@ -139,6 +490,8 @@ class MusicManager: ObservableObject {
private var lastArtworkArtist: String = "Me"
private var lastArtworkAlbum: String = "Self Love"
private var lastArtworkBundleIdentifier: String? = nil
private var lastArtworkContentIdentifier: String? = nil
private var lastArtworkContentURL: String? = nil
@Published var flipAngle: Double = 0
@Published var lastFlipDirection: SkipDirection = .forward
@ -227,6 +580,7 @@ class MusicManager: ObservableObject {
public func destroy() {
debounceIdleTask?.cancel()
explicitLookupTask?.cancel()
cancellables.removeAll()
controllerCancellables.removeAll()
transitionWorkItem?.cancel()
@ -342,10 +696,19 @@ class MusicManager: ObservableObject {
let artistChanged = state.artist != self.lastArtworkArtist
let albumChanged = state.album != self.lastArtworkAlbum
let bundleChanged = state.bundleIdentifier != self.lastArtworkBundleIdentifier
let contentIdentifierChanged = state.contentIdentifier != self.lastArtworkContentIdentifier
let contentURLChanged = state.contentURL != self.lastArtworkContentURL
// Check for artwork changes
let artworkChanged = state.artwork != nil && state.artwork != self.artworkData
let hasContentChange = titleChanged || artistChanged || albumChanged || artworkChanged || bundleChanged
let hasContentChange =
titleChanged
|| artistChanged
|| albumChanged
|| artworkChanged
|| bundleChanged
|| contentIdentifierChanged
|| contentURLChanged
// Handle artwork and visual transitions for changed content
let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange]
@ -369,14 +732,19 @@ class MusicManager: ObservableObject {
self.lastArtworkArtist = state.artist
self.lastArtworkAlbum = state.album
self.lastArtworkBundleIdentifier = state.bundleIdentifier
self.lastArtworkContentIdentifier = state.contentIdentifier
self.lastArtworkContentURL = state.contentURL
// Fetch lyrics for new track whenever content changes
self.fetchLyrics()
self.refreshExplicitFlag(for: state)
// Only update sneak peek if there's actual content and something changed
if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying {
self.updateSneakPeek()
}
} else if state.isExplicit != nil {
self.refreshExplicitFlag(for: state)
}
let timeChanged = state.currentTime != self.elapsedTime
@ -435,6 +803,122 @@ class MusicManager: ObservableObject {
}
}
@MainActor
private func refreshExplicitFlag(for state: PlaybackState) {
if let explicitValue = state.isExplicit {
explicitLookupTask?.cancel()
explicitLookupTask = nil
explicitLookupKey = nil
if isCurrentTrackExplicit != explicitValue {
isCurrentTrackExplicit = explicitValue
}
return
}
if state.bundleIdentifier == SpotifyController.bundleIdentifier,
let spotifyLookupKey = SpotifyExplicitnessResolver.LookupKey(
contentIdentifier: state.contentIdentifier,
contentURL: state.contentURL
) {
let lookupIdentifier = "spotify|\(spotifyLookupKey.trackID)"
let fallbackTitle = state.title
let fallbackArtist = state.artist
let fallbackAlbum = state.album
guard explicitLookupKey != lookupIdentifier else { return }
explicitLookupTask?.cancel()
explicitLookupKey = lookupIdentifier
if isCurrentTrackExplicit {
isCurrentTrackExplicit = false
}
explicitLookupTask = Task { [weak self] in
let isExplicit = await SpotifyExplicitnessResolver.shared.resolve(key: spotifyLookupKey)
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self,
self.explicitLookupKey == lookupIdentifier
else {
return
}
self.explicitLookupTask = nil
if let isExplicit {
self.isCurrentTrackExplicit = isExplicit
} else {
self.explicitLookupKey = nil
self.refreshGenericExplicitFlag(
title: fallbackTitle,
artist: fallbackArtist,
album: fallbackAlbum
)
}
}
}
return
}
refreshGenericExplicitFlag(title: state.title, artist: state.artist, album: state.album)
}
@MainActor
private func refreshGenericExplicitFlag(title: String, artist: String, album: String) {
let lookupKey = MusicExplicitnessResolver.LookupKey(
title: title,
artist: artist,
album: album
)
let lookupIdentifier = "generic|\(lookupKey.title)|\(lookupKey.artist)|\(lookupKey.album)"
guard lookupKey.canResolve,
!Self.placeholderTitles.contains(lookupKey.title),
!Self.placeholderArtists.contains(lookupKey.artist)
else {
explicitLookupTask?.cancel()
explicitLookupTask = nil
explicitLookupKey = nil
if isCurrentTrackExplicit {
isCurrentTrackExplicit = false
}
return
}
guard explicitLookupKey != lookupIdentifier else { return }
explicitLookupTask?.cancel()
explicitLookupKey = lookupIdentifier
if isCurrentTrackExplicit {
isCurrentTrackExplicit = false
}
explicitLookupTask = Task { [weak self] in
let isExplicit = await MusicExplicitnessResolver.shared.resolve(
title: title,
artist: artist,
album: album
)
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self,
self.explicitLookupKey == lookupIdentifier
else {
return
}
self.isCurrentTrackExplicit = isExplicit
self.explicitLookupTask = nil
}
}
}
private func triggerFlipAnimation() {
// Debounce: rapid metadata updates (title, artwork, bundle arriving
// separately for one track change) should only produce a single flip.