mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
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:
@@ -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>
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -19,7 +19,7 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
||||
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
|
||||
SpotiFLACBackend: e0c81656e8e4c31e86b9f14bbb8e245fb3a14011
|
||||
|
||||
PODFILE CHECKSUM: 6ca58d78915f3e4fcb64938b9167b26e33d44a8a
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user