function adapt
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "widget_fav_click.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "widget_fav_click 1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "widget_fav_click 2.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "widget_fav_unclick.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "widget_fav_unclick 1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "widget_fav_unclick 2.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "player_play_next.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg height="14"
|
||||
viewBox="0 0 16 14"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m13.7695312 0h.7421876c.6040624 0 1.09375.4896875 1.09375 1.09375v11.5820312c0 .6040625-.4896876 1.09375-1.09375 1.09375h-.7421876c-.6040624 0-1.09375-.4896875-1.09375-1.09375v-11.5820312c0-.6040625.4896876-1.09375 1.09375-1.09375zm-3.2946484 7.54279297-8.24812499 5.92578123c-.63074218.4531446-1.50941406.3091797-1.96257812-.3215625-.17177985-.2391048-.26417969-.5260941-.26417969-.8205078v-10.9190039c0-.77664062.62958984-1.40625 1.40625-1.40625.27772132 0 .54922472.08223197.78027344.23632813l8.25707026 5.50701171c.5025391.33515625.6382227 1.01425782.3030665 1.51679688-.0729441.10937328-.165009.20469996-.2717774.28140625z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 748 B |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "player_play_prev.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg height="14"
|
||||
viewBox="0 0 16 14"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m13.7695312 0h.7421876c.6040624 0 1.09375.4896875 1.09375 1.09375v11.5820312c0 .6040625-.4896876 1.09375-1.09375 1.09375h-.7421876c-.6040624 0-1.09375-.4896875-1.09375-1.09375v-11.5820312c0-.6040625.4896876-1.09375 1.09375-1.09375zm-3.2946484 7.54279297-8.24812499 5.92578123c-.63074218.4531446-1.50941406.3091797-1.96257812-.3215625-.17177985-.2391048-.26417969-.5260941-.26417969-.8205078v-10.9190039c0-.77664062.62958984-1.40625 1.40625-1.40625.27772132 0 .54922472.08223197.78027344.23632813l8.25707026 5.50701171c.5025391.33515625.6382227 1.01425782.3030665 1.51679688-.0729441.10937328-.165009.20469996-.2717774.28140625z"
|
||||
transform="matrix(-1 0 0 1 16 0)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 798 B |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "1.000",
|
||||
"green" : "1.000",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "cd.png",
|
||||
"idiom": "universal",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"filename": "cd 1.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "cd 2.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 210 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "cd.png",
|
||||
"idiom": "universal",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"filename": "cd 1.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "cd 2.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 210 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "logo.png",
|
||||
"idiom": "universal",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"filename": "logo 1.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "logo 2.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -3,7 +3,7 @@ import Foundation
|
||||
|
||||
@available(iOS 17, *)
|
||||
public struct BackgroundIntent: AppIntent {
|
||||
static public var title: LocalizedStringResource = "LLMP Widget Action"
|
||||
static public var title: LocalizedStringResource = "HomeWidget Background Intent"
|
||||
|
||||
@Parameter(title: "Widget URI")
|
||||
var url: URL?
|
||||
@@ -16,7 +16,7 @@ public struct BackgroundIntent: AppIntent {
|
||||
|
||||
public func perform() async throws -> some IntentResult {
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.Name("fromWidgetToHost"),
|
||||
name: Notification.Name("fromWigetToRunner"),
|
||||
object: nil,
|
||||
userInfo: ["url": url?.absoluteString ?? ""]
|
||||
)
|
||||
@@ -24,6 +24,7 @@ public struct BackgroundIntent: AppIntent {
|
||||
}
|
||||
}
|
||||
|
||||
/// 在宿主进程中继续执行,使小组件点击能通知主 App(对齐 Flutter Runner/BackgroundIntent)。
|
||||
@available(iOS 17, *)
|
||||
@available(iOSApplicationExtension, unavailable)
|
||||
extension BackgroundIntent: ForegroundContinuableIntent {}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import SwiftUI
|
||||
//
|
||||
// HomeWidgetExampleBundle.swift
|
||||
// HomeWidgetExample
|
||||
//
|
||||
// Created by hoshizora-rin on 2024/9/19.
|
||||
//
|
||||
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct HomeWidgetExampleBundle: WidgetBundle {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// HomeWidgetExampleControl.swift
|
||||
// HomeWidgetExample
|
||||
//
|
||||
// Created by hoshizora-rin on 2024/9/19.
|
||||
//
|
||||
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
struct HomeWidgetExampleControl: ControlWidget {
|
||||
static let kind: String = "com.zhushenwudi.lovelivemusicplayer.HomeWidgetExample"
|
||||
|
||||
var body: some ControlWidgetConfiguration {
|
||||
AppIntentControlConfiguration(
|
||||
kind: Self.kind,
|
||||
provider: Provider()
|
||||
) { value in
|
||||
ControlWidgetToggle(
|
||||
"Start Timer",
|
||||
isOn: value.isRunning,
|
||||
action: StartTimerIntent(value.name)
|
||||
) { isRunning in
|
||||
Label(isRunning ? "On" : "Off", systemImage: "timer")
|
||||
}
|
||||
}
|
||||
.displayName("Timer")
|
||||
.description("A an example control that runs a timer.")
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
extension HomeWidgetExampleControl {
|
||||
struct Value {
|
||||
var isRunning: Bool
|
||||
var name: String
|
||||
}
|
||||
|
||||
struct Provider: AppIntentControlValueProvider {
|
||||
func previewValue(configuration: TimerConfiguration) -> Value {
|
||||
HomeWidgetExampleControl.Value(isRunning: false, name: configuration.timerName)
|
||||
}
|
||||
|
||||
func currentValue(configuration: TimerConfiguration) async throws -> Value {
|
||||
let isRunning = true // Check if the timer is running
|
||||
return HomeWidgetExampleControl.Value(isRunning: isRunning, name: configuration.timerName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
struct TimerConfiguration: ControlConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "Timer Name Configuration"
|
||||
|
||||
@Parameter(title: "Timer Name", default: "Timer")
|
||||
var timerName: String
|
||||
}
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
struct StartTimerIntent: SetValueIntent {
|
||||
static let title: LocalizedStringResource = "Start a timer"
|
||||
|
||||
@Parameter(title: "Timer Name")
|
||||
var name: String
|
||||
|
||||
@Parameter(title: "Timer is running")
|
||||
var value: Bool
|
||||
|
||||
init() {}
|
||||
|
||||
init(_ name: String) {
|
||||
self.name = name
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
// Start the timer…
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// HomeWidgetExampleLiveActivity.swift
|
||||
// HomeWidgetExample
|
||||
//
|
||||
// Created by hoshizora-rin on 2024/9/19.
|
||||
//
|
||||
|
||||
import ActivityKit
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
struct HomeWidgetExampleAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable {
|
||||
// Dynamic stateful properties about your activity go here!
|
||||
var emoji: String
|
||||
}
|
||||
|
||||
// Fixed non-changing properties about your activity go here!
|
||||
var name: String
|
||||
}
|
||||
|
||||
@available(iOS 16.2, *)
|
||||
struct HomeWidgetExampleLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: HomeWidgetExampleAttributes.self) { context in
|
||||
// Lock screen/banner UI goes here
|
||||
VStack {
|
||||
Text("Hello \(context.state.emoji)")
|
||||
}
|
||||
.activityBackgroundTint(Color.cyan)
|
||||
.activitySystemActionForegroundColor(Color.black)
|
||||
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
// Expanded UI goes here. Compose the expanded UI through
|
||||
// various regions, like leading/trailing/center/bottom
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Text("Leading")
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
Text("Trailing")
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
Text("Bottom \(context.state.emoji)")
|
||||
// more content
|
||||
}
|
||||
} compactLeading: {
|
||||
Text("L")
|
||||
} compactTrailing: {
|
||||
Text("T \(context.state.emoji)")
|
||||
} minimal: {
|
||||
Text(context.state.emoji)
|
||||
}
|
||||
.widgetURL(URL(string: "http://www.apple.com"))
|
||||
.keylineTint(Color.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeWidgetExampleAttributes {
|
||||
fileprivate static var preview: HomeWidgetExampleAttributes {
|
||||
HomeWidgetExampleAttributes(name: "World")
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeWidgetExampleAttributes.ContentState {
|
||||
fileprivate static var smiley: HomeWidgetExampleAttributes.ContentState {
|
||||
HomeWidgetExampleAttributes.ContentState(emoji: "😀")
|
||||
}
|
||||
|
||||
fileprivate static var starEyes: HomeWidgetExampleAttributes.ContentState {
|
||||
HomeWidgetExampleAttributes.ContentState(emoji: "🤩")
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,24 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>LLMP</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.2.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.top.zhushenwudi.llmp</string>
|
||||
<string>group.com.zhushenwudi.lovelivemusicplayer</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||