mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge PR #107 (feature/lyrics) into feature/media for testing and iteration
This commit is contained in:
commit
dc90b8cbb0
8 changed files with 464 additions and 47 deletions
|
|
@ -9,42 +9,73 @@
|
|||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
// Note: lyrics display is inlined into the main minimalistic view below and is controlled by Defaults[.enableLyrics]
|
||||
|
||||
struct MinimalisticMusicView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@ObservedObject var musicManager = MusicManager.shared
|
||||
@Default(.enableLyrics) var enableLyrics
|
||||
@State private var isHovering: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Left: Album Art
|
||||
albumArtView
|
||||
|
||||
// Middle: Song Title (scrolling if needed)
|
||||
Rectangle()
|
||||
.fill(.black)
|
||||
.overlay(
|
||||
GeometryReader { geo in
|
||||
HStack(alignment: .center) {
|
||||
if !musicManager.songTitle.isEmpty {
|
||||
MarqueeText(
|
||||
$musicManager.songTitle,
|
||||
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)
|
||||
)
|
||||
VStack(spacing: 2) {
|
||||
// Main content row
|
||||
HStack(spacing: 0) {
|
||||
// Left: Album Art
|
||||
albumArtView
|
||||
|
||||
// Middle: Song Title, Artist and Lyrics (lyrics shown under artist when enabled)
|
||||
Rectangle()
|
||||
.fill(.black)
|
||||
.overlay(
|
||||
GeometryReader { geo in
|
||||
VStack(alignment: .center, spacing: 2) {
|
||||
if !musicManager.songTitle.isEmpty {
|
||||
MarqueeText(
|
||||
$musicManager.songTitle,
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
// Artist name
|
||||
if !musicManager.artistName.isEmpty {
|
||||
Text(musicManager.artistName)
|
||||
.font(.system(size: 10, weight: .regular))
|
||||
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
// Lyrics under the author name (same font size as author)
|
||||
if enableLyrics {
|
||||
Text(musicManager.currentLyrics)
|
||||
.font(.system(size: 10, weight: .regular))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.horizontal, 4)
|
||||
.transition(.opacity)
|
||||
.animation(.easeInOut(duration: 0.3), value: musicManager.currentLyrics)
|
||||
}
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height, alignment: .center)
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height, alignment: .center)
|
||||
}
|
||||
)
|
||||
.frame(width: vm.closedNotchSize.width)
|
||||
|
||||
// Right: Music Visualizer
|
||||
visualizerView
|
||||
)
|
||||
.frame(width: vm.closedNotchSize.width)
|
||||
|
||||
// Right: Music Visualizer
|
||||
visualizerView
|
||||
}
|
||||
|
||||
// (lyrics are displayed inline under the artist name)
|
||||
}
|
||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
||||
// reserve extra height when lyrics are enabled
|
||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0) + (enableLyrics ? 22 : 0), alignment: .center)
|
||||
.onHover { hovering in
|
||||
isHovering = hovering
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,15 @@ import Defaults
|
|||
import AppKit
|
||||
#endif
|
||||
|
||||
// Lyrics are shown/hidden only via Defaults[.enableLyrics] in settings. Inline display is used in the player views.
|
||||
|
||||
struct MinimalisticMusicPlayerView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
let albumArtNamespace: Namespace.ID
|
||||
@Default(.showMediaOutputControl) var showMediaOutputControl
|
||||
@ObservedObject private var reminderManager = ReminderLiveActivityManager.shared
|
||||
@Default(.enableReminderLiveActivity) private var enableReminderLiveActivity
|
||||
@Default(.enableLyrics) private var enableLyrics
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
|
|
@ -46,6 +49,19 @@ struct MinimalisticMusicPlayerView: View {
|
|||
.font(.system(size: 10, weight: .regular))
|
||||
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
|
||||
.lineLimit(1)
|
||||
|
||||
// Lyrics shown under the author name, same font size as author
|
||||
if enableLyrics {
|
||||
Text(musicManager.currentLyrics)
|
||||
.font(.system(size: 10, weight: .regular))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 4)
|
||||
.transition(.opacity)
|
||||
.animation(.easeInOut(duration: 0.3), value: musicManager.currentLyrics)
|
||||
}
|
||||
}
|
||||
.frame(width: textWidth, alignment: .leading)
|
||||
|
||||
|
|
@ -65,12 +81,75 @@ struct MinimalisticMusicPlayerView: View {
|
|||
playbackControls
|
||||
.padding(.top, 4)
|
||||
|
||||
// Lyrics are displayed inline under the artist name (see header area)
|
||||
|
||||
reminderList
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, -15)
|
||||
.padding(.bottom, shouldShowReminderList ? ReminderLiveActivityManager.listBottomPadding : ReminderLiveActivityManager.baselineMinimalisticBottomPadding)
|
||||
.padding(.bottom, shouldShowReminderList ? ReminderLiveActivityManager.listBottomPadding : ReminderLiveActivityManager.baselineMinimalisticBottomPadding)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: calculateDynamicHeight(), alignment: .center)
|
||||
.animation(.easeInOut(duration: 0.3), value: musicManager.currentLyrics)
|
||||
}
|
||||
|
||||
// MARK: - TypingLyricView
|
||||
|
||||
struct TypingLyricView: View {
|
||||
let text: String
|
||||
let color: Color
|
||||
let id: Int
|
||||
let playbackRate: Double
|
||||
let isPlaying: Bool
|
||||
@State private var displayed: String = ""
|
||||
@State private var lastText: String = ""
|
||||
@State private var animationTask: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
Text(displayed)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(color)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.top, 4)
|
||||
.id(id)
|
||||
.onChange(of: text) { _, newText in
|
||||
animateTyping(newText)
|
||||
}
|
||||
.onChange(of: isPlaying) { _, playing in
|
||||
if !playing {
|
||||
animationTask?.cancel()
|
||||
} else if displayed != text {
|
||||
animateTyping(text)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
animateTyping(text)
|
||||
}
|
||||
.onDisappear {
|
||||
animationTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func animateTyping(_ newText: String) {
|
||||
animationTask?.cancel()
|
||||
displayed = ""
|
||||
lastText = newText
|
||||
let chars = Array(newText)
|
||||
|
||||
animationTask = Task {
|
||||
for (i, c) in chars.enumerated() {
|
||||
if Task.isCancelled { return }
|
||||
try? await Task.sleep(for: .milliseconds(Int(30 / max(playbackRate, 0.1))))
|
||||
if Task.isCancelled { return }
|
||||
if lastText == newText {
|
||||
displayed += String(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var reminderEntries: [ReminderLiveActivityManager.ReminderEntry] {
|
||||
|
|
@ -85,6 +164,32 @@ struct MinimalisticMusicPlayerView: View {
|
|||
ReminderLiveActivityManager.additionalHeight(forRowCount: reminderEntries.count)
|
||||
}
|
||||
|
||||
private func calculateDynamicHeight() -> CGFloat {
|
||||
var height: CGFloat = 50 // Base height for header
|
||||
|
||||
// Add progress bar height
|
||||
height += 6 + 4 // progress bar + top padding
|
||||
|
||||
// Add playback controls height
|
||||
height += 40 + 2 // controls + top padding
|
||||
|
||||
// Add lyrics height if enabled in settings (reserve space even while loading)
|
||||
if Defaults[.enableLyrics] {
|
||||
height += 20 + 4 // lyrics + top padding
|
||||
}
|
||||
|
||||
// Add reminder list height if showing
|
||||
if shouldShowReminderList {
|
||||
height += reminderListHeight + ReminderLiveActivityManager.listTopPadding
|
||||
}
|
||||
|
||||
// Add padding
|
||||
height += 15 // top padding
|
||||
height += shouldShowReminderList ? ReminderLiveActivityManager.listBottomPadding : ReminderLiveActivityManager.baselineMinimalisticBottomPadding
|
||||
|
||||
return height
|
||||
}
|
||||
|
||||
private var reminderList: some View {
|
||||
MinimalisticReminderEventListView(reminders: reminderEntries)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
|
@ -472,23 +577,25 @@ private struct MinimalisticReminderDetailsView: View {
|
|||
// MARK: - Playback Controls (Larger)
|
||||
|
||||
private var playbackControls: some View {
|
||||
HStack(spacing: 20) {
|
||||
HStack(spacing: 16) {
|
||||
if Defaults[.showShuffleAndRepeat] {
|
||||
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
|
||||
Task { await musicManager.toggleShuffle() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
controlButton(icon: "backward.fill", size: 18) {
|
||||
Task { await musicManager.previousTrack() }
|
||||
}
|
||||
|
||||
|
||||
playPauseButton
|
||||
|
||||
|
||||
controlButton(icon: "forward.fill", size: 18) {
|
||||
Task { await musicManager.nextTrack() }
|
||||
}
|
||||
|
||||
|
||||
// (lyrics toggle removed from UI; controlled via Settings -> Media -> Enable Lyrics)
|
||||
|
||||
if Defaults[.showShuffleAndRepeat] {
|
||||
if showMediaOutputControl {
|
||||
MinimalisticMediaOutputButton()
|
||||
|
|
|
|||
|
|
@ -155,6 +155,18 @@ struct MusicControlsView: View {
|
|||
frameWidth: width
|
||||
)
|
||||
.fontWeight(.medium)
|
||||
// Lyrics shown under the author name (same font size as author) when enabled in settings
|
||||
if Defaults[.enableLyrics] {
|
||||
Text(musicManager.currentLyrics)
|
||||
.font(.headline)
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 2)
|
||||
.transition(.opacity)
|
||||
.animation(.easeInOut(duration: 0.3), value: musicManager.currentLyrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -908,6 +908,7 @@ struct Media: View {
|
|||
isOn: $coordinator.musicLiveActivityEnabled.animation()
|
||||
)
|
||||
Toggle("Enable sneak peek", isOn: $enableSneakPeek)
|
||||
Defaults.Toggle("Enable lyrics", key: .enableLyrics)
|
||||
Picker("Sneak Peek Style", selection: $sneakPeekStyles){
|
||||
ForEach(SneakPeekStyle.allCases) { style in
|
||||
Text(style.rawValue).tag(style)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue