initial commit
This commit is contained in:
71
iosApp/README.md
Normal file
71
iosApp/README.md
Normal 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.
|
||||
29
iosApp/WidgetExtension/BackgroundIntent.swift
Normal file
29
iosApp/WidgetExtension/BackgroundIntent.swift
Normal 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 {}
|
||||
264
iosApp/WidgetExtension/HomeWidgetExample.swift
Normal file
264
iosApp/WidgetExtension/HomeWidgetExample.swift
Normal 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!媒体播放器")
|
||||
}
|
||||
}
|
||||
10
iosApp/WidgetExtension/HomeWidgetExampleBundle.swift
Normal file
10
iosApp/WidgetExtension/HomeWidgetExampleBundle.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct HomeWidgetExampleBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
HomeWidgetExampleWhite()
|
||||
HomeWidgetExampleBlack()
|
||||
}
|
||||
}
|
||||
15
iosApp/WidgetExtension/Info.plist
Normal file
15
iosApp/WidgetExtension/Info.plist
Normal 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>
|
||||
10
iosApp/WidgetExtension/WidgetExtension.entitlements
Normal file
10
iosApp/WidgetExtension/WidgetExtension.entitlements
Normal 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>
|
||||
65
iosApp/iosApp/AppDelegate.swift
Normal file
65
iosApp/iosApp/AppDelegate.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
201
iosApp/iosApp/CarPlay/CarPlaySceneDelegate.swift
Normal file
201
iosApp/iosApp/CarPlay/CarPlaySceneDelegate.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
17
iosApp/iosApp/ContentView.swift
Normal file
17
iosApp/iosApp/ContentView.swift
Normal 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) {}
|
||||
}
|
||||
61
iosApp/iosApp/HostBridge.swift
Normal file
61
iosApp/iosApp/HostBridge.swift
Normal 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
81
iosApp/iosApp/Info.plist
Normal 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>
|
||||
67
iosApp/iosApp/SceneDelegate.swift
Normal file
67
iosApp/iosApp/SceneDelegate.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
14
iosApp/iosApp/iosApp.entitlements
Normal file
14
iosApp/iosApp/iosApp.entitlements
Normal 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>
|
||||
50
iosApp/iosApp/pip/GradientView.swift
Normal file
50
iosApp/iosApp/pip/GradientView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
162
iosApp/iosApp/pip/PipScreenManager.swift
Normal file
162
iosApp/iosApp/pip/PipScreenManager.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
110
iosApp/iosApp/pip/PipScreenView.swift
Normal file
110
iosApp/iosApp/pip/PipScreenView.swift
Normal 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
BIN
iosApp/iosApp/pip/black.mp4
Normal file
Binary file not shown.
Reference in New Issue
Block a user