initial commit

This commit is contained in:
2026-08-02 20:00:21 +08:00
commit bea5ead33d
295 changed files with 20101 additions and 0 deletions

71
iosApp/README.md Normal file
View File

@@ -0,0 +1,71 @@
# iOS App (Xcode host)
Kotlin Multiplatform Compose UI runs inside a native iOS shell. Sources here are **Xcode-ready scaffolding**; building requires **macOS + Xcode**.
## App Group migration
| Old (Flutter) | New (KMP) |
|---|---|
| `group.com.zhushenwudi.lovelivemusicplayer` | **`group.top.zhushenwudi.llmp`** |
Kotlin writes widget / PiP / CarPlay keys via `WidgetDataWriter` (`shared/.../IosPlatformHooks.kt`). Swift Widget + CarPlay read the same suite.
## Build steps (macOS)
```bash
cd LoveLiveMusicPlayerKMP
./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64
# or iosArm64 for device
```
1. Open Xcode → **File → New → Project → App** (or add targets to an existing workspace).
2. Set bundle ID **`top.zhushenwudi.llmp`** (main app).
3. Copy `iosApp/iosApp/*` into the app target; add `WidgetExtension/*` as a Widget Extension target (`top.zhushenwudi.llmp.widget`).
4. Link the Gradle-produced **`ComposeApp.framework`** (and embedded **`Shared.framework`** if split).
5. Signing: enable App Groups `group.top.zhushenwudi.llmp` on **both** app + widget targets (`iosApp.entitlements`, `WidgetExtension.entitlements`).
6. CarPlay: entitlements include `com.apple.developer.carplay-audio` and `com.apple.developer.playable-content`**requires Apple Developer approval** before TestFlight/App Store CarPlay works.
7. Add **`black.mp4`** (minimal black video) to the app bundle for PiP — copy from Flutter `ios/Runner` if present, or generate a 1-frame H.264 clip.
8. Copy widget image assets from Flutter `ios/HomeWidgetExample/Assets.xcassets` (`logo`, `cd_white`, `cd_black`, favorites).
Entry point:
```swift
MainViewControllerKt.MainViewController() // ComposeApp framework
```
`MainViewController.kt` calls `installIosPlatformServices()` on startup.
## Targets & layout
```
iosApp/
iosApp/
AppDelegate.swift — lifecycle, widget shutdown, notification wiring
SceneDelegate.swift — UIWindow + PiP notification handlers
ContentView.swift — SwiftUI host for Compose UIViewController
HostBridge.swift — share sheet, deep links, widget/CarPlay → host
Info.plist
iosApp.entitlements
CarPlay/CarPlaySceneDelegate.swift
pip/ — desktop lyric PiP (from Flutter Runner/pip)
WidgetExtension/ — White/Black home widgets (from Flutter HomeWidgetExample)
```
## Kotlin ↔ Swift bridge (remaining macOS work)
| Feature | Kotlin side | Swift side | Gap |
|---|---|---|---|
| Playback | `PlayerController.ios.kt` AVPlayer + MPRemoteCommandCenter | — | Done in Kotlin |
| Home widget data | `IosHomeWidgetController` → UserDefaults | Widget reads keys | Wire widget tap → Kotlin player (export callback or observe `llmpHostTogglePlay`) |
| PiP lyrics | `IosDesktopLyricController` posts notifications | `PipScreenManager` | Done via NotificationCenter |
| Share | `IosShareBridge` posts `llmpShareRequest` | `HostBridge.presentShare` | Done |
| Deep link `llmp://` | `IosDeepLinkHandler` | `SceneDelegate` + `HostBridge` | Handle payload in Kotlin UI layer |
| CarPlay | `IosCarPlayHook.refreshCatalog()` writes JSON | `CarPlaySceneDelegate` | Play/group/menu callbacks need Kotlin export |
## CarPlay note
`CarPlaySceneDelegate` reads `carplay_*` JSON from the App Group. Full parity with Flutter `flutter_carplay` needs exported Kotlin handlers for `onPlay(musicId)` — until then, NotificationCenter events (`llmpHostCarPlayPlay`, etc.) are posted for the host to forward.
## Permissions
Add usage strings in Xcode as needed (camera for QR, photo library, etc.) — see Flutter `ios/Runner/Info.plist` for reference.

View File

@@ -0,0 +1,29 @@
import AppIntents
import Foundation
@available(iOS 17, *)
public struct BackgroundIntent: AppIntent {
static public var title: LocalizedStringResource = "LLMP Widget Action"
@Parameter(title: "Widget URI")
var url: URL?
public init() {}
public init(url: URL?) {
self.url = url
}
public func perform() async throws -> some IntentResult {
NotificationCenter.default.post(
name: Notification.Name("fromWidgetToHost"),
object: nil,
userInfo: ["url": url?.absoluteString ?? ""]
)
return .result()
}
}
@available(iOS 17, *)
@available(iOSApplicationExtension, unavailable)
extension BackgroundIntent: ForegroundContinuableIntent {}

View File

@@ -0,0 +1,264 @@
import SwiftUI
import WidgetKit
private let widgetGroupId = "group.top.zhushenwudi.llmp"
private var lastLyricLine1 = ""
private var lastLyricLine2 = ""
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"
)
}
func getSnapshot(in context: Context, completion: @escaping (ExampleEntry) -> Void) {
completion(readEntry(family: context.family))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
completion(Timeline(entries: [readEntry(family: context.family)], policy: .atEnd))
}
private func readEntry(family: WidgetFamily) -> 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 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,
lyricLine1: lastLyricLine1,
lyricLine2: lastLyricLine2,
currentLine: currentLine,
isShutdown: isShutdown,
bgColor: bgColor
)
}
}
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 lyricLine1: String
let lyricLine2: String
let currentLine: Int
let isShutdown: Bool
let bgColor: String
}
struct HomeWidgetExampleEntryView: View {
var entry: Provider.Entry
let isWhiteBackground: Bool
var body: some View {
GeometryReader { geometry in
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 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(225)
)
)
} else {
Rectangle()
.fill(Color(red: 49/255, green: 49/255, blue: 49/255))
}
ZStack(alignment: .topTrailing) {
Image(isWhiteBackground ? "cd_white" : "cd_black")
.resizable()
.scaledToFit()
.frame(width: cdSize, height: cdSize)
.offset(x: offsetX, y: offsetY)
if let image = loadImageFromFile() {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(width: coverSize, height: coverSize)
.clipShape(Circle())
.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)
}
}
VStack(alignment: .leading) {
Image("logo")
.resizable()
.scaledToFit()
.frame(width: 30, height: 30)
.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)
}
.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 }
}
}
}
}
private func lyricColor(for currentLine: Int, active: Int) -> Color {
let highlighted = currentLine == -1 || currentLine == active
if highlighted {
return isWhiteBackground ? .black : .white
}
return Color(red: 0.7, green: 0.7, blue: 0.7, opacity: 0.7)
}
func calcCdOffsetX(entry: ExampleEntry, geometry: GeometryProxy) -> CGFloat {
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
}
}
func calcCdOffsetY(entry: ExampleEntry, geometry: GeometryProxy) -> CGFloat {
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
}
}
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)
}
func loadImageFromFile() -> UIImage? {
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
}
}
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)
}
.configurationDisplayName("LLMP音乐组件")
.contentMarginsDisabled()
.supportedFamilies([.systemSmall, .systemMedium])
.description("LoveLive!媒体播放器")
}
}
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)
}
.configurationDisplayName("LLMP音乐组件")
.contentMarginsDisabled()
.supportedFamilies([.systemSmall, .systemMedium])
.description("LoveLive!媒体播放器")
}
}

View File

@@ -0,0 +1,10 @@
import SwiftUI
import WidgetKit
@main
struct HomeWidgetExampleBundle: WidgetBundle {
var body: some Widget {
HomeWidgetExampleWhite()
HomeWidgetExampleBlack()
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleShortVersionString</key>
<string>0.2.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.top.zhushenwudi.llmp</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,65 @@
import UIKit
let widgetGroupId = "group.top.zhushenwudi.llmp"
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
let userDefaults = UserDefaults(suiteName: widgetGroupId)
userDefaults?.set(false, forKey: "isShutdown")
registerHostNotifications()
return true
}
func applicationWillTerminate(_ application: UIApplication) {
let userDefaults = UserDefaults(suiteName: widgetGroupId)
userDefaults?.set(false, forKey: "isPlaying")
userDefaults?.set("", forKey: "curJpLrc")
userDefaults?.set("", forKey: "nextJpLrc")
userDefaults?.set(true, forKey: "isShutdown")
}
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
if connectingSceneSession.role == UISceneSession.Role.carTemplateApplication {
return UISceneConfiguration(
name: "CarPlay Configuration",
sessionRole: connectingSceneSession.role
)
}
return UISceneConfiguration(
name: "Default Configuration",
sessionRole: connectingSceneSession.role
)
}
private func registerHostNotifications() {
NotificationCenter.default.addObserver(
forName: Notification.Name("llmpShareRequest"),
object: nil,
queue: .main
) { notification in
HostBridge.shared.presentShare(from: notification.userInfo)
}
NotificationCenter.default.addObserver(
forName: Notification.Name("fromWidgetToHost"),
object: nil,
queue: .main
) { notification in
HostBridge.shared.handleWidgetAction(from: notification.userInfo)
}
NotificationCenter.default.addObserver(
forName: Notification.Name("llmpCarPlayAction"),
object: nil,
queue: .main
) { notification in
HostBridge.shared.handleCarPlayAction(from: notification.userInfo)
}
}
}

View File

@@ -0,0 +1,201 @@
import CarPlay
import Foundation
/// CarPlay UI reading catalog JSON from App Group UserDefaults (written by Kotlin [WidgetDataWriter]).
/// Play actions post NotificationCenter events; wire to Kotlin via exported framework callbacks on macOS.
class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
private var interfaceController: CPInterfaceController?
private let defaults = UserDefaults(suiteName: widgetGroupId)
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController
) {
self.interfaceController = interfaceController
rebuildTabs()
NotificationCenter.default.addObserver(
self,
selector: #selector(onCatalogRefresh),
name: Notification.Name("llmpCarPlayAction"),
object: nil
)
}
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didDisconnectInterfaceController interfaceController: CPInterfaceController
) {
self.interfaceController = nil
NotificationCenter.default.removeObserver(self)
}
@objc private func onCatalogRefresh() {
rebuildTabs()
}
private func rebuildTabs() {
let musicTab = CPTabBarTemplate(templates: [
buildMusicTab(),
buildAlbumTab(),
buildMineTab(),
])
interfaceController?.setRootTemplate(musicTab, animated: true, completion: nil)
}
private func buildMusicTab() -> CPListTemplate {
let nowPlaying = defaults?.string(forKey: "carplay_nowPlaying") ?? "正在播放"
let groups = decodeGroups()
var sections: [CPListSection] = []
let nowItem = CPListItem(text: nowPlaying, detailText: "Now Playing")
nowItem.handler = { _, completion in
NotificationCenter.default.post(
name: Notification.Name("llmpHostTogglePlay"),
object: nil
)
completion()
}
sections.append(CPListSection(items: [nowItem]))
let groupItems = groups.map { group -> CPListItem in
let item = CPListItem(text: group.displayName, detailText: group.detail)
item.handler = { [weak self] _, completion in
self?.pushSongs(for: group.displayName, title: group.displayName)
completion()
}
return item
}
if !groupItems.isEmpty {
sections.append(CPListSection(items: groupItems, header: "分组", sectionIndexTitle: nil))
}
let loveSongs = decodeLoveSongs()
let loveItems = loveSongs.map { song -> CPListItem in
let item = CPListItem(text: song.title, detailText: song.artist)
item.handler = { _, completion in
NotificationCenter.default.post(
name: Notification.Name("llmpHostCarPlayPlay"),
object: nil,
userInfo: ["musicId": song.musicId]
)
completion()
}
return item
}
if !loveItems.isEmpty {
sections.append(CPListSection(items: loveItems, header: "收藏", sectionIndexTitle: nil))
}
let template = CPListTemplate(title: "音乐", sections: sections)
template.tabImage = UIImage(systemName: "music.note")
return template
}
private func buildAlbumTab() -> CPListTemplate {
let groups = decodeGroups()
let items = groups.map { group -> CPListItem in
let item = CPListItem(text: group.displayName, detailText: "专辑")
item.handler = { [weak self] _, completion in
self?.pushAlbums(for: group.displayName)
completion()
}
return item
}
let template = CPListTemplate(
title: "专辑",
sections: [CPListSection(items: items)]
)
template.tabImage = UIImage(systemName: "square.stack")
return template
}
private func buildMineTab() -> CPListTemplate {
let menus = decodeMenus()
let items = menus.map { menu -> CPListItem in
let item = CPListItem(text: menu.name, detailText: "\(menu.count)")
item.handler = { [weak self] _, completion in
self?.pushMenuSongs(menuId: menu.menuId, title: menu.name)
completion()
}
return item
}
let template = CPListTemplate(
title: "我的",
sections: [CPListSection(items: items)]
)
template.tabImage = UIImage(systemName: "person")
return template
}
private func pushSongs(for group: String, title: String) {
// Full song lists per group require additional JSON keys from Kotlin catalog refresh.
let item = CPListItem(text: "播放 \(title)", detailText: nil)
item.handler = { _, completion in
NotificationCenter.default.post(
name: Notification.Name("llmpCarPlayAction"),
object: nil,
userInfo: ["action": "playGroup", "group": group]
)
completion()
}
let template = CPListTemplate(title: title, sections: [CPListSection(items: [item])])
interfaceController?.pushTemplate(template, animated: true, completion: nil)
}
private func pushAlbums(for group: String) {
let item = CPListItem(text: "浏览 \(group) 专辑", detailText: nil)
let template = CPListTemplate(title: group, sections: [CPListSection(items: [item])])
interfaceController?.pushTemplate(template, animated: true, completion: nil)
}
private func pushMenuSongs(menuId: Int64, title: String) {
let item = CPListItem(text: "播放歌单", detailText: title)
item.handler = { _, completion in
NotificationCenter.default.post(
name: Notification.Name("llmpCarPlayAction"),
object: nil,
userInfo: ["action": "playMenu", "menuId": String(menuId)]
)
completion()
}
let template = CPListTemplate(title: title, sections: [CPListSection(items: [item])])
interfaceController?.pushTemplate(template, animated: true, completion: nil)
}
// MARK: - JSON decode helpers
private struct CarPlayGroupDTO: Decodable {
let displayName: String
let detail: String
}
private struct CarPlaySongDTO: Decodable {
let musicId: String
let title: String
let artist: String?
}
private struct CarPlayMenuDTO: Decodable {
let menuId: Int64
let name: String
let count: Int
}
private func decodeGroups() -> [CarPlayGroupDTO] {
decode(key: "carplay_groups") ?? []
}
private func decodeLoveSongs() -> [CarPlaySongDTO] {
decode(key: "carplay_love") ?? []
}
private func decodeMenus() -> [CarPlayMenuDTO] {
decode(key: "carplay_menus") ?? []
}
private func decode<T: Decodable>(key: String) -> T? {
guard let json = defaults?.string(forKey: key),
let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}
}

View File

@@ -0,0 +1,17 @@
import SwiftUI
import ComposeApp
struct ContentView: View {
var body: some View {
ComposeView()
.ignoresSafeArea()
}
}
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.MainViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

View File

@@ -0,0 +1,61 @@
import UIKit
/// Bridges Swift host events to Kotlin (via UserDefaults + notifications).
/// Full bidirectional callback requires exporting Kotlin handlers through the ComposeApp framework.
final class HostBridge {
static let shared = HostBridge()
func handleDeepLink(_ url: String) {
UserDefaults(suiteName: widgetGroupId)?.set(url, forKey: "pendingDeepLink")
NotificationCenter.default.post(
name: Notification.Name("llmpDeepLink"),
object: nil,
userInfo: ["url": url]
)
}
func handleWidgetAction(from userInfo: [AnyHashable: Any]?) {
guard let url = userInfo?["url"] as? String else { return }
if url.contains("toggle_play") {
NotificationCenter.default.post(name: Notification.Name("llmpHostTogglePlay"), object: nil)
} else if url.contains("toggle_love") {
NotificationCenter.default.post(name: Notification.Name("llmpHostToggleLove"), object: nil)
}
}
func handleCarPlayAction(from userInfo: [AnyHashable: Any]?) {
guard let action = userInfo?["action"] as? String else { return }
if action == "play", let musicId = userInfo?["musicId"] as? String {
NotificationCenter.default.post(
name: Notification.Name("llmpHostCarPlayPlay"),
object: nil,
userInfo: ["musicId": musicId]
)
}
}
func presentShare(from userInfo: [AnyHashable: Any]?) {
guard let root = topViewController() else { return }
var items: [Any] = []
if let text = userInfo?["text"] as? String, !text.isEmpty {
items.append(text)
}
if let urlString = userInfo?["url"] as? String, let url = URL(string: urlString) {
items.append(url)
}
guard !items.isEmpty else { return }
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
root.present(controller, animated: true)
}
private func topViewController() -> UIViewController? {
let scenes = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
let window = scenes.flatMap { $0.windows }.first { $0.isKeyWindow }
var top = window?.rootViewController
while let presented = top?.presentedViewController {
top = presented
}
return top
}
}

81
iosApp/iosApp/Info.plist Normal file
View File

@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<!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>LLMP</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.2.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>llmp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>llmp</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>homeWidget</string>
<key>CFBundleURLSchemes</key>
<array>
<string>homeWidgetExample</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>CarPlay Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).CarPlaySceneDelegate</string>
</dict>
</array>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
</dict>
</array>
</dict>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchScreen</key>
<dict/>
</dict>
</plist>

View File

@@ -0,0 +1,67 @@
import UIKit
import SwiftUI
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let pipManager = PipScreenManager()
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: ContentView())
self.window = window
window.makeKeyAndVisible()
if let url = connectionOptions.urlContexts.first?.url {
HostBridge.shared.handleDeepLink(url.absoluteString)
}
registerSceneObservers()
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
URLContexts.first.map { HostBridge.shared.handleDeepLink($0.url.absoluteString) }
}
private func registerSceneObservers() {
NotificationCenter.default.addObserver(
forName: Notification.Name("llmpPiPRequest"),
object: nil,
queue: .main
) { [weak self] notification in
guard let self else { return }
let action = notification.userInfo?["action"] as? String ?? ""
switch action {
case "show", "enter":
if let root = self.window?.rootViewController?.view {
self.pipManager.addScreenView(on: root)
self.pipManager.manualChangePicInPic(needStart: true)
}
case "hide":
self.pipManager.manualChangePicInPic(needStart: false)
self.pipManager.removeScreenView()
default:
break
}
}
NotificationCenter.default.addObserver(
forName: Notification.Name("llmpDesktopLyricUpdate"),
object: nil,
queue: .main
) { [weak self] notification in
let line1 = notification.userInfo?["line1"] as? String
let line2 = notification.userInfo?["line2"] as? String
let currentLine = Int(notification.userInfo?["currentLine"] as? String ?? "-1") ?? -1
self?.pipManager.updatePipScreenView(
lyricLine1: line1,
lyricLine2: line2,
currentLine: currentLine
)
}
}
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.carplay-audio</key>
<true/>
<key>com.apple.developer.playable-content</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.top.zhushenwudi.llmp</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,50 @@
import UIKit
class GradientLayer: CAGradientLayer, CAAnimationDelegate {
let colorOne = UIColor(red: 0.24, green: 0.67, blue: 0.97, alpha: 1).cgColor
let colorTwo = UIColor(red: 0.81, green: 0.03, blue: 0.33, alpha: 1).cgColor
let colorThree = UIColor(red: 0.96, green: 0.71, blue: 0.20, alpha: 1).cgColor
let gradientChangeAnimation = CABasicAnimation(keyPath: "colors")
var currentGradient = 0
var gradientSet = [[CGColor]]()
func animateGradient() {
removeAnimation()
gradientChangeAnimation.fromValue = gradientSet[currentGradient]
colors = gradientSet[currentGradient]
if currentGradient < gradientSet.count - 1 {
currentGradient += 1
} else {
currentGradient = 0
}
gradientChangeAnimation.duration = 5.0
gradientChangeAnimation.toValue = gradientSet[currentGradient]
gradientChangeAnimation.fillMode = .forwards
gradientChangeAnimation.isRemovedOnCompletion = false
gradientChangeAnimation.delegate = self
add(gradientChangeAnimation, forKey: "moveAnimation")
}
func createGradientView() {
gradientSet.append([colorOne, colorTwo])
gradientSet.append([colorTwo, colorThree])
gradientSet.append([colorThree, colorOne])
colors = gradientSet[currentGradient]
startPoint = CGPoint(x: 0, y: 0)
endPoint = CGPoint(x: 1, y: 1)
drawsAsynchronously = true
}
func removeAnimation() {
removeAnimation(forKey: "moveAnimation")
}
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if flag {
animateGradient()
}
}
}

View File

@@ -0,0 +1,162 @@
import AVKit
import UIKit
@MainActor
class PipScreenManager: NSObject, @preconcurrency AVPictureInPictureControllerDelegate {
private var firstWindow: UIWindow?
private var pipController: AVPictureInPictureController?
private var screenView = PipScreenView()
private var picInPicView = PipScreenView()
private var isOpenPicInPic = true
private var suspectedWindows: [UIWindow] = []
override init() {
super.init()
isOpenPicInPic = true
}
deinit {
NotificationCenter.default.removeObserver(self)
}
func addScreenView(on view: UIView) {
screenView.removeFromSuperview()
view.addSubview(screenView)
NSLayoutConstraint.activate([
screenView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 30),
screenView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -30),
screenView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 50),
screenView.heightAnchor.constraint(equalToConstant: 50),
])
view.layoutIfNeeded()
preparePicInPic(on: screenView)
}
func removeScreenView() {
screenView.removeFromSuperview()
NotificationCenter.default.removeObserver(self)
suspectedWindows.removeAll()
}
private func preparePicInPic(on view: UIView) {
do {
try AVAudioSession.sharedInstance().setCategory(.playback)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("Error setting up audio session: \(error)")
}
guard let url = Bundle.main.url(forResource: "black", withExtension: "mp4") else {
print("Missing black.mp4 in app bundle — add from Flutter ios/Runner or create a 1-frame black video.")
return
}
let item = AVPlayerItem(asset: AVAsset(url: url))
let player = AVPlayer(playerItem: item)
let playerLayer = AVPlayerLayer(player: player)
playerLayer.frame = CGRect(x: 0, y: 0, width: 0.1, height: 0.1)
playerLayer.backgroundColor = UIColor.black.cgColor
playerLayer.videoGravity = .resizeAspectFill
view.layer.insertSublayer(playerLayer, at: 0)
pipController = AVPictureInPictureController(playerLayer: playerLayer)
if #available(iOS 14.0, *) {
pipController?.requiresLinearPlayback = true
}
pipController?.setValue(1, forKey: "controlsStyle")
pipController?.delegate = self
if #available(iOS 14.2, *) {
pipController?.canStartPictureInPictureAutomaticallyFromInline = true
}
NotificationCenter.default.addObserver(
self,
selector: #selector(playerItemDidReachEnd),
name: .AVPlayerItemDidPlayToEndTime,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidBecomeVisible),
name: UIWindow.didBecomeVisibleNotification,
object: nil
)
}
func picInPicAutoOpen(_ isOpen: Bool?) {
if let isOpen { isOpenPicInPic = isOpen }
let isPlayingNow = pipController?.playerLayer.player?.timeControlStatus == .playing
if isOpenPicInPic {
if !isPlayingNow { pipController?.playerLayer.player?.play() }
} else if isPlayingNow {
pipController?.playerLayer.player?.pause()
}
}
@objc func updatePipScreenView(lyricLine1: String?, lyricLine2: String?, currentLine: Int) {
screenView.updateContent(lyricLine1: lyricLine1, lyricLine2: lyricLine2, currentLine: currentLine)
picInPicView.updateContent(lyricLine1: lyricLine1, lyricLine2: lyricLine2, currentLine: currentLine)
}
@objc private func windowDidBecomeVisible(notification: Notification) {
guard let object = notification.object else { return }
if String(describing: type(of: object)) == "PGHostedWindow" {
firstWindow = notification.object as? UIWindow
NotificationCenter.default.removeObserver(self, name: UIWindow.didBecomeVisibleNotification, object: nil)
} else if let targetWindow = object as? UIWindow {
suspectedWindows.append(targetWindow)
}
}
private func filterTargetWindow() -> UIWindow? {
for window in suspectedWindows where String(describing: type(of: window)) == "PGHostedWindow" {
return window
}
for window in suspectedWindows where window.windowLevel == UIWindow.Level(rawValue: -10_000_000) {
return window
}
for window in suspectedWindows where window.frame.size.height < 300 {
return window
}
return suspectedWindows.first
}
@objc private func playerItemDidReachEnd(notification: Notification) {
pipController?.playerLayer.player?.seek(to: .zero)
pipController?.playerLayer.player?.play()
}
func manualChangePicInPic(needStart: Bool) {
if needStart {
pipController?.playerLayer.player?.play()
if pipController?.isPictureInPictureActive == false {
pipController?.startPictureInPicture()
}
} else {
if pipController?.isPictureInPictureActive == true {
pipController?.stopPictureInPicture()
}
pipController?.playerLayer.player?.pause()
}
}
func pictureInPictureControllerDidStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
if firstWindow == nil {
firstWindow = filterTargetWindow()
suspectedWindows.removeAll()
}
if let firstWindow {
firstWindow.addSubview(picInPicView)
picInPicView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
picInPicView.topAnchor.constraint(equalTo: firstWindow.topAnchor),
picInPicView.leadingAnchor.constraint(equalTo: firstWindow.leadingAnchor),
picInPicView.trailingAnchor.constraint(equalTo: firstWindow.trailingAnchor),
picInPicView.bottomAnchor.constraint(equalTo: firstWindow.bottomAnchor),
])
}
picInPicView.gradientLayer.animateGradient()
}
}

View File

@@ -0,0 +1,110 @@
import UIKit
class PipScreenView: UIView {
private let bgView = UIView()
private var lyricLine1 = UITextView()
private var lyricLine2 = UITextView()
private var noLyricLine = UITextView()
private var icon = UIImageView(image: UIImage(named: "AppIcon"))
let gradientLayer = GradientLayer()
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupView()
}
private func setupView() {
backgroundColor = .clear
layer.cornerRadius = 8
layer.masksToBounds = true
translatesAutoresizingMaskIntoConstraints = false
bgView.translatesAutoresizingMaskIntoConstraints = false
addSubview(bgView)
gradientLayer.createGradientView()
layer.insertSublayer(gradientLayer, at: 0)
for line in [lyricLine1, lyricLine2, noLyricLine] {
line.backgroundColor = .clear
line.textColor = .white
line.translatesAutoresizingMaskIntoConstraints = false
line.isScrollEnabled = false
line.isEditable = false
}
noLyricLine.textAlignment = .center
noLyricLine.isHidden = true
let font = UIFont.boldSystemFont(ofSize: 18)
lyricLine1.font = font
lyricLine2.font = font
noLyricLine.font = font
bgView.addSubview(lyricLine1)
bgView.addSubview(lyricLine2)
bgView.addSubview(noLyricLine)
icon.contentMode = .scaleAspectFit
icon.translatesAutoresizingMaskIntoConstraints = false
bgView.addSubview(icon)
NSLayoutConstraint.activate([
bgView.topAnchor.constraint(equalTo: topAnchor),
bgView.leadingAnchor.constraint(equalTo: leadingAnchor),
bgView.trailingAnchor.constraint(equalTo: trailingAnchor),
bgView.bottomAnchor.constraint(equalTo: bottomAnchor),
lyricLine1.topAnchor.constraint(equalTo: bgView.topAnchor, constant: 2),
lyricLine1.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 10),
lyricLine1.trailingAnchor.constraint(equalTo: bgView.trailingAnchor, constant: -10),
lyricLine1.heightAnchor.constraint(equalToConstant: 55),
lyricLine2.topAnchor.constraint(equalTo: lyricLine1.bottomAnchor, constant: 2),
lyricLine2.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 10),
lyricLine2.trailingAnchor.constraint(equalTo: bgView.trailingAnchor, constant: -10),
lyricLine2.heightAnchor.constraint(equalToConstant: 55),
noLyricLine.centerYAnchor.constraint(equalTo: bgView.centerYAnchor),
noLyricLine.leadingAnchor.constraint(equalTo: bgView.leadingAnchor),
noLyricLine.trailingAnchor.constraint(equalTo: bgView.trailingAnchor),
noLyricLine.heightAnchor.constraint(equalToConstant: 38),
icon.bottomAnchor.constraint(equalTo: bgView.bottomAnchor, constant: -10),
icon.trailingAnchor.constraint(equalTo: bgView.trailingAnchor, constant: -10),
icon.widthAnchor.constraint(equalToConstant: 30),
icon.heightAnchor.constraint(equalToConstant: 30),
])
}
@objc func updateContent(lyricLine1: String?, lyricLine2: String?, currentLine: Int) {
if let lyricLine1 { self.lyricLine1.text = lyricLine1 }
if let lyricLine2 { self.lyricLine2.text = lyricLine2 }
self.lyricLine1.textColor = currentLine == 2
? UIColor.white.withAlphaComponent(0.4) : .white
self.lyricLine2.textColor = currentLine == 1
? UIColor.white.withAlphaComponent(0.4) : .white
if currentLine == -1 {
noLyricLine.text = lyricLine1
self.lyricLine1.isHidden = true
self.lyricLine2.isHidden = true
noLyricLine.isHidden = false
} else {
self.lyricLine1.isHidden = false
self.lyricLine2.isHidden = false
noLyricLine.isHidden = true
}
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
gradientLayer.animateGradient()
}
}

BIN
iosApp/iosApp/pip/black.mp4 Normal file

Binary file not shown.