feat: ios widget

This commit is contained in:
Fredrik Burmester
2026-01-05 21:28:00 +01:00
parent cbbac3c25c
commit 6c95962e12
28 changed files with 759 additions and 23 deletions

View File

@@ -0,0 +1,20 @@
{
"colors": [
{
"color": {
"color-space": "display-p3",
"components": {
"red": 0.5764705882352941,
"green": 0.2,
"blue": 0.9176470588235294,
"alpha": 1
}
},
"idiom": "universal"
}
],
"info": {
"version": 1,
"author": "expo"
}
}

View File

@@ -0,0 +1,38 @@
{
"colors": [
{
"color": {
"color-space": "display-p3",
"components": {
"red": 0.10196078431372549,
"green": 0.10196078431372549,
"blue": 0.10196078431372549,
"alpha": 1
}
},
"idiom": "universal"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"color": {
"color-space": "display-p3",
"components": {
"red": 0.10196078431372549,
"green": 0.10196078431372549,
"blue": 0.10196078431372549,
"alpha": 1
}
},
"idiom": "universal"
}
],
"info": {
"version": 1,
"author": "expo"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -0,0 +1,122 @@
{
"images": [
{
"idiom": "iphone",
"size": "20x20",
"scale": "2x",
"filename": "App-Icon-20x20@2x.png"
},
{
"idiom": "iphone",
"size": "20x20",
"scale": "3x",
"filename": "App-Icon-20x20@3x.png"
},
{
"idiom": "iphone",
"size": "29x29",
"scale": "1x",
"filename": "App-Icon-29x29@1x.png"
},
{
"idiom": "iphone",
"size": "29x29",
"scale": "2x",
"filename": "App-Icon-29x29@2x.png"
},
{
"idiom": "iphone",
"size": "29x29",
"scale": "3x",
"filename": "App-Icon-29x29@3x.png"
},
{
"idiom": "iphone",
"size": "40x40",
"scale": "2x",
"filename": "App-Icon-40x40@2x.png"
},
{
"idiom": "iphone",
"size": "40x40",
"scale": "3x",
"filename": "App-Icon-40x40@3x.png"
},
{
"idiom": "iphone",
"size": "60x60",
"scale": "2x",
"filename": "App-Icon-60x60@2x.png"
},
{
"idiom": "iphone",
"size": "60x60",
"scale": "3x",
"filename": "App-Icon-60x60@3x.png"
},
{
"idiom": "ipad",
"size": "20x20",
"scale": "1x",
"filename": "App-Icon-20x20@1x.png"
},
{
"idiom": "ipad",
"size": "20x20",
"scale": "2x",
"filename": "App-Icon-20x20@2x.png"
},
{
"idiom": "ipad",
"size": "29x29",
"scale": "1x",
"filename": "App-Icon-29x29@1x.png"
},
{
"idiom": "ipad",
"size": "29x29",
"scale": "2x",
"filename": "App-Icon-29x29@2x.png"
},
{
"idiom": "ipad",
"size": "40x40",
"scale": "1x",
"filename": "App-Icon-40x40@1x.png"
},
{
"idiom": "ipad",
"size": "40x40",
"scale": "2x",
"filename": "App-Icon-40x40@2x.png"
},
{
"idiom": "ipad",
"size": "76x76",
"scale": "1x",
"filename": "App-Icon-76x76@1x.png"
},
{
"idiom": "ipad",
"size": "76x76",
"scale": "2x",
"filename": "App-Icon-76x76@2x.png"
},
{
"idiom": "ipad",
"size": "83.5x83.5",
"scale": "2x",
"filename": "App-Icon-83.5x83.5@2x.png"
},
{
"idiom": "ios-marketing",
"size": "1024x1024",
"scale": "1x",
"filename": "ItunesArtwork@2x.png"
}
],
"info": {
"version": 1,
"author": "expo"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

11
targets/widget/Info.plist Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,19 @@
/** @type {import('@bacons/apple-targets').Config} */
module.exports = (config) => ({
type: "widget",
name: "StreamyfinWidget",
icon: "../../assets/images/icon-ios-plain.png",
deploymentTarget: "17.0",
entitlements: {
"com.apple.security.application-groups": config.ios?.entitlements?.[
"com.apple.security.application-groups"
] || ["group.com.fredrikburmester.streamyfin.widgets"],
},
colors: {
$accent: "#9333EA",
$widgetBackground: {
light: "#1a1a1a",
dark: "#1a1a1a",
},
},
});

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.fredrikburmester.streamyfin.widgets</string>
</array>
</dict>
</plist>

352
targets/widget/widget.swift Normal file
View File

@@ -0,0 +1,352 @@
import SwiftUI
import WidgetKit
// MARK: - Data Models
struct NowPlayingData: Codable {
let title: String
let artist: String
let album: String
let artworkUrl: String?
let isPlaying: Bool
let updatedAt: Double
}
struct NowPlayingEntry: TimelineEntry {
let date: Date
let title: String
let artist: String
let album: String
let artworkUrl: String?
let isPlaying: Bool
let isEmpty: Bool
static var empty: NowPlayingEntry {
NowPlayingEntry(
date: Date(),
title: "Not Playing",
artist: "Open Streamyfin to play music",
album: "",
artworkUrl: nil,
isPlaying: false,
isEmpty: true
)
}
}
// MARK: - Timeline Provider
struct NowPlayingProvider: TimelineProvider {
let appGroupId = "group.com.fredrikburmester.streamyfin.widgets"
func placeholder(in context: Context) -> NowPlayingEntry {
NowPlayingEntry(
date: Date(),
title: "Song Title",
artist: "Artist Name",
album: "Album",
artworkUrl: nil,
isPlaying: true,
isEmpty: false
)
}
func getSnapshot(in context: Context, completion: @escaping (NowPlayingEntry) -> Void) {
let entry = fetchCurrentEntry()
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<NowPlayingEntry>) -> Void) {
let entry = fetchCurrentEntry()
// Use .never policy since we manually refresh via WidgetCenter
let timeline = Timeline(entries: [entry], policy: .never)
completion(timeline)
}
private func fetchCurrentEntry() -> NowPlayingEntry {
guard let defaults = UserDefaults(suiteName: appGroupId),
let jsonString = defaults.string(forKey: "nowPlaying"),
let data = jsonString.data(using: .utf8),
let nowPlaying = try? JSONDecoder().decode(NowPlayingData.self, from: data) else {
return .empty
}
return NowPlayingEntry(
date: Date(),
title: nowPlaying.title,
artist: nowPlaying.artist,
album: nowPlaying.album,
artworkUrl: nowPlaying.artworkUrl,
isPlaying: nowPlaying.isPlaying,
isEmpty: false
)
}
}
// MARK: - Artwork Image View
struct ArtworkView: View {
let url: String?
let size: CGFloat
var body: some View {
Group {
if let urlString = url, let imageUrl = URL(string: urlString) {
AsyncImage(url: imageUrl) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure(_):
placeholderView
case .empty:
placeholderView
.overlay(ProgressView().tint(.white))
@unknown default:
placeholderView
}
}
} else {
placeholderView
}
}
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private var placeholderView: some View {
ZStack {
Color(hex: "#2a2a2a")
Image(systemName: "music.note")
.font(.system(size: size * 0.4))
.foregroundColor(.gray)
}
}
}
// MARK: - Small Widget View
struct SmallWidgetView: View {
let entry: NowPlayingEntry
var body: some View {
GeometryReader { geometry in
ZStack {
// Background artwork (blurred)
if !entry.isEmpty {
ArtworkView(url: entry.artworkUrl, size: geometry.size.width)
.blur(radius: 20)
.opacity(0.6)
}
// Content
VStack(alignment: .leading, spacing: 4) {
Spacer()
// Small artwork
if !entry.isEmpty {
ArtworkView(url: entry.artworkUrl, size: 48)
.shadow(radius: 4)
} else {
Image(systemName: "music.note.house.fill")
.font(.system(size: 32))
.foregroundColor(Color(hex: "#9333EA"))
}
Spacer().frame(height: 8)
// Track info
Text(entry.title)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white)
.lineLimit(2)
Text(entry.artist)
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.7))
.lineLimit(1)
// Playing indicator
if entry.isPlaying && !entry.isEmpty {
HStack(spacing: 2) {
ForEach(0..<3) { _ in
RoundedRectangle(cornerRadius: 1)
.fill(Color(hex: "#9333EA"))
.frame(width: 3, height: 8)
}
}
.padding(.top, 4)
}
}
.padding(12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
}
}
.containerBackground(for: .widget) {
Color(hex: "#1a1a1a")
}
}
}
// MARK: - Medium Widget View
struct MediumWidgetView: View {
let entry: NowPlayingEntry
var body: some View {
HStack(spacing: 16) {
// Artwork
ArtworkView(url: entry.artworkUrl, size: 100)
.shadow(radius: 8)
// Track info
VStack(alignment: .leading, spacing: 4) {
Text(entry.title)
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.white)
.lineLimit(2)
Text(entry.artist)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
.lineLimit(1)
if !entry.album.isEmpty {
Text(entry.album)
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.6))
.lineLimit(1)
}
Spacer()
// Playing indicator
if entry.isPlaying && !entry.isEmpty {
HStack(spacing: 4) {
Image(systemName: "waveform")
.font(.system(size: 12))
.foregroundColor(Color(hex: "#9333EA"))
Text("Now Playing")
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: "#9333EA"))
}
} else if !entry.isEmpty {
HStack(spacing: 4) {
Image(systemName: "pause.fill")
.font(.system(size: 10))
.foregroundColor(.white.opacity(0.5))
Text("Paused")
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.5))
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(16)
.containerBackground(for: .widget) {
Color(hex: "#1a1a1a")
}
}
}
// MARK: - Widget Configuration
struct StreamyfinNowPlayingWidget: Widget {
let kind: String = "StreamyfinNowPlayingWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: NowPlayingProvider()) { entry in
StreamyfinWidgetEntryView(entry: entry)
}
.configurationDisplayName("Now Playing")
.description("See what's currently playing in Streamyfin")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
struct StreamyfinWidgetEntryView: View {
@Environment(\.widgetFamily) var family
let entry: NowPlayingEntry
var body: some View {
switch family {
case .systemSmall:
SmallWidgetView(entry: entry)
case .systemMedium:
MediumWidgetView(entry: entry)
default:
SmallWidgetView(entry: entry)
}
}
}
// MARK: - Widget Bundle Entry Point
@main
struct StreamyfinWidgets: WidgetBundle {
var body: some Widget {
StreamyfinNowPlayingWidget()
}
}
// MARK: - Color Extension
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}
// MARK: - Preview
#Preview(as: .systemSmall) {
StreamyfinNowPlayingWidget()
} timeline: {
NowPlayingEntry(
date: Date(),
title: "Bohemian Rhapsody",
artist: "Queen",
album: "A Night at the Opera",
artworkUrl: nil,
isPlaying: true,
isEmpty: false
)
NowPlayingEntry.empty
}
#Preview(as: .systemMedium) {
StreamyfinNowPlayingWidget()
} timeline: {
NowPlayingEntry(
date: Date(),
title: "Bohemian Rhapsody",
artist: "Queen",
album: "A Night at the Opera",
artworkUrl: nil,
isPlaying: true,
isEmpty: false
)
NowPlayingEntry.empty
}