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.
This commit is contained in:
zarzet committed 2026-09-25 01:22:58 +07:00
1 parent 64445a09fc
commit 040c328479
34 files changed
+1601 -16

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
<?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>CFBundleDisplayName</key><string>SpotiFLAC Player</string>
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
<key>CFBundleName</key><string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key><string>XPC!</string>
<key>CFBundleShortVersionString</key><string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key><string>$(FLUTTER_BUILD_NUMBER)</string>
<key>PlayerWidgetAppGroup</key><string>$(PLAYER_WIDGET_APP_GROUP)</string>
<key>NSExtension</key><dict>
<key>NSExtensionPointIdentifier</key><string>com.apple.widgetkit-extension</string>
</dict>
</dict></plist>
@@ -0,0 +1,19 @@
import AppIntents
@available(iOS 17.0, *)
struct PlayerControlIntent: AudioPlaybackIntent {
static var title: LocalizedStringResource = "Control music playback"
static var openAppWhenRun = false
@Parameter(title: "Action") var action: String
init() { action = "toggle" }
init(_ action: String) { self.action = action }
func perform() async throws -> some IntentResult {
#if !PLAYER_WIDGET_EXTENSION
_ = await PlayerWidgetBridge.shared.perform(action)
#endif
return .result()
}
}
@@ -0,0 +1,5 @@
<?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>$(PLAYER_WIDGET_APP_GROUP)</string></array>
</dict></plist>
+160
View File
@@ -0,0 +1,160 @@
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])
}
}
+15
View File
@@ -0,0 +1,15 @@
#include "../Flutter/Generated.xcconfig"
APPLICATION_EXTENSION_API_ONLY = YES
CODE_SIGN_ENTITLEMENTS = PlayerWidget/PlayerWidget.entitlements
CODE_SIGN_STYLE = Automatic
INFOPLIST_FILE = PlayerWidget/Info.plist
IPHONEOS_DEPLOYMENT_TARGET = 17.0
LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks @executable_path/../../Frameworks
PLAYER_WIDGET_APP_GROUP = group.com.zarz.spotiflac
PRODUCT_BUNDLE_IDENTIFIER = com.zarz.spotiflac.PlayerWidget
PRODUCT_NAME = $(TARGET_NAME)
SKIP_INSTALL = YES
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PLAYER_WIDGET_EXTENSION
SWIFT_VERSION = 5.0
TARGETED_DEVICE_FAMILY = 1,2
+45
View File
@@ -0,0 +1,45 @@
import Foundation
struct PlayerWidgetState: Codable {
var id = ""
var title = ""
var artist = ""
var album = ""
var playing = false
var canPlay = false
var canPrevious = false
var canNext = false
var background: UInt32 = 0xff292433
var artworkKey = ""
var artworkName = ""
static let kind = "SpotiFLACPlayer"
static var container: URL? {
let group = Bundle.main.object(forInfoDictionaryKey: "PlayerWidgetAppGroup") as? String
?? "group.com.zarz.spotiflac"
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group)
}
static func read() -> PlayerWidgetState {
guard let url = container?.appendingPathComponent("player.json"),
let data = try? Data(contentsOf: url),
let state = try? JSONDecoder().decode(Self.self, from: data) else { return Self() }
return state
}
var artworkURL: URL? {
guard !artworkName.isEmpty, !artworkName.contains("/") else { return nil }
return Self.container?.appendingPathComponent(artworkName)
}
static var preview: PlayerWidgetState {
var value = Self()
value.title = "Your favorite music"
value.artist = "SpotiFLAC"
value.album = "Now Playing"
value.canPlay = true
value.canPrevious = true
value.canNext = true
return value
}
}