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

@ -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 {