Files
SpotiFLAC-Mobile/ios/PlayerWidget/PlayerWidget.swift
T
zarzet 040c328479 feat(widgets): add native Android and iOS home screen players
Publish artwork, metadata, and transport state through a shared Dart bridge. Add configurable Android RemoteViews and iOS WidgetKit players with controls and links into the full player.

Serialize widget commands and session restoration, and share one headless-capable iOS Flutter engine with the foreground scene. Include native integration tests, Dart bridge tests, and widget setup documentation.
2026-09-25 01:22:58 +07:00

161 lines
7.2 KiB
Swift

import AppIntents
import SwiftUI
import WidgetKit
enum PlayerWidgetAppearance: String, AppEnum {
case artwork, light, dark
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Appearance"
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
.artwork: "Artwork colors", .light: "Light", .dark: "Dark",
]
}
struct PlayerWidgetConfiguration: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Player appearance"
static var description = IntentDescription("Choose the colors and details shown in your music widget.")
@Parameter(title: "Appearance", default: .artwork) var appearance: PlayerWidgetAppearance
@Parameter(title: "Show artist name", default: true) var showArtist: Bool
}
struct PlayerEntry: TimelineEntry {
let date: Date
let state: PlayerWidgetState
let configuration: PlayerWidgetConfiguration
}
struct PlayerTimelineProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> PlayerEntry {
PlayerEntry(date: .now, state: .preview, configuration: PlayerWidgetConfiguration())
}
func snapshot(for configuration: PlayerWidgetConfiguration, in context: Context) async -> PlayerEntry {
PlayerEntry(date: .now, state: context.isPreview ? .preview : .read(), configuration: configuration)
}
func timeline(for configuration: PlayerWidgetConfiguration, in context: Context) async -> Timeline<PlayerEntry> {
Timeline(entries: [PlayerEntry(date: .now, state: .read(), configuration: configuration)], policy: .never)
}
}
struct PlayerWidgetView: View {
let entry: PlayerEntry
@Environment(\.widgetFamily) private var family
private var isLight: Bool { entry.configuration.appearance == .light }
private var foreground: Color { isLight ? Color(red: 0.10, green: 0.09, blue: 0.12) : .white }
private var background: Color {
switch entry.configuration.appearance {
case .light: return Color(red: 0.96, green: 0.95, blue: 0.97)
case .dark: return Color(red: 0.11, green: 0.10, blue: 0.13)
case .artwork:
let value = entry.state.background
return Color(red: Double((value >> 16) & 255) / 255,
green: Double((value >> 8) & 255) / 255,
blue: Double(value & 255) / 255)
}
}
var body: some View {
GeometryReader { bounds in
if family == .systemMedium {
HStack(spacing: 16) {
artwork.frame(width: min(116, bounds.size.height), height: min(116, bounds.size.height))
VStack(alignment: .leading, spacing: 8) {
metadata
Spacer(minLength: 0)
controls
}
}
} else if family == .systemLarge {
VStack(alignment: .leading, spacing: 12) {
artwork.frame(maxWidth: .infinity, maxHeight: .infinity)
metadata
controls
}
} else {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top) {
artwork.frame(width: max(44, bounds.size.height - 76), height: max(44, bounds.size.height - 76))
Spacer(minLength: 4)
Image(systemName: "music.note").font(.system(size: 19, weight: .semibold)).opacity(0.8)
}
Spacer(minLength: 0)
HStack(alignment: .bottom, spacing: 6) {
metadata
Spacer(minLength: 0)
control(entry.state.playing ? "pause" : "play", symbol: entry.state.playing ? "pause.fill" : "play.fill", prominent: true, enabled: entry.state.canPlay)
}
}
}
}
.foregroundStyle(foreground)
.containerBackground(for: .widget) {
LinearGradient(colors: [background.opacity(0.88), background], startPoint: .topLeading, endPoint: .bottomTrailing)
}
.widgetURL(URL(string: "spotiflac://player"))
}
private var artwork: some View {
Group {
if let url = entry.state.artworkURL, let data = try? Data(contentsOf: url), let image = UIImage(data: data) {
Image(uiImage: image).resizable().scaledToFit()
} else {
RoundedRectangle(cornerRadius: 12).fill(foreground.opacity(0.08))
.overlay(Image(systemName: "music.note").font(.system(size: 32, weight: .medium)))
}
}
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.accessibilityHidden(true)
}
private var metadata: some View {
VStack(alignment: .leading, spacing: 3) {
Text(entry.state.title.isEmpty ? "SpotiFLAC Player" : entry.state.title)
.font(.system(size: family == .systemSmall ? 14 : 17, weight: .semibold))
.lineLimit(family == .systemMedium ? 2 : 1)
.minimumScaleFactor(0.85)
if entry.configuration.showArtist {
Text(entry.state.canPlay ? entry.state.artist : "Choose music in the app")
.font(.system(size: family == .systemSmall ? 12 : 14))
.foregroundStyle(foreground.opacity(0.7)).lineLimit(1)
}
}
}
private var controls: some View {
HStack {
control("previous", symbol: "backward.fill", enabled: entry.state.canPlay && entry.state.canPrevious)
Spacer(minLength: 4)
control(entry.state.playing ? "pause" : "play", symbol: entry.state.playing ? "pause.fill" : "play.fill", prominent: true, enabled: entry.state.canPlay)
Spacer(minLength: 4)
control("next", symbol: "forward.fill", enabled: entry.state.canPlay && entry.state.canNext)
}
}
private func control(_ action: String, symbol: String, prominent: Bool = false, enabled: Bool) -> some View {
Button(intent: PlayerControlIntent(action == "play" || action == "pause" ? "toggle" : action)) {
Image(systemName: symbol)
.font(.system(size: prominent ? 19 : 20, weight: .semibold))
.frame(width: family == .systemSmall ? 36 : 44, height: family == .systemSmall ? 36 : 44)
.foregroundStyle(prominent ? background : foreground)
.background(prominent ? foreground : .clear, in: Circle())
}
.buttonStyle(.plain)
.disabled(!enabled)
.opacity(enabled ? 1 : 0.4)
.accessibilityLabel(action == "previous" ? "Previous track" : action == "next" ? "Next track" : action == "pause" ? "Pause" : "Play")
}
}
@main
struct SpotiFLACPlayerWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: PlayerWidgetState.kind, intent: PlayerWidgetConfiguration.self, provider: PlayerTimelineProvider()) { entry in
PlayerWidgetView(entry: entry)
}
.configurationDisplayName("SpotiFLAC Player")
.description("Your music, artwork, and playback controls.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}