mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-04 04:58:30 +01:00
wip: widget
This commit is contained in:
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user