wip: widget

This commit is contained in:
Fredrik Burmester
2026-01-05 21:28:00 +01:00
parent 6c95962e12
commit 4c8dfa0e2f

View File

@@ -1,5 +1,29 @@
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
import AppIntents
import MediaPlayer
// MARK: - App Intents
struct TogglePlaybackIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Playback"
static var description = IntentDescription("Play or pause the current track")
func perform() async throws -> some IntentResult {
MPRemoteCommandCenter.shared().togglePlayPauseCommand.send()
return .result()
}
}
struct NextTrackIntent: AppIntent {
static var title: LocalizedStringResource = "Next Track"
static var description = IntentDescription("Skip to next track")
func perform() async throws -> some IntentResult {
MPRemoteCommandCenter.shared().nextTrackCommand.send()
return .result()
}
}
// MARK: - Data Models // MARK: - Data Models
@@ -17,7 +41,7 @@ struct NowPlayingEntry: TimelineEntry {
let title: String let title: String
let artist: String let artist: String
let album: String let album: String
let artworkUrl: String? let artworkImage: UIImage? // Store actual image, not URL (AsyncImage doesn't work in widgets)
let isPlaying: Bool let isPlaying: Bool
let isEmpty: Bool let isEmpty: Bool
@@ -27,7 +51,7 @@ struct NowPlayingEntry: TimelineEntry {
title: "Not Playing", title: "Not Playing",
artist: "Open Streamyfin to play music", artist: "Open Streamyfin to play music",
album: "", album: "",
artworkUrl: nil, artworkImage: nil,
isPlaying: false, isPlaying: false,
isEmpty: true isEmpty: true
) )
@@ -45,7 +69,7 @@ struct NowPlayingProvider: TimelineProvider {
title: "Song Title", title: "Song Title",
artist: "Artist Name", artist: "Artist Name",
album: "Album", album: "Album",
artworkUrl: nil, artworkImage: nil,
isPlaying: true, isPlaying: true,
isEmpty: false isEmpty: false
) )
@@ -71,12 +95,20 @@ struct NowPlayingProvider: TimelineProvider {
return .empty return .empty
} }
// Download artwork synchronously (AsyncImage doesn't work in widgets)
var artworkImage: UIImage? = nil
if let urlString = nowPlaying.artworkUrl,
let url = URL(string: urlString),
let imageData = try? Data(contentsOf: url) {
artworkImage = UIImage(data: imageData)
}
return NowPlayingEntry( return NowPlayingEntry(
date: Date(), date: Date(),
title: nowPlaying.title, title: nowPlaying.title,
artist: nowPlaying.artist, artist: nowPlaying.artist,
album: nowPlaying.album, album: nowPlaying.album,
artworkUrl: nowPlaying.artworkUrl, artworkImage: artworkImage,
isPlaying: nowPlaying.isPlaying, isPlaying: nowPlaying.isPlaying,
isEmpty: false isEmpty: false
) )
@@ -86,27 +118,15 @@ struct NowPlayingProvider: TimelineProvider {
// MARK: - Artwork Image View // MARK: - Artwork Image View
struct ArtworkView: View { struct ArtworkView: View {
let url: String? let image: UIImage?
let size: CGFloat let size: CGFloat
var body: some View { var body: some View {
Group { Group {
if let urlString = url, let imageUrl = URL(string: urlString) { if let uiImage = image {
AsyncImage(url: imageUrl) { phase in Image(uiImage: uiImage)
switch phase { .resizable()
case .success(let image): .aspectRatio(contentMode: .fill)
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure(_):
placeholderView
case .empty:
placeholderView
.overlay(ProgressView().tint(.white))
@unknown default:
placeholderView
}
}
} else { } else {
placeholderView placeholderView
} }
@@ -125,6 +145,23 @@ struct ArtworkView: View {
} }
} }
// MARK: - Control Button
struct ControlButton: View {
let systemName: String
let size: CGFloat
var body: some View {
ZStack {
Circle()
.fill(Color.white.opacity(0.15))
Image(systemName: systemName)
.font(.system(size: size, weight: .semibold))
.foregroundColor(.white)
}
}
}
// MARK: - Small Widget View // MARK: - Small Widget View
struct SmallWidgetView: View { struct SmallWidgetView: View {
@@ -135,7 +172,7 @@ struct SmallWidgetView: View {
ZStack { ZStack {
// Background artwork (blurred) // Background artwork (blurred)
if !entry.isEmpty { if !entry.isEmpty {
ArtworkView(url: entry.artworkUrl, size: geometry.size.width) ArtworkView(image: entry.artworkImage, size: geometry.size.width)
.blur(radius: 20) .blur(radius: 20)
.opacity(0.6) .opacity(0.6)
} }
@@ -146,7 +183,7 @@ struct SmallWidgetView: View {
// Small artwork // Small artwork
if !entry.isEmpty { if !entry.isEmpty {
ArtworkView(url: entry.artworkUrl, size: 48) ArtworkView(image: entry.artworkImage, size: 48)
.shadow(radius: 4) .shadow(radius: 4)
} else { } else {
Image(systemName: "music.note.house.fill") Image(systemName: "music.note.house.fill")
@@ -167,15 +204,16 @@ struct SmallWidgetView: View {
.foregroundColor(.white.opacity(0.7)) .foregroundColor(.white.opacity(0.7))
.lineLimit(1) .lineLimit(1)
// Playing indicator // Play/Pause button
if entry.isPlaying && !entry.isEmpty { if !entry.isEmpty {
HStack(spacing: 2) { Button(intent: TogglePlaybackIntent()) {
ForEach(0..<3) { _ in ControlButton(
RoundedRectangle(cornerRadius: 1) systemName: entry.isPlaying ? "pause.fill" : "play.fill",
.fill(Color(hex: "#9333EA")) size: 14
.frame(width: 3, height: 8) )
} .frame(width: 32, height: 32)
} }
.buttonStyle(.plain)
.padding(.top, 4) .padding(.top, 4)
} }
} }
@@ -197,7 +235,7 @@ struct MediumWidgetView: View {
var body: some View { var body: some View {
HStack(spacing: 16) { HStack(spacing: 16) {
// Artwork // Artwork
ArtworkView(url: entry.artworkUrl, size: 100) ArtworkView(image: entry.artworkImage, size: 100)
.shadow(radius: 8) .shadow(radius: 8)
// Track info // Track info
@@ -221,24 +259,25 @@ struct MediumWidgetView: View {
Spacer() Spacer()
// Playing indicator // Playback controls
if entry.isPlaying && !entry.isEmpty { if !entry.isEmpty {
HStack(spacing: 4) { HStack(spacing: 12) {
Image(systemName: "waveform") // Play/Pause button
.font(.system(size: 12)) Button(intent: TogglePlaybackIntent()) {
.foregroundColor(Color(hex: "#9333EA")) ControlButton(
Text("Now Playing") systemName: entry.isPlaying ? "pause.fill" : "play.fill",
.font(.system(size: 11, weight: .medium)) size: 18
.foregroundColor(Color(hex: "#9333EA")) )
} .frame(width: 44, height: 44)
} else if !entry.isEmpty { }
HStack(spacing: 4) { .buttonStyle(.plain)
Image(systemName: "pause.fill")
.font(.system(size: 10)) // Next button
.foregroundColor(.white.opacity(0.5)) Button(intent: NextTrackIntent()) {
Text("Paused") ControlButton(systemName: "forward.fill", size: 14)
.font(.system(size: 11)) .frame(width: 36, height: 36)
.foregroundColor(.white.opacity(0.5)) }
.buttonStyle(.plain)
} }
} }
} }
@@ -329,7 +368,7 @@ extension Color {
title: "Bohemian Rhapsody", title: "Bohemian Rhapsody",
artist: "Queen", artist: "Queen",
album: "A Night at the Opera", album: "A Night at the Opera",
artworkUrl: nil, artworkImage: nil,
isPlaying: true, isPlaying: true,
isEmpty: false isEmpty: false
) )
@@ -344,7 +383,7 @@ extension Color {
title: "Bohemian Rhapsody", title: "Bohemian Rhapsody",
artist: "Queen", artist: "Queen",
album: "A Night at the Opera", album: "A Night at the Opera",
artworkUrl: nil, artworkImage: nil,
isPlaying: true, isPlaying: true,
isEmpty: false isEmpty: false
) )