Files
LoveLiveMusicPlayerKMP/iosApp/WidgetExtension/HomeWidgetExample.swift
T
2026-10-09 22:41:45 +08:00

520 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// BackgroundIntent.swift
// Runner
//
// Created by hoshizora-rin on 2024/9/19.
//
import SwiftUI
import WidgetKit
private let widgetGroupId = "group.com.zhushenwudi.lovelivemusicplayer"
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> ExampleEntry {
sampleEntry(family: context.family, isPreview: true)
}
func getSnapshot(in context: Context, completion: @escaping (ExampleEntry) -> Void) {
// 与 Flutter 一样读 App Group;isPreview 仅用于避免 Button(intent) 把添加页打成鱼骨。
completion(readEntry(family: context.family, isPreview: true))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
// 数据由主应用写入 App Group 后主动 reload;不要让单条当前时间的
// timeline 以 .atEnd 反复申请无意义的系统刷新预算。
completion(Timeline(entries: [readEntry(family: context.family, isPreview: false)], policy: .never))
}
private func readEntry(family: WidgetFamily, isPreview: Bool) -> ExampleEntry {
let data = UserDefaults(suiteName: widgetGroupId)
let songName = data?.string(forKey: "songName") ?? ""
let songArtist = data?.string(forKey: "songArtist") ?? ""
let isFavorite = data?.bool(forKey: "songFavorite") ?? false
let isPlaying = data?.bool(forKey: "isPlaying") ?? false
let playText = data?.string(forKey: "playText") ?? ""
let isShutdown = data?.bool(forKey: "isShutdown") ?? true
let bgColor = data?.string(forKey: "bgColor") ?? "255,255,255"
return ExampleEntry(
date: Date(),
widgetFamily: family,
songName: songName,
songArtist: songArtist,
isFavorite: isFavorite,
isPlaying: isPlaying,
playText: playText,
isShutdown: isShutdown,
bgColor: bgColor,
appIconCharacterId: data?.string(forKey: "appIconCharacterId") ?? "default",
isPreview: isPreview
)
}
}
private func sampleEntry(family: WidgetFamily, isPreview: Bool) -> ExampleEntry {
ExampleEntry(
date: Date(),
widgetFamily: family,
songName: "",
songArtist: "",
isFavorite: false,
isPlaying: false,
playText: "",
isShutdown: true,
bgColor: "255,255,255",
appIconCharacterId: "default",
isPreview: isPreview
)
}
struct ExampleEntry: TimelineEntry {
let date: Date
let widgetFamily: WidgetFamily
let songName: String
let songArtist: String
let isFavorite: Bool
let isPlaying: Bool
let playText: String
let isShutdown: Bool
let bgColor: String
let appIconCharacterId: String
let isPreview: Bool
}
struct HomeWidgetExampleEntryView: View {
var entry: Provider.Entry
var isWhiteBackground = true
private let actionButtonSize: CGFloat = 34
private let transportIconSize: CGFloat = 20
init(entry: Provider.Entry, isWhiteBackground: Bool) {
self.entry = entry
self.isWhiteBackground = isWhiteBackground
}
private var selectedAppIcon: UIImage? {
let id = entry.appIconCharacterId
guard id != "default", !id.contains("/"),
let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: widgetGroupId)?
.appendingPathComponent("app-icon-\(id)-\(isWhiteBackground ? "light" : "dark").png") else { return nil }
return UIImage(contentsOfFile: url.path)
}
private var appLogo: Image {
if let selected = selectedAppIcon { return Image(uiImage: selected) }
return Image("logo")
}
var body: some View {
let canvas = familyCanvasSize
let cdSize: CGFloat = 140
let coverSize: CGFloat = cdSize * 0.55
let coverAndCdDiffSize: CGFloat = (cdSize - coverSize) / 2
let playButtonAndCdDiffSize: CGFloat = (cdSize - 34) / 2 // width:18 + padding:8x2
let offsetX = calcCdOffsetX(entry: entry, canvas: canvas)
let offsetY = calcCdOffsetY(entry: entry, canvas: canvas)
let bgColor = calcBgColor(entry: entry)
let playText = calcPlayText(entry: entry)
ZStack {
// 填充整个布局颜色
if isWhiteBackground {
Rectangle()
.fill(
AngularGradient(
gradient: Gradient(
colors: [
Color(red: 229/255, green: 233/255, blue: 235/255),
Color(red: 219/255, green: 223/255, blue: 225/255),
bgColor
]
),
center: .topLeading,
angle: .degrees(180 + 45)
)
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
Rectangle()
.fill(Color(red: 49/255, green: 49/255, blue: 49/255))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// 绘制右上角唱片
ZStack(alignment: .topTrailing) {
Image(isWhiteBackground ? "cd_white" : "cd_black")
.resizable()
.interpolation(.high)
.scaledToFit()
.frame(width: cdSize, height: cdSize)
.clipped()
.offset(x: offsetX, y: offsetY)
if let image = loadImageFromFile() {
Image(uiImage: image)
.resizable()
.interpolation(.high)
.scaledToFit()
.frame(width: coverSize, height: coverSize)
.clipShape(Circle())
.clipped()
.offset(x: -coverAndCdDiffSize + offsetX, y: coverAndCdDiffSize + offsetY)
}
playButton(
offsetX: -playButtonAndCdDiffSize + offsetX,
offsetY: playButtonAndCdDiffSize + offsetY
)
}
.contentTransition(.interpolate)
.animation(.smooth(duration: 0.45), value: entry.isPlaying)
// 绘制左侧一列歌曲信息
VStack(alignment: .leading) {
appLogo
.resizable()
.scaledToFit()
.frame(width: 30, height: 30)
.clipped()
.padding(.bottom, 5)
Spacer()
Text(playText)
.font(.system(size: 10))
.bold()
.foregroundColor(.gray)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, actionReservedWidth)
.padding(.bottom, 3)
Text(entry.songName)
.font(.system(size: 12))
.bold()
.foregroundColor(lyricForeground(isPrimary: true))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, actionReservedWidth)
.padding(.bottom, 2)
Text(entry.songArtist)
.font(.system(size: 12, weight: .light))
.foregroundColor(.gray)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, actionReservedWidth)
}
.padding()
VStack {
Spacer()
favoriteButton()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.widgetURL(shutdownOpenURL)
}
/// 关机态点卡片打开 App;用 widgetURL 而不是全屏 Link,避免桌面白卡渲染成鱼骨。
var shutdownOpenURL: URL? {
if entry.isShutdown && !entry.isPreview {
return URL(string: "llmp://")
}
return nil
}
var familyCanvasSize: CGSize {
switch entry.widgetFamily {
case .systemMedium:
return CGSize(width: 338, height: 158)
default:
return CGSize(width: 158, height: 158)
}
}
var actionReservedWidth: CGFloat {
entry.widgetFamily == .systemMedium ? 110 : 72
}
/// 黑卡必须用明确 RGB,系统 `.white` 在 Widget 预览里可能被折成浅色叠在白底上,歌词会消失。
func lyricForeground(isPrimary: Bool) -> Color {
if isPrimary {
return isWhiteBackground
? Color(red: 0, green: 0, blue: 0)
: Color(red: 1, green: 1, blue: 1)
}
return Color(red: 0.7, green: 0.7, blue: 0.7, opacity: 0.7)
}
func calcCdOffsetX(entry: ExampleEntry, canvas: CGSize) -> CGFloat {
var offset = 0.0
switch entry.widgetFamily {
case .systemSmall:
offset = (canvas.width - 70) / 2
case .systemMedium:
offset = (canvas.width - 152) / 2 + (entry.isPlaying ? 55 : 15)
default:
break
}
return offset
}
func calcCdOffsetY(entry: ExampleEntry, canvas: CGSize) -> CGFloat {
var offset = 0.0
switch entry.widgetFamily {
case .systemSmall:
offset = (70 - canvas.height) / 2
case .systemMedium:
offset = (152 - canvas.height) / 2 - (entry.isPlaying ? 55 : 15)
default:
break
}
return offset
}
func calcBgColor(entry: ExampleEntry) -> Color {
var intColors: [Double] = [230.0, 215.0, 210.0]
if entry.bgColor.contains(",") {
intColors = entry.bgColor.components(separatedBy: ",").compactMap(Double.init)
}
return Color(
red: Double(intColors[0]) / 255,
green: Double(intColors[1]) / 255,
blue: Double(intColors[2]) / 255
)
}
func loadImageFromFile() -> UIImage? {
// 没歌/关机/添加页预览都不该读 App Group 里的旧 sharedImage
//(和 Flutter 共用 group,重装后文件还在)。
if entry.isPreview || entry.isShutdown || entry.songName.isEmpty {
return nil
}
guard let fileURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: widgetGroupId
)?.appendingPathComponent("sharedImage.png") else { return nil }
return UIImage(contentsOfFile: fileURL.path)
}
func calcPlayText(entry: ExampleEntry) -> String {
var playTextArr: [String] = [WidgetI18n.t("playing"), WidgetI18n.t("paused")]
if entry.playText.contains(",") {
playTextArr = entry.playText.components(separatedBy: ",")
}
return entry.isPlaying ? playTextArr[0] : playTextArr[1]
}
@ViewBuilder
func transportButton(assetName: String, action: String) -> some View {
let icon = Image(assetName)
.renderingMode(.template)
.resizable()
.scaledToFit()
.foregroundColor(.white)
.frame(width: transportIconSize, height: transportIconSize)
.frame(width: actionButtonSize, height: actionButtonSize)
if entry.isPreview || entry.isShutdown {
icon
} else if #available(iOSApplicationExtension 17, *) {
Button(
intent: BackgroundIntent(
url: URL(string: "homeWidgetExample://\(action)")
)
) {
icon
}
.buttonStyle(.plain)
} else {
icon
}
}
@ViewBuilder
func playButton(offsetX: CGFloat, offsetY: CGFloat) -> some View {
let icon = Image(systemName: entry.isPlaying ? "pause.fill" : "play.fill")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
.foregroundColor(.white)
// 预览和关机态都不能挂 App Intent,否则桌面白卡会掉回 placeholder 鱼骨。
if entry.isPreview || entry.isShutdown {
icon
.padding(8)
.offset(x: offsetX, y: offsetY)
} else if #available(iOSApplicationExtension 17, *) {
Button(intent: BackgroundIntent(url: URL(string: "homeWidgetExample://toggle_play"))) {
icon
}
.buttonStyle(.plain)
.padding(8)
.offset(x: offsetX, y: offsetY)
}
}
@ViewBuilder
func favoriteButton() -> some View {
HStack(spacing: 8) {
if entry.widgetFamily == .systemMedium {
transportButton(
assetName: "PlayerPrev",
action: "previous"
)
}
transportButton(
assetName: "PlayerNext",
action: "next"
)
favoriteControl()
}
.frame(maxWidth: .infinity, alignment: .bottomTrailing)
.padding(8)
}
@ViewBuilder
func favoriteControl() -> some View {
let icon = Image(entry.isFavorite ? "FavoriteClick" : "FavoriteUnClick")
.resizable()
.scaledToFit()
.frame(width: actionButtonSize, height: actionButtonSize)
if entry.isPreview || entry.isShutdown {
icon
} else if #available(iOSApplicationExtension 17, *) {
Button(intent: BackgroundIntent(url: URL(string: "homeWidgetExample://toggle_love"))) {
icon
}
.buttonStyle(.plain)
} else {
icon
}
}
}
struct HomeWidgetExampleWhite: Widget {
let kind = "HomeWidgetExampleWhite"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { (entry) in
widgetRoot(entry: entry, isWhiteBackground: true)
}
.configurationDisplayName(WidgetI18n.t("widget_white"))
.contentMarginsDisabled()
.supportedFamilies([.systemSmall, .systemMedium])
.description(WidgetI18n.t("widget_desc"))
}
}
struct HomeWidgetExampleBlack: Widget {
let kind = "HomeWidgetExampleBlack"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { (entry) in
widgetRoot(entry: entry, isWhiteBackground: false)
}
.configurationDisplayName(WidgetI18n.t("widget_black"))
.contentMarginsDisabled()
.supportedFamilies([.systemSmall, .systemMedium])
.description(WidgetI18n.t("widget_desc"))
}
}
@ViewBuilder
private func widgetRoot(entry: ExampleEntry, isWhiteBackground: Bool) -> some View {
let view = HomeWidgetExampleEntryView(entry: entry, isWhiteBackground: isWhiteBackground)
.containerBackground(for: .widget) {
widgetContainerFill(entry: entry, isWhiteBackground: isWhiteBackground)
}
if entry.isPreview {
view.unredacted()
} else {
view
}
}
@ViewBuilder
private func widgetContainerFill(entry: ExampleEntry, isWhiteBackground: Bool) -> some View {
if isWhiteBackground {
Color(red: 229/255, green: 233/255, blue: 235/255)
} else {
Color(red: 49/255, green: 49/255, blue: 49/255)
}
}
private func sampleBgColor(_ bgColor: String) -> Color {
var intColors: [Double] = [230.0, 215.0, 210.0]
if bgColor.contains(",") {
intColors = bgColor.components(separatedBy: ",").compactMap(Double.init)
}
return Color(
red: Double(intColors[0]) / 255,
green: Double(intColors[1]) / 255,
blue: Double(intColors[2]) / 255
)
}
/// Widget 扩展未链接 ComposeApp,按系统语言对齐主工程四套文案。
private enum WidgetI18n {
static func t(_ key: String) -> String {
let table: [String: String]
switch Locale.current.language.languageCode?.identifier {
case "en":
table = [
"playing": "Playing",
"paused": "Paused",
"widget_white": "LLMP Music Widget (White)",
"widget_black": "LLMP Music Widget (Black)",
"widget_desc": "LoveLive! music player",
]
case "ja":
table = [
"playing": "放送中",
"paused": "停止しました",
"widget_white": "LLMP 音楽ウィジェット (白)",
"widget_black": "LLMP 音楽ウィジェット (黒)",
"widget_desc": "LoveLive!メディアプレーヤー",
]
case "de":
table = [
"playing": "Wird abgespielt",
"paused": "Pausiert",
"widget_white": "LLMP Musik-Widget (Weiß)",
"widget_black": "LLMP Musik-Widget (Schwarz)",
"widget_desc": "LoveLive! Musikplayer",
]
default:
table = [
"playing": "播放中",
"paused": "已暂停",
"widget_white": "LLMP 音乐组件 (白)",
"widget_black": "LLMP 音乐组件 (黑)",
"widget_desc": "LoveLive!媒体播放器",
]
}
return table[key] ?? key
}
}
#Preview(as: .systemSmall) {
HomeWidgetExampleWhite()
} timeline: {
ExampleEntry(
date: Date(),
widgetFamily: .systemSmall,
songName: "Song Name",
songArtist: "Song Artist",
isFavorite: true,
isPlaying: true,
playText: "playing,paused",
isShutdown: true,
bgColor: "255,255,255",
appIconCharacterId: "default",
isPreview: true
)
}
#Preview(as: .systemMedium) {
HomeWidgetExampleWhite()
} timeline: {
sampleEntry(family: .systemMedium, isPreview: true)
}