function adapt

This commit is contained in:
2026-08-30 19:36:25 +08:00
parent bea5ead33d
commit 560f3cbac3
450 changed files with 21193 additions and 8334 deletions
+371 -133
View File
@@ -1,48 +1,41 @@
//
// BackgroundIntent.swift
// Runner
//
// Created by hoshizora-rin on 2024/9/19.
//
import SwiftUI
import WidgetKit
private let widgetGroupId = "group.top.zhushenwudi.llmp"
private var lastLyricLine1 = ""
private var lastLyricLine2 = ""
private let widgetGroupId = "group.com.zhushenwudi.lovelivemusicplayer"
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> ExampleEntry {
ExampleEntry(
date: Date(),
widgetFamily: context.family,
songName: "",
songArtist: "",
isFavorite: false,
isPlaying: false,
playText: "",
lyricLine1: "",
lyricLine2: "",
currentLine: 1,
isShutdown: true,
bgColor: "255,255,255"
)
sampleEntry(family: context.family, isPreview: true)
}
func getSnapshot(in context: Context, completion: @escaping (ExampleEntry) -> Void) {
completion(readEntry(family: context.family))
// 与 Flutter 一样读 App Group;isPreview 仅用于避免 Button(intent) 把添加页打成鱼骨。
completion(readEntry(family: context.family, isPreview: true))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
completion(Timeline(entries: [readEntry(family: context.family)], policy: .atEnd))
// 数据由主应用写入 App Group 后主动 reload;不要让单条当前时间的
// timeline 以 .atEnd 反复申请无意义的系统刷新预算。
completion(Timeline(entries: [readEntry(family: context.family, isPreview: false)], policy: .never))
}
private func readEntry(family: WidgetFamily) -> ExampleEntry {
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") ?? "播放中,已暂停"
if let line1 = data?.string(forKey: "lyricLine1") { lastLyricLine1 = line1 }
if let line2 = data?.string(forKey: "lyricLine2") { lastLyricLine2 = line2 }
let currentLine = data?.integer(forKey: "currentLine") ?? 1
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,
@@ -51,15 +44,28 @@ struct Provider: TimelineProvider {
isFavorite: isFavorite,
isPlaying: isPlaying,
playText: playText,
lyricLine1: lastLyricLine1,
lyricLine2: lastLyricLine2,
currentLine: currentLine,
isShutdown: isShutdown,
bgColor: bgColor
bgColor: bgColor,
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",
isPreview: isPreview
)
}
struct ExampleEntry: TimelineEntry {
let date: Date
let widgetFamily: WidgetFamily
@@ -68,168 +74,303 @@ struct ExampleEntry: TimelineEntry {
let isFavorite: Bool
let isPlaying: Bool
let playText: String
let lyricLine1: String
let lyricLine2: String
let currentLine: Int
let isShutdown: Bool
let bgColor: String
let isPreview: Bool
}
struct HomeWidgetExampleEntryView: View {
var entry: Provider.Entry
let isWhiteBackground: Bool
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
}
var body: some View {
GeometryReader { geometry in
let canvas = familyCanvasSize
let cdSize: CGFloat = 140
let coverSize = cdSize * 0.55
let coverAndCdDiffSize = (cdSize - coverSize) / 2
let playButtonAndCdDiffSize = (cdSize - 34) / 2
let offsetX = calcCdOffsetX(entry: entry, geometry: geometry)
let offsetY = calcCdOffsetY(entry: entry, geometry: geometry)
let lyricMaxWidth = geometry.size.width - cdSize
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,
]),
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(225)
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)
}
if #available(iOSApplicationExtension 17, *) {
Button(intent: BackgroundIntent(url: URL(string: "homeWidgetExample://toggle_play"))) {
Image(systemName: entry.isPlaying ? "pause.fill" : "play.fill")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
.foregroundColor(.white)
}
.buttonStyle(.plain)
.padding(8)
.offset(x: -playButtonAndCdDiffSize + offsetX, y: playButtonAndCdDiffSize + offsetY)
}
playButton(
offsetX: -playButtonAndCdDiffSize + offsetX,
offsetY: playButtonAndCdDiffSize + offsetY
)
}
.contentTransition(.interpolate)
.animation(.smooth(duration: 0.45), value: entry.isPlaying)
// 绘制左侧一列歌曲信息
VStack(alignment: .leading) {
Image("logo")
.resizable()
.scaledToFit()
.frame(width: 30, height: 30)
.clipped()
.padding(.bottom, 5)
if entry.widgetFamily == .systemMedium {
Text(entry.lyricLine1)
.font(.system(size: 15))
.foregroundColor(lyricColor(for: entry.currentLine, active: 1))
.frame(maxWidth: entry.isPlaying ? .infinity : lyricMaxWidth, alignment: .leading)
Text(entry.lyricLine2)
.font(.system(size: 15))
.foregroundColor(lyricColor(for: entry.currentLine, active: 2))
.frame(maxWidth: entry.isPlaying ? .infinity : lyricMaxWidth, alignment: .leading)
}
Spacer()
Text(playText).font(.system(size: 10)).bold().foregroundColor(.gray)
Text(entry.songName).font(.system(size: 12)).bold()
.foregroundColor(isWhiteBackground ? .black : .white)
Text(entry.songArtist).font(.system(size: 12, weight: .light)).foregroundColor(.gray)
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()
if #available(iOSApplicationExtension 17, *) {
Button(intent: BackgroundIntent(url: URL(string: "homeWidgetExample://toggle_love"))) {
Image(entry.isFavorite ? "FavoriteClick" : "FavoriteUnClick")
.resizable()
.scaledToFit()
.frame(width: 34, height: 34)
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .bottomTrailing)
.padding(8)
}
}
if entry.isShutdown {
Link(destination: URL(string: "llmp://")!) { Color.clear }
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)
}
}
private func lyricColor(for currentLine: Int, active: Int) -> Color {
let highlighted = currentLine == -1 || currentLine == active
if highlighted {
return isWhiteBackground ? .black : .white
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, geometry: GeometryProxy) -> CGFloat {
func calcCdOffsetX(entry: ExampleEntry, canvas: CGSize) -> CGFloat {
var offset = 0.0
switch entry.widgetFamily {
case .systemSmall: return (geometry.size.width - 70) / 2
case .systemMedium: return (geometry.size.width - 152) / 2 + (entry.isPlaying ? 40 : 0)
default: return 0
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, geometry: GeometryProxy) -> CGFloat {
func calcCdOffsetY(entry: ExampleEntry, canvas: CGSize) -> CGFloat {
var offset = 0.0
switch entry.widgetFamily {
case .systemSmall: return (70 - geometry.size.height) / 2
case .systemMedium: return (152 - geometry.size.height) / 2 - (entry.isPlaying ? 40 : 0)
default: return 0
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 {
let parts = entry.bgColor.split(separator: ",").compactMap { Double($0) }
let rgb = parts.count >= 3 ? parts : [230, 215, 210]
return Color(red: rgb[0]/255, green: rgb[1]/255, blue: rgb[2]/255)
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? {
guard let fileURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: widgetGroupId)?
.appendingPathComponent("sharedImage.png") else { return nil }
// 没歌/关机/添加页预览都不该读 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 {
let parts = entry.playText.split(separator: ",").map(String.init)
let playing = parts.first ?? "播放中"
let paused = parts.count > 1 ? parts[1] : "已暂停"
return entry.isPlaying ? playing : paused
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
}
}
}
@@ -237,14 +378,13 @@ struct HomeWidgetExampleWhite: Widget {
let kind = "HomeWidgetExampleWhite"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
HomeWidgetExampleEntryView(entry: entry, isWhiteBackground: true)
.containerBackground(.fill.tertiary, for: .widget)
StaticConfiguration(kind: kind, provider: Provider()) { (entry) in
widgetRoot(entry: entry, isWhiteBackground: true)
}
.configurationDisplayName("LLMP音乐组件")
.contentMarginsDisabled()
.supportedFamilies([.systemSmall, .systemMedium])
.description("LoveLive!媒体播放器")
.configurationDisplayName(WidgetI18n.t("widget_white"))
.contentMarginsDisabled()
.supportedFamilies([.systemSmall, .systemMedium])
.description(WidgetI18n.t("widget_desc"))
}
}
@@ -252,13 +392,111 @@ struct HomeWidgetExampleBlack: Widget {
let kind = "HomeWidgetExampleBlack"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
HomeWidgetExampleEntryView(entry: entry, isWhiteBackground: false)
.containerBackground(.fill.tertiary, for: .widget)
StaticConfiguration(kind: kind, provider: Provider()) { (entry) in
widgetRoot(entry: entry, isWhiteBackground: false)
}
.configurationDisplayName("LLMP音乐组件")
.contentMarginsDisabled()
.supportedFamilies([.systemSmall, .systemMedium])
.description("LoveLive!媒体播放器")
.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",
isPreview: true
)
}
#Preview(as: .systemMedium) {
HomeWidgetExampleWhite()
} timeline: {
sampleEntry(family: .systemMedium, isPreview: true)
}