small improvements

This commit is contained in:
delli 2026-04-21 23:54:51 +02:00
commit f7fbb81d36
16 changed files with 834 additions and 49 deletions

View file

@ -454,11 +454,12 @@
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
DEVELOPMENT_TEAM = 9Y64TRM77N;
DEVELOPMENT_TEAM = RPC964BP8A;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
@ -525,13 +526,13 @@
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N;
"DEVELOPMENT_TEAM[sdk=macosx*]" = RPC964BP8A;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;

View file

@ -1034,11 +1034,13 @@ struct ContentView: View {
.overlay(
HStack(alignment: .top) {
if(coordinator.expandingView.show && coordinator.expandingView.type == .music) {
MarqueeText(
.constant(musicManager.songTitle),
MusicTitleMarqueeView(
text: musicManager.songTitle,
isExplicit: musicManager.isCurrentTrackExplicit,
textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray,
minDuration: 0.4,
frameWidth: max(0, (effectiveCenterWidth - vm.closedNotchSize.width) / 2 - 12)
frameWidth: max(0, (effectiveCenterWidth - vm.closedNotchSize.width) / 2 - 12),
badgeHeight: 13
)
.padding(.leading, 8)
.opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)

View file

@ -5620,6 +5620,10 @@
}
}
},
"Amazon Music" : {
"comment" : "Spotify music service.",
"isCommentAutoGenerated" : true
},
"Animation" : {
"extractionState" : "stale",
"localizations" : {
@ -43755,6 +43759,10 @@
}
}
},
"Uses macOS Now Playing when the Amazon Music app is the active media source. Playback controls follow the system Now Playing target. Scrubbing the timeline may not work if the Amazon Music app does not support remote seek." : {
"comment" : "Description of the Amazon Music media controller.",
"isCommentAutoGenerated" : true
},
"Uses the frosted blur treatment while glass mode is enabled." : {
"comment" : "A description of the frosted blur treatment when glass mode is enabled. | A description of the frosted blur treatment when glass mode is enabled. | merge:ko:alt='Glass 모드가 활성화되어 있는 동안에는 Frosted 블러 효과가 적용됩니다.'",
"isCommentAutoGenerated" : true,

View file

@ -25,9 +25,11 @@ import Combine
import SwiftUI
class SpotifyController: MediaControllerProtocol {
static let bundleIdentifier = "com.spotify.client"
// MARK: - Properties
@Published private var playbackState: PlaybackState = PlaybackState(
bundleIdentifier: "com.spotify.client"
bundleIdentifier: SpotifyController.bundleIdentifier
)
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
@ -100,7 +102,7 @@ class SpotifyController: MediaControllerProtocol {
func updatePlaybackInfo() async {
guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
guard descriptor.numberOfItems >= 9 else { return }
guard descriptor.numberOfItems >= 11 else { return }
let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown"
@ -111,9 +113,11 @@ class SpotifyController: MediaControllerProtocol {
let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false
let artworkURL = descriptor.atIndex(9)?.stringValue ?? ""
let trackIdentifier = descriptor.atIndex(10)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let trackSpotifyURL = descriptor.atIndex(11)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
var state = PlaybackState(
bundleIdentifier: "com.spotify.client",
bundleIdentifier: Self.bundleIdentifier,
isPlaying: isPlaying,
title: currentTrack,
artist: currentTrackArtist,
@ -125,6 +129,8 @@ class SpotifyController: MediaControllerProtocol {
repeatMode: isRepeating ? .all : .off,
lastUpdated: Date()
)
state.contentIdentifier = trackIdentifier.isEmpty ? nil : trackIdentifier
state.contentURL = trackSpotifyURL.isEmpty ? nil : trackSpotifyURL
if artworkURL == lastArtworkURL, let existingArtwork = self.playbackState.artwork {
state.artwork = existingArtwork
@ -186,9 +192,17 @@ class SpotifyController: MediaControllerProtocol {
set shuffleState to shuffling
set repeatState to repeating
set artworkURL to artwork url of current track
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL}
set trackIdentifier to ""
try
set trackIdentifier to id of current track
end try
set trackSpotifyURL to ""
try
set trackSpotifyURL to spotify url of current track
end try
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL, trackIdentifier, trackSpotifyURL}
on error
return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, ""}
return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, "", "", ""}
end try
end tell
"""

View file

@ -116,3 +116,157 @@ struct MarqueeText: View {
}
}
struct MusicExplicitBadge: View {
var label: String = "E"
var fontSize: CGFloat = 9
var height: CGFloat = 14
var horizontalPadding: CGFloat = 4
var minWidth: CGFloat? = nil
var foregroundColor: Color = .white.opacity(0.92)
var backgroundColor: Color = .white.opacity(0.18)
var cornerRadius: CGFloat? = nil
var body: some View {
Text(label)
.font(.system(size: fontSize, weight: .bold, design: .rounded))
.foregroundColor(foregroundColor)
.padding(.horizontal, max(horizontalPadding, height * 0.2))
.frame(minWidth: minWidth, minHeight: height)
.background(
RoundedRectangle(
cornerRadius: cornerRadius ?? max(4, height * 0.28),
style: .continuous
)
.fill(backgroundColor)
)
.fixedSize()
}
}
struct MusicTitleMarqueeView: View {
let text: String
let isExplicit: Bool
let font: Font
let nsFont: NSFont.TextStyle
let textColor: Color
let backgroundColor: Color
let minDuration: Double
let frameWidth: CGFloat
let alignment: Alignment
let badgeSpacing: CGFloat
let badgeLabel: String
let badgeFontSize: CGFloat?
let badgeHeight: CGFloat?
let badgeForegroundColor: Color
let badgeBackgroundColor: Color
let badgeHorizontalPadding: CGFloat
let badgeMinWidth: CGFloat?
let badgeCornerRadius: CGFloat?
init(
text: String,
isExplicit: Bool,
font: Font = .body,
nsFont: NSFont.TextStyle = .body,
textColor: Color = .primary,
backgroundColor: Color = .clear,
minDuration: Double = 3.0,
frameWidth: CGFloat = 200,
alignment: Alignment = .leading,
badgeSpacing: CGFloat = 6,
badgeLabel: String = "E",
badgeFontSize: CGFloat? = nil,
badgeHeight: CGFloat? = nil,
badgeForegroundColor: Color = .white.opacity(0.92),
badgeBackgroundColor: Color = .white.opacity(0.18),
badgeHorizontalPadding: CGFloat = 4,
badgeMinWidth: CGFloat? = nil,
badgeCornerRadius: CGFloat? = nil
) {
self.text = text
self.isExplicit = isExplicit
self.font = font
self.nsFont = nsFont
self.textColor = textColor
self.backgroundColor = backgroundColor
self.minDuration = minDuration
self.frameWidth = frameWidth
self.alignment = alignment
self.badgeSpacing = badgeSpacing
self.badgeLabel = badgeLabel
self.badgeFontSize = badgeFontSize
self.badgeHeight = badgeHeight
self.badgeForegroundColor = badgeForegroundColor
self.badgeBackgroundColor = badgeBackgroundColor
self.badgeHorizontalPadding = badgeHorizontalPadding
self.badgeMinWidth = badgeMinWidth
self.badgeCornerRadius = badgeCornerRadius
}
private var resolvedBadgeHeight: CGFloat {
badgeHeight ?? max(12, NSFont.preferredFont(forTextStyle: nsFont).pointSize * 1.12)
}
private var resolvedBadgeFontSize: CGFloat {
badgeFontSize ?? max(8, resolvedBadgeHeight * 0.6)
}
private var reservedBadgeWidth: CGFloat {
guard isExplicit else { return 0 }
return resolvedBadgeHeight + (resolvedBadgeHeight * 0.7) + badgeSpacing
}
private var titleFrameWidth: CGFloat {
max(0, frameWidth - reservedBadgeWidth)
}
private var measurementFont: NSFont {
NSFont.preferredFont(forTextStyle: nsFont)
}
private var measuredTextWidth: CGFloat {
let attributes: [NSAttributedString.Key: Any] = [.font: measurementFont]
return ceil((text as NSString).size(withAttributes: attributes).width)
}
private var needsScrolling: Bool {
measuredTextWidth > titleFrameWidth
}
var body: some View {
HStack(spacing: badgeSpacing) {
if needsScrolling {
MarqueeText(
.constant(text),
font: font,
nsFont: nsFont,
textColor: textColor,
backgroundColor: backgroundColor,
minDuration: minDuration,
frameWidth: titleFrameWidth
)
} else {
Text(text)
.font(font)
.foregroundColor(textColor)
.lineLimit(1)
.truncationMode(.tail)
.fixedSize(horizontal: true, vertical: false)
}
if isExplicit {
MusicExplicitBadge(
label: badgeLabel,
fontSize: resolvedBadgeFontSize,
height: resolvedBadgeHeight,
horizontalPadding: badgeHorizontalPadding,
minWidth: badgeMinWidth,
foregroundColor: badgeForegroundColor,
backgroundColor: badgeBackgroundColor,
cornerRadius: badgeCornerRadius
)
}
}
.frame(width: frameWidth, alignment: alignment)
}
}

View file

@ -255,19 +255,34 @@ struct LockScreenMusicPanel: View {
HStack(alignment: .center, spacing: 16) {
albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius)
VStack(alignment: .leading, spacing: 1) {
Text(musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
.lineLimit(1)
GeometryReader { geo in
VStack(alignment: .leading, spacing: 1) {
MusicTitleMarqueeView(
text: musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle,
isExplicit: !musicManager.songTitle.isEmpty && musicManager.isCurrentTrackExplicit,
font: .system(size: 12, weight: .semibold),
nsFont: .subheadline,
textColor: .white,
minDuration: 0.45,
frameWidth: geo.size.width,
badgeSpacing: 5,
badgeLabel: "E",
badgeHeight: 13,
badgeForegroundColor: Color.black.opacity(0.72),
badgeBackgroundColor: Color.white.opacity(0.46),
badgeHorizontalPadding: 4,
badgeMinWidth: 14,
badgeCornerRadius: 4
)
Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName)
.font(.system(size: 10, weight: .regular))
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
.lineLimit(1)
Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName)
.font(.system(size: 10, weight: .regular))
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
.lineLimit(1)
}
.frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
}
Spacer()
.frame(maxWidth: .infinity, minHeight: 30, maxHeight: 30, alignment: .leading)
visualizer(width: 20, height: 16)
}
@ -276,19 +291,34 @@ struct LockScreenMusicPanel: View {
private var expandedHeader: some View {
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text(musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle)
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
.lineLimit(2)
GeometryReader { geo in
VStack(alignment: .leading, spacing: 6) {
MusicTitleMarqueeView(
text: musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle,
isExplicit: !musicManager.songTitle.isEmpty && musicManager.isCurrentTrackExplicit,
font: .system(size: 18, weight: .semibold),
nsFont: .title3,
textColor: .white,
minDuration: 0.55,
frameWidth: geo.size.width,
badgeSpacing: 6,
badgeLabel: "E",
badgeHeight: 16,
badgeForegroundColor: Color.black.opacity(0.74),
badgeBackgroundColor: Color.white.opacity(0.5),
badgeHorizontalPadding: 5,
badgeMinWidth: 17,
badgeCornerRadius: 5
)
Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName)
.font(.system(size: 13, weight: .medium))
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.7) : .gray)
.lineLimit(2)
Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName)
.font(.system(size: 13, weight: .medium))
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.7) : .gray)
.lineLimit(2)
}
.frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
}
Spacer()
.frame(maxWidth: .infinity, minHeight: 42, alignment: .leading)
visualizer(width: 24, height: 20)
}

View file

@ -41,13 +41,16 @@ struct MinimalisticMusicView: View {
GeometryReader { geo in
VStack(alignment: .center, spacing: 2) {
if !musicManager.songTitle.isEmpty {
MarqueeText(
$musicManager.songTitle,
MusicTitleMarqueeView(
text: musicManager.songTitle,
isExplicit: musicManager.isCurrentTrackExplicit,
font: .system(size: 12, weight: .semibold),
nsFont: .subheadline,
textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray,
minDuration: 0.4,
frameWidth: max(0, geo.size.width - 8)
frameWidth: max(0, geo.size.width - 8),
alignment: .center,
badgeHeight: 13
)
}

View file

@ -83,12 +83,14 @@ struct MinimalisticMusicPlayerView: View {
VStack(alignment: .leading, spacing: 1) {
if !musicManager.songTitle.isEmpty {
MarqueeText(
$musicManager.songTitle,
MusicTitleMarqueeView(
text: musicManager.songTitle,
isExplicit: musicManager.isCurrentTrackExplicit,
font: .system(size: 12, weight: .semibold),
nsFont: .subheadline,
textColor: .white,
frameWidth: textWidth
frameWidth: textWidth,
badgeHeight: 13
)
}

View file

@ -187,7 +187,15 @@ struct MusicControlsView: View {
private func songInfo(width: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 0) {
MarqueeText($musicManager.songTitle, font: .headline, nsFont: .headline, textColor: .white, frameWidth: width)
MusicTitleMarqueeView(
text: musicManager.songTitle,
isExplicit: musicManager.isCurrentTrackExplicit,
font: .headline,
nsFont: .headline,
textColor: .white,
frameWidth: width,
badgeHeight: 14
)
MarqueeText(
$musicManager.artistName,
font: .headline,

View file

@ -113,7 +113,7 @@ final class ShelfItemViewModel: ObservableObject {
// MARK: - Actions
func handleClick(event: NSEvent, view: NSView) {
let flags = event.modifierFlags
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags.contains(.shift) {
selection.shiftSelect(to: item, in: ShelfStateViewModel.shared.items)
} else if flags.contains(.command) {

View file

@ -83,6 +83,19 @@ final class ShelfSelectionModel: ObservableObject {
lastAnchorID = nil
}
func reconcileSelection(with allItems: [ShelfItem]) {
let validIDs = Set(allItems.map(\.id))
let filteredSelection = selectedIDs.intersection(validIDs)
if filteredSelection != selectedIDs {
selectedIDs = filteredSelection
}
if let anchor = lastAnchorID, !validIDs.contains(anchor) {
lastAnchorID = selectedIDs.first
}
}
// Keep anchor sane if items array changed drastically (optional helper)
func ensureValidAnchor(in allItems: [ShelfItem]) {
if let anchor = lastAnchorID, !allItems.contains(where: { $0.id == anchor }) {

View file

@ -28,7 +28,10 @@ final class ShelfStateViewModel: ObservableObject {
static let shared = ShelfStateViewModel()
@Published private(set) var items: [ShelfItem] = [] {
didSet { ShelfPersistenceService.shared.save(items) }
didSet {
ShelfPersistenceService.shared.save(items)
ShelfSelectionModel.shared.reconcileSelection(with: items)
}
}
@Published var isLoading: Bool = false

View file

@ -237,6 +237,7 @@ private struct DraggableClickHandler<Content: View>: NSViewRepresentable {
private let dragThreshold: CGFloat = 3.0
private var draggedURLs: [URL] = []
private var draggedItems: [ShelfItem] = []
private var didStartDragSession = false
override func rightMouseDown(with event: NSEvent) {
onRightClick?(event, self)
@ -244,7 +245,21 @@ private struct DraggableClickHandler<Content: View>: NSViewRepresentable {
override func mouseDown(with event: NSEvent) {
mouseDownEvent = event
onClick?(event, self)
didStartDragSession = false
}
override func mouseUp(with event: NSEvent) {
defer {
mouseDownEvent = nil
didStartDragSession = false
}
guard let mouseDownEvent, !didStartDragSession else {
super.mouseUp(with: event)
return
}
onClick?(mouseDownEvent, self)
}
override func mouseDragged(with event: NSEvent) {
@ -259,12 +274,26 @@ private struct DraggableClickHandler<Content: View>: NSViewRepresentable {
)
if dragDistance > dragThreshold {
didStartDragSession = true
prepareSelectionForDrag(using: mouseDownEvent)
startDragSession(with: event)
self.mouseDownEvent = nil
} else {
super.mouseDragged(with: event)
}
}
private func prepareSelectionForDrag(using initialEvent: NSEvent) {
let flags = initialEvent.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags.contains(.shift) || flags.contains(.command) || flags.contains(.control) {
return
}
if !ShelfSelectionModel.shared.isSelected(item.id) {
ShelfSelectionModel.shared.selectSingle(item)
}
}
private func startDragSession(with event: NSEvent) {
// Prepare dragging items

View file

@ -23,6 +23,28 @@
import SwiftUI
import AppKit
private struct ShelfBackgroundClickCatcher: NSViewRepresentable {
let onClick: () -> Void
func makeNSView(context: Context) -> BackgroundClickView {
let view = BackgroundClickView()
view.onClick = onClick
return view
}
func updateNSView(_ nsView: BackgroundClickView, context: Context) {
nsView.onClick = onClick
}
final class BackgroundClickView: NSView {
var onClick: (() -> Void)?
override func mouseUp(with event: NSEvent) {
onClick?()
}
}
}
struct ShelfView: View {
@EnvironmentObject var vm: DynamicIslandViewModel
@StateObject var tvm = ShelfStateViewModel.shared
@ -82,14 +104,20 @@ struct ShelfView: View {
style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [10])
)
.overlay {
content
.padding()
ZStack {
ShelfBackgroundClickCatcher {
guard !selection.isDragging else { return }
selection.clear()
}
content
.padding()
}
}
.transaction { transaction in
transaction.animation = vm.animation
}
.contentShape(Rectangle())
.onTapGesture { selection.clear() }
}
var content: some View {

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.

View file

@ -34,6 +34,9 @@ struct PlaybackState {
var title: String = "I'm Handsome"
var artist: String = "Me"
var album: String = "Self Love"
var contentIdentifier: String?
var contentURL: String?
var isExplicit: Bool?
var currentTime: Double = 0
var duration: Double = 0
var playbackRate: Double = 1
@ -50,6 +53,9 @@ extension PlaybackState: Equatable {
&& lhs.title == rhs.title
&& lhs.artist == rhs.artist
&& lhs.album == rhs.album
&& lhs.contentIdentifier == rhs.contentIdentifier
&& lhs.contentURL == rhs.contentURL
&& lhs.isExplicit == rhs.isExplicit
&& lhs.currentTime == rhs.currentTime
&& lhs.duration == rhs.duration
&& lhs.isShuffled == rhs.isShuffled