// // 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) -> 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) }