mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-01 05:39:34 +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,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])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user