mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +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:
1 parent
64445a09fc
commit
040c328479
34 files changed
+1601
-16
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user