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
2026-09-25 01:22:58 +07:00
parent 64445a09fc
commit 040c328479
34 changed files with 1601 additions and 16 deletions
+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
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
SpotiFLACBackend: e0c81656e8e4c31e86b9f14bbb8e245fb3a14011
PODFILE CHECKSUM: 6ca58d78915f3e4fcb64938b9167b26e33d44a8a
+105
View File
@@ -7,6 +7,13 @@
objects = {
/* Begin PBXBuildFile section */
F59500000000000000000011 /* PlayerWidgetState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000001 /* PlayerWidgetState.swift */; };
F59500000000000000000012 /* PlayerControlIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000002 /* PlayerControlIntent.swift */; };
F59500000000000000000013 /* PlayerWidgetBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000004 /* PlayerWidgetBridge.swift */; };
F59500000000000000000014 /* PlayerWidgetState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000001 /* PlayerWidgetState.swift */; };
F59500000000000000000015 /* PlayerControlIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000002 /* PlayerControlIntent.swift */; };
F59500000000000000000016 /* PlayerWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000003 /* PlayerWidget.swift */; };
F59500000000000000000017 /* PlayerWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = F59500000000000000000008 /* PlayerWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
@@ -23,6 +30,13 @@
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
F59500000000000000000052 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = F59500000000000000000031;
remoteInfo = PlayerWidget;
};
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
@@ -33,6 +47,15 @@
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
F59500000000000000000024 /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (F59500000000000000000017 /* PlayerWidget.appex in Embed App Extensions */, );
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@@ -46,6 +69,15 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
F59500000000000000000009 /* PlayerWidget.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = PlayerWidget.xcconfig; sourceTree = "<group>"; };
F59500000000000000000001 /* PlayerWidgetState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerWidgetState.swift; sourceTree = "<group>"; };
F59500000000000000000002 /* PlayerControlIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlIntent.swift; sourceTree = "<group>"; };
F59500000000000000000003 /* PlayerWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerWidget.swift; sourceTree = "<group>"; };
F59500000000000000000004 /* PlayerWidgetBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerWidgetBridge.swift; sourceTree = "<group>"; };
F59500000000000000000005 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
F59500000000000000000006 /* PlayerWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PlayerWidget.entitlements; sourceTree = "<group>"; };
F59500000000000000000007 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
F59500000000000000000008 /* PlayerWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; path = PlayerWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
@@ -76,6 +108,7 @@
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
F59500000000000000000022 /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -96,6 +129,19 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
F59500000000000000000061 /* PlayerWidget */ = {
isa = PBXGroup;
children = (
F59500000000000000000001 /* PlayerWidgetState.swift */,
F59500000000000000000002 /* PlayerControlIntent.swift */,
F59500000000000000000003 /* PlayerWidget.swift */,
F59500000000000000000005 /* Info.plist */,
F59500000000000000000006 /* PlayerWidget.entitlements */,
F59500000000000000000009 /* PlayerWidget.xcconfig */,
);
path = PlayerWidget;
sourceTree = "<group>";
};
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
@@ -135,6 +181,7 @@
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
F59500000000000000000061 /* PlayerWidget */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
3824DF5478555AFB009D7C8C /* Pods */,
@@ -146,6 +193,7 @@
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
F59500000000000000000008 /* PlayerWidget.appex */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
@@ -161,6 +209,8 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
F59500000000000000000004 /* PlayerWidgetBridge.swift */,
F59500000000000000000007 /* Runner.entitlements */,
C03400000000000000000002 /* CoreBackend.swift */,
D08A594730CF000100000002 /* DownloadProgressSubscription.swift */,
A11CE0022F00000000000001 /* ExtensionCallbackParser.swift */,
@@ -181,6 +231,17 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
F59500000000000000000031 /* PlayerWidget */ = {
isa = PBXNativeTarget;
buildConfigurationList = F59500000000000000000044 /* Build configuration list for PBXNativeTarget "PlayerWidget" */;
buildPhases = (F59500000000000000000021 /* Sources */, F59500000000000000000022 /* Frameworks */, F59500000000000000000023 /* Resources */, );
buildRules = ();
dependencies = ();
name = PlayerWidget;
productName = PlayerWidget;
productReference = F59500000000000000000008 /* PlayerWidget.appex */;
productType = "com.apple.product-type.app-extension";
};
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
@@ -210,12 +271,14 @@
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
F59500000000000000000024 /* Embed App Extensions */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
936893445B11DF44026A1E38 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
F59500000000000000000051 /* PBXTargetDependency */,
);
name = Runner;
packageProductDependencies = (
@@ -263,11 +326,13 @@
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
F59500000000000000000031 /* PlayerWidget */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
F59500000000000000000023 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -384,6 +449,12 @@
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
F59500000000000000000021 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (F59500000000000000000014 /* PlayerWidgetState.swift in Sources */, F59500000000000000000015 /* PlayerControlIntent.swift in Sources */, F59500000000000000000016 /* PlayerWidget.swift in Sources */, );
runOnlyForDeploymentPostprocessing = 0;
};
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -397,6 +468,9 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
F59500000000000000000011 /* PlayerWidgetState.swift in Sources */,
F59500000000000000000012 /* PlayerControlIntent.swift in Sources */,
F59500000000000000000013 /* PlayerWidgetBridge.swift in Sources */,
C03400000000000000000001 /* CoreBackend.swift in Sources */,
D08A594730CF000100000001 /* DownloadProgressSubscription.swift in Sources */,
A11CE0012F00000000000001 /* ExtensionCallbackParser.swift in Sources */,
@@ -407,6 +481,7 @@
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
F59500000000000000000051 /* PBXTargetDependency */ = {isa = PBXTargetDependency; target = F59500000000000000000031 /* PlayerWidget */; targetProxy = F59500000000000000000052 /* PBXContainerItemProxy */; };
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
@@ -434,6 +509,24 @@
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
F59500000000000000000041 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = F59500000000000000000009 /* PlayerWidget.xcconfig */;
buildSettings = {SWIFT_OPTIMIZATION_LEVEL = "-Onone"; };
name = Debug;
};
F59500000000000000000042 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = F59500000000000000000009 /* PlayerWidget.xcconfig */;
buildSettings = {};
name = Release;
};
F59500000000000000000043 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = F59500000000000000000009 /* PlayerWidget.xcconfig */;
buildSettings = {};
name = Profile;
};
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -490,6 +583,8 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
PLAYER_WIDGET_APP_GROUP = group.com.zarz.spotiflac;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
@@ -672,6 +767,8 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
PLAYER_WIDGET_APP_GROUP = group.com.zarz.spotiflac;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
@@ -694,6 +791,8 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
PLAYER_WIDGET_APP_GROUP = group.com.zarz.spotiflac;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
@@ -714,6 +813,12 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
F59500000000000000000044 /* Build configuration list for PBXNativeTarget "PlayerWidget" */ = {
isa = XCConfigurationList;
buildConfigurations = (F59500000000000000000041 /* Debug */, F59500000000000000000042 /* Release */, F59500000000000000000043 /* Profile */, );
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
+21 -10
View File
@@ -4,7 +4,10 @@ import UIKit
import UniformTypeIdentifiers
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@objc class AppDelegate: FlutterAppDelegate {
// Widget playback intents can launch the process before any scene exists.
// Keep one headless-capable engine and attach that same player to the UI.
lazy var playerEngine = FlutterEngine(name: "spotiflac-player", project: nil, allowHeadlessExecution: true)
private let CHANNEL = "com.zarz.spotiflac/backend"
private let DOWNLOAD_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/download_progress_stream"
private let LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/library_scan_progress_stream"
@@ -43,18 +46,16 @@ import UniformTypeIdentifiers
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// With the UIScene lifecycle (required from iOS 27) there is no root
// view controller yet: channels and plugins are registered once the
// storyboard's implicit engine exists, in
// didInitializeImplicitFlutterEngine below.
playerEngine.run()
configureFlutterEngine(playerEngine.binaryMessenger, registry: playerEngine)
if let url = launchOptions?[.url] as? URL {
_ = handleExtensionOAuthRedirect(url: url)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
let messenger = engineBridge.applicationRegistrar.messenger()
private func configureFlutterEngine(_ messenger: FlutterBinaryMessenger, registry: FlutterPluginRegistry) {
PlayerWidgetBridge.shared.attach(messenger)
let channel = FlutterMethodChannel(
name: CHANNEL,
binaryMessenger: messenger
@@ -103,8 +104,8 @@ import UniformTypeIdentifiers
}
)
)
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
GeneratedPluginRegistrant.register(with: registry)
}
/// The window of the foreground scene. Under the UIScene lifecycle the
@@ -120,6 +121,10 @@ import UniformTypeIdentifiers
/// - Signed session: spotiflac://session-grant?grant=...&state=<one_time_nonce>
@discardableResult
func handleExtensionOAuthRedirect(url: URL) -> Bool {
if url.scheme == "spotiflac", url.host == "player" {
Task { _ = await PlayerWidgetBridge.shared.perform("open") }
return true
}
guard let route = ExtensionCallbackParser.parse(url) else { return false }
streamQueue.async {
var extensionId = ""
@@ -848,7 +853,7 @@ private final class ClosureStreamHandler: NSObject, FlutterStreamHandler {
}
/// UIScene lifecycle entry point (required from iOS 27). FlutterSceneDelegate
/// hosts the storyboard's FlutterViewController; this subclass forwards what
/// hosts the shared engine's FlutterViewController; this subclass forwards what
/// UIKit now delivers to the scene instead of the app delegate: OAuth
/// redirect URLs and the background/foreground transitions that keep
/// downloads alive.
@@ -862,6 +867,12 @@ class SceneDelegate: FlutterSceneDelegate {
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
if let windowScene = scene as? UIWindowScene, let appDelegate {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = FlutterViewController(engine: appDelegate.playerEngine, nibName: nil, bundle: nil)
self.window = window
window.makeKeyAndVisible()
}
super.scene(scene, willConnectTo: session, options: connectionOptions)
for context in connectionOptions.urlContexts {
_ = appDelegate?.handleExtensionOAuthRedirect(url: context.url)
+2 -4
View File
@@ -41,10 +41,10 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>PlayerWidgetAppGroup</key>
<string>$(PLAYER_WIDGET_APP_GROUP)</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -60,8 +60,6 @@
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
+111
View File
@@ -0,0 +1,111 @@
import Flutter
import Foundation
import WidgetKit
@MainActor
final class PlayerWidgetBridge {
static let shared = PlayerWidgetBridge()
private var channel: FlutterMethodChannel?
private var ready = false
private var pending: [(UUID, String, CheckedContinuation<Bool, Never>)] = []
private let writer = DispatchQueue(label: "com.zarz.spotiflac.player-widget", qos: .utility)
func attach(_ messenger: FlutterBinaryMessenger) {
let channel = FlutterMethodChannel(name: "com.zarz.spotiflac/player_widget", binaryMessenger: messenger)
self.channel = channel
channel.setMethodCallHandler { [weak self] call, result in
guard let self else { result(nil); return }
switch call.method {
case "ready":
self.ready = true
result(nil)
self.flush()
case "update":
guard let values = call.arguments as? [String: Any] else {
result(FlutterError(code: "bad_state", message: "Missing widget state", details: nil))
return
}
self.writer.async {
do {
try Self.write(values)
WidgetCenter.shared.reloadTimelines(ofKind: PlayerWidgetState.kind)
DispatchQueue.main.async { result(nil) }
} catch {
DispatchQueue.main.async {
result(FlutterError(code: "widget_update", message: error.localizedDescription, details: nil))
}
}
}
default:
result(FlutterMethodNotImplemented)
}
}
}
func perform(_ command: String) async -> Bool {
guard ["play", "pause", "toggle", "previous", "next", "open"].contains(command) else { return false }
return await withCheckedContinuation { continuation in
let id = UUID()
pending.append((id, command, continuation))
flush()
Task { [weak self] in
try? await Task.sleep(nanoseconds: 15_000_000_000)
self?.finish(id, success: false)
}
}
}
private var sent = Set<UUID>()
private func flush() {
guard ready, let channel else { return }
for (id, command, _) in pending where !sent.contains(id) {
sent.insert(id)
channel.invokeMethod("command", arguments: command) { [weak self] result in
self?.finish(id, success: result as? Bool == true)
}
}
}
private func finish(_ id: UUID, success: Bool) {
guard let index = pending.firstIndex(where: { $0.0 == id }) else { return }
let entry = pending.remove(at: index)
sent.remove(id)
entry.2.resume(returning: success)
}
nonisolated private static func write(_ values: [String: Any]) throws {
guard let directory = PlayerWidgetState.container else {
throw NSError(domain: "PlayerWidget", code: 1, userInfo: [NSLocalizedDescriptionKey: "Widget App Group is unavailable"])
}
let previous = PlayerWidgetState.read()
var state = PlayerWidgetState()
state.id = values["id"] as? String ?? ""
state.title = values["title"] as? String ?? ""
state.artist = values["artist"] as? String ?? ""
state.album = values["album"] as? String ?? ""
state.playing = values["playing"] as? Bool ?? false
state.canPlay = values["canPlay"] as? Bool ?? false
state.canPrevious = values["canPrevious"] as? Bool ?? false
state.canNext = values["canNext"] as? Bool ?? false
state.background = (values["background"] as? NSNumber)?.uint32Value ?? state.background
state.artworkKey = values["artworkKey"] as? String ?? ""
if !state.artworkKey.isEmpty {
if state.artworkKey == previous.artworkKey, previous.artworkURL != nil {
state.artworkName = previous.artworkName
} else if let artwork = values["artwork"] as? FlutterStandardTypedData {
state.artworkName = "cover-\(UUID().uuidString).png"
try artwork.data.write(to: directory.appendingPathComponent(state.artworkName), options: .atomic)
}
}
try JSONEncoder().encode(state).write(to: directory.appendingPathComponent("player.json"), options: .atomic)
// Retain the current and preceding image while an older timeline is
// being rendered. The widget never receives the music files.
for url in (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] {
if url.lastPathComponent.hasPrefix("cover-"), url.pathExtension == "png",
url.lastPathComponent != state.artworkName, url.lastPathComponent != previous.artworkName {
try? FileManager.default.removeItem(at: url)
}
}
}
}
+5
View File
@@ -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>
+19
View File
@@ -1,8 +1,27 @@
import Foundation
import Flutter
import XCTest
@testable import Runner
class RunnerTests: XCTestCase {
@MainActor
func testPlayerWidgetSharesSceneEngineAndCompletesCommands() async throws {
let delegate = try XCTUnwrap(UIApplication.shared.delegate as? AppDelegate)
let controller = try XCTUnwrap(delegate.activeWindow?.rootViewController as? FlutterViewController)
XCTAssertTrue(controller.engine === delegate.playerEngine)
// Exercise the real native -> Dart -> audio handler -> native path.
// With an empty queue, opening the widget leaves the app on its home
// or onboarding screen and still publishes a valid empty snapshot.
let completed = await PlayerWidgetBridge.shared.perform("open")
XCTAssertTrue(completed)
let container = try XCTUnwrap(PlayerWidgetState.container)
let data = try Data(contentsOf: container.appendingPathComponent("player.json"))
let state = try JSONDecoder().decode(PlayerWidgetState.self, from: data)
XCTAssertFalse(state.canPlay)
XCTAssertFalse(state.playing)
}
private func isSymbolicLink(_ url: URL) -> Bool {
guard let values = try? url.resourceValues(forKeys: [.isSymbolicLinkKey]) else { return false }
return values.isSymbolicLink == true