import ActivityKit import WidgetKit import SwiftUI import AppIntents struct SkipTrackIntent: AppIntent { static let title: LocalizedStringResource = "Skip Track" @MainActor func perform() async throws -> some IntentResult & OpensIntent { let url = URL(string: "tabatago://skipTrack")! return .result(opensIntent: OpenURLIntent(url)) } } struct MusicLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: MusicActivityAttributes.self) { context in @Environment(\.activityFamily) var activityFamily @Environment(\.isActivityFullscreen) var isFullscreen @Environment(\.isLuminanceReduced) var isLuminanceReduced switch activityFamily { case .small: smallLockScreenView(context: context) default: lockScreenView(context: context, isFullscreen: isFullscreen, isLuminanceReduced: isLuminanceReduced) } } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { HStack(spacing: 6) { Image(systemName: "music.note") .foregroundStyle(.green) .accessibilityLabel("Music") Text(context.state.title) .font(.caption.weight(.semibold)) .lineLimit(1) .accessibilityLabel(context.state.title) if context.state.isPlaying { LiveActivityMusicBars() } } } DynamicIslandExpandedRegion(.center) { Text(context.state.artist) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) .accessibilityLabel(context.state.artist) } DynamicIslandExpandedRegion(.trailing) { Button(intent: SkipTrackIntent()) { HStack(spacing: 3) { Image(systemName: "forward.fill") .font(.system(size: 10, weight: .semibold)) Text("Skip") .font(.caption2.weight(.medium)) } .foregroundStyle(.white.opacity(0.6)) .padding(.horizontal, 8) .padding(.vertical, 3) .background(.white.opacity(0.15)) .clipShape(Capsule()) } .buttonStyle(.plain) .accessibilityLabel("Skip track") } } compactLeading: { Image(systemName: "music.note") .foregroundStyle(.green) .font(.system(size: 14)) .accessibilityLabel("Music playing") } compactTrailing: { HStack(spacing: 3) { Image(systemName: "music.note") .font(.system(size: 10)) .foregroundStyle(.green) .accessibilityHidden(true) Text(context.state.title) .font(.system(size: 10, weight: .medium)) .lineLimit(1) .accessibilityLabel(context.state.title) } .accessibilityLabel("Now playing: \(context.state.title)") } minimal: { Image(systemName: "music.note") .foregroundStyle(.green) .font(.system(size: 13)) .accessibilityLabel("Music playing") } .keylineTint(.green) .contentMargins(.leading, 8, for: .expanded) .contentMargins(.trailing, 8, for: .expanded) .widgetURL(URL(string: "tabatago://music")!) } .supplementalActivityFamilies([.small, .medium]) } // MARK: - Lock Screen / Banner @ViewBuilder private func lockScreenView( context: ActivityViewContext, isFullscreen: Bool, isLuminanceReduced: Bool ) -> some View { VStack(spacing: 0) { HStack(spacing: 10) { Image(systemName: "music.note") .font(isFullscreen ? .title.weight(.semibold) : .title3.weight(.semibold)) .foregroundStyle(.green) .accessibilityLabel("Music") VStack(alignment: .leading, spacing: 2) { Text(context.state.title) .font(isFullscreen ? .title2 : .headline) .foregroundStyle(.primary) .lineLimit(1) .accessibilityLabel(context.state.title) Text(context.state.artist) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .accessibilityLabel(context.state.artist) } Spacer() if context.state.isPlaying, !isLuminanceReduced { LiveActivityMusicBars() } } .padding(.horizontal, 16) .padding(.vertical, isFullscreen ? 20 : 12) } .activityBackgroundTint(.black.opacity(0.9)) .activitySystemActionForegroundColor(.white) } // MARK: - Small (Apple Watch / CarPlay) @ViewBuilder private func smallLockScreenView(context: ActivityViewContext) -> some View { HStack(spacing: 6) { Image(systemName: "music.note") .font(.caption) .foregroundStyle(.green) .accessibilityLabel("Music") Text(context.state.title) .font(.caption.weight(.medium)) .lineLimit(1) .accessibilityLabel(context.state.title) Spacer() Text(context.state.artist) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } .padding(.horizontal, 12) .padding(.vertical, 8) .activityBackgroundTint(.black.opacity(0.9)) } } // MARK: - LiveActivityMusicBars struct LiveActivityMusicBars: View { var body: some View { TimelineView(.periodic(from: .now, by: 0.45 / Double(barSpeeds.count))) { context in HStack(spacing: 2) { ForEach(0..<4, id: \.self) { i in let t = context.date.timeIntervalSinceReferenceDate let phase = t * barSpeeds[i] * 2 * .pi let h = barMin + (barMaxHeights[i] - barMin) * abs(sin(phase)) Capsule() .fill(.green) .frame(width: 2, height: h) } } } .accessibilityHidden(true) } } private let barMaxHeights: [CGFloat] = [8, 14, 6, 12] private let barMin: CGFloat = 3 private let barSpeeds: [Double] = [2.2, 1.8, 2.8, 1.5]