503 lines
18 KiB
Swift
503 lines
18 KiB
Swift
//
|
||
// 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,
|
||
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
|
||
let songName: String
|
||
let songArtist: String
|
||
let isFavorite: Bool
|
||
let isPlaying: Bool
|
||
let playText: String
|
||
let isShutdown: Bool
|
||
let bgColor: 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
|
||
}
|
||
|
||
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) {
|
||
Image("logo")
|
||
.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",
|
||
isPreview: true
|
||
)
|
||
}
|
||
|
||
#Preview(as: .systemMedium) {
|
||
HomeWidgetExampleWhite()
|
||
} timeline: {
|
||
sampleEntry(family: .systemMedium, isPreview: true)
|
||
}
|