9 Commits
Author SHA1 Message Date
Jin fdde6c51ce fix iOS 2026-05-14 17:50:06 +08:00
JinJiangHuang 63e29be346 feat: Update iOS export options to enterprise with manual signing, increment marketing version, and add build artifacts to gitignore. 2026-03-23 21:10:50 +08:00
JinJiangHuang fac70d7496 fix network 2026-03-10 21:37:51 +08:00
JinJiangHuang 76ed32ccf3 fix bug 2026-03-03 12:28:02 +08:00
JinJiangHuang aebf17dfb5 fix bug 2026-03-02 17:37:29 +08:00
JinJiangHuang dc1a010e05 fix bug 2026-03-02 12:53:16 +08:00
JinJiangHuang e58322339f fix search 2026-03-01 22:57:20 +08:00
JinJiangHuang ee8c71dcd4 增加注解 2026-03-01 22:23:38 +08:00
JinJiangHuang 99a8cc799b fix bug 2026-03-01 21:43:02 +08:00
49 changed files with 5074 additions and 528 deletions
+6
View File
@@ -9,3 +9,9 @@ DerivedData/
/build /build
TVBox-*.dmg TVBox-*.dmg
*.py *.py
/IOS-key
/TVBox.ipa
/ExportOptions.plist
.kiro
package_ios.sh
image_cache
+5 -3
View File
@@ -3,13 +3,15 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>method</key> <key>method</key>
<string>development</string> <!-- 可选: ad-hoc, enterprise, app-store, development --> <string>enterprise</string>
<key>signingStyle</key> <key>signingStyle</key>
<string>automatic</string> <string>manual</string>
<key>stripSwiftSymbols</key> <key>stripSwiftSymbols</key>
<true/> <true/>
<key>teamID</key> <key>teamID</key>
<string></string> <!-- 此处填写您的 Apple Team ID --> <string>ZDV8SQG3XZ</string>
<key>signingCertificate</key>
<string>Apple Distribution</string>
<key>thinning</key> <key>thinning</key>
<string>&lt;none&gt;</string> <string>&lt;none&gt;</string>
</dict> </dict>
+31 -1
View File
@@ -9,6 +9,31 @@ rm -rf build
rm -rf tvbox.xcarchive rm -rf tvbox.xcarchive
rm -f TVBox.ipa rm -f TVBox.ipa
# 证书和配置 (从 secrets.sh 加载,此文件在 IOS-key 中已 gitignore)
if [ -f "IOS-key/secrets.sh" ]; then
source "IOS-key/secrets.sh"
else
echo "❌ 错误: 未找到 IOS-key/secrets.sh,请确保该文件存在。"
exit 1
fi
P12_PATH="IOS-key/cert.p12"
PROVISION_PATH="IOS-key/cert.mobileprovision"
# 变量已从 secrets.sh 加载: $P12_PASSWORD, $PROVISION_UUID, $TEAM_ID, $BUNDLE_ID, $SIGNING_IDENTITY
echo "安装 Provisioning Profile..."
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
cp "$PROVISION_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles/$PROVISION_UUID.mobileprovision"
echo "导入 P12 证书..."
# 将证书导入当前用户的默认钥匙串 (login.keychain)
security import "$P12_PATH" -k ~/Library/Keychains/login.keychain-db -P "$P12_PASSWORD" -T /usr/bin/codesign || true
# 解锁钥匙串以防超时
security unlock-keychain -p "" ~/Library/Keychains/login.keychain-db
echo "Regenerating project with XcodeGen..."
xcodegen generate
SCHEME="tvbox" SCHEME="tvbox"
CONFIGURATION="Release" CONFIGURATION="Release"
ARCHIVE_PATH="build/tvbox.xcarchive" ARCHIVE_PATH="build/tvbox.xcarchive"
@@ -21,7 +46,12 @@ xcodebuild archive \
-scheme "$SCHEME" \ -scheme "$SCHEME" \
-configuration "$CONFIGURATION" \ -configuration "$CONFIGURATION" \
-destination "generic/platform=iOS" \ -destination "generic/platform=iOS" \
-archivePath "$ARCHIVE_PATH" -archivePath "$ARCHIVE_PATH" \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="$SIGNING_IDENTITY" \
PROVISIONING_PROFILE_SPECIFIER="$PROVISION_UUID" \
DEVELOPMENT_TEAM="$TEAM_ID" \
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
if [ -f "$EXPORT_OPTIONS" ]; then if [ -f "$EXPORT_OPTIONS" ]; then
echo "发现 $EXPORT_OPTIONS,尝试导出 IPA..." echo "发现 $EXPORT_OPTIONS,尝试导出 IPA..."
+66 -5
View File
@@ -8,7 +8,7 @@ options:
settings: settings:
base: base:
SWIFT_VERSION: "5.9" SWIFT_VERSION: "5.9"
DEVELOPMENT_TEAM: "" DEVELOPMENT_TEAM: WP3M36TK8M
CODE_SIGN_STYLE: Automatic CODE_SIGN_STYLE: Automatic
packages: packages:
@@ -30,7 +30,7 @@ targets:
- Info.plist - Info.plist
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.tvbox.app PRODUCT_BUNDLE_IDENTIFIER: xyz.appinstall.nov.carbon.lam
INFOPLIST_FILE: tvbox/Info.plist INFOPLIST_FILE: tvbox/Info.plist
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
@@ -39,7 +39,7 @@ targets:
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
CURRENT_PROJECT_VERSION: "1" CURRENT_PROJECT_VERSION: "1"
MARKETING_VERSION: "1.0.0" MARKETING_VERSION: "1.0.3"
PRODUCT_NAME: TVBox PRODUCT_NAME: TVBox
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
@@ -52,6 +52,47 @@ targets:
Release: Release:
SWIFT_OPTIMIZATION_LEVEL: "-O" SWIFT_OPTIMIZATION_LEVEL: "-O"
tvbox-Tests:
type: bundle.unit-test
platform: iOS
deploymentTarget: "17.0"
sources:
- path: tvbox-Tests
- path: tvboxTests
dependencies:
- target: tvbox
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.tvbox.app.tests
GENERATE_INFOPLIST_FILE: YES
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/TVBox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TVBox"
BUNDLE_LOADER: "$(TEST_HOST)"
configs:
Debug:
SWIFT_OPTIMIZATION_LEVEL: "-Onone"
Release:
SWIFT_OPTIMIZATION_LEVEL: "-O"
tvboxTests:
type: bundle.unit-test
platform: iOS
deploymentTarget: "17.0"
dependencies:
- target: tvbox
sources:
- path: tvboxTests
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.tvbox.app.tests
GENERATE_INFOPLIST_FILE: YES
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/TVBox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TVBox"
BUNDLE_LOADER: "$(TEST_HOST)"
configs:
Debug:
SWIFT_OPTIMIZATION_LEVEL: "-Onone"
Release:
SWIFT_OPTIMIZATION_LEVEL: "-O"
tvbox-macOS: tvbox-macOS:
type: application type: application
platform: macOS platform: macOS
@@ -69,13 +110,33 @@ targets:
INFOPLIST_FILE: tvbox/Info.plist INFOPLIST_FILE: tvbox/Info.plist
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
CURRENT_PROJECT_VERSION: "1" CURRENT_PROJECT_VERSION: "1"
MARKETING_VERSION: "1.0.0" MARKETING_VERSION: "1.0.3"
PRODUCT_NAME: TVBox PRODUCT_NAME: TVBox
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
SWIFT_EMIT_LOC_STRINGS: YES SWIFT_EMIT_LOC_STRINGS: YES
CODE_SIGN_IDENTITY: "-" CODE_SIGN_IDENTITY: "-"
CODE_SIGN_ENTITLEMENTS: "" CODE_SIGN_ENTITLEMENTS: tvbox/tvbox-macOS.entitlements
configs:
Debug:
SWIFT_OPTIMIZATION_LEVEL: "-Onone"
Release:
SWIFT_OPTIMIZATION_LEVEL: "-O"
tvboxTests:
type: bundle.unit-test
platform: iOS
deploymentTarget: "17.0"
dependencies:
- target: tvbox
sources:
- path: tvboxTests
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.tvbox.app.tests
GENERATE_INFOPLIST_FILE: YES
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/TVBox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TVBox"
BUNDLE_LOADER: "$(TEST_HOST)"
configs: configs:
Debug: Debug:
SWIFT_OPTIMIZATION_LEVEL: "-Onone" SWIFT_OPTIMIZATION_LEVEL: "-Onone"
+168
View File
@@ -0,0 +1,168 @@
import Testing
import Foundation
@testable import TVBox
/// Property-based tests for PlayerGestureLayer.computeSeekTarget.
/// These tests validate that seek target computation is correctly clamped.
///
/// **Validates: Requirements 1.2, 1.3, 1.5, 1.6**
// MARK: - Property 1: Seek target computation is correctly clamped
/// For any currentTime t [0, duration], any offset (positive or negative, large or small),
/// and duration d > 0, computeSeekTarget returns a value in [0, d].
@Suite("Property 1: Seek target computation is correctly clamped")
struct SeekTargetClampingTests {
@Test("Seek target is always within [0, duration] for randomized inputs")
func seekTargetAlwaysClamped() {
for _ in 0..<200 {
let duration = Double.random(in: 0.001...36000.0)
let currentTime = Double.random(in: 0...duration)
let offset = Double.random(in: -100000...100000)
let result = PlayerGestureLayer.computeSeekTarget(
currentTime: currentTime,
offset: offset,
duration: duration
)
#expect(
result >= 0,
"Seek target \(result) should be >= 0 (currentTime=\(currentTime), offset=\(offset), duration=\(duration))"
)
#expect(
result <= duration,
"Seek target \(result) should be <= duration \(duration) (currentTime=\(currentTime), offset=\(offset))"
)
}
}
@Test("Seek target clamps to 0 when offset is very negative")
func seekTargetClampsToZero() {
for _ in 0..<100 {
let duration = Double.random(in: 1.0...10000.0)
let currentTime = Double.random(in: 0...duration)
// Offset guaranteed to pull below zero
let offset = -(currentTime + Double.random(in: 1.0...100000.0))
let result = PlayerGestureLayer.computeSeekTarget(
currentTime: currentTime,
offset: offset,
duration: duration
)
#expect(result == 0, "Expected 0 when offset pulls below zero, got \(result)")
}
}
@Test("Seek target clamps to duration when offset is very positive")
func seekTargetClampsToDuration() {
for _ in 0..<100 {
let duration = Double.random(in: 1.0...10000.0)
let currentTime = Double.random(in: 0...duration)
// Offset guaranteed to exceed duration
let offset = (duration - currentTime) + Double.random(in: 1.0...100000.0)
let result = PlayerGestureLayer.computeSeekTarget(
currentTime: currentTime,
offset: offset,
duration: duration
)
#expect(result == duration, "Expected duration \(duration) when offset exceeds bounds, got \(result)")
}
}
@Test("Seek target equals currentTime + offset when within bounds")
func seekTargetExactWhenInBounds() {
for _ in 0..<100 {
let duration = Double.random(in: 10.0...10000.0)
let currentTime = Double.random(in: 1.0...(duration - 1.0))
// Ensure offset keeps result within [0, duration]
let maxPositiveOffset = duration - currentTime - 0.001
let maxNegativeOffset = -(currentTime - 0.001)
let offset = Double.random(in: maxNegativeOffset...maxPositiveOffset)
let result = PlayerGestureLayer.computeSeekTarget(
currentTime: currentTime,
offset: offset,
duration: duration
)
let expected = currentTime + offset
#expect(
abs(result - expected) < 1e-10,
"Expected \(expected), got \(result) (currentTime=\(currentTime), offset=\(offset), duration=\(duration))"
)
}
}
@Test("Seek target handles very small duration")
func seekTargetSmallDuration() {
let duration = 0.001
let currentTime = 0.0005
let offsets: [Double] = [-100, -1, -0.001, 0, 0.001, 1, 100]
for offset in offsets {
let result = PlayerGestureLayer.computeSeekTarget(
currentTime: currentTime,
offset: offset,
duration: duration
)
#expect(result >= 0)
#expect(result <= duration)
}
}
@Test("Seek target with zero offset returns currentTime")
func seekTargetZeroOffset() {
for _ in 0..<100 {
let duration = Double.random(in: 0.001...36000.0)
let currentTime = Double.random(in: 0...duration)
let result = PlayerGestureLayer.computeSeekTarget(
currentTime: currentTime,
offset: 0,
duration: duration
)
#expect(
abs(result - currentTime) < 1e-10,
"With zero offset, result should equal currentTime"
)
}
}
@Test("Seek target at zero boundary with negative offset stays at 0")
func seekTargetAtZeroBoundary() {
for _ in 0..<100 {
let duration = Double.random(in: 1.0...10000.0)
let offset = -Double.random(in: 0.001...100000.0)
let result = PlayerGestureLayer.computeSeekTarget(
currentTime: 0,
offset: offset,
duration: duration
)
#expect(result == 0)
}
}
@Test("Seek target at duration boundary with positive offset stays at duration")
func seekTargetAtDurationBoundary() {
for _ in 0..<100 {
let duration = Double.random(in: 1.0...10000.0)
let offset = Double.random(in: 0.001...100000.0)
let result = PlayerGestureLayer.computeSeekTarget(
currentTime: duration,
offset: offset,
duration: duration
)
#expect(result == duration)
}
}
}
@@ -0,0 +1,295 @@
import Testing
import CoreGraphics
@testable import TVBox
/// Property-based tests for PlayerGestureLayer.classifyGesture zone classification.
/// These tests validate that gesture classification is deterministic and mutually exclusive.
///
/// **Validates: Requirements 1.1, 2.1, 3.1**
// MARK: - Property 2: Gesture zone classification is deterministic and mutually exclusive
/// For any touch start position x within a container of width w > 0, and any drag translation
/// (dx, dy) exceeding the threshold, the gesture classification function SHALL return exactly
/// one of: .seeking, .adjustingBrightness, or .adjustingVolume, where brightness is selected
/// iff x < w/2 and |dy| > |dx|, and volume is selected iff x >= w/2 and |dy| > |dx|.
@Suite("Property 2: Gesture zone classification is deterministic and mutually exclusive")
struct GestureZoneClassificationPropertyTests {
static let threshold: CGFloat = 10.0
// MARK: - Determinism: same inputs always produce same output
@Test("Classification is deterministic - same inputs always produce same result")
func classificationIsDeterministic() {
for _ in 0..<500 {
let containerWidth = CGFloat.random(in: 1.0...2000.0)
let startX = CGFloat.random(in: 0.0..<containerWidth)
let dx = CGFloat.random(in: -1000.0...1000.0)
let dy = CGFloat.random(in: -1000.0...1000.0)
let translation = CGSize(width: dx, height: dy)
let result1 = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: containerWidth,
translation: translation,
threshold: Self.threshold
)
let result2 = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: containerWidth,
translation: translation,
threshold: Self.threshold
)
#expect(result1 == result2,
"Classification should be deterministic for startX=\(startX), w=\(containerWidth), dx=\(dx), dy=\(dy)")
}
}
// MARK: - Mutual exclusivity: exactly one mode returned when threshold exceeded
@Test("Exactly one mode is returned when gesture exceeds threshold")
func exactlyOneModeReturned() {
for _ in 0..<500 {
let containerWidth = CGFloat.random(in: 1.0...2000.0)
let startX = CGFloat.random(in: 0.0..<containerWidth)
// Generate translations that exceed threshold
let magnitude = CGFloat.random(in: (Self.threshold + 1)...1000.0)
let angle = CGFloat.random(in: 0.0...(2.0 * .pi))
let dx = magnitude * cos(angle)
let dy = magnitude * sin(angle)
let translation = CGSize(width: dx, height: dy)
let result = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: containerWidth,
translation: translation,
threshold: Self.threshold
)
// When magnitude exceeds threshold, we should get exactly one non-none mode
// (either seeking, brightness, or volume)
let isSeeking: Bool
let isBrightness: Bool
let isVolume: Bool
let isNone: Bool
switch result {
case .seeking: isSeeking = true; isBrightness = false; isVolume = false; isNone = false
case .adjustingBrightness: isSeeking = false; isBrightness = true; isVolume = false; isNone = false
case .adjustingVolume: isSeeking = false; isBrightness = false; isVolume = true; isNone = false
case .none: isSeeking = false; isBrightness = false; isVolume = false; isNone = true
}
// At most one mode is active (mutual exclusivity)
let activeCount = [isSeeking, isBrightness, isVolume, isNone].filter { $0 }.count
#expect(activeCount == 1,
"Exactly one mode should be active, got \(activeCount) for dx=\(dx), dy=\(dy)")
}
}
// MARK: - Horizontal dominance: |dx| > |dy| and |dx| > threshold seeking
@Test("Horizontal gesture returns .seeking when |dx| > |dy| and |dx| > threshold")
func horizontalGestureReturnsSeeking() {
for _ in 0..<500 {
let containerWidth = CGFloat.random(in: 1.0...2000.0)
let startX = CGFloat.random(in: 0.0..<containerWidth)
// Generate dx that exceeds threshold
let absDx = CGFloat.random(in: (Self.threshold + 1)...1000.0)
let dx = Bool.random() ? absDx : -absDx
// Generate dy with |dy| < |dx| to ensure horizontal dominance
let absDy = CGFloat.random(in: 0.0..<absDx)
let dy = Bool.random() ? absDy : -absDy
let translation = CGSize(width: dx, height: dy)
let result = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: containerWidth,
translation: translation,
threshold: Self.threshold
)
if case .seeking = result {
// Expected
} else {
Issue.record("Expected .seeking for |dx|=\(abs(dx)) > |dy|=\(abs(dy)) > threshold, got \(result)")
}
}
}
// MARK: - Left half vertical: |dy| > |dx| and |dy| > threshold and x < w/2 brightness
@Test("Left half vertical gesture returns .adjustingBrightness when |dy| > |dx| and startX < w/2")
func leftHalfVerticalReturnsBrightness() {
for _ in 0..<500 {
let containerWidth = CGFloat.random(in: 2.0...2000.0)
// startX strictly in left half
let startX = CGFloat.random(in: 0.0..<(containerWidth / 2.0))
// Generate dy that exceeds threshold
let absDy = CGFloat.random(in: (Self.threshold + 1)...1000.0)
let dy = Bool.random() ? absDy : -absDy
// Generate dx with |dx| < |dy| to ensure vertical dominance
// Also ensure |dx| <= |dy| so the first condition (absH > absV) is false
let absDx = CGFloat.random(in: 0.0...absDy)
let dx = Bool.random() ? absDx : -absDx
let translation = CGSize(width: dx, height: dy)
let result = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: containerWidth,
translation: translation,
threshold: Self.threshold
)
if case .adjustingBrightness = result {
// Expected
} else {
Issue.record("Expected .adjustingBrightness for startX=\(startX) < w/2=\(containerWidth/2), |dy|=\(abs(dy)) > |dx|=\(abs(dx)), got \(result)")
}
}
}
// MARK: - Right half vertical: |dy| > |dx| and |dy| > threshold and x >= w/2 volume
@Test("Right half vertical gesture returns .adjustingVolume when |dy| > |dx| and startX >= w/2")
func rightHalfVerticalReturnsVolume() {
for _ in 0..<500 {
let containerWidth = CGFloat.random(in: 2.0...2000.0)
// startX in right half (>= w/2)
let startX = CGFloat.random(in: (containerWidth / 2.0)...containerWidth)
// Generate dy that exceeds threshold
let absDy = CGFloat.random(in: (Self.threshold + 1)...1000.0)
let dy = Bool.random() ? absDy : -absDy
// Generate dx with |dx| < |dy| to ensure vertical dominance
let absDx = CGFloat.random(in: 0.0...absDy)
let dx = Bool.random() ? absDx : -absDx
let translation = CGSize(width: dx, height: dy)
let result = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: containerWidth,
translation: translation,
threshold: Self.threshold
)
if case .adjustingVolume = result {
// Expected
} else {
Issue.record("Expected .adjustingVolume for startX=\(startX) >= w/2=\(containerWidth/2), |dy|=\(abs(dy)) > |dx|=\(abs(dx)), got \(result)")
}
}
}
// MARK: - Below threshold: neither exceeds threshold .none
@Test("Returns .none when neither component exceeds threshold")
func belowThresholdReturnsNone() {
for _ in 0..<500 {
let containerWidth = CGFloat.random(in: 1.0...2000.0)
let startX = CGFloat.random(in: 0.0..<containerWidth)
// Both dx and dy within threshold
let dx = CGFloat.random(in: -Self.threshold...Self.threshold)
let dy = CGFloat.random(in: -Self.threshold...Self.threshold)
let translation = CGSize(width: dx, height: dy)
let result = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: containerWidth,
translation: translation,
threshold: Self.threshold
)
#expect(result == .none,
"Expected .none when |dx|=\(abs(dx)) <= threshold and |dy|=\(abs(dy)) <= threshold, got \(result)")
}
}
// MARK: - Boundary: exact midpoint x = w/2 goes to volume (right half)
@Test("Exact midpoint startX == w/2 classifies as volume (right half)")
func exactMidpointGoesToVolume() {
let containerWidths: [CGFloat] = [100.0, 200.0, 375.0, 414.0, 500.0, 1000.0]
for containerWidth in containerWidths {
let startX = containerWidth / 2.0
let translation = CGSize(width: 0, height: -50.0) // Vertical gesture
let result = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: containerWidth,
translation: translation,
threshold: Self.threshold
)
if case .adjustingVolume = result {
// Expected: x >= w/2 means right half volume
} else {
Issue.record("Expected .adjustingVolume at exact midpoint startX=\(startX), w=\(containerWidth), got \(result)")
}
}
}
// MARK: - Comprehensive zone correctness with random inputs
@Test("Zone classification matches specification for all random inputs")
func zoneClassificationMatchesSpec() {
for _ in 0..<1000 {
let containerWidth = CGFloat.random(in: 1.0...2000.0)
let startX = CGFloat.random(in: 0.0..<containerWidth)
let dx = CGFloat.random(in: -1000.0...1000.0)
let dy = CGFloat.random(in: -1000.0...1000.0)
let translation = CGSize(width: dx, height: dy)
let result = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: containerWidth,
translation: translation,
threshold: Self.threshold
)
let absH = abs(dx)
let absV = abs(dy)
if absH > absV && absH > Self.threshold {
// Should be seeking
if case .seeking = result {
// Correct
} else {
Issue.record("Expected .seeking when |dx|=\(absH) > |dy|=\(absV) and |dx| > threshold, got \(result)")
}
} else if absV > Self.threshold {
// Should be brightness or volume based on position
let isLeftHalf = startX < containerWidth / 2.0
if isLeftHalf {
if case .adjustingBrightness = result {
// Correct
} else {
Issue.record("Expected .adjustingBrightness for left half (startX=\(startX) < w/2=\(containerWidth/2)), got \(result)")
}
} else {
if case .adjustingVolume = result {
// Correct
} else {
Issue.record("Expected .adjustingVolume for right half (startX=\(startX) >= w/2=\(containerWidth/2)), got \(result)")
}
}
} else {
// Neither exceeds threshold .none
#expect(result == .none,
"Expected .none when neither exceeds threshold (|dx|=\(absH), |dy|=\(absV)), got \(result)")
}
}
}
}
+288 -12
View File
@@ -13,12 +13,15 @@
058BC2FBB08D016A6FCF4C13 /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CD8B07440DBB1EF2807795 /* FavoritesView.swift */; }; 058BC2FBB08D016A6FCF4C13 /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CD8B07440DBB1EF2807795 /* FavoritesView.swift */; };
073BA70E29280BDF914166CF /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD980DF9D6ABDD040AC805 /* SearchView.swift */; }; 073BA70E29280BDF914166CF /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD980DF9D6ABDD040AC805 /* SearchView.swift */; };
0B78C3CA1D93FA29531ABD0B /* Movie.swift in Sources */ = {isa = PBXBuildFile; fileRef = D791457269BE952C496ECE55 /* Movie.swift */; }; 0B78C3CA1D93FA29531ABD0B /* Movie.swift in Sources */ = {isa = PBXBuildFile; fileRef = D791457269BE952C496ECE55 /* Movie.swift */; };
0CE56FEF7BEDF2D9AF2DD1AD /* PlayerGestureLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7491D05031A04661718FF52 /* PlayerGestureLayer.swift */; };
0D5200B31179039E1BB61DE5 /* HapticManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF32540F2107786AF4ECD571 /* HapticManager.swift */; };
0E9ABBA046E84A91B5DC3BBB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F4341B27BB1A860CB183467 /* ContentView.swift */; }; 0E9ABBA046E84A91B5DC3BBB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F4341B27BB1A860CB183467 /* ContentView.swift */; };
100ED189186C10410BE683C5 /* PlayerEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */; }; 100ED189186C10410BE683C5 /* PlayerEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */; };
12314BE9C595577FEE1839BB /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016DC7547EE0DE345E510288 /* Extensions.swift */; }; 12314BE9C595577FEE1839BB /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016DC7547EE0DE345E510288 /* Extensions.swift */; };
16D0E98EA5DF105764396919 /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE08247136FF788C621DA20 /* DesignSystem.swift */; }; 16D0E98EA5DF105764396919 /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE08247136FF788C621DA20 /* DesignSystem.swift */; };
17BAC049400FFF5487ECD23B /* SourceBean.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617855BC3AE2B293C71A0DA4 /* SourceBean.swift */; }; 17BAC049400FFF5487ECD23B /* SourceBean.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617855BC3AE2B293C71A0DA4 /* SourceBean.swift */; };
19291FACD401CDE0E6E2963E /* LiveViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21306B53D5CA8BB88593A55D /* LiveViewModel.swift */; }; 19291FACD401CDE0E6E2963E /* LiveViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21306B53D5CA8BB88593A55D /* LiveViewModel.swift */; };
19FCB4D2A58F2CA96183E67A /* PlayerGestureLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7491D05031A04661718FF52 /* PlayerGestureLayer.swift */; };
1D0C0D4DD4E22EC319637BDF /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */; }; 1D0C0D4DD4E22EC319637BDF /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */; };
205509C747774AFBAB4F9645 /* VodCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F905D1244D5DE13DBCEE170F /* VodCardView.swift */; }; 205509C747774AFBAB4F9645 /* VodCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F905D1244D5DE13DBCEE170F /* VodCardView.swift */; };
211847D0D4F382678F5EDE96 /* VLCKitSPM in Frameworks */ = {isa = PBXBuildFile; productRef = A8E4ADD9D85C0FCB9811382F /* VLCKitSPM */; }; 211847D0D4F382678F5EDE96 /* VLCKitSPM in Frameworks */ = {isa = PBXBuildFile; productRef = A8E4ADD9D85C0FCB9811382F /* VLCKitSPM */; };
@@ -28,6 +31,7 @@
2C85CE605FC864EB479F3CCC /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE08247136FF788C621DA20 /* DesignSystem.swift */; }; 2C85CE605FC864EB479F3CCC /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE08247136FF788C621DA20 /* DesignSystem.swift */; };
2F841DB8CE3BFB7C4F391B52 /* PlayerEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */; }; 2F841DB8CE3BFB7C4F391B52 /* PlayerEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */; };
32340EF75714EF664DC9512D /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */; }; 32340EF75714EF664DC9512D /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */; };
329F390FCD9530CE2B19B397 /* HapticManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF32540F2107786AF4ECD571 /* HapticManager.swift */; };
3E399383D92192CDA8840FEB /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6B7E1747D5E2C29B54066 /* AppConfig.swift */; }; 3E399383D92192CDA8840FEB /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6B7E1747D5E2C29B54066 /* AppConfig.swift */; };
3F69F139D892A91CA7DA6C73 /* ApiConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */; }; 3F69F139D892A91CA7DA6C73 /* ApiConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */; };
45D7A96C98872769AE01C131 /* tvboxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D163D4F6A45E9ACA696071 /* tvboxApp.swift */; }; 45D7A96C98872769AE01C131 /* tvboxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D163D4F6A45E9ACA696071 /* tvboxApp.swift */; };
@@ -40,6 +44,7 @@
5931E2182CBA71FD467BC61B /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1006DD8209F63DC3AC3C718 /* NetworkManager.swift */; }; 5931E2182CBA71FD467BC61B /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1006DD8209F63DC3AC3C718 /* NetworkManager.swift */; };
59DB9B1D12BAAA2F654579FE /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD980DF9D6ABDD040AC805 /* SearchView.swift */; }; 59DB9B1D12BAAA2F654579FE /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD980DF9D6ABDD040AC805 /* SearchView.swift */; };
5D208E481C6F97783B92E78C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */; }; 5D208E481C6F97783B92E78C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */; };
5E87760B3FDD9FCA2A532221 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81C1B0D20EE3EFB6B58418B /* ProfileView.swift */; };
5EB3F1571D44835F105ED6E9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90F26F870D1B587C1292A189 /* HomeView.swift */; }; 5EB3F1571D44835F105ED6E9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90F26F870D1B587C1292A189 /* HomeView.swift */; };
5FE9E153523D77AB24B16A7E /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016DC7547EE0DE345E510288 /* Extensions.swift */; }; 5FE9E153523D77AB24B16A7E /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016DC7547EE0DE345E510288 /* Extensions.swift */; };
633ECC16BBEA9AB61841F8A3 /* ApiConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */; }; 633ECC16BBEA9AB61841F8A3 /* ApiConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */; };
@@ -53,50 +58,92 @@
7B76864A9BC631F1A86E8104 /* MovieSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0212ABEA698460948737CABE /* MovieSort.swift */; }; 7B76864A9BC631F1A86E8104 /* MovieSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0212ABEA698460948737CABE /* MovieSort.swift */; };
7C95C692E1A80145DED49DD3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3EE159FC0B6328166F56CE36 /* Assets.xcassets */; }; 7C95C692E1A80145DED49DD3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3EE159FC0B6328166F56CE36 /* Assets.xcassets */; };
7CC2D33B7B438A89B03990EC /* LiveModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE83E7D21400ACFE3BC6ED38 /* LiveModels.swift */; }; 7CC2D33B7B438A89B03990EC /* LiveModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE83E7D21400ACFE3BC6ED38 /* LiveModels.swift */; };
7D0F71AA151C3F8CE8EB962B /* LivePlayerGestureLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A12D4549628C3FFBA736748 /* LivePlayerGestureLayer.swift */; };
80886C0A593DE429B8A62C4C /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7F257F6B68F93D58E8C032 /* SettingsViewModel.swift */; }; 80886C0A593DE429B8A62C4C /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7F257F6B68F93D58E8C032 /* SettingsViewModel.swift */; };
83A7D5C99EF218624DBBCAA2 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */; }; 83A7D5C99EF218624DBBCAA2 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */; };
85094ADB7683C758B5F5F835 /* VLCPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */; }; 85094ADB7683C758B5F5F835 /* VLCPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */; };
9231BB5D5B5D6E7172B46775 /* ChannelOverlayGroupWidthPropertyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FAB47D447BF6A9FC1A80BE9 /* ChannelOverlayGroupWidthPropertyTests.swift */; };
960791F95D922E104CD855F8 /* VLCKitSPM in Frameworks */ = {isa = PBXBuildFile; productRef = 1C6BDE11E85522F286D15861 /* VLCKitSPM */; }; 960791F95D922E104CD855F8 /* VLCKitSPM in Frameworks */ = {isa = PBXBuildFile; productRef = 1C6BDE11E85522F286D15861 /* VLCKitSPM */; };
969EE3F9ACF8E1A3026CC37F /* DetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8B3DC7E0B9C01A294C47FC /* DetailViewModel.swift */; }; 969EE3F9ACF8E1A3026CC37F /* DetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8B3DC7E0B9C01A294C47FC /* DetailViewModel.swift */; };
97B3904616111CE93232423E /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90F26F870D1B587C1292A189 /* HomeView.swift */; }; 97B3904616111CE93232423E /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90F26F870D1B587C1292A189 /* HomeView.swift */; };
9995EE797BCD482A8D371F26 /* PlayerGestureLayerZoomClampingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5767F0D984BBEFBB9101110 /* PlayerGestureLayerZoomClampingTests.swift */; };
9B53D7CD9BD54DE0834D7FC7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3EE159FC0B6328166F56CE36 /* Assets.xcassets */; }; 9B53D7CD9BD54DE0834D7FC7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3EE159FC0B6328166F56CE36 /* Assets.xcassets */; };
9FCA2C5CC08A9369EA53B130 /* LiveViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21306B53D5CA8BB88593A55D /* LiveViewModel.swift */; }; 9FCA2C5CC08A9369EA53B130 /* LiveViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21306B53D5CA8BB88593A55D /* LiveViewModel.swift */; };
B06E33645C9D8322D7474962 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE52456C613016065CF59F9C /* HistoryView.swift */; }; B06E33645C9D8322D7474962 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE52456C613016065CF59F9C /* HistoryView.swift */; };
B271873FE89F02840EAC6975 /* VLCPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */; }; B271873FE89F02840EAC6975 /* VLCPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */; };
B42C3B637B6082ECD044DBFE /* ChannelOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B2E6B616F6AE034C7CC242 /* ChannelOverlayView.swift */; };
B86C3ACE754B5F52B70588A9 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7752B731B0A015EB4B85BFB1 /* NetworkMonitor.swift */; };
BB051B00E5F63DCFF9BC013A /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */; }; BB051B00E5F63DCFF9BC013A /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */; };
BB36351815C53AC6EACB475A /* VodInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA608E781D094B187D3034FD /* VodInfo.swift */; }; BB36351815C53AC6EACB475A /* VodInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA608E781D094B187D3034FD /* VodInfo.swift */; };
BD7E2108F8C884E1DD6EC1B9 /* tvboxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D163D4F6A45E9ACA696071 /* tvboxApp.swift */; }; BD7E2108F8C884E1DD6EC1B9 /* tvboxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D163D4F6A45E9ACA696071 /* tvboxApp.swift */; };
C04126B16A89AF84E7EAC441 /* PlayerGestureLayerZoneClassificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EDFB004DA6642926C611BC9 /* PlayerGestureLayerZoneClassificationTests.swift */; };
C13E02E9A6451F57D21590A3 /* SourceBean.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617855BC3AE2B293C71A0DA4 /* SourceBean.swift */; }; C13E02E9A6451F57D21590A3 /* SourceBean.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617855BC3AE2B293C71A0DA4 /* SourceBean.swift */; };
CF28A416A2BCEF2E37892899 /* ChannelOverlayGroupWidthPropertyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FAB47D447BF6A9FC1A80BE9 /* ChannelOverlayGroupWidthPropertyTests.swift */; };
CFF1FF7DF205B2E3CFBF9DD0 /* PlayerGestureLayerZoomClampingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5767F0D984BBEFBB9101110 /* PlayerGestureLayerZoomClampingTests.swift */; };
D3B09E23F0DFA6298CCE6B40 /* PlayerGestureLayerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9777D260D16B089DA2DACB22 /* PlayerGestureLayerTests.swift */; };
D6F4B32786BF79CBF6AB77EA /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6B7E1747D5E2C29B54066 /* AppConfig.swift */; }; D6F4B32786BF79CBF6AB77EA /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6B7E1747D5E2C29B54066 /* AppConfig.swift */; };
D8412DB044659CA4507DE219 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE52456C613016065CF59F9C /* HistoryView.swift */; }; D8412DB044659CA4507DE219 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE52456C613016065CF59F9C /* HistoryView.swift */; };
DB893501C654D6CC2D71D198 /* CacheStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD32454522C32D876921A30 /* CacheStore.swift */; }; DB893501C654D6CC2D71D198 /* CacheStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD32454522C32D876921A30 /* CacheStore.swift */; };
E983FD4D6902C9F359528F2E /* LiveModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE83E7D21400ACFE3BC6ED38 /* LiveModels.swift */; }; E983FD4D6902C9F359528F2E /* LiveModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE83E7D21400ACFE3BC6ED38 /* LiveModels.swift */; };
E9DE1232C207925E93343E15 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81C1B0D20EE3EFB6B58418B /* ProfileView.swift */; };
ED9018DE19A38BEF616B71A3 /* LivePlayerGestureLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A12D4549628C3FFBA736748 /* LivePlayerGestureLayer.swift */; };
F01490300EAFDF38F8BD5D46 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7752B731B0A015EB4B85BFB1 /* NetworkMonitor.swift */; };
F19D2281D427FEE5B1349769 /* ChannelOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B2E6B616F6AE034C7CC242 /* ChannelOverlayView.swift */; };
F351B1ECC80D0C2A15DE2BEC /* PlayerGestureLayerPropertyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269FD665F80615609817E428 /* PlayerGestureLayerPropertyTests.swift */; };
F49BBFD0CCDE27C63FD97180 /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CD8B07440DBB1EF2807795 /* FavoritesView.swift */; }; F49BBFD0CCDE27C63FD97180 /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CD8B07440DBB1EF2807795 /* FavoritesView.swift */; };
F6E863F58C52916EAB766B60 /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */; }; F6E863F58C52916EAB766B60 /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */; };
F911F241653C76EABDA844A9 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */; }; F911F241653C76EABDA844A9 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */; };
FA5621E49A1014D014A5906E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10969CAA236B75C35062BCE /* SettingsView.swift */; }; FA5621E49A1014D014A5906E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10969CAA236B75C35062BCE /* SettingsView.swift */; };
FFA89F351987A1217DBC56B2 /* PlayerGestureLayerPropertyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269FD665F80615609817E428 /* PlayerGestureLayerPropertyTests.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
7239DE519CCBFDEB377CA655 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 4FDAB104B1C6110A770CC389 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 11562C2D9CA08B82105BC7B6;
remoteInfo = tvbox;
};
A86A49D753626DA6D45004CE /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 4FDAB104B1C6110A770CC389 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 11562C2D9CA08B82105BC7B6;
remoteInfo = tvbox;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
016DC7547EE0DE345E510288 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; }; 016DC7547EE0DE345E510288 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; };
0212ABEA698460948737CABE /* MovieSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieSort.swift; sourceTree = "<group>"; }; 0212ABEA698460948737CABE /* MovieSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieSort.swift; sourceTree = "<group>"; };
06CD8B07440DBB1EF2807795 /* FavoritesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesView.swift; sourceTree = "<group>"; }; 06CD8B07440DBB1EF2807795 /* FavoritesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesView.swift; sourceTree = "<group>"; };
09FEA232F8084173AAC649D4 /* tvboxTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = tvboxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
0A7F257F6B68F93D58E8C032 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; }; 0A7F257F6B68F93D58E8C032 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = "<group>"; }; 0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = "<group>"; };
131429F267F6A418C282831A /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = "<group>"; }; 131429F267F6A418C282831A /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = "<group>"; };
21306B53D5CA8BB88593A55D /* LiveViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveViewModel.swift; sourceTree = "<group>"; }; 21306B53D5CA8BB88593A55D /* LiveViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveViewModel.swift; sourceTree = "<group>"; };
269FD665F80615609817E428 /* PlayerGestureLayerPropertyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerGestureLayerPropertyTests.swift; sourceTree = "<group>"; };
2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; }; 2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
3EE159FC0B6328166F56CE36 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 3EE159FC0B6328166F56CE36 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
54D163D4F6A45E9ACA696071 /* tvboxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvboxApp.swift; sourceTree = "<group>"; }; 54D163D4F6A45E9ACA696071 /* tvboxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvboxApp.swift; sourceTree = "<group>"; };
5EDFB004DA6642926C611BC9 /* PlayerGestureLayerZoneClassificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerGestureLayerZoneClassificationTests.swift; sourceTree = "<group>"; };
617855BC3AE2B293C71A0DA4 /* SourceBean.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceBean.swift; sourceTree = "<group>"; }; 617855BC3AE2B293C71A0DA4 /* SourceBean.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceBean.swift; sourceTree = "<group>"; };
6D8B3DC7E0B9C01A294C47FC /* DetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailViewModel.swift; sourceTree = "<group>"; }; 6D8B3DC7E0B9C01A294C47FC /* DetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailViewModel.swift; sourceTree = "<group>"; };
6EE08247136FF788C621DA20 /* DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignSystem.swift; sourceTree = "<group>"; }; 6EE08247136FF788C621DA20 /* DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignSystem.swift; sourceTree = "<group>"; };
6F4341B27BB1A860CB183467 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; 6F4341B27BB1A860CB183467 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "tvbox-macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "tvbox-macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
74B2E6B616F6AE034C7CC242 /* ChannelOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelOverlayView.swift; sourceTree = "<group>"; };
7752B731B0A015EB4B85BFB1 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
7A12D4549628C3FFBA736748 /* LivePlayerGestureLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivePlayerGestureLayer.swift; sourceTree = "<group>"; };
7FAB47D447BF6A9FC1A80BE9 /* ChannelOverlayGroupWidthPropertyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelOverlayGroupWidthPropertyTests.swift; sourceTree = "<group>"; };
8AAD980DF9D6ABDD040AC805 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; }; 8AAD980DF9D6ABDD040AC805 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateView.swift; sourceTree = "<group>"; }; 8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateView.swift; sourceTree = "<group>"; };
90F26F870D1B587C1292A189 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; }; 90F26F870D1B587C1292A189 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
9777D260D16B089DA2DACB22 /* PlayerGestureLayerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerGestureLayerTests.swift; sourceTree = "<group>"; };
98A5ADBA89C07920FE7DE302 /* tvbox-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "tvbox-macOS.entitlements"; sourceTree = "<group>"; };
A10969CAA236B75C35062BCE /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; A10969CAA236B75C35062BCE /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A5767F0D984BBEFBB9101110 /* PlayerGestureLayerZoomClampingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerGestureLayerZoomClampingTests.swift; sourceTree = "<group>"; };
AA608E781D094B187D3034FD /* VodInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VodInfo.swift; sourceTree = "<group>"; }; AA608E781D094B187D3034FD /* VodInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VodInfo.swift; sourceTree = "<group>"; };
B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerEngine.swift; sourceTree = "<group>"; }; B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerEngine.swift; sourceTree = "<group>"; };
B3D8D6F9D894DBE0594084F9 /* LiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveView.swift; sourceTree = "<group>"; }; B3D8D6F9D894DBE0594084F9 /* LiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveView.swift; sourceTree = "<group>"; };
@@ -112,6 +159,10 @@
DE52456C613016065CF59F9C /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; }; DE52456C613016065CF59F9C /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerView.swift; sourceTree = "<group>"; }; DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerView.swift; sourceTree = "<group>"; };
E5234A75D14EAE9141633FCA /* tvbox.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = tvbox.app; sourceTree = BUILT_PRODUCTS_DIR; }; E5234A75D14EAE9141633FCA /* tvbox.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = tvbox.app; sourceTree = BUILT_PRODUCTS_DIR; };
E64444D4CBE0502858CBD7E4 /* tvbox-Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "tvbox-Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
E7491D05031A04661718FF52 /* PlayerGestureLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerGestureLayer.swift; sourceTree = "<group>"; };
E81C1B0D20EE3EFB6B58418B /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
EF32540F2107786AF4ECD571 /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = "<group>"; };
F905D1244D5DE13DBCEE170F /* VodCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VodCardView.swift; sourceTree = "<group>"; }; F905D1244D5DE13DBCEE170F /* VodCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VodCardView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -139,15 +190,26 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
016DC7547EE0DE345E510288 /* Extensions.swift */, 016DC7547EE0DE345E510288 /* Extensions.swift */,
EF32540F2107786AF4ECD571 /* HapticManager.swift */,
); );
path = Utils; path = Utils;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
0F55DB9D84FD47BF11F99DE5 /* tvbox-Tests */ = {
isa = PBXGroup;
children = (
9777D260D16B089DA2DACB22 /* PlayerGestureLayerTests.swift */,
5EDFB004DA6642926C611BC9 /* PlayerGestureLayerZoneClassificationTests.swift */,
);
path = "tvbox-Tests";
sourceTree = "<group>";
};
22CB65DE627DB94747387944 /* Services */ = { 22CB65DE627DB94747387944 /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */, D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */,
D1006DD8209F63DC3AC3C718 /* NetworkManager.swift */, D1006DD8209F63DC3AC3C718 /* NetworkManager.swift */,
7752B731B0A015EB4B85BFB1 /* NetworkMonitor.swift */,
D5DFDD8C597739AD4B443C03 /* SourceService.swift */, D5DFDD8C597739AD4B443C03 /* SourceService.swift */,
); );
path = Services; path = Services;
@@ -156,6 +218,8 @@
2990EE415D2440DE11757F52 /* Player */ = { 2990EE415D2440DE11757F52 /* Player */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
7A12D4549628C3FFBA736748 /* LivePlayerGestureLayer.swift */,
E7491D05031A04661718FF52 /* PlayerGestureLayer.swift */,
BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */, BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */,
DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */, DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */,
); );
@@ -170,6 +234,24 @@
path = Search; path = Search;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
2CDB960D512B40AF659C0811 /* tvboxTests */ = {
isa = PBXGroup;
children = (
7FAB47D447BF6A9FC1A80BE9 /* ChannelOverlayGroupWidthPropertyTests.swift */,
269FD665F80615609817E428 /* PlayerGestureLayerPropertyTests.swift */,
A5767F0D984BBEFBB9101110 /* PlayerGestureLayerZoomClampingTests.swift */,
);
path = tvboxTests;
sourceTree = "<group>";
};
2D13F96D465DAF4A57E7F402 /* Profile */ = {
isa = PBXGroup;
children = (
E81C1B0D20EE3EFB6B58418B /* ProfileView.swift */,
);
path = Profile;
sourceTree = "<group>";
};
2E56FD5A2A716ED24B655259 /* Models */ = { 2E56FD5A2A716ED24B655259 /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -202,6 +284,7 @@
FD7DFF54835B377CB9BF5A82 /* Home */, FD7DFF54835B377CB9BF5A82 /* Home */,
D3F1B37CB4D7643D7F7639CE /* Live */, D3F1B37CB4D7643D7F7639CE /* Live */,
2990EE415D2440DE11757F52 /* Player */, 2990EE415D2440DE11757F52 /* Player */,
2D13F96D465DAF4A57E7F402 /* Profile */,
2B5161C9D7D5ECC250A7FFC0 /* Search */, 2B5161C9D7D5ECC250A7FFC0 /* Search */,
395C32C174B961E32AF81E4F /* Settings */, 395C32C174B961E32AF81E4F /* Settings */,
6F4341B27BB1A860CB183467 /* ContentView.swift */, 6F4341B27BB1A860CB183467 /* ContentView.swift */,
@@ -214,6 +297,8 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
8B5798117EA20DF680EFD3CA /* tvbox */, 8B5798117EA20DF680EFD3CA /* tvbox */,
0F55DB9D84FD47BF11F99DE5 /* tvbox-Tests */,
2CDB960D512B40AF659C0811 /* tvboxTests */,
AF26E3B8CC541C336E1ECDDB /* Products */, AF26E3B8CC541C336E1ECDDB /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@@ -261,6 +346,7 @@
E1FD870365F6D4FD67730DBD /* ViewModels */, E1FD870365F6D4FD67730DBD /* ViewModels */,
4D47ACD45B1BF337CCB04FCC /* Views */, 4D47ACD45B1BF337CCB04FCC /* Views */,
3EE159FC0B6328166F56CE36 /* Assets.xcassets */, 3EE159FC0B6328166F56CE36 /* Assets.xcassets */,
98A5ADBA89C07920FE7DE302 /* tvbox-macOS.entitlements */,
54D163D4F6A45E9ACA696071 /* tvboxApp.swift */, 54D163D4F6A45E9ACA696071 /* tvboxApp.swift */,
); );
path = tvbox; path = tvbox;
@@ -270,7 +356,9 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */, 7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */,
E64444D4CBE0502858CBD7E4 /* tvbox-Tests.xctest */,
E5234A75D14EAE9141633FCA /* tvbox.app */, E5234A75D14EAE9141633FCA /* tvbox.app */,
09FEA232F8084173AAC649D4 /* tvboxTests.xctest */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -278,6 +366,7 @@
D3F1B37CB4D7643D7F7639CE /* Live */ = { D3F1B37CB4D7643D7F7639CE /* Live */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
74B2E6B616F6AE034C7CC242 /* ChannelOverlayView.swift */,
B3D8D6F9D894DBE0594084F9 /* LiveView.swift */, B3D8D6F9D894DBE0594084F9 /* LiveView.swift */,
); );
path = Live; path = Live;
@@ -335,6 +424,24 @@
productReference = E5234A75D14EAE9141633FCA /* tvbox.app */; productReference = E5234A75D14EAE9141633FCA /* tvbox.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
6654D9217FBAAC8D637C69A8 /* tvbox-Tests */ = {
isa = PBXNativeTarget;
buildConfigurationList = E2BCF6682D0CE82B69B74411 /* Build configuration list for PBXNativeTarget "tvbox-Tests" */;
buildPhases = (
0B9604C19AD94C5FF651FC38 /* Sources */,
);
buildRules = (
);
dependencies = (
AEB1CA8F6E9D3FE63870B42F /* PBXTargetDependency */,
);
name = "tvbox-Tests";
packageProductDependencies = (
);
productName = "tvbox-Tests";
productReference = E64444D4CBE0502858CBD7E4 /* tvbox-Tests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
793E3BB44A68A9F75C89F0C1 /* tvbox-macOS */ = { 793E3BB44A68A9F75C89F0C1 /* tvbox-macOS */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = B562A6D461B4EAA4BD297CA0 /* Build configuration list for PBXNativeTarget "tvbox-macOS" */; buildConfigurationList = B562A6D461B4EAA4BD297CA0 /* Build configuration list for PBXNativeTarget "tvbox-macOS" */;
@@ -355,6 +462,24 @@
productReference = 7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */; productReference = 7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
A8BE1A152DDC63C329EA702B /* tvboxTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = E226A42B8E14F285EA8D5D9A /* Build configuration list for PBXNativeTarget "tvboxTests" */;
buildPhases = (
7FF57815F26F4F3DCFA4F2EC /* Sources */,
);
buildRules = (
);
dependencies = (
A407E73109D81FAE254645AA /* PBXTargetDependency */,
);
name = tvboxTests;
packageProductDependencies = (
);
productName = tvboxTests;
productReference = 09FEA232F8084173AAC649D4 /* tvboxTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -365,11 +490,19 @@
LastUpgradeCheck = 1500; LastUpgradeCheck = 1500;
TargetAttributes = { TargetAttributes = {
11562C2D9CA08B82105BC7B6 = { 11562C2D9CA08B82105BC7B6 = {
DevelopmentTeam = ""; DevelopmentTeam = WP3M36TK8M;
ProvisioningStyle = Automatic;
};
6654D9217FBAAC8D637C69A8 = {
DevelopmentTeam = WP3M36TK8M;
ProvisioningStyle = Automatic; ProvisioningStyle = Automatic;
}; };
793E3BB44A68A9F75C89F0C1 = { 793E3BB44A68A9F75C89F0C1 = {
DevelopmentTeam = ""; DevelopmentTeam = WP3M36TK8M;
ProvisioningStyle = Automatic;
};
A8BE1A152DDC63C329EA702B = {
DevelopmentTeam = WP3M36TK8M;
ProvisioningStyle = Automatic; ProvisioningStyle = Automatic;
}; };
}; };
@@ -392,7 +525,9 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
11562C2D9CA08B82105BC7B6 /* tvbox */, 11562C2D9CA08B82105BC7B6 /* tvbox */,
6654D9217FBAAC8D637C69A8 /* tvbox-Tests */,
793E3BB44A68A9F75C89F0C1 /* tvbox-macOS */, 793E3BB44A68A9F75C89F0C1 /* tvbox-macOS */,
A8BE1A152DDC63C329EA702B /* tvboxTests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -417,6 +552,28 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
0B9604C19AD94C5FF651FC38 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CF28A416A2BCEF2E37892899 /* ChannelOverlayGroupWidthPropertyTests.swift in Sources */,
F351B1ECC80D0C2A15DE2BEC /* PlayerGestureLayerPropertyTests.swift in Sources */,
D3B09E23F0DFA6298CCE6B40 /* PlayerGestureLayerTests.swift in Sources */,
C04126B16A89AF84E7EAC441 /* PlayerGestureLayerZoneClassificationTests.swift in Sources */,
CFF1FF7DF205B2E3CFBF9DD0 /* PlayerGestureLayerZoomClampingTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
7FF57815F26F4F3DCFA4F2EC /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
9231BB5D5B5D6E7172B46775 /* ChannelOverlayGroupWidthPropertyTests.swift in Sources */,
FFA89F351987A1217DBC56B2 /* PlayerGestureLayerPropertyTests.swift in Sources */,
9995EE797BCD482A8D371F26 /* PlayerGestureLayerZoomClampingTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
89EE06EE9942A5C42303E3C1 /* Sources */ = { 89EE06EE9942A5C42303E3C1 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -424,6 +581,7 @@
633ECC16BBEA9AB61841F8A3 /* ApiConfig.swift in Sources */, 633ECC16BBEA9AB61841F8A3 /* ApiConfig.swift in Sources */,
3E399383D92192CDA8840FEB /* AppConfig.swift in Sources */, 3E399383D92192CDA8840FEB /* AppConfig.swift in Sources */,
DB893501C654D6CC2D71D198 /* CacheStore.swift in Sources */, DB893501C654D6CC2D71D198 /* CacheStore.swift in Sources */,
F19D2281D427FEE5B1349769 /* ChannelOverlayView.swift in Sources */,
0E9ABBA046E84A91B5DC3BBB /* ContentView.swift in Sources */, 0E9ABBA046E84A91B5DC3BBB /* ContentView.swift in Sources */,
2C85CE605FC864EB479F3CCC /* DesignSystem.swift in Sources */, 2C85CE605FC864EB479F3CCC /* DesignSystem.swift in Sources */,
4E84E7083C81F5A8E3C8FFD8 /* DetailView.swift in Sources */, 4E84E7083C81F5A8E3C8FFD8 /* DetailView.swift in Sources */,
@@ -432,17 +590,22 @@
69BDB52A3ADDA43EAE59DE4B /* EpisodeListView.swift in Sources */, 69BDB52A3ADDA43EAE59DE4B /* EpisodeListView.swift in Sources */,
12314BE9C595577FEE1839BB /* Extensions.swift in Sources */, 12314BE9C595577FEE1839BB /* Extensions.swift in Sources */,
F49BBFD0CCDE27C63FD97180 /* FavoritesView.swift in Sources */, F49BBFD0CCDE27C63FD97180 /* FavoritesView.swift in Sources */,
0D5200B31179039E1BB61DE5 /* HapticManager.swift in Sources */,
D8412DB044659CA4507DE219 /* HistoryView.swift in Sources */, D8412DB044659CA4507DE219 /* HistoryView.swift in Sources */,
97B3904616111CE93232423E /* HomeView.swift in Sources */, 97B3904616111CE93232423E /* HomeView.swift in Sources */,
5D208E481C6F97783B92E78C /* HomeViewModel.swift in Sources */, 5D208E481C6F97783B92E78C /* HomeViewModel.swift in Sources */,
E983FD4D6902C9F359528F2E /* LiveModels.swift in Sources */, E983FD4D6902C9F359528F2E /* LiveModels.swift in Sources */,
7D0F71AA151C3F8CE8EB962B /* LivePlayerGestureLayer.swift in Sources */,
267A5751434568E302E0C902 /* LiveView.swift in Sources */, 267A5751434568E302E0C902 /* LiveView.swift in Sources */,
19291FACD401CDE0E6E2963E /* LiveViewModel.swift in Sources */, 19291FACD401CDE0E6E2963E /* LiveViewModel.swift in Sources */,
0B78C3CA1D93FA29531ABD0B /* Movie.swift in Sources */, 0B78C3CA1D93FA29531ABD0B /* Movie.swift in Sources */,
68A7BF3D3ECDA73637B644C8 /* MovieSort.swift in Sources */, 68A7BF3D3ECDA73637B644C8 /* MovieSort.swift in Sources */,
5931E2182CBA71FD467BC61B /* NetworkManager.swift in Sources */, 5931E2182CBA71FD467BC61B /* NetworkManager.swift in Sources */,
F01490300EAFDF38F8BD5D46 /* NetworkMonitor.swift in Sources */,
2F841DB8CE3BFB7C4F391B52 /* PlayerEngine.swift in Sources */, 2F841DB8CE3BFB7C4F391B52 /* PlayerEngine.swift in Sources */,
0CE56FEF7BEDF2D9AF2DD1AD /* PlayerGestureLayer.swift in Sources */,
00DC0B0A8D261A9015CE9308 /* PlayerView.swift in Sources */, 00DC0B0A8D261A9015CE9308 /* PlayerView.swift in Sources */,
5E87760B3FDD9FCA2A532221 /* ProfileView.swift in Sources */,
073BA70E29280BDF914166CF /* SearchView.swift in Sources */, 073BA70E29280BDF914166CF /* SearchView.swift in Sources */,
1D0C0D4DD4E22EC319637BDF /* SearchViewModel.swift in Sources */, 1D0C0D4DD4E22EC319637BDF /* SearchViewModel.swift in Sources */,
FA5621E49A1014D014A5906E /* SettingsView.swift in Sources */, FA5621E49A1014D014A5906E /* SettingsView.swift in Sources */,
@@ -463,6 +626,7 @@
3F69F139D892A91CA7DA6C73 /* ApiConfig.swift in Sources */, 3F69F139D892A91CA7DA6C73 /* ApiConfig.swift in Sources */,
D6F4B32786BF79CBF6AB77EA /* AppConfig.swift in Sources */, D6F4B32786BF79CBF6AB77EA /* AppConfig.swift in Sources */,
6EC44AD626C64BC902CD9D1B /* CacheStore.swift in Sources */, 6EC44AD626C64BC902CD9D1B /* CacheStore.swift in Sources */,
B42C3B637B6082ECD044DBFE /* ChannelOverlayView.swift in Sources */,
01259DBB84CEFA52CB5BC8C1 /* ContentView.swift in Sources */, 01259DBB84CEFA52CB5BC8C1 /* ContentView.swift in Sources */,
16D0E98EA5DF105764396919 /* DesignSystem.swift in Sources */, 16D0E98EA5DF105764396919 /* DesignSystem.swift in Sources */,
6765DB90DA62DBEE185104F7 /* DetailView.swift in Sources */, 6765DB90DA62DBEE185104F7 /* DetailView.swift in Sources */,
@@ -471,17 +635,22 @@
72A1006A1A66F1721DD1618E /* EpisodeListView.swift in Sources */, 72A1006A1A66F1721DD1618E /* EpisodeListView.swift in Sources */,
5FE9E153523D77AB24B16A7E /* Extensions.swift in Sources */, 5FE9E153523D77AB24B16A7E /* Extensions.swift in Sources */,
058BC2FBB08D016A6FCF4C13 /* FavoritesView.swift in Sources */, 058BC2FBB08D016A6FCF4C13 /* FavoritesView.swift in Sources */,
329F390FCD9530CE2B19B397 /* HapticManager.swift in Sources */,
B06E33645C9D8322D7474962 /* HistoryView.swift in Sources */, B06E33645C9D8322D7474962 /* HistoryView.swift in Sources */,
5EB3F1571D44835F105ED6E9 /* HomeView.swift in Sources */, 5EB3F1571D44835F105ED6E9 /* HomeView.swift in Sources */,
32340EF75714EF664DC9512D /* HomeViewModel.swift in Sources */, 32340EF75714EF664DC9512D /* HomeViewModel.swift in Sources */,
7CC2D33B7B438A89B03990EC /* LiveModels.swift in Sources */, 7CC2D33B7B438A89B03990EC /* LiveModels.swift in Sources */,
ED9018DE19A38BEF616B71A3 /* LivePlayerGestureLayer.swift in Sources */,
530B86138F2A404353CE37AB /* LiveView.swift in Sources */, 530B86138F2A404353CE37AB /* LiveView.swift in Sources */,
9FCA2C5CC08A9369EA53B130 /* LiveViewModel.swift in Sources */, 9FCA2C5CC08A9369EA53B130 /* LiveViewModel.swift in Sources */,
485F358B964A4CB6E5AC4E03 /* Movie.swift in Sources */, 485F358B964A4CB6E5AC4E03 /* Movie.swift in Sources */,
7B76864A9BC631F1A86E8104 /* MovieSort.swift in Sources */, 7B76864A9BC631F1A86E8104 /* MovieSort.swift in Sources */,
5230AE350F993A4792B03F74 /* NetworkManager.swift in Sources */, 5230AE350F993A4792B03F74 /* NetworkManager.swift in Sources */,
B86C3ACE754B5F52B70588A9 /* NetworkMonitor.swift in Sources */,
100ED189186C10410BE683C5 /* PlayerEngine.swift in Sources */, 100ED189186C10410BE683C5 /* PlayerEngine.swift in Sources */,
19FCB4D2A58F2CA96183E67A /* PlayerGestureLayer.swift in Sources */,
F6E863F58C52916EAB766B60 /* PlayerView.swift in Sources */, F6E863F58C52916EAB766B60 /* PlayerView.swift in Sources */,
E9DE1232C207925E93343E15 /* ProfileView.swift in Sources */,
59DB9B1D12BAAA2F654579FE /* SearchView.swift in Sources */, 59DB9B1D12BAAA2F654579FE /* SearchView.swift in Sources */,
F911F241653C76EABDA844A9 /* SearchViewModel.swift in Sources */, F911F241653C76EABDA844A9 /* SearchViewModel.swift in Sources */,
020C5667E8D08A0C9B03EC8E /* SettingsView.swift in Sources */, 020C5667E8D08A0C9B03EC8E /* SettingsView.swift in Sources */,
@@ -497,13 +666,26 @@
}; };
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
A407E73109D81FAE254645AA /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 11562C2D9CA08B82105BC7B6 /* tvbox */;
targetProxy = A86A49D753626DA6D45004CE /* PBXContainerItemProxy */;
};
AEB1CA8F6E9D3FE63870B42F /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 11562C2D9CA08B82105BC7B6 /* tvbox */;
targetProxy = 7239DE519CCBFDEB377CA655 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
05A4C2FBFE824459DAF6F032 /* Release */ = { 05A4C2FBFE824459DAF6F032 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_ENTITLEMENTS = "tvbox/tvbox-macOS.entitlements";
CODE_SIGN_IDENTITY = "-"; CODE_SIGN_IDENTITY = "-";
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
@@ -514,7 +696,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.macos; PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.macos;
PRODUCT_NAME = TVBox; PRODUCT_NAME = TVBox;
SDKROOT = macosx; SDKROOT = macosx;
@@ -559,7 +741,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = WP3M36TK8M;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -584,7 +766,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_ENTITLEMENTS = "tvbox/tvbox-macOS.entitlements";
CODE_SIGN_IDENTITY = "-"; CODE_SIGN_IDENTITY = "-";
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
@@ -595,7 +777,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.macos; PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.macos;
PRODUCT_NAME = TVBox; PRODUCT_NAME = TVBox;
SDKROOT = macosx; SDKROOT = macosx;
@@ -604,6 +786,63 @@
}; };
name = Debug; name = Debug;
}; };
43D3C5B79428BA37ABA0A3D1 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.tests;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TVBox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TVBox";
};
name = Release;
};
600A01562B84DD8644C72B26 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.tests;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TVBox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TVBox";
};
name = Release;
};
6D108BA4F95E6E4B71DB1AD4 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.tests;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TVBox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TVBox";
};
name = Debug;
};
8A2175BF0264F53B0EF674FF /* Debug */ = { 8A2175BF0264F53B0EF674FF /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -624,8 +863,8 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app; PRODUCT_BUNDLE_IDENTIFIER = xyz.appinstall.nov.carbon.lam;
PRODUCT_NAME = TVBox; PRODUCT_NAME = TVBox;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTS_MACCATALYST = YES; SUPPORTS_MACCATALYST = YES;
@@ -635,6 +874,25 @@
}; };
name = Debug; name = Debug;
}; };
C2C422BFD7EA2EA2983FF28C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.tests;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TVBox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TVBox";
};
name = Debug;
};
D7359975052DCED591BCFD09 /* Debug */ = { D7359975052DCED591BCFD09 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -671,7 +929,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = WP3M36TK8M;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -718,8 +976,8 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app; PRODUCT_BUNDLE_IDENTIFIER = xyz.appinstall.nov.carbon.lam;
PRODUCT_NAME = TVBox; PRODUCT_NAME = TVBox;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTS_MACCATALYST = YES; SUPPORTS_MACCATALYST = YES;
@@ -759,6 +1017,24 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug; defaultConfigurationName = Debug;
}; };
E226A42B8E14F285EA8D5D9A /* Build configuration list for PBXNativeTarget "tvboxTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C2C422BFD7EA2EA2983FF28C /* Debug */,
43D3C5B79428BA37ABA0A3D1 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
E2BCF6682D0CE82B69B74411 /* Build configuration list for PBXNativeTarget "tvbox-Tests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6D108BA4F95E6E4B71DB1AD4 /* Debug */,
600A01562B84DD8644C72B26 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */
+28
View File
@@ -214,3 +214,31 @@ struct AppConfigData: Codable {
var script: [String]? var script: [String]?
} }
} }
extension AppConfigData {
///
/// AppConfigData JSON
///
var hasUsableContent: Bool {
let hasSites = !(sites?.isEmpty ?? true)
let hasLives = !(lives?.isEmpty ?? true)
let hasParses = !(parses?.isEmpty ?? true)
return hasSites || hasLives || hasParses
}
}
/// tvboxmulti.json
struct MultiRepoConfigData: Codable {
var urls: [Entry]?
struct Entry: Codable {
var name: String?
var url: String?
}
var candidateUrls: [String] {
(urls ?? [])
.compactMap { $0.url?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
}
+22 -2
View File
@@ -4,10 +4,15 @@ import Foundation
/// ///
struct LiveChannelGroup: Codable, Identifiable, Hashable { struct LiveChannelGroup: Codable, Identifiable, Hashable {
/// 便 SwiftUI diff
var id: String { groupName } var id: String { groupName }
///
var groupName: String = "" var groupName: String = ""
///
var groupIndex: Int = 0 var groupIndex: Int = 0
///
var channels: [LiveChannelItem] = [] var channels: [LiveChannelItem] = []
///
var isPassword: Bool = false var isPassword: Bool = false
init(groupName: String = "", groupIndex: Int = 0) { init(groupName: String = "", groupIndex: Int = 0) {
@@ -18,12 +23,19 @@ struct LiveChannelGroup: Codable, Identifiable, Hashable {
/// ///
struct LiveChannelItem: Codable, Identifiable, Hashable { struct LiveChannelItem: Codable, Identifiable, Hashable {
/// +
var id: String { "\(channelName)_\(channelIndex)" } var id: String { "\(channelName)_\(channelIndex)" }
///
var channelName: String = "" var channelName: String = ""
///
var channelIndex: Int = 0 var channelIndex: Int = 0
/// 线
var channelUrls: [String] = [] var channelUrls: [String] = []
/// 线
var sourceIndex: Int = 0 var sourceIndex: Int = 0
/// 线
var sourceNum: Int { channelUrls.count } var sourceNum: Int { channelUrls.count }
///
var logo: String = "" var logo: String = ""
init(channelName: String = "", channelIndex: Int = 0) { init(channelName: String = "", channelIndex: Int = 0) {
@@ -31,11 +43,14 @@ struct LiveChannelItem: Codable, Identifiable, Hashable {
self.channelIndex = channelIndex self.channelIndex = channelIndex
} }
/// 线
/// 线
var currentUrl: String? { var currentUrl: String? {
guard sourceIndex >= 0, sourceIndex < channelUrls.count else { return channelUrls.first } guard sourceIndex >= 0, sourceIndex < channelUrls.count else { return channelUrls.first }
return channelUrls[sourceIndex] return channelUrls[sourceIndex]
} }
/// 线
mutating func nextSource() { mutating func nextSource() {
if channelUrls.count > 0 { if channelUrls.count > 0 {
sourceIndex = (sourceIndex + 1) % channelUrls.count sourceIndex = (sourceIndex + 1) % channelUrls.count
@@ -51,6 +66,7 @@ struct Epginfo: Codable, Identifiable, Hashable {
var endTime: String = "" var endTime: String = ""
var index: Int = 0 var index: Int = 0
/// `HH:mm`
var isLive: Bool { var isLive: Bool {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "HH:mm" formatter.dateFormat = "HH:mm"
@@ -65,8 +81,12 @@ struct Epginfo: Codable, Identifiable, Hashable {
/// EPG /// EPG
struct LiveEpgDate: Codable, Identifiable, Hashable { struct LiveEpgDate: Codable, Identifiable, Hashable {
var id: String { datePresent } var id: String { datePresent }
var datePresent: String = "" // /// UI
var date: String = "" // var datePresent: String = ""
///
var date: String = ""
///
var index: Int = 0 var index: Int = 0
/// UI
var isSelected: Bool = false var isSelected: Bool = false
} }
+27 -7
View File
@@ -2,28 +2,47 @@ import Foundation
/// / - Android Movie.java /// / - Android Movie.java
struct Movie: Codable { struct Movie: Codable {
///
var videoList: [Video] = [] var videoList: [Video] = []
///
var pagecount: Int = 0 var pagecount: Int = 0
///
var page: Int = 0 var page: Int = 0
///
var total: Int = 0 var total: Int = 0
///
var limit: Int = 0 var limit: Int = 0
/// ///
struct Video: Codable, Identifiable, Hashable { struct Video: Codable, Identifiable, Hashable {
/// ID Int String
var id: String var id: String
///
var name: String = "" var name: String = ""
///
var pic: String = "" var pic: String = ""
var note: String = "" // "20" /// 20
var note: String = ""
///
var year: String = "" var year: String = ""
///
var area: String = "" var area: String = ""
var type: String = "" // / /// /
var type: String = ""
///
var director: String = "" var director: String = ""
///
var actor: String = "" var actor: String = ""
var des: String = "" // ///
var sourceKey: String = "" // key var des: String = ""
var tid: String = "" // ID /// key
var last: String = "" // var sourceKey: String = ""
var dt: String = "" // /// ID
var tid: String = ""
///
var last: String = ""
///
var dt: String = ""
init(id: String = UUID().uuidString, name: String = "", pic: String = "", init(id: String = UUID().uuidString, name: String = "", pic: String = "",
note: String = "", sourceKey: String = "") { note: String = "", sourceKey: String = "") {
@@ -51,6 +70,7 @@ struct Movie: Codable {
case sourceKey case sourceKey
} }
/// `vod_id` / `type_id` Int String
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
// String Int id // String Int id
+14 -2
View File
@@ -2,13 +2,18 @@ import Foundation
/// - Android MovieSort.java /// - Android MovieSort.java
struct MovieSort: Codable { struct MovieSort: Codable {
///
var sortList: [SortData] = [] var sortList: [SortData] = []
/// ///
struct SortData: Codable, Identifiable, Hashable { struct SortData: Codable, Identifiable, Hashable {
/// type_id
var id: String var id: String
///
var name: String = "" var name: String = ""
/// /
var flag: String = "" var flag: String = ""
///
var filters: [SortFilter] = [] var filters: [SortFilter] = []
init(id: String = "", name: String = "", flag: String = "") { init(id: String = "", name: String = "", flag: String = "") {
@@ -17,6 +22,8 @@ struct MovieSort: Codable {
self.flag = flag self.flag = flag
} }
///
///
static func home() -> SortData { static func home() -> SortData {
SortData(id: "home", name: "推荐", flag: "1") SortData(id: "home", name: "推荐", flag: "1")
} }
@@ -24,13 +31,18 @@ struct MovieSort: Codable {
/// ///
struct SortFilter: Codable, Hashable { struct SortFilter: Codable, Hashable {
/// `year``area`
var key: String = "" var key: String = ""
/// UI
var name: String = "" var name: String = ""
///
var values: [SortFilterValue] = [] var values: [SortFilterValue] = []
struct SortFilterValue: Codable, Hashable { struct SortFilterValue: Codable, Hashable {
var n: String = "" // ///
var v: String = "" // var n: String = ""
///
var v: String = ""
} }
} }
} }
+18
View File
@@ -2,11 +2,14 @@ import Foundation
/// ///
enum PlayerEngine: Int, CaseIterable, Identifiable { enum PlayerEngine: Int, CaseIterable, Identifiable {
/// AVPlayer
case system = 0 case system = 0
/// VLC VLCKitSPM
case vlc = 10 case vlc = 10
var id: Int { rawValue } var id: Int { rawValue }
/// UI
var title: String { var title: String {
switch self { switch self {
case .system: case .system:
@@ -16,6 +19,7 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
} }
} }
/// VLC
static var isVLCAvailable: Bool { static var isVLCAvailable: Bool {
#if canImport(VLCKitSPM) #if canImport(VLCKitSPM)
return true return true
@@ -24,6 +28,8 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
#endif #endif
} }
///
/// VLC
static var availableEngines: [PlayerEngine] { static var availableEngines: [PlayerEngine] {
var engines: [PlayerEngine] = [.system] var engines: [PlayerEngine] = [.system]
if isVLCAvailable { if isVLCAvailable {
@@ -32,6 +38,7 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
return engines return engines
} }
///
static func fromStoredValue(_ rawValue: Int) -> PlayerEngine { static func fromStoredValue(_ rawValue: Int) -> PlayerEngine {
guard let engine = PlayerEngine(rawValue: rawValue) else { guard let engine = PlayerEngine(rawValue: rawValue) else {
return .system return .system
@@ -47,8 +54,11 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
/// ///
enum VideoDecodeMode: Int, CaseIterable, Identifiable { enum VideoDecodeMode: Int, CaseIterable, Identifiable {
///
case auto = 0 case auto = 0
///
case hardware = 1 case hardware = 1
///
case software = 2 case software = 2
var id: Int { rawValue } var id: Int { rawValue }
@@ -69,6 +79,7 @@ enum VideoDecodeMode: Int, CaseIterable, Identifiable {
} }
/// VLC /// VLC
/// `avcodec-hw`
var vlcHardwareDecodeOption: String? { var vlcHardwareDecodeOption: String? {
switch self { switch self {
case .auto: case .auto:
@@ -84,8 +95,11 @@ enum VideoDecodeMode: Int, CaseIterable, Identifiable {
/// VLC /// VLC
enum VLCBufferMode: Int, CaseIterable, Identifiable { enum VLCBufferMode: Int, CaseIterable, Identifiable {
///
case lowLatency = 0 case lowLatency = 0
///
case balanced = 1 case balanced = 1
///
case smooth = 2 case smooth = 2
var id: Int { rawValue } var id: Int { rawValue }
@@ -111,6 +125,10 @@ enum VLCBufferMode: Int, CaseIterable, Identifiable {
self == .lowLatency self == .lowLatency
} }
/// /
/// - Parameters:
/// - isLive:
/// - Returns: network/live/file
func cacheConfig(isLive: Bool) -> (network: Int, live: Int, file: Int) { func cacheConfig(isLive: Bool) -> (network: Int, live: Int, file: Int) {
switch self { switch self {
case .lowLatency: case .lowLatency:
+18 -5
View File
@@ -2,25 +2,37 @@ import Foundation
/// - Android SourceBean.java /// - Android SourceBean.java
struct SourceBean: Codable, Identifiable, Hashable { struct SourceBean: Codable, Identifiable, Hashable {
/// key
var id: String { key } var id: String { key }
///
let key: String let key: String
///
let name: String let name: String
///
let api: String let api: String
let searchable: Int // 0: 1: /// 0 1
let filterable: Int // 0: 1: let searchable: Int
let playerType: Int // 0: 1:IJK 2:EXO /// 0 1
let type: Int // 0:xml 1:json 3:jar 4:remote let filterable: Int
/// 0 1 remote quick
let quickSearch: Int
/// Swift
let playerType: Int
/// 0 XML1 JSON3 JAR4 Remote
let type: Int
/// remote
let ext: String? let ext: String?
init(key: String = "", name: String = "", api: String = "", init(key: String = "", name: String = "", api: String = "",
searchable: Int = 1, filterable: Int = 1, searchable: Int = 1, filterable: Int = 1, quickSearch: Int = 0,
playerType: Int = 0, type: Int = 1, ext: String? = nil) { playerType: Int = 0, type: Int = 1, ext: String? = nil) {
self.key = key self.key = key
self.name = name self.name = name
self.api = api self.api = api
self.searchable = searchable self.searchable = searchable
self.filterable = filterable self.filterable = filterable
self.quickSearch = quickSearch
self.playerType = playerType self.playerType = playerType
self.type = type self.type = type
self.ext = ext self.ext = ext
@@ -28,6 +40,7 @@ struct SourceBean: Codable, Identifiable, Hashable {
var isSearchable: Bool { searchable == 1 } var isSearchable: Bool { searchable == 1 }
var isFilterable: Bool { filterable == 1 } var isFilterable: Bool { filterable == 1 }
var isQuickSearchEnabled: Bool { quickSearch == 1 }
/// Swift type=3 JAR/Spider Java /// Swift type=3 JAR/Spider Java
var isSupportedInSwift: Bool { var isSupportedInSwift: Bool {
+20 -3
View File
@@ -2,16 +2,27 @@ import Foundation
/// - Android VodInfo.java /// - Android VodInfo.java
struct VodInfo: Codable, Identifiable { struct VodInfo: Codable, Identifiable {
/// ID
var id: String var id: String
///
var name: String = "" var name: String = ""
///
var pic: String = "" var pic: String = ""
///
var note: String = "" var note: String = ""
///
var year: String = "" var year: String = ""
///
var area: String = "" var area: String = ""
///
var typeName: String = "" var typeName: String = ""
///
var director: String = "" var director: String = ""
///
var actor: String = "" var actor: String = ""
///
var des: String = "" var des: String = ""
/// key
var sourceKey: String = "" var sourceKey: String = ""
/// 线 /// 线
@@ -19,13 +30,17 @@ struct VodInfo: Codable, Identifiable {
/// key: flag, value: /// key: flag, value:
var playUrlMap: [String: [Episode]] = [:] var playUrlMap: [String: [Episode]] = [:]
var playFlag: String = "" // 线 /// 线
var playIndex: Int = 0 // var playFlag: String = ""
///
var playIndex: Int = 0
/// ///
struct Episode: Codable, Identifiable, Hashable { struct Episode: Codable, Identifiable, Hashable {
var id: String { name } var id: String { name }
///
let name: String let name: String
///
let url: String let url: String
init(name: String, url: String) { init(name: String, url: String) {
@@ -48,7 +63,7 @@ struct VodInfo: Codable, Identifiable {
info.des = video.des.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) info.des = video.des.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
info.sourceKey = video.sourceKey info.sourceKey = video.sourceKey
// //
// playFrom : "线1$$$线2$$$线3" // playFrom : "线1$$$线2$$$线3"
// playUrl : "1$url1#2$url2$$$1$url3#2$url4" // playUrl : "1$url1#2$url2$$$1$url3#2$url4"
let flags = playFrom.components(separatedBy: "$$$").filter { !$0.isEmpty } let flags = playFrom.components(separatedBy: "$$$").filter { !$0.isEmpty }
@@ -73,10 +88,12 @@ struct VodInfo: Codable, Identifiable {
return info return info
} }
/// 线
var currentEpisodes: [Episode] { var currentEpisodes: [Episode] {
playUrlMap[playFlag] ?? [] playUrlMap[playFlag] ?? []
} }
/// 线 +
var currentEpisode: Episode? { var currentEpisode: Episode? {
let eps = currentEpisodes let eps = currentEpisodes
guard playIndex >= 0, playIndex < eps.count else { return nil } guard playIndex >= 0, playIndex < eps.count else { return nil }
+150 -52
View File
@@ -3,23 +3,41 @@ import SwiftData
/// SwiftData - Android Room /// SwiftData - Android Room
/// /source + vodId
private func makeVodBusinessKey(vodId: String, sourceKey: String) -> String {
let normalizedVodId = vodId.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedSourceKey = sourceKey.trimmingCharacters(in: .whitespacesAndNewlines)
return "\(normalizedSourceKey)::\(normalizedVodId)"
}
/// ///
struct VodPlaybackState: Codable { struct VodPlaybackState: Codable {
/// 线
var flag: String var flag: String
///
var episodeIndex: Int var episodeIndex: Int
///
var progressSeconds: Double var progressSeconds: Double
} }
/// ///
@Model @Model
final class VodCollect { final class VodCollect {
/// sourceKey + vodId
var bizKey: String = ""
/// ID sourceKey
var vodId: String = "" var vodId: String = ""
///
var vodName: String = "" var vodName: String = ""
///
var vodPic: String = "" var vodPic: String = ""
/// key
var sourceKey: String = "" var sourceKey: String = ""
/// /
var updateTime: Date = Date() var updateTime: Date = Date()
init(vodId: String, vodName: String, vodPic: String, sourceKey: String) { init(vodId: String, vodName: String, vodPic: String, sourceKey: String) {
self.bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
self.vodId = vodId self.vodId = vodId
self.vodName = vodName self.vodName = vodName
self.vodPic = vodPic self.vodPic = vodPic
@@ -31,15 +49,25 @@ final class VodCollect {
/// ///
@Model @Model
final class VodRecord { final class VodRecord {
/// sourceKey + vodId
var bizKey: String = ""
/// ID
var vodId: String = "" var vodId: String = ""
///
var vodName: String = "" var vodName: String = ""
///
var vodPic: String = "" var vodPic: String = ""
/// key
var sourceKey: String = "" var sourceKey: String = ""
var playNote: String = "" // "5 03:45" /// 5 03:45
var dataJson: String = "" // JSONVodPlaybackState var playNote: String = ""
/// JSON`VodPlaybackState`
var dataJson: String = ""
///
var updateTime: Date = Date() var updateTime: Date = Date()
init(vodId: String, vodName: String, vodPic: String, sourceKey: String, playNote: String = "") { init(vodId: String, vodName: String, vodPic: String, sourceKey: String, playNote: String = "") {
self.bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
self.vodId = vodId self.vodId = vodId
self.vodName = vodName self.vodName = vodName
self.vodPic = vodPic self.vodPic = vodPic
@@ -52,8 +80,11 @@ final class VodRecord {
/// ///
@Model @Model
final class CacheItem { final class CacheItem {
///
@Attribute(.unique) var key: String = "" @Attribute(.unique) var key: String = ""
///
var value: String = "" var value: String = ""
///
var updateTime: Date = Date() var updateTime: Date = Date()
init(key: String, value: String) { init(key: String, value: String) {
@@ -71,49 +102,64 @@ actor CacheStore {
@MainActor @MainActor
func addCollect(_ video: Movie.Video, context: ModelContext) { func addCollect(_ video: Movie.Video, context: ModelContext) {
//
let vodId = video.id let vodId = video.id
let sourceKey = video.sourceKey let sourceKey = video.sourceKey
let predicate = #Predicate<VodCollect> { item in let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
item.vodId == vodId && item.sourceKey == sourceKey
}
let descriptor = FetchDescriptor<VodCollect>(predicate: predicate)
if let existing = try? context.fetch(descriptor), !existing.isEmpty { do {
return // let matched = try fetchCollects(vodId: vodId, sourceKey: sourceKey, context: context)
if let first = matched.first {
first.bizKey = bizKey
first.vodName = video.name
first.vodPic = video.pic
first.updateTime = Date()
for duplicate in matched.dropFirst() {
context.delete(duplicate)
}
} else {
let collect = VodCollect(
vodId: vodId,
vodName: video.name,
vodPic: video.pic,
sourceKey: sourceKey
)
context.insert(collect)
}
try context.save()
} catch {
print("写入收藏失败: \(error)")
} }
let collect = VodCollect(
vodId: video.id,
vodName: video.name,
vodPic: video.pic,
sourceKey: video.sourceKey
)
context.insert(collect)
try? context.save()
} }
@MainActor @MainActor
func removeCollect(vodId: String, sourceKey: String, context: ModelContext) { func removeCollect(vodId: String, sourceKey: String, context: ModelContext) {
let predicate = #Predicate<VodCollect> { item in do {
item.vodId == vodId && item.sourceKey == sourceKey let items = try fetchCollects(vodId: vodId, sourceKey: sourceKey, context: context)
} guard !items.isEmpty else { return }
let descriptor = FetchDescriptor<VodCollect>(predicate: predicate)
if let items = try? context.fetch(descriptor) {
for item in items { for item in items {
context.delete(item) context.delete(item)
} }
try? context.save() try context.save()
} catch {
print("删除收藏失败: \(error)")
} }
} }
@MainActor @MainActor
func isCollected(vodId: String, sourceKey: String, context: ModelContext) -> Bool { func isCollected(vodId: String, sourceKey: String, context: ModelContext) -> Bool {
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
let predicate = #Predicate<VodCollect> { item in let predicate = #Predicate<VodCollect> { item in
item.vodId == vodId && item.sourceKey == sourceKey item.bizKey == bizKey || (item.bizKey == "" && item.vodId == vodId && item.sourceKey == sourceKey)
} }
let descriptor = FetchDescriptor<VodCollect>(predicate: predicate) let descriptor = FetchDescriptor<VodCollect>(predicate: predicate)
return (try? context.fetchCount(descriptor)) ?? 0 > 0
do {
let count = try context.fetchCount(descriptor)
return count > 0
} catch {
print("查询收藏状态失败: \(error)")
return false
}
} }
@MainActor @MainActor
@@ -125,38 +171,55 @@ actor CacheStore {
) { ) {
let vodId = video.id let vodId = video.id
let sourceKey = video.sourceKey let sourceKey = video.sourceKey
let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context)
let encodedState = Self.encodePlaybackState(playbackState) let encodedState = Self.encodePlaybackState(playbackState)
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
// do {
if let record { let matched = try fetchRecords(vodId: vodId, sourceKey: sourceKey, context: context)
record.playNote = playNote
if let encodedState { //
record.dataJson = encodedState if let record = matched.first {
record.bizKey = bizKey
record.playNote = playNote
if let encodedState {
record.dataJson = encodedState
}
record.updateTime = Date()
for duplicate in matched.dropFirst() {
context.delete(duplicate)
}
} else {
let record = VodRecord(
vodId: vodId,
vodName: video.name,
vodPic: video.pic,
sourceKey: sourceKey,
playNote: playNote
)
if let encodedState {
record.dataJson = encodedState
}
context.insert(record)
} }
record.updateTime = Date()
} else { try context.save()
let record = VodRecord( } catch {
vodId: video.id, print("写入播放记录失败: \(error)")
vodName: video.name,
vodPic: video.pic,
sourceKey: video.sourceKey,
playNote: playNote
)
if let encodedState {
record.dataJson = encodedState
}
context.insert(record)
} }
try? context.save()
} }
/// JSON `nil`
@MainActor @MainActor
func getPlaybackState(vodId: String, sourceKey: String, context: ModelContext) -> VodPlaybackState? { func getPlaybackState(vodId: String, sourceKey: String, context: ModelContext) -> VodPlaybackState? {
guard let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context) else { do {
guard let record = try fetchRecords(vodId: vodId, sourceKey: sourceKey, context: context).first else {
return nil
}
return Self.decodePlaybackState(record.dataJson)
} catch {
print("读取续播状态失败: \(error)")
return nil return nil
} }
return Self.decodePlaybackState(record.dataJson)
} }
@MainActor @MainActor
@@ -170,13 +233,47 @@ actor CacheStore {
} }
@MainActor @MainActor
private func fetchRecord(vodId: String, sourceKey: String, context: ModelContext) -> VodRecord? { private func fetchRecords(vodId: String, sourceKey: String, context: ModelContext) throws -> [VodRecord] {
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
let predicate = #Predicate<VodRecord> { item in let predicate = #Predicate<VodRecord> { item in
item.vodId == vodId && item.sourceKey == sourceKey item.bizKey == bizKey || (item.bizKey == "" && item.vodId == vodId && item.sourceKey == sourceKey)
} }
let descriptor = FetchDescriptor<VodRecord>(predicate: predicate) let descriptor = FetchDescriptor<VodRecord>(predicate: predicate)
guard let records = try? context.fetch(descriptor) else { return nil } let records = try context.fetch(descriptor)
return records.first
// legacy
var needsSave = false
for record in records where record.bizKey.isEmpty {
record.bizKey = bizKey
needsSave = true
}
if needsSave {
try context.save()
}
return records.sorted(by: { $0.updateTime > $1.updateTime })
}
@MainActor
private func fetchCollects(vodId: String, sourceKey: String, context: ModelContext) throws -> [VodCollect] {
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
let predicate = #Predicate<VodCollect> { item in
item.bizKey == bizKey || (item.bizKey == "" && item.vodId == vodId && item.sourceKey == sourceKey)
}
let descriptor = FetchDescriptor<VodCollect>(predicate: predicate)
let collects = try context.fetch(descriptor)
// legacy
var needsSave = false
for collect in collects where collect.bizKey.isEmpty {
collect.bizKey = bizKey
needsSave = true
}
if needsSave {
try context.save()
}
return collects.sorted(by: { $0.updateTime > $1.updateTime })
} }
private nonisolated static func encodePlaybackState(_ state: VodPlaybackState?) -> String? { private nonisolated static func encodePlaybackState(_ state: VodPlaybackState?) -> String? {
@@ -185,6 +282,7 @@ actor CacheStore {
return String(data: data, encoding: .utf8) return String(data: data, encoding: .utf8)
} }
/// JSON
private nonisolated static func decodePlaybackState(_ json: String) -> VodPlaybackState? { private nonisolated static func decodePlaybackState(_ json: String) -> VodPlaybackState? {
guard let data = json.data(using: .utf8) else { return nil } guard let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(VodPlaybackState.self, from: data) return try? JSONDecoder().decode(VodPlaybackState.self, from: data)
+517 -33
View File
@@ -5,6 +5,17 @@ import Foundation
@MainActor @MainActor
class ApiConfig: ObservableObject { class ApiConfig: ObservableObject {
static let shared = ApiConfig() static let shared = ApiConfig()
private static let maxConfigResolveDepth = 6
private static let maxRedirectCandidates = 20
private static let rawConfigCacheTTL: TimeInterval = 20
private static let maxRawConfigCacheEntries = 24
struct MultiRepoOption: Identifiable, Equatable {
let name: String
let url: String
var id: String { url.lowercased() }
}
@Published var sourceBeanList: [SourceBean] = [] @Published var sourceBeanList: [SourceBean] = []
@Published var homeSourceBean: SourceBean? @Published var homeSourceBean: SourceBean?
@@ -17,6 +28,13 @@ class ApiConfig: ObservableObject {
@Published var wallpaper: String = "" @Published var wallpaper: String = ""
private let network = NetworkManager.shared private let network = NetworkManager.shared
private var activeLoadToken = UUID()
private var liveParseTask: Task<Void, Never>?
private struct RawConfigCacheEntry {
let content: String
let fetchedAt: Date
}
private var rawConfigCache: [String: RawConfigCacheEntry] = [:]
private init() {} private init() {}
@@ -32,27 +50,98 @@ class ApiConfig: ObservableObject {
guard !trimmedVod.isEmpty else { guard !trimmedVod.isEmpty else {
throw ConfigError.parseError("点播接口地址不能为空") throw ConfigError.parseError("点播接口地址不能为空")
} }
let loadToken = UUID()
activeLoadToken = loadToken
liveParseTask?.cancel()
liveParseTask = nil
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
self.configUrl = trimmedVod self.configUrl = trimmedVod
self.liveConfigUrl = resolvedLive self.liveConfigUrl = resolvedLive
if trimmedVod == resolvedLive { if trimmedVod == resolvedLive {
let config = try await fetchConfig(from: trimmedVod) let configResult = try await fetchConfig(from: trimmedVod)
parseConfig(config, apiUrl: trimmedVod, includeSources: true, includeLive: true) guard activeLoadToken == loadToken else { return }
await parseConfig(
configResult.config,
apiUrl: configResult.loadedFrom,
includeSources: true,
includeLive: false,
loadToken: loadToken
)
scheduleLiveParsing(
config: configResult.config,
apiUrl: configResult.loadedFrom,
loadToken: loadToken
)
} else { } else {
let vodConfig = try await fetchConfig(from: trimmedVod) async let vodConfigTask = fetchConfig(from: trimmedVod)
parseConfig(vodConfig, apiUrl: trimmedVod, includeSources: true, includeLive: false) async let liveConfigTask = fetchConfig(from: resolvedLive)
let (vodConfig, liveConfig) = try await (vodConfigTask, liveConfigTask)
let liveConfig = try await fetchConfig(from: resolvedLive) guard activeLoadToken == loadToken else { return }
parseConfig(liveConfig, apiUrl: resolvedLive, includeSources: false, includeLive: true) await parseConfig(
vodConfig.config,
apiUrl: vodConfig.loadedFrom,
includeSources: true,
includeLive: false,
loadToken: loadToken
)
scheduleLiveParsing(
config: liveConfig.config,
apiUrl: liveConfig.loadedFrom,
loadToken: loadToken
)
} }
guard activeLoadToken == loadToken else { return }
self.isLoaded = true self.isLoaded = true
} }
private func fetchConfig(from apiUrl: String) async throws -> AppConfigData { ///
let jsonStr = try await network.getString(from: apiUrl) private func scheduleLiveParsing(config: AppConfigData, apiUrl: String, loadToken: UUID) {
liveParseTask?.cancel()
liveParseTask = Task { [config, apiUrl] in
await parseConfig(
config,
apiUrl: apiUrl,
includeSources: false,
includeLive: true,
loadToken: loadToken
)
}
}
private func fetchConfig(from apiUrl: String) async throws -> (config: AppConfigData, loadedFrom: String) {
try await fetchConfig(
from: apiUrl,
visitedUrls: Set<String>(),
depth: 0
)
}
private func fetchConfig(
from apiUrl: String,
visitedUrls: Set<String>,
depth: Int
) async throws -> (config: AppConfigData, loadedFrom: String) {
guard depth <= Self.maxConfigResolveDepth else {
throw ConfigError.parseError("配置跳转层级过深(超过 \(Self.maxConfigResolveDepth) 层)")
}
let normalizedUrl = Self.normalizeConfigUrl(apiUrl)
guard !normalizedUrl.isEmpty else {
throw ConfigError.parseError("配置地址为空")
}
let visitKey = normalizedUrl.lowercased()
guard !visitedUrls.contains(visitKey) else {
throw ConfigError.parseError("检测到循环引用的配置地址: \(normalizedUrl)")
}
var nextVisited = visitedUrls
nextVisited.insert(visitKey)
let jsonStr = try await fetchConfigText(from: normalizedUrl)
// JSONAndroid Gson Swift // JSONAndroid Gson Swift
let cleanedJson = Self.stripJsonComments(jsonStr) let cleanedJson = Self.stripJsonComments(jsonStr)
@@ -61,7 +150,311 @@ class ApiConfig: ObservableObject {
throw ConfigError.parseError("无法解析配置数据") throw ConfigError.parseError("无法解析配置数据")
} }
return try JSONDecoder().decode(AppConfigData.self, from: data) let decoder = JSONDecoder()
let decodedConfig = try? decoder.decode(AppConfigData.self, from: data)
if let config = decodedConfig, config.hasUsableContent {
return (config, normalizedUrl)
}
if let multiRepo = try? decoder.decode(MultiRepoConfigData.self, from: data) {
let candidateUrls = Self.uniqueUrlsInOrder(
multiRepo.candidateUrls.map(Self.normalizeConfigUrl)
)
var lastError: Error?
for candidateUrl in candidateUrls {
do {
return try await fetchConfig(
from: candidateUrl,
visitedUrls: nextVisited,
depth: depth + 1
)
} catch {
lastError = error
}
}
if let lastError {
throw ConfigError.parseError("多仓库配置中没有可用地址,最后错误: \(lastError.localizedDescription)")
}
throw ConfigError.parseError("多仓库配置中没有可用地址")
}
let redirectCandidates = Self.extractConfigRedirectCandidates(from: cleanedJson)
.filter { $0.lowercased() != visitKey && !nextVisited.contains($0.lowercased()) }
if !redirectCandidates.isEmpty {
var lastError: Error?
for candidate in redirectCandidates {
do {
return try await fetchConfig(
from: candidate,
visitedUrls: nextVisited,
depth: depth + 1
)
} catch {
lastError = error
}
}
if let lastError {
throw ConfigError.parseError("页面跳转配置解析失败,最后错误: \(lastError.localizedDescription)")
}
}
if decodedConfig != nil {
throw ConfigError.parseError("配置缺少可用站点(sites / lives / parses")
}
throw ConfigError.parseError("配置格式不受支持")
}
/// +
private func fetchConfigText(from normalizedUrl: String) async throws -> String {
let key = normalizedUrl.lowercased()
let now = Date()
if let entry = rawConfigCache[key] {
if now.timeIntervalSince(entry.fetchedAt) <= Self.rawConfigCacheTTL {
return entry.content
}
rawConfigCache.removeValue(forKey: key)
}
let content = try await network.getString(from: normalizedUrl)
rawConfigCache[key] = RawConfigCacheEntry(content: content, fetchedAt: now)
trimRawConfigCacheIfNeeded()
return content
}
private func trimRawConfigCacheIfNeeded() {
guard rawConfigCache.count > Self.maxRawConfigCacheEntries else { return }
let overflow = rawConfigCache.count - Self.maxRawConfigCacheEntries
let staleKeys = rawConfigCache
.sorted { $0.value.fetchedAt < $1.value.fetchedAt }
.prefix(overflow)
.map(\.key)
staleKeys.forEach { rawConfigCache.removeValue(forKey: $0) }
}
private static func uniqueUrlsInOrder(_ urls: [String]) -> [String] {
var seen: Set<String> = []
var result: [String] = []
for url in urls {
let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
let key = trimmed.lowercased()
guard !seen.contains(key) else { continue }
seen.insert(key)
result.append(trimmed)
}
return result
}
/// /
/// - URL
/// - data-clipboard-text
/// - JSON url
private static func extractConfigRedirectCandidates(from content: String) -> [String] {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
var rawCandidates: [String] = []
if (trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://")) && !trimmed.contains("\n") {
rawCandidates.append(trimmed)
}
rawCandidates.append(contentsOf: matchCaptureGroup(
pattern: #"data-clipboard-text\s*=\s*["']([^"']+)["']"#,
in: content
))
rawCandidates.append(contentsOf: matchCaptureGroup(
pattern: #""url"\s*:\s*"([^"]+)""#,
in: content
))
rawCandidates.append(contentsOf: matchCaptureGroup(
pattern: #"(https?://[^\s"'<>\\]+)"#,
in: content
))
let normalized = rawCandidates
.map(sanitizeExtractedUrl)
.map(normalizeConfigUrl)
.filter { !$0.isEmpty }
.filter(isLikelyConfigPointerUrl)
.filter { !isLikelyBinaryAssetUrl($0) }
return Array(uniqueUrlsInOrder(normalized).prefix(maxRedirectCandidates))
}
private static func matchCaptureGroup(pattern: String, in content: String) -> [String] {
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
return []
}
let range = NSRange(content.startIndex..<content.endIndex, in: content)
let matches = regex.matches(in: content, options: [], range: range)
return matches.compactMap { match in
guard match.numberOfRanges >= 2,
let subRange = Range(match.range(at: 1), in: content) else {
return nil
}
return String(content[subRange])
}
}
private static func sanitizeExtractedUrl(_ value: String) -> String {
var result = value.trimmingCharacters(in: .whitespacesAndNewlines)
result = result.replacingOccurrences(of: "&amp;", with: "&")
result = result.replacingOccurrences(of: "\\/", with: "/")
while let last = result.last, [".", ",", ";", ")", "]", "}", "\"", "'"].contains(last) {
result.removeLast()
}
while let first = result.first, ["\"", "'", "(", "[", "{"].contains(first) {
result.removeFirst()
}
return result.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func isLikelyBinaryAssetUrl(_ urlString: String) -> Bool {
guard let components = URLComponents(string: urlString) else { return false }
let path = components.path.lowercased()
return path.hasSuffix(".png")
|| path.hasSuffix(".jpg")
|| path.hasSuffix(".jpeg")
|| path.hasSuffix(".webp")
|| path.hasSuffix(".gif")
|| path.hasSuffix(".svg")
|| path.hasSuffix(".ico")
|| path.hasSuffix(".css")
|| path.hasSuffix(".woff")
|| path.hasSuffix(".woff2")
|| path.hasSuffix(".ttf")
}
private static func isLikelyConfigPointerUrl(_ urlString: String) -> Bool {
guard let components = URLComponents(string: urlString) else { return false }
let host = (components.host ?? "").lowercased()
let path = components.path.lowercased()
let query = (components.percentEncodedQuery ?? "").lowercased()
if host.contains("raw.githubusercontent.com") || host.contains("githubusercontent.com") {
return true
}
if path.contains(".json")
|| path.hasSuffix("/tv")
|| path.hasSuffix("/tv/")
|| path.hasSuffix("/m")
|| path.hasSuffix("/m/")
|| path.contains("tvbox")
|| path.contains("box")
|| query.contains("json")
|| query.contains("config")
|| query.contains("url=") {
return true
}
return false
}
/// URL
/// 1) https:/xxx
/// 2) github.com/.../blob/... raw.githubusercontent.com/...
/// 3) gh-proxy + github/blob
static func normalizeConfigUrl(_ rawUrl: String) -> String {
let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
let fixedScheme = fixMalformedSchemeIfNeeded(trimmed)
if let normalizedProxy = normalizeGhProxyWrappedUrl(fixedScheme) {
return normalizedProxy
}
if let githubRaw = convertGitHubBlobUrlToRaw(fixedScheme) {
return githubRaw
}
return fixedScheme
}
private static func fixMalformedSchemeIfNeeded(_ urlString: String) -> String {
let fixedHttps = urlString.replacingOccurrences(
of: #"^https:/(?!/)"#,
with: "https://",
options: .regularExpression
)
return fixedHttps.replacingOccurrences(
of: #"^http:/(?!/)"#,
with: "http://",
options: .regularExpression
)
}
private static func normalizeGhProxyWrappedUrl(_ urlString: String) -> String? {
guard let components = URLComponents(string: urlString),
let host = components.host?.lowercased(),
host.contains("gh-proxy") else {
return nil
}
let path = components.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard !path.isEmpty else { return nil }
let decodedPath = path.removingPercentEncoding ?? path
let fixedEmbedded = fixMalformedSchemeIfNeeded(decodedPath)
guard fixedEmbedded.hasPrefix("http://") || fixedEmbedded.hasPrefix("https://") else {
return nil
}
let normalizedEmbedded = convertGitHubBlobUrlToRaw(fixedEmbedded) ?? fixedEmbedded
let scheme = components.scheme ?? "https"
let portSuffix = components.port.map { ":\($0)" } ?? ""
var rebuilt = "\(scheme)://\(host)\(portSuffix)/\(normalizedEmbedded)"
if let query = components.percentEncodedQuery, !query.isEmpty {
rebuilt += "?\(query)"
}
if let fragment = components.percentEncodedFragment, !fragment.isEmpty {
rebuilt += "#\(fragment)"
}
return rebuilt
}
private static func convertGitHubBlobUrlToRaw(_ urlString: String) -> String? {
guard let components = URLComponents(string: urlString),
let host = components.host?.lowercased(),
host == "github.com" || host == "www.github.com" else {
return nil
}
let parts = components.path.split(separator: "/", omittingEmptySubsequences: true)
guard parts.count >= 5, parts[2] == "blob" else {
return nil
}
let owner = String(parts[0])
let repo = String(parts[1])
let branch = String(parts[3])
let filePath = parts.dropFirst(4).joined(separator: "/")
guard !filePath.isEmpty else {
return nil
}
var rawUrl = "https://raw.githubusercontent.com/\(owner)/\(repo)/\(branch)/\(filePath)"
if let query = components.percentEncodedQuery, !query.isEmpty {
rawUrl += "?\(query)"
}
if let fragment = components.percentEncodedFragment, !fragment.isEmpty {
rawUrl += "#\(fragment)"
}
return rawUrl
} }
/// JSON // TVBox /// JSON // TVBox
@@ -124,13 +517,64 @@ class ApiConfig: ObservableObject {
return line return line
} }
///
/// nil
func fetchMultiRepoOptions(from apiUrl: String) async throws -> [MultiRepoOption]? {
let normalizedUrl = Self.normalizeConfigUrl(apiUrl)
let jsonStr = try await fetchConfigText(from: normalizedUrl)
let cleanedJson = Self.stripJsonComments(jsonStr)
guard let data = cleanedJson.data(using: .utf8) else {
throw ConfigError.parseError("无法解析配置数据")
}
let decoder = JSONDecoder()
if let config = try? decoder.decode(AppConfigData.self, from: data), config.hasUsableContent {
return nil
}
guard let multiRepo = try? decoder.decode(MultiRepoConfigData.self, from: data) else {
return nil
}
let normalizedCandidates = Self.uniqueUrlsInOrder(
multiRepo.candidateUrls.map(Self.normalizeConfigUrl)
)
var options: [MultiRepoOption] = []
for candidate in normalizedCandidates {
let matchedEntry = multiRepo.urls?.first(where: {
Self.normalizeConfigUrl($0.url ?? "") == candidate
})
let displayName = matchedEntry?.name?.trimmingCharacters(in: .whitespacesAndNewlines)
let fallbackName = URL(string: candidate)?.host ?? candidate
let resolvedName: String
if let displayName, !displayName.isEmpty {
resolvedName = displayName
} else {
resolvedName = fallbackName
}
options.append(
MultiRepoOption(
name: resolvedName,
url: candidate
)
)
}
return options
}
/// ///
private func parseConfig( private func parseConfig(
_ config: AppConfigData, _ config: AppConfigData,
apiUrl: String, apiUrl: String,
includeSources: Bool, includeSources: Bool,
includeLive: Bool includeLive: Bool,
) { loadToken: UUID
) async {
guard activeLoadToken == loadToken else { return }
if includeSources { if includeSources {
// //
var sources: [SourceBean] = [] var sources: [SourceBean] = []
@@ -142,6 +586,7 @@ class ApiConfig: ObservableObject {
api: site.api ?? "", api: site.api ?? "",
searchable: site.searchable?.value ?? 1, searchable: site.searchable?.value ?? 1,
filterable: site.filterable?.value ?? 1, filterable: site.filterable?.value ?? 1,
quickSearch: site.quickSearch?.value ?? 0,
playerType: site.playerType?.value ?? 0, playerType: site.playerType?.value ?? 0,
type: site.type?.value ?? 1, type: site.type?.value ?? 1,
ext: site.ext?.stringValue ext: site.ext?.stringValue
@@ -165,6 +610,8 @@ class ApiConfig: ObservableObject {
self.parseBeanList = parses.map { p in self.parseBeanList = parses.map { p in
ParseBean(name: p.name ?? "", url: p.url ?? "", type: p.type?.value ?? 0) ParseBean(name: p.name ?? "", url: p.url ?? "", type: p.type?.value ?? 0)
} }
} else {
self.parseBeanList = []
} }
// DoH // DoH
@@ -173,6 +620,8 @@ class ApiConfig: ObservableObject {
guard let name = d.name, let url = d.url else { return nil } guard let name = d.name, let url = d.url else { return nil }
return (name: name, url: url) return (name: name, url: url)
} }
} else {
self.dohList = []
} }
// //
@@ -181,7 +630,9 @@ class ApiConfig: ObservableObject {
if includeLive { if includeLive {
if let lives = config.lives { if let lives = config.lives {
parseLives(lives, apiUrl: apiUrl) let parsedGroups = await parseLives(lives, apiUrl: apiUrl, loadToken: loadToken)
guard activeLoadToken == loadToken else { return }
liveChannelGroupList = parsedGroups
} else { } else {
liveChannelGroupList = [] liveChannelGroupList = []
} }
@@ -189,32 +640,65 @@ class ApiConfig: ObservableObject {
} }
/// ///
private func parseLives(_ lives: [AppConfigData.LiveConfig], apiUrl: String) { private func parseLives(
Task { _ lives: [AppConfigData.LiveConfig],
var mergedGroups: [String: LiveChannelGroup] = [:] apiUrl: String,
loadToken: UUID
) async -> [LiveChannelGroup] {
var mergedGroups: [String: LiveChannelGroup] = [:]
var remoteLiveTargets: [(order: Int, url: String)] = []
for live in lives { for (index, live) in lives.enumerated() {
// url guard activeLoadToken == loadToken else { return [] }
if let liveUrl = live.url, !liveUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let resolvedUrl = resolveLiveUrl(liveUrl, baseConfigUrl: apiUrl) // url
do { if let liveUrl = live.url, !liveUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let content = try await network.getString(from: resolvedUrl) let resolvedUrl = resolveLiveUrl(liveUrl, baseConfigUrl: apiUrl)
let groups = parseLiveContent(content) remoteLiveTargets.append((order: index, url: resolvedUrl))
mergeLiveGroups(groups, into: &mergedGroups) }
} catch {
print("加载直播源失败: \(resolvedUrl), error: \(error)") //
if let channels = live.channels {
let inlineGroups = parseInlineLiveChannels(channels)
mergeLiveGroups(inlineGroups, into: &mergedGroups)
}
}
if !remoteLiveTargets.isEmpty {
let fetchedContents = await withTaskGroup(
of: (Int, String?).self,
returning: [(Int, String)].self
) { group in
for target in remoteLiveTargets {
group.addTask {
do {
let content = try await NetworkManager.shared.getString(from: target.url)
return (target.order, content)
} catch {
print("加载直播源失败: \(target.url), error: \(error)")
return (target.order, nil)
}
} }
} }
// var results: [(Int, String)] = []
if let channels = live.channels { for await (order, content) in group {
let inlineGroups = parseInlineLiveChannels(channels) if let content {
mergeLiveGroups(inlineGroups, into: &mergedGroups) results.append((order, content))
}
} }
return results
} }
self.liveChannelGroupList = sortedGroups(from: mergedGroups) for (_, content) in fetchedContents.sorted(by: { $0.0 < $1.0 }) {
guard activeLoadToken == loadToken else { return [] }
let groups = parseLiveContent(content)
mergeLiveGroups(groups, into: &mergedGroups)
liveChannelGroupList = sortedGroups(from: mergedGroups)
}
} }
return sortedGroups(from: mergedGroups)
} }
/// m3u / txt /// m3u / txt
@@ -389,13 +873,13 @@ class ApiConfig: ObservableObject {
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed } guard !trimmed.isEmpty else { return trimmed }
if let url = URL(string: trimmed), url.scheme != nil { if let url = URL(string: trimmed), url.scheme != nil {
return trimmed return Self.normalizeConfigUrl(trimmed)
} }
guard let baseUrl = URL(string: baseConfigUrl), guard let baseUrl = URL(string: baseConfigUrl),
let resolved = URL(string: trimmed, relativeTo: baseUrl)?.absoluteURL else { let resolved = URL(string: trimmed, relativeTo: baseUrl)?.absoluteURL else {
return trimmed return trimmed
} }
return resolved.absoluteString return Self.normalizeConfigUrl(resolved.absoluteString)
} }
private static func uniqueLiveUrls(_ urls: [String]) -> [String] { private static func uniqueLiveUrls(_ urls: [String]) -> [String] {
+231 -19
View File
@@ -2,21 +2,64 @@ import Foundation
/// - Android OkGo /// - Android OkGo
class NetworkManager { class NetworkManager {
///
static let shared = NetworkManager() static let shared = NetworkManager()
///
static let defaultMaxRetries = 2
/// = base * 2^attempt + jitter
private static let retryBaseDelay: TimeInterval = 0.5
///
private static let retryMaxDelay: TimeInterval = 8.0
///
private static let fallbackCharsetNames: [String] = [
"utf-8",
"gb18030",
"gbk",
"gb2312",
"utf-16",
"utf-16le",
"utf-16be",
"utf-32",
"windows-1252",
"iso-8859-1"
]
///
private static let fallbackStringEncodings: [String.Encoding] = [
.utf8,
.utf16,
.utf16LittleEndian,
.utf16BigEndian,
.utf32,
.utf32LittleEndian,
.utf32BigEndian,
.windowsCP1252,
.isoLatin1
]
///
private let session: URLSession private let session: URLSession
/// JSON
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
///
private init() { private init() {
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15 config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 30 config.timeoutIntervalForResource = 30
config.httpMaximumConnectionsPerHost = 5 config.httpMaximumConnectionsPerHost = 5
config.waitsForConnectivity = true
self.session = URLSession(configuration: config) self.session = URLSession(configuration: config)
} }
/// GET /// GET
func getString(from urlString: String, headers: [String: String]? = nil) async throws -> String { func getString(
from urlString: String,
headers: [String: String]? = nil,
maxRetries: Int = NetworkManager.defaultMaxRetries
) async throws -> String {
guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else { guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else {
throw NetworkError.invalidURL(urlString) throw NetworkError.invalidURL(urlString)
} }
@@ -25,48 +68,188 @@ class NetworkManager {
request.httpMethod = "GET" request.httpMethod = "GET"
headers?.forEach { request.setValue($1, forHTTPHeaderField: $0) } headers?.forEach { request.setValue($1, forHTTPHeaderField: $0) }
let (data, response) = try await session.data(for: request) let (data, httpResponse) = try await performRequest(request, maxRetries: maxRetries)
guard let httpResponse = response as? HTTPURLResponse else { guard let str = Self.decodeString(data: data, response: httpResponse) else {
throw NetworkError.invalidResponse throw NetworkError.decodingError("文本解码失败")
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.httpError(httpResponse.statusCode)
}
guard let str = String(data: data, encoding: .utf8) else {
throw NetworkError.decodingError("UTF-8 解码失败")
} }
return str return str
} }
/// GET JSON /// GET JSON
func getJSON<T: Decodable>(from urlString: String, type: T.Type, headers: [String: String]? = nil) async throws -> T { func getJSON<T: Decodable>(
let str = try await getString(from: urlString, headers: headers) from urlString: String,
type: T.Type,
headers: [String: String]? = nil,
maxRetries: Int = NetworkManager.defaultMaxRetries
) async throws -> T {
let str = try await getString(from: urlString, headers: headers, maxRetries: maxRetries)
guard let data = str.data(using: .utf8) else { guard let data = str.data(using: .utf8) else {
throw NetworkError.decodingError("字符串转 Data 失败") throw NetworkError.decodingError("字符串转 Data 失败")
} }
return try decoder.decode(T.self, from: data) return try decoder.decode(T.self, from: data)
} }
/// GET Data /// GET Data HTTP
func getData(from urlString: String) async throws -> Data { func getData(
from urlString: String,
maxRetries: Int = NetworkManager.defaultMaxRetries
) async throws -> Data {
guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else { guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else {
throw NetworkError.invalidURL(urlString) throw NetworkError.invalidURL(urlString)
} }
let (data, _) = try await session.data(from: url) let request = URLRequest(url: url)
let (data, _) = try await performRequest(request, maxRetries: maxRetries)
return data return data
} }
// MARK: -
/// HTTP 退
private func performRequest(
_ request: URLRequest,
maxRetries: Int
) async throws -> (Data, HTTPURLResponse) {
var lastError: Error = NetworkError.invalidResponse
let totalAttempts = max(1, maxRetries + 1)
for attempt in 0..<totalAttempts {
do {
try Task.checkCancellation()
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
let error = NetworkError.httpError(httpResponse.statusCode)
if Self.isRetryableHTTPStatus(httpResponse.statusCode) && attempt < totalAttempts - 1 {
lastError = error
try await retryDelay(attempt: attempt)
continue
}
throw error
}
return (data, httpResponse)
} catch is CancellationError {
throw CancellationError()
} catch {
lastError = error
if Self.isRetryableError(error) && attempt < totalAttempts - 1 {
try await retryDelay(attempt: attempt)
continue
}
throw error
}
}
throw lastError
}
/// 退 +
private func retryDelay(attempt: Int) async throws {
let base = Self.retryBaseDelay * pow(2.0, Double(attempt))
let jitter = Double.random(in: 0...0.5)
let delay = min(base + jitter, Self.retryMaxDelay)
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
/// HTTP
private static func isRetryableHTTPStatus(_ statusCode: Int) -> Bool {
switch statusCode {
case 408, 429, 500, 502, 503, 504: return true
default: return false
}
}
///
private static func isRetryableError(_ error: Error) -> Bool {
if error is CancellationError { return false }
if let networkError = error as? NetworkError {
if case .httpError(let code) = networkError {
return isRetryableHTTPStatus(code)
}
if case .invalidURL = networkError { return false }
if case .decodingError = networkError { return false }
}
let nsError = error as NSError
if nsError.domain == NSURLErrorDomain {
switch nsError.code {
case NSURLErrorTimedOut,
NSURLErrorCannotFindHost,
NSURLErrorCannotConnectToHost,
NSURLErrorNetworkConnectionLost,
NSURLErrorDNSLookupFailed,
NSURLErrorNotConnectedToInternet,
NSURLErrorSecureConnectionFailed,
NSURLErrorDataNotAllowed:
return true
default:
return false
}
}
return false
}
///
/// 1)
/// 2)
/// 3) UTF-8
private static func decodeString(data: Data, response: HTTPURLResponse) -> String? {
// 使 gbk / gb2312 / gb18030
if let charset = response.textEncodingName,
let declaredEncoding = encoding(fromIANACharset: charset),
let value = String(data: data, encoding: declaredEncoding) {
return value
}
for charset in fallbackCharsetNames {
if let encoding = encoding(fromIANACharset: charset),
let value = String(data: data, encoding: encoding) {
return value
}
}
for encoding in fallbackStringEncodings {
if let value = String(data: data, encoding: encoding) {
return value
}
}
//
if !data.isEmpty {
return String(decoding: data, as: UTF8.self)
}
return nil
}
/// IANA `String.Encoding`
private static func encoding(fromIANACharset charset: String) -> String.Encoding? {
let cfEncoding = CFStringConvertIANACharSetNameToEncoding(charset as CFString)
guard cfEncoding != kCFStringEncodingInvalidId else {
return nil
}
let nsEncoding = CFStringConvertEncodingToNSStringEncoding(cfEncoding)
return String.Encoding(rawValue: nsEncoding)
}
} }
///
enum NetworkError: LocalizedError { enum NetworkError: LocalizedError {
case invalidURL(String) case invalidURL(String)
case invalidResponse case invalidResponse
case httpError(Int) case httpError(Int)
case decodingError(String) case decodingError(String)
/// UI/
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .invalidURL(let url): return "无效的URL: \(url)" case .invalidURL(let url): return "无效的URL: \(url)"
@@ -75,4 +258,33 @@ enum NetworkError: LocalizedError {
case .decodingError(let msg): return "解码错误: \(msg)" case .decodingError(let msg): return "解码错误: \(msg)"
} }
} }
/// UI
var isNetworkUnavailable: Bool {
if case .httpError = self { return false }
return true
}
}
extension Error {
/// DNS
var isNetworkConnectionError: Bool {
if self is CancellationError { return false }
let nsError = self as NSError
if nsError.domain == NSURLErrorDomain {
switch nsError.code {
case NSURLErrorTimedOut,
NSURLErrorCannotFindHost,
NSURLErrorCannotConnectToHost,
NSURLErrorNetworkConnectionLost,
NSURLErrorDNSLookupFailed,
NSURLErrorNotConnectedToInternet,
NSURLErrorDataNotAllowed:
return true
default:
return false
}
}
return false
}
} }
+66
View File
@@ -0,0 +1,66 @@
import Foundation
import Network
import Combine
/// NWPathMonitor
/// UI 线
@MainActor
final class NetworkMonitor: ObservableObject {
static let shared = NetworkMonitor()
///
@Published private(set) var isConnected: Bool = true
/// Wi-Fi / / 线 /
@Published private(set) var connectionType: ConnectionType = .unknown
///
let networkRestoredPublisher = PassthroughSubject<Void, Never>()
enum ConnectionType: String {
case wifi = "Wi-Fi"
case cellular = "蜂窝"
case wiredEthernet = "有线"
case unknown = "未知"
}
private let monitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(label: "com.tvbox.networkmonitor", qos: .utility)
private var wasDisconnected = false
private init() {
startMonitoring()
}
private func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor [weak self] in
guard let self else { return }
let connected = path.status == .satisfied
let previouslyConnected = self.isConnected
self.isConnected = connected
self.connectionType = Self.resolveConnectionType(path)
if connected && !previouslyConnected {
self.networkRestoredPublisher.send()
}
if !connected {
self.wasDisconnected = true
}
}
}
monitor.start(queue: monitorQueue)
}
private static func resolveConnectionType(_ path: NWPath) -> ConnectionType {
if path.usesInterfaceType(.wifi) { return .wifi }
if path.usesInterfaceType(.cellular) { return .cellular }
if path.usesInterfaceType(.wiredEthernet) { return .wiredEthernet }
return .unknown
}
deinit {
monitor.cancel()
}
}
+264 -56
View File
@@ -34,19 +34,24 @@ class SourceService {
jsonStr = try await network.getString(from: api) jsonStr = try await network.getString(from: api)
} else if sourceBean.type == 4 { } else if sourceBean.type == 4 {
// Type 4: extend filter // Type 4: extend filter
var url = api.contains("?") ? "\(api)&filter=true" : "\(api)?filter=true" var queryItems: [URLQueryItem] = [
URLQueryItem(name: "filter", value: "true")
]
// extend // extend
if let ext = sourceBean.ext, !ext.isEmpty { if let ext = sourceBean.ext, !ext.isEmpty {
let extend = await resolveExtend(ext) let extend = await resolveExtend(ext)
if !extend.isEmpty { if !extend.isEmpty {
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend queryItems.append(URLQueryItem(name: "extend", value: extend))
url += "&extend=\(encoded)"
} }
} }
let url = try buildURL(base: api, queryItems: queryItems)
jsonStr = try await network.getString(from: url) jsonStr = try await network.getString(from: url)
} else { } else {
// JSON (type=1) // JSON (type=1)
let url = api.contains("?") ? "\(api)&ac=class" : "\(api)?ac=class" let url = try buildURL(
base: api,
queryItems: [URLQueryItem(name: "ac", value: "class")]
)
jsonStr = try await network.getString(from: url) jsonStr = try await network.getString(from: url)
} }
@@ -62,12 +67,24 @@ class SourceService {
if sourceBean.type == 4 { if sourceBean.type == 4 {
// type=4 ac=detail getList // type=4 ac=detail getList
let ext = Data("{}".utf8).base64EncodedString() let ext = Data("{}".utf8).base64EncodedString()
listUrl = api.contains("?") listUrl = try buildURL(
? "\(api)&ac=detail&filter=true&pg=1&ext=\(ext)" base: api,
: "\(api)?ac=detail&filter=true&pg=1&ext=\(ext)" queryItems: [
URLQueryItem(name: "ac", value: "detail"),
URLQueryItem(name: "filter", value: "true"),
URLQueryItem(name: "pg", value: "1"),
URLQueryItem(name: "ext", value: ext)
]
)
} else { } else {
// type=1 ac=videolist // type=1 ac=videolist
listUrl = api.contains("?") ? "\(api)&ac=videolist&pg=1" : "\(api)?ac=videolist&pg=1" listUrl = try buildURL(
base: api,
queryItems: [
URLQueryItem(name: "ac", value: "videolist"),
URLQueryItem(name: "pg", value: "1")
]
)
} }
if let listStr = try? await network.getString(from: listUrl) { if let listStr = try? await network.getString(from: listUrl) {
let fallback = (try? parseVideoList(listStr, sourceKey: sourceBean.key, type: sourceBean.type)) ?? [] let fallback = (try? parseVideoList(listStr, sourceKey: sourceBean.key, type: sourceBean.type)) ?? []
@@ -152,48 +169,61 @@ class SourceService {
guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) } guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) }
guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) } guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) }
var url: String let url: String
if sourceBean.type == 0 { if sourceBean.type == 0 {
// XML // XML
url = "\(api)?ac=videolist&t=\(sortData.id)&pg=\(page)" url = try buildURL(
base: api,
queryItems: [
URLQueryItem(name: "ac", value: "videolist"),
URLQueryItem(name: "t", value: sortData.id),
URLQueryItem(name: "pg", value: String(page))
]
)
} else if sourceBean.type == 4 { } else if sourceBean.type == 4 {
// Type 4: // Type 4:
url = api.contains("?") var queryItems: [URLQueryItem] = [
? "\(api)&ac=detail&filter=true&t=\(sortData.id)&pg=\(page)" URLQueryItem(name: "ac", value: "detail"),
: "\(api)?ac=detail&filter=true&t=\(sortData.id)&pg=\(page)" URLQueryItem(name: "filter", value: "true"),
URLQueryItem(name: "t", value: sortData.id),
URLQueryItem(name: "pg", value: String(page))
]
// base64 // base64
if let filters = filters, !filters.isEmpty { if let filters = filters, !filters.isEmpty {
if let filterData = try? JSONSerialization.data(withJSONObject: filters), if let filterData = try? JSONSerialization.data(withJSONObject: filters),
let filterStr = String(data: filterData, encoding: .utf8) { let filterStr = String(data: filterData, encoding: .utf8) {
let ext = Data(filterStr.utf8).base64EncodedString() let ext = Data(filterStr.utf8).base64EncodedString()
url += "&ext=\(ext)" queryItems.append(URLQueryItem(name: "ext", value: ext))
} }
} else { } else {
let ext = Data("{}".utf8).base64EncodedString() let ext = Data("{}".utf8).base64EncodedString()
url += "&ext=\(ext)" queryItems.append(URLQueryItem(name: "ext", value: ext))
} }
// extend // extend
if let ext = sourceBean.ext, !ext.isEmpty { if let ext = sourceBean.ext, !ext.isEmpty {
let extend = await resolveExtend(ext) let extend = await resolveExtend(ext)
if !extend.isEmpty { if !extend.isEmpty {
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend queryItems.append(URLQueryItem(name: "extend", value: extend))
url += "&extend=\(encoded)"
} }
} }
url = try buildURL(base: api, queryItems: queryItems)
} else { } else {
// JSON (type=1) // JSON (type=1)
url = api.contains("?") var queryItems: [URLQueryItem] = [
? "\(api)&ac=videolist&t=\(sortData.id)&pg=\(page)" URLQueryItem(name: "ac", value: "videolist"),
: "\(api)?ac=videolist&t=\(sortData.id)&pg=\(page)" URLQueryItem(name: "t", value: sortData.id),
URLQueryItem(name: "pg", value: String(page))
]
// //
if let filters = filters { if let filters = filters {
for (key, value) in filters { for (key, value) in filters {
url += "&\(key)=\(value)" queryItems.append(URLQueryItem(name: key, value: value))
} }
} }
url = try buildURL(base: api, queryItems: queryItems)
} }
let jsonStr = try await network.getString(from: url) let jsonStr = try await network.getString(from: url)
@@ -255,28 +285,39 @@ class SourceService {
guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) } guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) }
guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) } guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) }
var url: String let url: String
if sourceBean.type == 0 { if sourceBean.type == 0 {
url = "\(api)?ac=videolist&ids=\(vodId)" url = try buildURL(
base: api,
queryItems: [
URLQueryItem(name: "ac", value: "videolist"),
URLQueryItem(name: "ids", value: vodId)
]
)
} else if sourceBean.type == 4 { } else if sourceBean.type == 4 {
// Type 4: // Type 4:
url = api.contains("?") var queryItems: [URLQueryItem] = [
? "\(api)&ac=detail&ids=\(vodId)" URLQueryItem(name: "ac", value: "detail"),
: "\(api)?ac=detail&ids=\(vodId)" URLQueryItem(name: "ids", value: vodId)
]
// extend // extend
if let ext = sourceBean.ext, !ext.isEmpty { if let ext = sourceBean.ext, !ext.isEmpty {
let extend = await resolveExtend(ext) let extend = await resolveExtend(ext)
if !extend.isEmpty { if !extend.isEmpty {
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend queryItems.append(URLQueryItem(name: "extend", value: extend))
url += "&extend=\(encoded)"
} }
} }
url = try buildURL(base: api, queryItems: queryItems)
} else { } else {
// JSON (type=1) // JSON (type=1)
url = api.contains("?") url = try buildURL(
? "\(api)&ac=detail&ids=\(vodId)" base: api,
: "\(api)?ac=detail&ids=\(vodId)" queryItems: [
URLQueryItem(name: "ac", value: "detail"),
URLQueryItem(name: "ids", value: vodId)
]
)
} }
let jsonStr = try await network.getString(from: url) let jsonStr = try await network.getString(from: url)
@@ -284,25 +325,27 @@ class SourceService {
} }
private func parseDetail(_ jsonStr: String, sourceKey: String, type: Int) throws -> VodInfo? { private func parseDetail(_ jsonStr: String, sourceKey: String, type: Int) throws -> VodInfo? {
if type == 0 {
return parseXMLDetail(jsonStr, sourceKey: sourceKey)
}
guard let data = jsonStr.data(using: .utf8) else { guard let data = jsonStr.data(using: .utf8) else {
throw SourceError.parseError("无法解析数据") throw SourceError.parseError("无法解析数据")
} }
if type == 1 || type == 4 { if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let list = json["list"] as? [[String: Any]],
let list = json["list"] as? [[String: Any]], let first = list.first {
let first = list.first {
let decoder = JSONDecoder() let decoder = JSONDecoder()
if let itemData = try? JSONSerialization.data(withJSONObject: first), if let itemData = try? JSONSerialization.data(withJSONObject: first),
var video = try? decoder.decode(Movie.Video.self, from: itemData) { var video = try? decoder.decode(Movie.Video.self, from: itemData) {
video.sourceKey = sourceKey video.sourceKey = sourceKey
let playFrom = first["vod_play_from"] as? String ?? "" let playFrom = first["vod_play_from"] as? String ?? ""
let playUrl = first["vod_play_url"] as? String ?? "" let playUrl = first["vod_play_url"] as? String ?? ""
return VodInfo.from(video: video, playFrom: playFrom, playUrl: playUrl) return VodInfo.from(video: video, playFrom: playFrom, playUrl: playUrl)
}
} }
} }
@@ -318,34 +361,40 @@ class SourceService {
guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) } guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) }
guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) } guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) }
let encodedKeyword = keyword.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? keyword let url: String
var url: String
if sourceBean.type == 0 { if sourceBean.type == 0 {
url = "\(api)?wd=\(encodedKeyword)" url = try buildURL(
base: api,
queryItems: [URLQueryItem(name: "wd", value: keyword)]
)
} else if sourceBean.type == 4 { } else if sourceBean.type == 4 {
// Type 4: // Type 4:
url = api.contains("?") let quickValue = sourceBean.isQuickSearchEnabled ? "true" : "false"
? "\(api)&wd=\(encodedKeyword)&ac=detail&quick=false" var queryItems: [URLQueryItem] = [
: "\(api)?wd=\(encodedKeyword)&ac=detail&quick=false" URLQueryItem(name: "wd", value: keyword),
URLQueryItem(name: "ac", value: "detail"),
URLQueryItem(name: "quick", value: quickValue)
]
// extend // extend
if let ext = sourceBean.ext, !ext.isEmpty { if let ext = sourceBean.ext, !ext.isEmpty {
let extend = await resolveExtend(ext) let extend = await resolveExtend(ext)
if !extend.isEmpty { if !extend.isEmpty {
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend queryItems.append(URLQueryItem(name: "extend", value: extend))
url += "&extend=\(encoded)"
} }
} }
url = try buildURL(base: api, queryItems: queryItems)
} else { } else {
// JSON (type=1) // JSON (type=1)
url = api.contains("?") url = try buildURL(
? "\(api)&wd=\(encodedKeyword)" base: api,
: "\(api)?wd=\(encodedKeyword)" queryItems: [URLQueryItem(name: "wd", value: keyword)]
)
} }
let jsonStr = try await network.getString(from: url) let jsonStr = try await network.getString(from: url)
return try parseVideoList(jsonStr, sourceKey: sourceBean.key, type: sourceBean.type) let videos = try parseVideoList(jsonStr, sourceKey: sourceBean.key, type: sourceBean.type)
return filterSearchResults(videos, keyword: keyword)
} }
/// ///
@@ -374,6 +423,43 @@ class SourceService {
} }
} }
/// /
private func filterSearchResults(_ videos: [Movie.Video], keyword: String) -> [Movie.Video] {
let tokens = keyword
.split(whereSeparator: \.isWhitespace)
.map { normalizeSearchText(String($0)) }
.filter { !$0.isEmpty }
guard !tokens.isEmpty else { return videos }
return videos.filter { video in
let searchableText = normalizeSearchText([
video.name,
video.note,
video.actor,
video.director,
video.type,
video.area,
video.year
].joined(separator: " "))
guard !searchableText.isEmpty else { return false }
return tokens.allSatisfy { searchableText.contains($0) }
}
}
private func normalizeSearchText(_ text: String) -> String {
let folded = text.folding(
options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive],
locale: .current
)
let scalars = folded.unicodeScalars.filter { scalar in
!CharacterSet.whitespacesAndNewlines.contains(scalar) &&
!CharacterSet.punctuationCharacters.contains(scalar) &&
!CharacterSet.symbols.contains(scalar)
}
return String(String.UnicodeScalarView(scalars)).lowercased()
}
// MARK: - Extend // MARK: - Extend
/// extend Android getFixUrl /// extend Android getFixUrl
@@ -398,6 +484,128 @@ class SourceService {
return extend return extend
} }
} }
private func parseXMLDetail(_ xml: String, sourceKey: String) -> VodInfo? {
guard let videoBlock = firstMatch(
pattern: #"<video[\s\S]*?</video>"#,
in: xml
) else {
return nil
}
let vodId = extractXMLTag("id", in: videoBlock)
guard !vodId.isEmpty else { return nil }
var video = Movie.Video(id: vodId)
video.name = extractXMLTag("name", in: videoBlock)
video.pic = extractXMLTag("pic", in: videoBlock)
video.note = extractXMLTag("note", in: videoBlock)
video.year = extractXMLTag("year", in: videoBlock)
video.area = extractXMLTag("area", in: videoBlock)
video.type = extractXMLTag("type", in: videoBlock)
video.director = extractXMLTag("director", in: videoBlock)
video.actor = extractXMLTag("actor", in: videoBlock)
video.des = extractXMLTag("des", in: videoBlock)
video.sourceKey = sourceKey
let ddNodes = extractXMLDDNodes(from: videoBlock)
let playFrom: String
let playUrl: String
if ddNodes.isEmpty {
playFrom = "默认"
playUrl = ""
} else {
playFrom = ddNodes.map { $0.flag }.joined(separator: "$$$")
playUrl = ddNodes.map { $0.url }.joined(separator: "$$$")
}
return VodInfo.from(video: video, playFrom: playFrom, playUrl: playUrl)
}
private func extractXMLDDNodes(from block: String) -> [(flag: String, url: String)] {
guard let regex = try? NSRegularExpression(
pattern: #"<dd([^>]*)>([\s\S]*?)</dd>"#,
options: [.caseInsensitive]
) else {
return []
}
let nsRange = NSRange(block.startIndex..<block.endIndex, in: block)
let matches = regex.matches(in: block, range: nsRange)
var result: [(flag: String, url: String)] = []
for (index, match) in matches.enumerated() {
guard match.numberOfRanges >= 3 else { continue }
guard let attrRange = Range(match.range(at: 1), in: block),
let valueRange = Range(match.range(at: 2), in: block) else {
continue
}
let attrs = String(block[attrRange])
let rawUrl = decodeXMLText(String(block[valueRange]))
guard !rawUrl.isEmpty else { continue }
let flag = firstMatch(
pattern: #"flag\s*=\s*["']([^"']+)["']"#,
in: attrs,
captureGroup: 1
) ?? "线\(index + 1)"
result.append((flag: decodeXMLText(flag), url: rawUrl))
}
return result
}
private func extractXMLTag(_ tag: String, in content: String) -> String {
let escapedTag = NSRegularExpression.escapedPattern(for: tag)
let pattern = "<\(escapedTag)>\\s*([\\s\\S]*?)\\s*</\(escapedTag)>"
let value = firstMatch(pattern: pattern, in: content, captureGroup: 1) ?? ""
return decodeXMLText(value)
}
private func firstMatch(pattern: String, in content: String, captureGroup: Int = 0) -> String? {
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
return nil
}
let range = NSRange(content.startIndex..<content.endIndex, in: content)
guard let match = regex.firstMatch(in: content, options: [], range: range),
match.numberOfRanges > captureGroup,
let subRange = Range(match.range(at: captureGroup), in: content) else {
return nil
}
return String(content[subRange])
}
private func decodeXMLText(_ raw: String) -> String {
var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if value.hasPrefix("<![CDATA["), value.hasSuffix("]]>"), value.count >= 12 {
value.removeFirst(9)
value.removeLast(3)
}
value = value.replacingOccurrences(of: "&amp;", with: "&")
value = value.replacingOccurrences(of: "&lt;", with: "<")
value = value.replacingOccurrences(of: "&gt;", with: ">")
value = value.replacingOccurrences(of: "&quot;", with: "\"")
value = value.replacingOccurrences(of: "&#39;", with: "'")
return value.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func buildURL(base: String, queryItems: [URLQueryItem]) throws -> String {
let trimmedBase = base.trimmingCharacters(in: .whitespacesAndNewlines)
guard var components = URLComponents(string: trimmedBase) else {
throw SourceError.invalidApiUrl(base)
}
var mergedQueryItems = components.queryItems ?? []
mergedQueryItems.append(contentsOf: queryItems)
components.queryItems = mergedQueryItems
guard let url = components.url else {
throw SourceError.invalidApiUrl(base)
}
return url.absoluteString
}
} }
enum SourceError: LocalizedError { enum SourceError: LocalizedError {
+55 -12
View File
@@ -218,6 +218,9 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
@ViewBuilder let placeholder: () -> Placeholder @ViewBuilder let placeholder: () -> Placeholder
@State private var loadedImage: PlatformImage? @State private var loadedImage: PlatformImage?
@State private var loadFailed = false
private static var delayedRetryDelay: TimeInterval { 3.0 }
init( init(
url: URL?, url: URL?,
@@ -246,6 +249,7 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
} }
.onDisappear { .onDisappear {
loadedImage = nil loadedImage = nil
loadFailed = false
} }
} }
@@ -265,9 +269,17 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
ImageCache.shared.set(image, for: url) ImageCache.shared.set(image, for: url)
loadedImage = image loadedImage = image
loadFailed = false
} catch { } catch {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
loadedImage = nil loadedImage = nil
if error.isNetworkConnectionError && !loadFailed {
loadFailed = true
try? await Task.sleep(nanoseconds: UInt64(Self.delayedRetryDelay * 1_000_000_000))
guard !Task.isCancelled else { return }
await loadImage()
}
} }
} }
} }
@@ -307,33 +319,64 @@ final class ImageLoader {
self.session = URLSession(configuration: config) self.session = URLSession(configuration: config)
} }
private static let maxImageRetries = 1
private static let imageRetryDelay: TimeInterval = 1.0
func load(url: URL) async throws -> PlatformImage { func load(url: URL) async throws -> PlatformImage {
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "GET" request.httpMethod = "GET"
// User-Agent
request.setValue( request.setValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
forHTTPHeaderField: "User-Agent" forHTTPHeaderField: "User-Agent"
) )
// Referer
if let host = url.host { if let host = url.host {
request.setValue("https://\(host)/", forHTTPHeaderField: "Referer") request.setValue("https://\(host)/", forHTTPHeaderField: "Referer")
} }
let (data, response) = try await session.data(for: request) var lastError: Error = ImageLoadError.invalidData
let totalAttempts = Self.maxImageRetries + 1
// HTTP for attempt in 0..<totalAttempts {
if let httpResponse = response as? HTTPURLResponse, do {
!(200...299).contains(httpResponse.statusCode) { try Task.checkCancellation()
throw ImageLoadError.httpError(httpResponse.statusCode)
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
let error = ImageLoadError.httpError(httpResponse.statusCode)
if Self.isRetryableImageError(error, statusCode: httpResponse.statusCode),
attempt < totalAttempts - 1 {
lastError = error
try await Task.sleep(nanoseconds: UInt64(Self.imageRetryDelay * 1_000_000_000))
continue
}
throw error
}
let maxPixelSize = thumbnailMaxPixelSize
guard let image = Self.decodeImage(data: data, maxPixelSize: maxPixelSize) else {
throw ImageLoadError.invalidData
}
return image
} catch is CancellationError {
throw CancellationError()
} catch {
lastError = error
if error.isNetworkConnectionError && attempt < totalAttempts - 1 {
try? await Task.sleep(nanoseconds: UInt64(Self.imageRetryDelay * 1_000_000_000))
continue
}
if attempt >= totalAttempts - 1 { throw error }
}
} }
let maxPixelSize = thumbnailMaxPixelSize throw lastError
guard let image = Self.decodeImage(data: data, maxPixelSize: maxPixelSize) else { }
throw ImageLoadError.invalidData
}
return image private static func isRetryableImageError(_ error: Error, statusCode: Int) -> Bool {
[408, 429, 500, 502, 503, 504].contains(statusCode)
} }
var cacheUsage: (memory: Int, disk: Int) { var cacheUsage: (memory: Int, disk: Int) {
+33
View File
@@ -0,0 +1,33 @@
#if os(iOS)
import UIKit
/// -
final class HapticManager {
static let shared = HapticManager()
private let lightGenerator = UIImpactFeedbackGenerator(style: .light)
private let mediumGenerator = UIImpactFeedbackGenerator(style: .medium)
private let selectionGenerator = UISelectionFeedbackGenerator()
private init() {
lightGenerator.prepare()
mediumGenerator.prepare()
selectionGenerator.prepare()
}
/// -
func lightImpact() {
lightGenerator.impactOccurred()
}
/// -
func mediumImpact() {
mediumGenerator.impactOccurred()
}
/// -
func selection() {
selectionGenerator.selectionChanged()
}
}
#endif
+33
View File
@@ -2,9 +2,13 @@ import Foundation
import SwiftUI import SwiftUI
struct PlaybackQualityOption: Identifiable, Hashable { struct PlaybackQualityOption: Identifiable, Hashable {
///
static let autoIdentifier = "auto" static let autoIdentifier = "auto"
/// 使 auto id
let id: String let id: String
/// UI 1080p / 720p /
let name: String let name: String
///
let url: String let url: String
var isAuto: Bool { var isAuto: Bool {
@@ -19,23 +23,39 @@ struct PlaybackQualityOption: Identifiable, Hashable {
/// ViewModel /// ViewModel
@MainActor @MainActor
class DetailViewModel: ObservableObject { class DetailViewModel: ObservableObject {
///
@Published var vodInfo: VodInfo? @Published var vodInfo: VodInfo?
///
@Published var isLoading = false @Published var isLoading = false
///
@Published var errorMessage: String? @Published var errorMessage: String?
/// 线
@Published var selectedFlag: String = "" @Published var selectedFlag: String = ""
///
@Published var selectedEpisodeIndex: Int = 0 @Published var selectedEpisodeIndex: Int = 0
///
@Published var isPlaying = false @Published var isPlaying = false
///
@Published var playUrl: String? @Published var playUrl: String?
///
@Published var resumeSeconds: Double = 0 @Published var resumeSeconds: Double = 0
///
@Published var qualityOptions: [PlaybackQualityOption] = [] @Published var qualityOptions: [PlaybackQualityOption] = []
/// id
@Published var selectedQualityId: String = PlaybackQualityOption.autoIdentifier @Published var selectedQualityId: String = PlaybackQualityOption.autoIdentifier
/// UI
private var realtimeProgressSeconds: Double = 0 private var realtimeProgressSeconds: Double = 0
///
private let sourceService = SourceService.shared private let sourceService = SourceService.shared
private let network = NetworkManager.shared private let network = NetworkManager.shared
///
private var qualityBaseEpisodeURL: String = "" private var qualityBaseEpisodeURL: String = ""
/// key URL
private var qualityOptionCache: [String: [PlaybackQualityOption]] = [:] private var qualityOptionCache: [String: [PlaybackQualityOption]] = [:]
///
private var qualityResolveTask: Task<Void, Never>? private var qualityResolveTask: Task<Void, Never>?
///
private var qualityResolveToken = UUID() private var qualityResolveToken = UUID()
/// ///
@@ -105,6 +125,7 @@ class DetailViewModel: ObservableObject {
realtimeProgressSeconds = 0 realtimeProgressSeconds = 0
if let episode = vodInfo?.currentEpisode { if let episode = vodInfo?.currentEpisode {
// URL
let shouldResetQuality = qualityBaseEpisodeURL != episode.url let shouldResetQuality = qualityBaseEpisodeURL != episode.url
updateQualityOptions(for: episode.url, resetSelection: shouldResetQuality) updateQualityOptions(for: episode.url, resetSelection: shouldResetQuality)
playUrl = selectedPlayableURL(fallback: episode.url) playUrl = selectedPlayableURL(fallback: episode.url)
@@ -144,6 +165,7 @@ class DetailViewModel: ObservableObject {
selectedQualityId = option.id selectedQualityId = option.id
guard isPlaying else { return } guard isPlaying else { return }
// 使使
let targetURL = option.url.isEmpty ? qualityBaseEpisodeURL : option.url let targetURL = option.url.isEmpty ? qualityBaseEpisodeURL : option.url
guard !targetURL.isEmpty, playUrl != targetURL else { return } guard !targetURL.isEmpty, playUrl != targetURL else { return }
@@ -208,6 +230,7 @@ class DetailViewModel: ObservableObject {
} }
private func selectedPlayableURL(fallback: String) -> String { private func selectedPlayableURL(fallback: String) -> String {
// URL使退
let selected = qualityOptions.first(where: { $0.id == selectedQualityId })?.url let selected = qualityOptions.first(where: { $0.id == selectedQualityId })?.url
if let selected, !selected.isEmpty { if let selected, !selected.isEmpty {
return selected return selected
@@ -215,6 +238,7 @@ class DetailViewModel: ObservableObject {
return fallback return fallback
} }
///
private func resetQualityState() { private func resetQualityState() {
qualityResolveTask?.cancel() qualityResolveTask?.cancel()
qualityResolveTask = nil qualityResolveTask = nil
@@ -231,6 +255,7 @@ class DetailViewModel: ObservableObject {
return return
} }
//
qualityResolveTask?.cancel() qualityResolveTask?.cancel()
qualityResolveTask = nil qualityResolveTask = nil
@@ -245,6 +270,7 @@ class DetailViewModel: ObservableObject {
qualityOptions = [autoOption] qualityOptions = [autoOption]
if let cached = qualityOptionCache[trimmedEpisodeURL] { if let cached = qualityOptionCache[trimmedEpisodeURL] {
//
qualityOptions = cached qualityOptions = cached
if resetSelection || !cached.contains(where: { $0.id == selectedQualityId }) { if resetSelection || !cached.contains(where: { $0.id == selectedQualityId }) {
selectedQualityId = PlaybackQualityOption.autoIdentifier selectedQualityId = PlaybackQualityOption.autoIdentifier
@@ -277,12 +303,14 @@ class DetailViewModel: ObservableObject {
} }
} }
/// HLS
private func resolveQualityOptions(for episodeURL: String) async -> [PlaybackQualityOption] { private func resolveQualityOptions(for episodeURL: String) async -> [PlaybackQualityOption] {
guard let url = URL(string: episodeURL), Self.looksLikeHLSURL(url) else { return [] } guard let url = URL(string: episodeURL), Self.looksLikeHLSURL(url) else { return [] }
guard let playlist = try? await network.getString(from: episodeURL) else { return [] } guard let playlist = try? await network.getString(from: episodeURL) else { return [] }
return Self.parseMasterPlaylist(playlist, masterURL: url) return Self.parseMasterPlaylist(playlist, masterURL: url)
} }
/// HLS
private struct HLSVariant { private struct HLSVariant {
let url: String let url: String
let name: String? let name: String?
@@ -290,6 +318,7 @@ class DetailViewModel: ObservableObject {
let bandwidth: Int? let bandwidth: Int?
} }
/// URL HLS
private static func looksLikeHLSURL(_ url: URL) -> Bool { private static func looksLikeHLSURL(_ url: URL) -> Bool {
let lowercased = url.absoluteString.lowercased() let lowercased = url.absoluteString.lowercased()
if lowercased.contains(".m3u8") { return true } if lowercased.contains(".m3u8") { return true }
@@ -297,6 +326,8 @@ class DetailViewModel: ObservableObject {
return ext == "m3u8" || ext == "m3u" return ext == "m3u8" || ext == "m3u"
} }
/// HLS
/// 2
private static func parseMasterPlaylist(_ content: String, masterURL: URL) -> [PlaybackQualityOption] { private static func parseMasterPlaylist(_ content: String, masterURL: URL) -> [PlaybackQualityOption] {
guard content.localizedCaseInsensitiveContains("#EXT-X-STREAM-INF") else { return [] } guard content.localizedCaseInsensitiveContains("#EXT-X-STREAM-INF") else { return [] }
@@ -407,6 +438,7 @@ class DetailViewModel: ObservableObject {
return merged return merged
} }
/// `EXT-X-STREAM-INF`
private static func parseAttributeMap(_ raw: String) -> [String: String] { private static func parseAttributeMap(_ raw: String) -> [String: String] {
var result: [String: String] = [:] var result: [String: String] = [:]
let pairs = splitAttributes(raw) let pairs = splitAttributes(raw)
@@ -426,6 +458,7 @@ class DetailViewModel: ObservableObject {
return result return result
} }
///
private static func splitAttributes(_ raw: String) -> [String] { private static func splitAttributes(_ raw: String) -> [String] {
var parts: [String] = [] var parts: [String] = []
var buffer = "" var buffer = ""
+37 -1
View File
@@ -1,19 +1,36 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import Combine
/// ViewModel /// ViewModel
@MainActor @MainActor
class HomeViewModel: ObservableObject { class HomeViewModel: ObservableObject {
/// ""
@Published var sorts: [MovieSort.SortData] = [] @Published var sorts: [MovieSort.SortData] = []
///
@Published var selectedSort: MovieSort.SortData? @Published var selectedSort: MovieSort.SortData?
/// ""
@Published var homeVideos: [Movie.Video] = [] @Published var homeVideos: [Movie.Video] = []
///
@Published var categoryVideos: [Movie.Video] = [] @Published var categoryVideos: [Movie.Video] = []
///
@Published var isLoading = false @Published var isLoading = false
///
@Published var currentPage = 1 @Published var currentPage = 1
///
@Published var hasMore = true @Published var hasMore = true
///
@Published var errorMessage: String? @Published var errorMessage: String?
/// 访
private let sourceService = SourceService.shared private let sourceService = SourceService.shared
///
private var lastLoadFailedDueToNetwork = false
private var networkRestoredCancellable: AnyCancellable?
init() {
setupNetworkRestoredAutoRetry()
}
/// ///
func loadSorts() async { func loadSorts() async {
@@ -24,25 +41,41 @@ class HomeViewModel: ObservableObject {
do { do {
let result = try await sourceService.getSort(sourceBean: source) let result = try await sourceService.getSort(sourceBean: source)
// "" // "" UI Android
var allSorts = [MovieSort.SortData.home()] var allSorts = [MovieSort.SortData.home()]
allSorts.append(contentsOf: result.sorts) allSorts.append(contentsOf: result.sorts)
self.sorts = allSorts self.sorts = allSorts
self.homeVideos = result.homeVideos self.homeVideos = result.homeVideos
lastLoadFailedDueToNetwork = false
if selectedSort == nil { if selectedSort == nil {
selectedSort = allSorts.first selectedSort = allSorts.first
} }
} catch { } catch {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
lastLoadFailedDueToNetwork = error.isNetworkConnectionError
} }
isLoading = false isLoading = false
} }
///
private func setupNetworkRestoredAutoRetry() {
networkRestoredCancellable = NetworkMonitor.shared.networkRestoredPublisher
.sink { [weak self] in
guard let self else { return }
Task { @MainActor [weak self] in
guard let self else { return }
guard self.lastLoadFailedDueToNetwork || (self.sorts.isEmpty && self.homeVideos.isEmpty) else { return }
await self.refresh()
}
}
}
/// ///
func selectSort(_ sort: MovieSort.SortData) { func selectSort(_ sort: MovieSort.SortData) {
//
selectedSort = sort selectedSort = sort
errorMessage = nil errorMessage = nil
categoryVideos = [] categoryVideos = []
@@ -62,6 +95,7 @@ class HomeViewModel: ObservableObject {
private func loadCategoryVideos(page: Int, sort: MovieSort.SortData) async { private func loadCategoryVideos(page: Int, sort: MovieSort.SortData) async {
guard sort.id != "home" else { return } guard sort.id != "home" else { return }
guard let source = ApiConfig.shared.homeSourceBean else { return } guard let source = ApiConfig.shared.homeSourceBean else { return }
//
guard !isLoading else { return } guard !isLoading else { return }
isLoading = true isLoading = true
@@ -78,6 +112,7 @@ class HomeViewModel: ObservableObject {
} else { } else {
categoryVideos.append(contentsOf: videos) categoryVideos.append(contentsOf: videos)
} }
// ""
currentPage = page currentPage = page
hasMore = !videos.isEmpty hasMore = !videos.isEmpty
} catch { } catch {
@@ -105,6 +140,7 @@ class HomeViewModel: ObservableObject {
/// ///
func refresh() async { func refresh() async {
//
currentPage = 1 currentPage = 1
hasMore = true hasMore = true
categoryVideos = [] categoryVideos = []
+74 -5
View File
@@ -1,23 +1,35 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import Combine
/// ViewModel /// ViewModel
@MainActor @MainActor
class LiveViewModel: ObservableObject { class LiveViewModel: ObservableObject {
///
@Published var channelGroups: [LiveChannelGroup] = [] @Published var channelGroups: [LiveChannelGroup] = []
///
@Published var selectedGroupIndex: Int = 0 @Published var selectedGroupIndex: Int = 0
///
@Published var selectedChannelIndex: Int = 0 @Published var selectedChannelIndex: Int = 0
///
@Published var currentChannel: LiveChannelItem? @Published var currentChannel: LiveChannelItem?
///
@Published var epgList: [Epginfo] = [] @Published var epgList: [Epginfo] = []
/// 便 EPG
@Published var isLoading = false @Published var isLoading = false
/// TV
@Published var showChannelList = false @Published var showChannelList = false
///
private var cancellables: Set<AnyCancellable> = []
init() {
bindLiveChannelGroups()
}
/// ///
func loadChannels() { func loadChannels() {
self.channelGroups = ApiConfig.shared.liveChannelGroupList applyChannelGroups(ApiConfig.shared.liveChannelGroupList)
if let firstGroup = channelGroups.first, let firstChannel = firstGroup.channels.first {
currentChannel = firstChannel
}
} }
/// ///
@@ -67,6 +79,7 @@ class LiveViewModel: ObservableObject {
/// 线 /// 线
func switchSource() { func switchSource() {
// `currentChannel` mutating @Published
currentChannel?.nextSource() currentChannel?.nextSource()
} }
@@ -78,13 +91,69 @@ class LiveViewModel: ObservableObject {
/// EPG /// EPG
private func loadEPG(for channel: LiveChannelItem) { private func loadEPG(for channel: LiveChannelItem) {
// EPG - // / ID EPG
//
epgList = [] epgList = []
} }
private func bindLiveChannelGroups() {
ApiConfig.shared.$liveChannelGroupList
.sink { [weak self] groups in
self?.applyChannelGroups(groups)
}
.store(in: &cancellables)
}
private func applyChannelGroups(_ groups: [LiveChannelGroup]) {
let previousChannelId = currentChannel?.id
channelGroups = groups
guard !groups.isEmpty else {
selectedGroupIndex = 0
selectedChannelIndex = 0
currentChannel = nil
return
}
if let previousChannelId,
let located = locateChannel(withId: previousChannelId, in: groups) {
selectedGroupIndex = located.groupIndex
selectedChannelIndex = located.channelIndex
currentChannel = groups[located.groupIndex].channels[located.channelIndex]
return
}
let clampedGroupIndex = min(max(0, selectedGroupIndex), groups.count - 1)
selectedGroupIndex = clampedGroupIndex
let channels = groups[clampedGroupIndex].channels
guard !channels.isEmpty else {
selectedChannelIndex = 0
currentChannel = nil
return
}
let clampedChannelIndex = min(max(0, selectedChannelIndex), channels.count - 1)
selectedChannelIndex = clampedChannelIndex
currentChannel = channels[clampedChannelIndex]
}
private func locateChannel(
withId channelId: String,
in groups: [LiveChannelGroup]
) -> (groupIndex: Int, channelIndex: Int)? {
for (groupIndex, group) in groups.enumerated() {
if let channelIndex = group.channels.firstIndex(where: { $0.id == channelId }) {
return (groupIndex, channelIndex)
}
}
return nil
}
} }
// 访 // 访
extension Collection { extension Collection {
/// `nil`
subscript(safe index: Index) -> Element? { subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil indices.contains(index) ? self[index] : nil
} }
+23 -1
View File
@@ -4,14 +4,23 @@ import SwiftUI
/// ViewModel /// ViewModel
@MainActor @MainActor
class SearchViewModel: ObservableObject { class SearchViewModel: ObservableObject {
///
@Published var keyword: String = "" @Published var keyword: String = ""
///
@Published var results: [Movie.Video] = [] @Published var results: [Movie.Video] = []
///
@Published var isSearching = false @Published var isSearching = false
///
@Published var searchHistory: [String] = [] @Published var searchHistory: [String] = []
///
@Published var errorMessage: String? @Published var errorMessage: String?
///
private let sourceService = SourceService.shared private let sourceService = SourceService.shared
///
private var latestSearchRequestId: UUID = UUID()
///
init() { init() {
loadSearchHistory() loadSearchHistory()
} }
@@ -20,15 +29,19 @@ class SearchViewModel: ObservableObject {
func search() async { func search() async {
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return } guard !trimmed.isEmpty else { return }
let requestId = UUID()
latestSearchRequestId = requestId
isSearching = true isSearching = true
errorMessage = nil errorMessage = nil
results = [] results = []
// //
addToHistory(trimmed) addToHistory(trimmed)
//
let videos = await sourceService.searchAll(keyword: trimmed) let videos = await sourceService.searchAll(keyword: trimmed)
guard requestId == latestSearchRequestId else { return }
self.results = videos self.results = videos
if videos.isEmpty { if videos.isEmpty {
@@ -42,13 +55,18 @@ class SearchViewModel: ObservableObject {
func searchInSource(_ source: SourceBean) async { func searchInSource(_ source: SourceBean) async {
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return } guard !trimmed.isEmpty else { return }
let requestId = UUID()
latestSearchRequestId = requestId
isSearching = true isSearching = true
errorMessage = nil
do { do {
let videos = try await sourceService.search(sourceBean: source, keyword: trimmed) let videos = try await sourceService.search(sourceBean: source, keyword: trimmed)
guard requestId == latestSearchRequestId else { return }
self.results = videos self.results = videos
} catch { } catch {
guard requestId == latestSearchRequestId else { return }
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
} }
@@ -57,10 +75,12 @@ class SearchViewModel: ObservableObject {
// MARK: - // MARK: -
///
private func loadSearchHistory() { private func loadSearchHistory() {
searchHistory = UserDefaults.standard.stringArray(forKey: HawkConfig.SEARCH_HISTORY) ?? [] searchHistory = UserDefaults.standard.stringArray(forKey: HawkConfig.SEARCH_HISTORY) ?? []
} }
/// 20
private func addToHistory(_ keyword: String) { private func addToHistory(_ keyword: String) {
searchHistory.removeAll { $0 == keyword } searchHistory.removeAll { $0 == keyword }
searchHistory.insert(keyword, at: 0) searchHistory.insert(keyword, at: 0)
@@ -70,11 +90,13 @@ class SearchViewModel: ObservableObject {
UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY) UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY)
} }
///
func clearHistory() { func clearHistory() {
searchHistory = [] searchHistory = []
UserDefaults.standard.removeObject(forKey: HawkConfig.SEARCH_HISTORY) UserDefaults.standard.removeObject(forKey: HawkConfig.SEARCH_HISTORY)
} }
///
func removeFromHistory(_ keyword: String) { func removeFromHistory(_ keyword: String) {
searchHistory.removeAll { $0 == keyword } searchHistory.removeAll { $0 == keyword }
UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY) UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY)
+130
View File
@@ -4,24 +4,70 @@ import SwiftUI
/// ViewModel /// ViewModel
@MainActor @MainActor
class SettingsViewModel: ObservableObject { class SettingsViewModel: ObservableObject {
///
struct PendingMultiRepoSelection: Identifiable {
///
enum Target {
case vod
case live
var title: String {
switch self {
case .vod: return "点播"
case .live: return "直播"
}
}
}
let id = UUID()
///
let target: Target
/// live
let sourceUrl: String
///
let options: [ApiConfig.MultiRepoOption]
}
///
@Published var vodApiUrl: String = "" @Published var vodApiUrl: String = ""
///
@Published var liveApiUrl: String = "" @Published var liveApiUrl: String = ""
///
@Published var isLoadingConfig = false @Published var isLoadingConfig = false
///
@Published var configError: String? @Published var configError: String?
/// UI /
@Published var configSuccess = false @Published var configSuccess = false
/// nil
@Published var pendingMultiRepoSelection: PendingMultiRepoSelection?
/// API
@Published var apiHistory: [String] = [] @Published var apiHistory: [String] = []
///
@Published var vodPlayerEngine: PlayerEngine = .system @Published var vodPlayerEngine: PlayerEngine = .system
///
@Published var livePlayerEngine: PlayerEngine = .system @Published var livePlayerEngine: PlayerEngine = .system
///
@Published var decodeMode: VideoDecodeMode = .auto @Published var decodeMode: VideoDecodeMode = .auto
/// VLC
@Published var vlcBufferMode: VLCBufferMode = .defaultMode @Published var vlcBufferMode: VLCBufferMode = .defaultMode
/// /退
@Published var playTimeStep: Int = 10 @Published var playTimeStep: Int = 10
///
@Published var cacheSizeString: String = "0 KB" @Published var cacheSizeString: String = "0 KB"
///
let playTimeStepOptions: [Int] = [5, 10, 15, 30, 60] let playTimeStepOptions: [Int] = [5, 10, 15, 30, 60]
///
let playerEngineOptions: [PlayerEngine] = PlayerEngine.availableEngines let playerEngineOptions: [PlayerEngine] = PlayerEngine.availableEngines
///
let decodeModeOptions: [VideoDecodeMode] = VideoDecodeMode.allCases let decodeModeOptions: [VideoDecodeMode] = VideoDecodeMode.allCases
/// VLC
let vlcBufferModeOptions: [VLCBufferMode] = VLCBufferMode.allCases let vlcBufferModeOptions: [VLCBufferMode] = VLCBufferMode.allCases
///
/// 1)
/// 2)
/// 3) /
init() { init() {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
let savedVod = defaults.string(forKey: HawkConfig.API_URL) ?? "" let savedVod = defaults.string(forKey: HawkConfig.API_URL) ?? ""
@@ -74,10 +120,23 @@ class SettingsViewModel: ObservableObject {
isLoadingConfig = true isLoadingConfig = true
configError = nil configError = nil
configSuccess = false configSuccess = false
pendingMultiRepoSelection = nil
do { do {
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
//
if let pending = try await detectPendingMultiRepoSelection(
vodUrl: trimmedVod,
liveUrl: resolvedLive
) {
pendingMultiRepoSelection = pending
isLoadingConfig = false
return
}
try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive) try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive)
// live
UserDefaults.standard.set(trimmedVod, forKey: HawkConfig.API_URL) UserDefaults.standard.set(trimmedVod, forKey: HawkConfig.API_URL)
UserDefaults.standard.set(trimmedLive, forKey: HawkConfig.LIVE_API_URL) UserDefaults.standard.set(trimmedLive, forKey: HawkConfig.LIVE_API_URL)
vodApiUrl = trimmedVod vodApiUrl = trimmedVod
@@ -92,12 +151,80 @@ class SettingsViewModel: ObservableObject {
isLoadingConfig = false isLoadingConfig = false
} }
///
func selectPendingMultiRepoOption(_ option: ApiConfig.MultiRepoOption) async {
guard let pending = pendingMultiRepoSelection else { return }
let normalizedSource = ApiConfig.normalizeConfigUrl(pending.sourceUrl)
switch pending.target {
case .vod:
let normalizedLive = ApiConfig.normalizeConfigUrl(liveApiUrl)
// live vod
let shouldSyncLive = !liveApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& normalizedLive == normalizedSource
vodApiUrl = option.url
if shouldSyncLive {
liveApiUrl = option.url
}
case .live:
liveApiUrl = option.url
}
pendingMultiRepoSelection = nil
await loadConfig()
}
///
func cancelPendingMultiRepoSelection() {
pendingMultiRepoSelection = nil
isLoadingConfig = false
}
///
/// - Returns: `nil`
private func detectPendingMultiRepoSelection(
vodUrl: String,
liveUrl: String
) async throws -> PendingMultiRepoSelection? {
if let vodOptions = try await ApiConfig.shared.fetchMultiRepoOptions(from: vodUrl) {
guard !vodOptions.isEmpty else {
throw ConfigError.parseError("点播多仓库配置中没有可用地址")
}
return PendingMultiRepoSelection(
target: .vod,
sourceUrl: vodUrl,
options: vodOptions
)
}
let normalizedVod = ApiConfig.normalizeConfigUrl(vodUrl)
let normalizedLive = ApiConfig.normalizeConfigUrl(liveUrl)
guard normalizedLive != normalizedVod else {
return nil
}
if let liveOptions = try await ApiConfig.shared.fetchMultiRepoOptions(from: liveUrl) {
guard !liveOptions.isEmpty else {
throw ConfigError.parseError("直播多仓库配置中没有可用地址")
}
return PendingMultiRepoSelection(
target: .live,
sourceUrl: liveUrl,
options: liveOptions
)
}
return nil
}
// MARK: - API // MARK: - API
/// API
private func loadApiHistory() { private func loadApiHistory() {
apiHistory = UserDefaults.standard.stringArray(forKey: "api_history") ?? [] apiHistory = UserDefaults.standard.stringArray(forKey: "api_history") ?? []
} }
/// 10
private func addToApiHistory(_ url: String) { private func addToApiHistory(_ url: String) {
apiHistory.removeAll { $0 == url } apiHistory.removeAll { $0 == url }
apiHistory.insert(url, at: 0) apiHistory.insert(url, at: 0)
@@ -107,6 +234,7 @@ class SettingsViewModel: ObservableObject {
UserDefaults.standard.set(apiHistory, forKey: "api_history") UserDefaults.standard.set(apiHistory, forKey: "api_history")
} }
/// API
func removeApiHistory(_ url: String) { func removeApiHistory(_ url: String) {
apiHistory.removeAll { $0 == url } apiHistory.removeAll { $0 == url }
UserDefaults.standard.set(apiHistory, forKey: "api_history") UserDefaults.standard.set(apiHistory, forKey: "api_history")
@@ -155,12 +283,14 @@ class SettingsViewModel: ObservableObject {
UserDefaults.standard.set(mode.rawValue, forKey: HawkConfig.PLAY_VLC_BUFFER_MODE) UserDefaults.standard.set(mode.rawValue, forKey: HawkConfig.PLAY_VLC_BUFFER_MODE)
} }
/// +
private func refreshCacheSize() { private func refreshCacheSize() {
let sharedDisk = URLCache.shared.currentDiskUsage let sharedDisk = URLCache.shared.currentDiskUsage
let imageDisk = ImageLoader.shared.cacheUsage.disk let imageDisk = ImageLoader.shared.cacheUsage.disk
cacheSizeString = Self.formatSize(bytes: sharedDisk + imageDisk) cacheSizeString = Self.formatSize(bytes: sharedDisk + imageDisk)
} }
///
private static func formatSize(bytes: Int) -> String { private static func formatSize(bytes: Int) -> String {
let size = max(0, bytes) let size = max(0, bytes)
if size < 1024 * 1024 { if size < 1024 * 1024 {
+7
View File
@@ -1,8 +1,13 @@
import SwiftUI import SwiftUI
///
///
struct EmptyStateView: View { struct EmptyStateView: View {
/// SF Symbol
let icon: String let icon: String
///
let title: String let title: String
///
var message: String? = nil var message: String? = nil
var body: some View { var body: some View {
@@ -23,11 +28,13 @@ struct EmptyStateView: View {
} }
.padding(.bottom, 8) .padding(.bottom, 8)
//
Text(title) Text(title)
.font(.title3.bold()) .font(.title3.bold())
.foregroundColor(.white.opacity(0.9)) .foregroundColor(.white.opacity(0.9))
.tracking(1) .tracking(1)
//
if let message = message { if let message = message {
Text(message) Text(message)
.font(.callout) .font(.callout)
+87 -11
View File
@@ -2,15 +2,23 @@ import SwiftUI
/// - Android HomeActivity TabView /// - Android HomeActivity TabView
struct ContentView: View { struct ContentView: View {
/// 使
private enum ApiInputTarget { private enum ApiInputTarget {
case vod case vod
case live case live
} }
///
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
///
@EnvironmentObject var networkMonitor: NetworkMonitor
/// ViewModel
@StateObject private var settingsVM = SettingsViewModel() @StateObject private var settingsVM = SettingsViewModel()
///
@State private var selectedTab = 0 @State private var selectedTab = 0
/// `appState.isConfigLoaded`
@State private var showSetup = false @State private var showSetup = false
///
@State private var setupInputTarget: ApiInputTarget = .vod @State private var setupInputTarget: ApiInputTarget = .vod
var body: some View { var body: some View {
@@ -21,6 +29,10 @@ struct ContentView: View {
setupView setupView
} }
} }
.overlay(multiRepoSelectionOverlay)
.overlay(alignment: .top) {
networkStatusBanner
}
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.onAppear { .onAppear {
// //
@@ -28,6 +40,7 @@ struct ContentView: View {
let savedVodUrl = defaults.string(forKey: HawkConfig.API_URL) ?? "" let savedVodUrl = defaults.string(forKey: HawkConfig.API_URL) ?? ""
let savedLiveUrl = defaults.string(forKey: HawkConfig.LIVE_API_URL) ?? "" let savedLiveUrl = defaults.string(forKey: HawkConfig.LIVE_API_URL) ?? ""
if !savedVodUrl.isEmpty { if !savedVodUrl.isEmpty {
//
Task { Task {
await appState.loadConfig(vodUrl: savedVodUrl, liveUrl: savedLiveUrl) await appState.loadConfig(vodUrl: savedVodUrl, liveUrl: savedLiveUrl)
} }
@@ -35,8 +48,34 @@ struct ContentView: View {
} }
} }
@ViewBuilder
private var multiRepoSelectionOverlay: some View {
//
if let pending = settingsVM.pendingMultiRepoSelection {
SelectionModal(
title: "选择\(pending.target.title)仓库",
icon: "list.bullet.rectangle.portrait.fill",
items: pending.options,
selectedItem: nil,
itemTitle: { $0.name },
onSelect: { option in
Task {
await settingsVM.selectPendingMultiRepoOption(option)
if settingsVM.configSuccess {
appState.applyLoadedConfigState()
}
}
},
onCancel: {
settingsVM.cancelPendingMultiRepoSelection()
}
)
}
}
// MARK: - // MARK: -
/// iOS 使 TabViewmacOS 使 NavigationSplitView
private var mainTabView: some View { private var mainTabView: some View {
#if os(iOS) #if os(iOS)
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
@@ -46,7 +85,9 @@ struct ContentView: View {
} }
.tag(0) .tag(0)
LiveView() LiveView(onExit: {
selectedTab = 0
})
.tabItem { .tabItem {
Label("直播", systemImage: "tv.fill") Label("直播", systemImage: "tv.fill")
} }
@@ -58,19 +99,16 @@ struct ContentView: View {
} }
.tag(2) .tag(2)
FavoritesView() ProfileView()
.tabItem { .tabItem {
Label("收藏", systemImage: "heart.fill") Label("个人中心", systemImage: "person.fill")
} }
.tag(3) .tag(3)
SettingsView()
.tabItem {
Label("设置", systemImage: "gearshape.fill")
}
.tag(4)
} }
.tint(.orange) .tint(.orange)
.onChange(of: selectedTab) { _, _ in
HapticManager.shared.selection()
}
#else #else
NavigationSplitView(columnVisibility: $appState.splitViewVisibility) { NavigationSplitView(columnVisibility: $appState.splitViewVisibility) {
List(selection: $selectedTab) { List(selection: $selectedTab) {
@@ -94,9 +132,15 @@ struct ContentView: View {
case 0: HomeView() case 0: HomeView()
case 1: LiveView() case 1: LiveView()
case 2: SearchView() case 2: SearchView()
case 3: FavoritesView() case 3:
NavigationStack {
FavoritesView()
}
case 4: SettingsView() case 4: SettingsView()
case 5: HistoryView() case 5:
NavigationStack {
HistoryView()
}
default: HomeView() default: HomeView()
} }
} }
@@ -105,6 +149,7 @@ struct ContentView: View {
// MARK: - // MARK: -
///
private var setupView: some View { private var setupView: some View {
ZStack { ZStack {
// //
@@ -254,6 +299,7 @@ struct ContentView: View {
.clipShape(Capsule()) .clipShape(Capsule())
.shadow(color: .red.opacity(0.4), radius: 12, x: 0, y: 6) .shadow(color: .red.opacity(0.4), radius: 12, x: 0, y: 6)
} }
.buttonStyle(.plain)
.disabled( .disabled(
settingsVM.isLoadingConfig settingsVM.isLoadingConfig
|| settingsVM.vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || settingsVM.vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
@@ -317,10 +363,40 @@ struct ContentView: View {
} }
} }
///
@ViewBuilder
private var networkStatusBanner: some View {
if !networkMonitor.isConnected {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.font(.system(size: 13, weight: .semibold))
Text("网络连接已断开")
.font(.system(size: 13, weight: .medium))
if appState.isRetryingConfig {
ProgressView()
.scaleEffect(0.7)
.tint(.white)
}
}
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
Capsule()
.fill(Color.red.opacity(0.85))
)
.shadow(color: .black.opacity(0.3), radius: 8, y: 4)
.padding(.top, 8)
.transition(.move(edge: .top).combined(with: .opacity))
.animation(.easeInOut(duration: 0.3), value: networkMonitor.isConnected)
}
}
private func readPasteboardText() -> String? { private func readPasteboardText() -> String? {
#if os(iOS) #if os(iOS)
UIPasteboard.general.string UIPasteboard.general.string
#else #else
// macOS NSPasteboard
NSPasteboard.general.string(forType: .string) NSPasteboard.general.string(forType: .string)
#endif #endif
} }
+2 -1
View File
@@ -1 +1,2 @@
// This file has been moved to tvbox/Utils/Extensions.swift to ensure compatibility across targets. // `tvbox/Utils/Extensions.swift`
//
+43 -3
View File
@@ -13,6 +13,8 @@ struct DetailView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@State private var showFullScreen = false @State private var showFullScreen = false
/// VLC 退 true drawable
@State private var isFullScreenDismissing = false
#if os(macOS) #if os(macOS)
@State private var pendingMacWindowFullScreen = false @State private var pendingMacWindowFullScreen = false
#endif #endif
@@ -23,7 +25,7 @@ struct DetailView: View {
ScrollView { ScrollView {
VStack(spacing: 0) { VStack(spacing: 0) {
// //
if !showFullScreen, viewModel.isPlaying, let url = viewModel.playUrl { if !showFullScreen, !isFullScreenDismissing, viewModel.isPlaying, let url = viewModel.playUrl {
PlayerView( PlayerView(
urlString: url, urlString: url,
startPosition: viewModel.currentPlaybackSeconds(), startPosition: viewModel.currentPlaybackSeconds(),
@@ -136,7 +138,9 @@ struct DetailView: View {
} }
#endif #endif
#if os(iOS) #if os(iOS)
.fullScreenCover(isPresented: $showFullScreen) { .fullScreenCover(isPresented: $showFullScreen, onDismiss: {
isFullScreenDismissing = false
}) {
if let url = viewModel.playUrl { if let url = viewModel.playUrl {
FullScreenPlayerView( FullScreenPlayerView(
urlString: url, urlString: url,
@@ -146,7 +150,11 @@ struct DetailView: View {
canPlayNext: canPlayNextEpisode, canPlayNext: canPlayNextEpisode,
onPlayNext: playNextEpisodeIfNeeded, onPlayNext: playNextEpisodeIfNeeded,
systemController: sharedSystemController, systemController: sharedSystemController,
vlcController: sharedVLCController vlcController: sharedVLCController,
onCloseRequested: {
isFullScreenDismissing = true
showFullScreen = false
}
) )
} }
} }
@@ -155,6 +163,35 @@ struct DetailView: View {
// MARK: - // MARK: -
#if os(iOS)
@ViewBuilder
private var videoInfoSection: some View {
VStack(spacing: 16) {
// Poster centered, height capped to 30% of screen
let posterHeight = UIScreen.main.bounds.height * 0.30
CachedAsyncImage(url: URL.posterURL(from: video.pic)) { image in
image.resizable().aspectRatio(2/3, contentMode: .fit)
} placeholder: {
Color.white.opacity(0.05)
.aspectRatio(2/3, contentMode: .fit)
}
.frame(maxHeight: posterHeight)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardRadius))
// Info below poster
videoDetails
// Action buttons with 48pt height
HStack(spacing: 12) {
playButton
collectButton
}
.frame(minHeight: 48)
}
.padding(15)
.glassCard(cornerRadius: AppTheme.glassRadius)
}
#else
@ViewBuilder @ViewBuilder
private var videoInfoSection: some View { private var videoInfoSection: some View {
HStack(alignment: .top, spacing: 20) { HStack(alignment: .top, spacing: 20) {
@@ -167,6 +204,7 @@ struct DetailView: View {
.padding(15) .padding(15)
.glassCard(cornerRadius: AppTheme.glassRadius) .glassCard(cornerRadius: AppTheme.glassRadius)
} }
#endif
@ViewBuilder @ViewBuilder
private var videoPoster: some View { private var videoPoster: some View {
@@ -201,12 +239,14 @@ struct DetailView: View {
} }
} }
#if os(macOS)
Spacer(minLength: 10) Spacer(minLength: 10)
HStack(spacing: 10) { HStack(spacing: 10) {
playButton playButton
collectButton collectButton
} }
#endif
} }
} }
+39 -33
View File
@@ -2,17 +2,26 @@ import SwiftUI
/// - Android SeriesAdapter /// - Android SeriesAdapter
struct EpisodeListView: View { struct EpisodeListView: View {
/// 线
let episodes: [VodInfo.Episode] let episodes: [VodInfo.Episode]
///
let selectedIndex: Int let selectedIndex: Int
///
let onSelect: (Int) -> Void let onSelect: (Int) -> Void
/// 50
@State private var currentGroup = 0 @State private var currentGroup = 0
///
private let groupSize = 50 private let groupSize = 50
///
private let gridColumns = [GridItem(.adaptive(minimum: 78), spacing: 10)]
/// 1 0
private var groupCount: Int { private var groupCount: Int {
max(1, (episodes.count + groupSize - 1) / groupSize) max(1, (episodes.count + groupSize - 1) / groupSize)
} }
///
private var currentEpisodes: [VodInfo.Episode] { private var currentEpisodes: [VodInfo.Episode] {
let start = currentGroup * groupSize let start = currentGroup * groupSize
let end = min(start + groupSize, episodes.count) let end = min(start + groupSize, episodes.count)
@@ -56,43 +65,40 @@ struct EpisodeListView: View {
} }
} }
// //
ScrollView(.horizontal, showsIndicators: false) { LazyVGrid(columns: gridColumns, alignment: .leading, spacing: 10) {
LazyHGrid(rows: [ // 使 + 便
GridItem(.fixed(44)), ForEach(Array(currentEpisodes.enumerated()), id: \.offset) { index, episode in
GridItem(.fixed(44)) let actualIndex = currentGroup * groupSize + index
], spacing: 10) { Button {
ForEach(Array(currentEpisodes.enumerated()), id: \.offset) { index, episode in onSelect(actualIndex)
let actualIndex = currentGroup * groupSize + index } label: {
Button { Text(episode.name)
onSelect(actualIndex) .font(.system(size: 13, weight: actualIndex == selectedIndex ? .bold : .medium))
} label: { .foregroundColor(actualIndex == selectedIndex ? .white : .white.opacity(0.7))
Text(episode.name) .lineLimit(1)
.font(.system(size: 13, weight: actualIndex == selectedIndex ? .bold : .medium)) .minimumScaleFactor(0.85)
.foregroundColor(actualIndex == selectedIndex ? .white : .white.opacity(0.7)) .frame(maxWidth: .infinity)
.frame(minWidth: 70) .frame(height: 44)
.frame(height: 44) .background(
.background( ZStack {
ZStack { if actualIndex == selectedIndex {
if actualIndex == selectedIndex { AppTheme.accentGradient
AppTheme.accentGradient } else {
} else { Color.white.opacity(0.05)
Color.white.opacity(0.05)
}
} }
) }
.clipShape(RoundedRectangle(cornerRadius: 10)) )
.overlay( .clipShape(RoundedRectangle(cornerRadius: 10))
RoundedRectangle(cornerRadius: 10) .overlay(
.stroke(actualIndex == selectedIndex ? Color.clear : Color.white.opacity(0.1), lineWidth: 0.5) RoundedRectangle(cornerRadius: 10)
) .stroke(actualIndex == selectedIndex ? Color.clear : Color.white.opacity(0.1), lineWidth: 0.5)
} )
.buttonStyle(.plain)
} }
.buttonStyle(.plain)
} }
.padding(.horizontal, 20)
} }
.frame(height: 100) .padding(.horizontal, 20)
} }
} }
} }
+43 -28
View File
@@ -3,59 +3,71 @@ import SwiftData
/// - Android CollectActivity /// - Android CollectActivity
struct FavoritesView: View { struct FavoritesView: View {
/// /
@Query(sort: \VodCollect.updateTime, order: .reverse) @Query(sort: \VodCollect.updateTime, order: .reverse)
private var favorites: [VodCollect] private var favorites: [VodCollect]
/// SwiftData
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
#if os(iOS) #if os(iOS)
/// iOS
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12) GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
] ]
#else #else
/// macOS
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16) GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
] ]
#endif #endif
var body: some View { var body: some View {
NavigationStack { favoritesContent
Group { }
if favorites.isEmpty {
emptyState /// NavigationStack
} else { /// iOS ProfileView/SettingsView NavigationStack
ScrollView { /// macOS ContentView NavigationSplitView detail 使 NavigationStack
LazyVGrid(columns: columns, spacing: 16) { private var favoritesContent: some View {
ForEach(favorites) { item in Group {
NavigationLink(value: movieVideo(from: item)) { if favorites.isEmpty {
favoriteCard(item) emptyState
} } else {
.buttonStyle(.plain) ScrollView {
.contextMenu { LazyVGrid(columns: columns, spacing: 16) {
Button(role: .destructive) { //
modelContext.delete(item) ForEach(favorites) { item in
try? modelContext.save() NavigationLink(destination: DetailView(video: movieVideo(from: item))) {
} label: { favoriteCard(item)
Label("取消收藏", systemImage: "heart.slash") }
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
modelContext.delete(item)
do {
try modelContext.save()
} catch {
print("删除收藏失败: \(error)")
} }
} label: {
Label("取消收藏", systemImage: "heart.slash")
} }
} }
} }
.padding(.horizontal, 20)
.padding(.vertical, 12)
} }
.padding(.horizontal, 20)
.padding(.vertical, 12)
} }
} }
.background(Color(red: 0.08, green: 0.08, blue: 0.1))
.navigationTitle("收藏")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.navigationDestination(for: Movie.Video.self) { video in
DetailView(video: video)
}
} }
.background(Color(red: 0.08, green: 0.08, blue: 0.1))
.navigationTitle("收藏")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
} }
///
private var emptyState: some View { private var emptyState: some View {
EmptyStateView( EmptyStateView(
icon: "heart.text.square", icon: "heart.text.square",
@@ -65,6 +77,8 @@ struct FavoritesView: View {
.padding(40) .padding(40)
} }
///
///
private func favoriteCard(_ item: VodCollect) -> some View { private func favoriteCard(_ item: VodCollect) -> some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
CachedAsyncImage(url: URL.posterURL(from: item.vodPic)) { image in CachedAsyncImage(url: URL.posterURL(from: item.vodPic)) { image in
@@ -83,6 +97,7 @@ struct FavoritesView: View {
} }
} }
///
private func movieVideo(from item: VodCollect) -> Movie.Video { private func movieVideo(from item: VodCollect) -> Movie.Video {
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey) Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
} }
+54 -38
View File
@@ -3,73 +3,86 @@ import SwiftData
/// - Android HistoryActivity /// - Android HistoryActivity
struct HistoryView: View { struct HistoryView: View {
///
@Query(sort: \VodRecord.updateTime, order: .reverse) @Query(sort: \VodRecord.updateTime, order: .reverse)
private var records: [VodRecord] private var records: [VodRecord]
/// SwiftData
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
#if os(iOS) #if os(iOS)
/// iOS
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12) GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
] ]
#else #else
/// macOS
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16) GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
] ]
#endif #endif
var body: some View { var body: some View {
NavigationStack { historyContent
Group { }
if records.isEmpty {
emptyState /// NavigationStack
} else { /// iOS ProfileView/SettingsView NavigationStack
ScrollView { /// macOS ContentView NavigationSplitView detail 使 NavigationStack
LazyVGrid(columns: columns, spacing: 16) { private var historyContent: some View {
ForEach(records) { item in Group {
NavigationLink(value: movieVideo(from: item)) { if records.isEmpty {
recordCard(item) emptyState
} } else {
.buttonStyle(.plain) ScrollView {
.contextMenu { LazyVGrid(columns: columns, spacing: 16) {
Button(role: .destructive) { //
modelContext.delete(item) ForEach(records) { item in
try? modelContext.save() NavigationLink(destination: DetailView(video: movieVideo(from: item))) {
} label: { recordCard(item)
Label("删除记录", systemImage: "trash") }
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
modelContext.delete(item)
do {
try modelContext.save()
} catch {
print("删除历史记录失败: \(error)")
} }
} label: {
Label("删除记录", systemImage: "trash")
} }
} }
} }
.padding(.horizontal, 20)
.padding(.vertical, 12)
} }
.padding(.horizontal, 20)
.padding(.vertical, 12)
} }
} }
.background(Color(red: 0.08, green: 0.08, blue: 0.1)) }
.navigationTitle("历史记录") .background(Color(red: 0.08, green: 0.08, blue: 0.1))
#if os(iOS) .navigationTitle("历史记录")
.navigationBarTitleDisplayMode(.inline) #if os(iOS)
#endif .navigationBarTitleDisplayMode(.inline)
.toolbar { #endif
if !records.isEmpty { .toolbar {
ToolbarItem(placement: .automatic) { if !records.isEmpty {
Button { ToolbarItem(placement: .automatic) {
Task { // 使
CacheStore.shared.clearHistory(context: modelContext) Button {
} Task {
} label: { CacheStore.shared.clearHistory(context: modelContext)
Text("清空")
.foregroundColor(.orange)
} }
} label: {
Text("清空")
.foregroundColor(.orange)
} }
} }
} }
.navigationDestination(for: Movie.Video.self) { video in
DetailView(video: video)
}
} }
} }
///
private var emptyState: some View { private var emptyState: some View {
EmptyStateView( EmptyStateView(
icon: "clock.arrow.circlepath", icon: "clock.arrow.circlepath",
@@ -79,6 +92,8 @@ struct HistoryView: View {
.padding(40) .padding(40)
} }
///
/// 便
private func recordCard(_ item: VodRecord) -> some View { private func recordCard(_ item: VodRecord) -> some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .bottomLeading) { ZStack(alignment: .bottomLeading) {
@@ -115,6 +130,7 @@ struct HistoryView: View {
} }
} }
///
private func movieVideo(from item: VodRecord) -> Movie.Video { private func movieVideo(from item: VodRecord) -> Movie.Video {
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey) Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
} }
+55 -147
View File
@@ -5,12 +5,11 @@ struct HomeView: View {
@StateObject private var viewModel = HomeViewModel() @StateObject private var viewModel = HomeViewModel()
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@State private var categoryScrollAnchorId: String? @State private var categoryScrollAnchorId: String?
@State private var categoryDragTranslation: CGFloat = 0
// //
#if os(iOS) #if os(iOS)
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12) GridItem(.adaptive(minimum: 100, maximum: 140), spacing: 10)
] ]
#else #else
private let columns = [ private let columns = [
@@ -42,11 +41,11 @@ struct HomeView: View {
} }
} }
// MARK: - // MARK: -
private var headerBar: some View { private var headerBar: some View {
HStack(spacing: 15) { HStack(spacing: 12) {
// //
Menu { Menu {
ForEach(ApiConfig.shared.sourceBeanList.filter { $0.isSupportedInSwift }) { source in ForEach(ApiConfig.shared.sourceBeanList.filter { $0.isSupportedInSwift }) { source in
Button { Button {
@@ -63,132 +62,77 @@ struct HomeView: View {
} }
} label: { } label: {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: "sparkles") Image(systemName: "play.tv.fill")
.font(.system(size: 14))
.foregroundColor(.orange) .foregroundColor(.orange)
Text(ApiConfig.shared.homeSourceBean?.name ?? "TVBox") Text(ApiConfig.shared.homeSourceBean?.name ?? "TVBox")
.font(.system(size: 15, weight: .bold)) .font(.system(size: 15, weight: .semibold))
.foregroundColor(.white) .foregroundColor(.white)
.lineLimit(1) .lineLimit(1)
Image(systemName: "chevron.up.chevron.down") Image(systemName: "chevron.down")
.font(.system(size: 9, weight: .bold)) .font(.system(size: 10, weight: .semibold))
.foregroundColor(.white.opacity(0.5)) .foregroundColor(.white.opacity(0.5))
.padding(.leading, 2)
} }
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Color.white.opacity(0.1))
.clipShape(Capsule())
.overlay(Capsule().stroke(Color.white.opacity(0.1), lineWidth: 0.5))
} }
.menuStyle(.borderlessButton) .menuStyle(.borderlessButton)
.fixedSize() .fixedSize()
Spacer() Spacer()
//
HomeClockView()
} }
.padding(.horizontal, 20) .padding(.horizontal, 16)
.padding(.top, 15) .padding(.top, 10)
.padding(.bottom, 10) .padding(.bottom, 4)
} }
// MARK: - // MARK: -
private var categoryTabBar: some View { private var categoryTabBar: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
HStack(spacing: 8) { ScrollView(.horizontal, showsIndicators: false) {
categoryMoveButton( HStack(spacing: 0) {
systemName: "chevron.left", ForEach(viewModel.sorts) { sort in
enabled: canMoveCategory(by: -1) Button {
) { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
moveCategoryTabs(by: -3, proxy: proxy) viewModel.selectSort(sort)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(viewModel.sorts) { sort in
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
viewModel.selectSort(sort)
}
categoryScrollAnchorId = sort.id
scrollCategoryBar(to: sort.id, proxy: proxy)
} label: {
Text(sort.name)
.font(.system(size: 14, weight: viewModel.selectedSort?.id == sort.id ? .bold : .medium))
.foregroundColor(viewModel.selectedSort?.id == sort.id ? .orange : .white.opacity(0.8))
.padding(.horizontal, 18)
.padding(.vertical, 10)
.background(
ZStack {
if viewModel.selectedSort?.id == sort.id {
RoundedRectangle(cornerRadius: 12)
.fill(Color.orange.opacity(0.15))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.orange.opacity(0.5), lineWidth: 1)
)
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.white.opacity(0.05))
}
}
)
} }
.buttonStyle(.plain) categoryScrollAnchorId = sort.id
.id(sort.id) scrollCategoryBar(to: sort.id, proxy: proxy)
} label: {
VStack(spacing: 6) {
Text(sort.name)
.font(.system(size: 14, weight: viewModel.selectedSort?.id == sort.id ? .bold : .regular))
.foregroundColor(viewModel.selectedSort?.id == sort.id ? .white : .white.opacity(0.6))
//
RoundedRectangle(cornerRadius: 1.5)
.fill(Color.orange)
.frame(width: 20, height: 3)
.opacity(viewModel.selectedSort?.id == sort.id ? 1 : 0)
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
} }
.buttonStyle(.plain)
.id(sort.id)
} }
.padding(.horizontal, 8)
}
.simultaneousGesture(categoryDragGesture(proxy: proxy))
.onAppear {
syncCategoryScrollAnchorIfNeeded()
scrollCategoryBar(to: categoryScrollAnchorId, proxy: proxy, animated: false)
}
.onChange(of: viewModel.sorts.map(\.id)) { oldValue, newValue in
syncCategoryScrollAnchorIfNeeded()
scrollCategoryBar(to: categoryScrollAnchorId, proxy: proxy, animated: false)
}
.onChange(of: viewModel.selectedSort?.id) { oldId, newId in
guard let newId else { return }
categoryScrollAnchorId = newId
scrollCategoryBar(to: newId, proxy: proxy)
}
categoryMoveButton(
systemName: "chevron.right",
enabled: canMoveCategory(by: 1)
) {
moveCategoryTabs(by: 3, proxy: proxy)
} }
.padding(.horizontal, 12)
}
.onAppear {
syncCategoryScrollAnchorIfNeeded()
scrollCategoryBar(to: categoryScrollAnchorId, proxy: proxy, animated: false)
}
.onChange(of: viewModel.sorts.map(\.id)) { oldValue, newValue in
syncCategoryScrollAnchorIfNeeded()
scrollCategoryBar(to: categoryScrollAnchorId, proxy: proxy, animated: false)
}
.onChange(of: viewModel.selectedSort?.id) { oldId, newId in
guard let newId else { return }
categoryScrollAnchorId = newId
scrollCategoryBar(to: newId, proxy: proxy)
} }
.padding(.horizontal, 14)
} }
.padding(.vertical, 8) .padding(.bottom, 4)
}
private func categoryMoveButton(systemName: String, enabled: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white.opacity(enabled ? 0.9 : 0.35))
.frame(width: 26, height: 26)
.background(Color.white.opacity(0.08))
.clipShape(Circle())
}
.buttonStyle(.plain)
.disabled(!enabled)
}
private func canMoveCategory(by direction: Int) -> Bool {
guard !viewModel.sorts.isEmpty else { return false }
let currentIndex = categoryIndex(for: categoryScrollAnchorId) ?? 0
if direction < 0 {
return currentIndex > 0
}
return currentIndex < viewModel.sorts.count - 1
} }
private func categoryIndex(for id: String?) -> Int? { private func categoryIndex(for id: String?) -> Int? {
@@ -216,17 +160,6 @@ struct HomeView: View {
categoryScrollAnchorId = viewModel.sorts.first?.id categoryScrollAnchorId = viewModel.sorts.first?.id
} }
private func moveCategoryTabs(by delta: Int, proxy: ScrollViewProxy) {
guard !viewModel.sorts.isEmpty else { return }
let currentIndex = categoryIndex(for: categoryScrollAnchorId) ?? 0
let newIndex = min(max(0, currentIndex + delta), viewModel.sorts.count - 1)
guard newIndex != currentIndex else { return }
let targetId = viewModel.sorts[newIndex].id
categoryScrollAnchorId = targetId
scrollCategoryBar(to: targetId, proxy: proxy)
}
private func scrollCategoryBar(to id: String?, proxy: ScrollViewProxy, animated: Bool = true) { private func scrollCategoryBar(to id: String?, proxy: ScrollViewProxy, animated: Bool = true) {
guard let id else { return } guard let id else { return }
@@ -239,23 +172,6 @@ struct HomeView: View {
} }
} }
private func categoryDragGesture(proxy: ScrollViewProxy) -> some Gesture {
DragGesture(minimumDistance: 12)
.onChanged { value in
let delta = value.translation.width - categoryDragTranslation
if delta <= -28 {
moveCategoryTabs(by: 1, proxy: proxy)
categoryDragTranslation = value.translation.width
} else if delta >= 28 {
moveCategoryTabs(by: -1, proxy: proxy)
categoryDragTranslation = value.translation.width
}
}
.onEnded { _ in
categoryDragTranslation = 0
}
}
// MARK: - // MARK: -
private var contentArea: some View { private var contentArea: some View {
@@ -309,7 +225,11 @@ struct HomeView: View {
NavigationLink(value: video) { NavigationLink(value: video) {
VodCardView(video: video) VodCardView(video: video)
} }
#if os(iOS)
.buttonStyle(VodCardPressStyle())
#else
.buttonStyle(.plain) .buttonStyle(.plain)
#endif
.onAppear { .onAppear {
Task { await viewModel.loadMoreIfNeeded(currentItem: video) } Task { await viewModel.loadMoreIfNeeded(currentItem: video) }
} }
@@ -335,15 +255,3 @@ struct HomeView: View {
} }
} }
private struct HomeClockView: View {
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { timeline in
Text(timeline.date.homeDateString)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.6))
.padding(.horizontal, 16)
.padding(.vertical, 10)
.glassCard(cornerRadius: 12)
}
}
}
+21
View File
@@ -1,8 +1,23 @@
import SwiftUI import SwiftUI
// MARK: - VodCard Press Style (iOS only)
#if os(iOS)
/// - VodCard
struct VodCardPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
}
}
#endif
/// ///
struct VodCardView: View { struct VodCardView: View {
///
let video: Movie.Video let video: Movie.Video
/// macOS
@State private var isHovered = false @State private var isHovered = false
var body: some View { var body: some View {
@@ -44,6 +59,7 @@ struct VodCardView: View {
.padding(8) .padding(8)
} }
} }
//
.scaleEffect(isHovered ? 1.05 : 1.0) .scaleEffect(isHovered ? 1.05 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isHovered) .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isHovered)
.onHover { hovering in .onHover { hovering in
@@ -65,8 +81,13 @@ struct VodCardView: View {
} }
.padding(.horizontal, 4) .padding(.horizontal, 4)
} }
#if os(iOS)
.frame(minWidth: 44, minHeight: 44)
.contentShape(Rectangle())
#endif
} }
///
private var placeholderImage: some View { private var placeholderImage: some View {
RoundedRectangle(cornerRadius: AppTheme.cardRadius) RoundedRectangle(cornerRadius: AppTheme.cardRadius)
.fill(Color.white.opacity(0.05)) .fill(Color.white.opacity(0.05))
+195
View File
@@ -0,0 +1,195 @@
#if os(iOS)
import SwiftUI
/// - macOS 390pt
/// +
struct ChannelOverlayView: View {
let channelGroups: [LiveChannelGroup]
@Binding var selectedGroupIndex: Int
let currentChannels: [LiveChannelItem]
let currentChannel: LiveChannelItem?
let onSelectGroup: (Int) -> Void
let onSelectChannel: (LiveChannelItem) -> Void
let onDismiss: () -> Void
@State private var dragOffset: CGFloat = 0
/// 32%
/// - Parameter screenWidth:
/// - Returns: [screenWidth * 0.30, screenWidth * 0.35]
static func computeGroupWidth(screenWidth: CGFloat) -> CGFloat {
return screenWidth * 0.32
}
var body: some View {
GeometryReader { geometry in
ZStack {
// Dimmed background - tap to dismiss
Color.black.opacity(0.5)
.ignoresSafeArea()
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
onDismiss()
}
}
// Content panel
VStack(spacing: 0) {
// Handle bar for drag-to-dismiss
handleBar
// Two-column layout
HStack(spacing: 0) {
groupList
.frame(width: Self.computeGroupWidth(screenWidth: geometry.size.width))
Divider()
.overlay(Color.white.opacity(0.1))
channelList
}
}
.frame(maxHeight: geometry.size.height * 0.85)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 20))
.offset(y: max(dragOffset, 0))
.gesture(dismissDragGesture)
.padding(.top, geometry.safeAreaInsets.top + 40)
}
}
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: dragOffset)
}
// MARK: - Handle Bar
private var handleBar: some View {
VStack(spacing: 8) {
Capsule()
.fill(Color.white.opacity(0.4))
.frame(width: 36, height: 5)
.padding(.top, 10)
Text("频道列表")
.font(.system(size: 15, weight: .semibold))
.foregroundColor(.white.opacity(0.9))
.padding(.bottom, 8)
}
.frame(maxWidth: .infinity)
.background(Color.white.opacity(0.05))
}
// MARK: - Group List (Left Column)
private var groupList: some View {
ScrollView {
LazyVStack(spacing: 0) {
if channelGroups.isEmpty {
Text("暂无分组")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.5))
.padding(.vertical, 20)
.frame(maxWidth: .infinity)
} else {
ForEach(Array(channelGroups.enumerated()), id: \.offset) { index, group in
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
onSelectGroup(index)
}
} label: {
Text(group.groupName)
.font(.system(size: 14, weight: selectedGroupIndex == index ? .bold : .medium))
.foregroundColor(selectedGroupIndex == index ? .orange : .white.opacity(0.8))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.frame(minHeight: 48)
.background(
selectedGroupIndex == index
? Color.white.opacity(0.1)
: Color.clear
)
}
.buttonStyle(.plain)
}
}
}
}
}
// MARK: - Channel List (Right Column)
private var channelList: some View {
ScrollView {
LazyVStack(spacing: 0) {
if currentChannels.isEmpty {
Text("暂无频道")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.5))
.padding(.vertical, 20)
.frame(maxWidth: .infinity)
} else {
ForEach(Array(currentChannels.enumerated()), id: \.offset) { _, channel in
Button {
HapticManager.shared.mediumImpact()
onSelectChannel(channel)
} label: {
HStack {
Text(channel.channelName)
.font(.system(size: 14, weight: currentChannel?.channelName == channel.channelName ? .bold : .medium))
.foregroundColor(currentChannel?.channelName == channel.channelName ? .orange : .white.opacity(0.8))
Spacer()
if channel.sourceNum > 1 {
Text("\(channel.sourceNum)")
.font(.system(size: 10))
.foregroundColor(.white.opacity(0.3))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().stroke(Color.white.opacity(0.2), lineWidth: 0.5))
}
if currentChannel?.channelName == channel.channelName {
Image(systemName: "speaker.wave.2.fill")
.font(.system(size: 10))
.foregroundColor(.orange)
}
}
.padding(.horizontal, 16)
.frame(minHeight: 48)
.background(
currentChannel?.channelName == channel.channelName
? Color.orange.opacity(0.15)
: Color.clear
)
}
.buttonStyle(.plain)
}
}
}
}
}
// MARK: - Drag to Dismiss Gesture
private var dismissDragGesture: some Gesture {
DragGesture()
.onChanged { value in
dragOffset = value.translation.height
}
.onEnded { value in
if value.translation.height > 120 {
// Dismiss if dragged down far enough
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
onDismiss()
}
} else {
// Snap back
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
dragOffset = 0
}
}
}
}
}
#endif
+166 -4
View File
@@ -6,24 +6,49 @@ import AppKit
/// - Android LivePlayActivity /// - Android LivePlayActivity
struct LiveView: View { struct LiveView: View {
/// 退iOS TabBar
var onExit: (() -> Void)? = nil
///
@StateObject private var viewModel = LiveViewModel() @StateObject private var viewModel = LiveViewModel()
/// macOS
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
/// 使
@State private var avPlayer: AVPlayer? @State private var avPlayer: AVPlayer?
///
@AppStorage(HawkConfig.PLAY_TYPE_LIVE) private var livePlayTypeRaw = -1 @AppStorage(HawkConfig.PLAY_TYPE_LIVE) private var livePlayTypeRaw = -1
///
@AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue @AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue
///
@State private var showChannelDrawer = true @State private var showChannelDrawer = true
#if os(iOS)
/// iOS
@State private var showChannelOverlay = false
#endif
///
@State private var isWindowFullScreen = false @State private var isWindowFullScreen = false
///
private let currentChannelInfoMaxWidth: CGFloat = 600 private let currentChannelInfoMaxWidth: CGFloat = 600
/// AVPlayer
@State private var itemStatusObserver: NSKeyValueObservation? @State private var itemStatusObserver: NSKeyValueObservation?
///
@State private var playbackFailedObserver: NSObjectProtocol? @State private var playbackFailedObserver: NSObjectProtocol?
///
@State private var playbackStalledObserver: NSObjectProtocol? @State private var playbackStalledObserver: NSObjectProtocol?
/// 线线
@State private var failedSourceIndices: Set<Int> = [] @State private var failedSourceIndices: Set<Int> = []
/// ID
@State private var trackedChannelId: String = "" @State private var trackedChannelId: String = ""
///
@State private var showCurrentChannelInfo = true @State private var showCurrentChannelInfo = true
///
@State private var channelInfoTimer: Timer? @State private var channelInfoTimer: Timer?
///
private let channelInfoAutoHideDelay: TimeInterval = 3.0 private let channelInfoAutoHideDelay: TimeInterval = 3.0
/// VLC
@State private var vlcInteractionToken = 0 @State private var vlcInteractionToken = 0
///
/// 退使
private var selectedEngine: PlayerEngine { private var selectedEngine: PlayerEngine {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
let rawValue: Int let rawValue: Int
@@ -78,8 +103,12 @@ struct LiveView: View {
#endif #endif
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
.toolbar(.hidden, for: .navigationBar)
.toolbar(.hidden, for: .tabBar)
#endif #endif
.onAppear { .onAppear {
//
viewModel.loadChannels() viewModel.loadChannels()
wakeUpCurrentChannelInfo() wakeUpCurrentChannelInfo()
} }
@@ -92,6 +121,7 @@ struct LiveView: View {
wakeUpCurrentChannelInfo() wakeUpCurrentChannelInfo()
} }
.onChange(of: viewModel.currentChannel?.id) { _, _ in .onChange(of: viewModel.currentChannel?.id) { _, _ in
// 线
resetFailureTracking(for: viewModel.currentChannel) resetFailureTracking(for: viewModel.currentChannel)
wakeUpCurrentChannelInfo() wakeUpCurrentChannelInfo()
} }
@@ -149,6 +179,7 @@ struct LiveView: View {
private var overlayUI: some View { private var overlayUI: some View {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
#if os(macOS)
if showChannelDrawer { if showChannelDrawer {
Color.black.opacity(0.22) Color.black.opacity(0.22)
.ignoresSafeArea() .ignoresSafeArea()
@@ -159,9 +190,13 @@ struct LiveView: View {
} }
} }
} }
#endif
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { HStack {
#if os(iOS)
liveBackButton
#endif
channelDrawerToggleButton channelDrawerToggleButton
Spacer() Spacer()
} }
@@ -177,19 +212,44 @@ struct LiveView: View {
} }
} }
#if os(macOS)
if showChannelDrawer { if showChannelDrawer {
channelDrawer channelDrawer
.padding(.leading, 12) .padding(.leading, 12)
.padding(.vertical, 20) .padding(.vertical, 20)
.transition(.move(edge: .leading).combined(with: .opacity)) .transition(.move(edge: .leading).combined(with: .opacity))
} }
#endif
} }
.animation(.easeInOut(duration: 0.2), value: showCurrentChannelInfo) .animation(.easeInOut(duration: 0.2), value: showCurrentChannelInfo)
#if os(macOS)
.animation(.easeInOut(duration: 0.2), value: showChannelDrawer)
#endif
.simultaneousGesture( .simultaneousGesture(
TapGesture().onEnded { TapGesture().onEnded {
reportUserActivity() reportUserActivity()
} }
) )
#if os(iOS)
.fullScreenCover(isPresented: $showChannelOverlay) {
ChannelOverlayView(
channelGroups: viewModel.channelGroups,
selectedGroupIndex: $viewModel.selectedGroupIndex,
currentChannels: viewModel.currentChannels,
currentChannel: viewModel.currentChannel,
onSelectGroup: { viewModel.selectGroup($0) },
onSelectChannel: { channel in
viewModel.selectChannel(channel)
HapticManager.shared.mediumImpact()
showChannelOverlay = false
},
onDismiss: { showChannelOverlay = false }
)
.presentationBackground(.clear)
.transition(.move(edge: .bottom))
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: showChannelOverlay)
}
#endif
#if os(macOS) #if os(macOS)
.onContinuousHover { phase in .onContinuousHover { phase in
switch phase { switch phase {
@@ -245,20 +305,58 @@ struct LiveView: View {
.glassCard(cornerRadius: 14) .glassCard(cornerRadius: 14)
} }
#if os(iOS)
private var liveBackButton: some View {
Button {
HapticManager.shared.lightImpact()
onExit?()
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white.opacity(0.9))
.frame(width: 38, height: 38)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
.overlay(
Circle()
.stroke(Color.white.opacity(0.15), lineWidth: 0.5)
)
}
.buttonStyle(.plain)
}
#endif
private var channelDrawerToggleButton: some View { private var channelDrawerToggleButton: some View {
Button { Button {
#if os(iOS)
HapticManager.shared.lightImpact()
showChannelOverlay = true
#else
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
showChannelDrawer.toggle() showChannelDrawer.toggle()
} }
#endif
} label: { } label: {
HStack(spacing: 8) { HStack(spacing: 8) {
#if os(iOS)
Image(systemName: "list.bullet")
.font(.system(size: 15, weight: .semibold))
Text("频道")
.font(.system(size: 14, weight: .semibold))
#else
Image(systemName: showChannelDrawer ? "sidebar.left" : "sidebar.right") Image(systemName: showChannelDrawer ? "sidebar.left" : "sidebar.right")
Text(showChannelDrawer ? "收起菜单" : "频道菜单") Text(showChannelDrawer ? "收起菜单" : "频道菜单")
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
#endif
} }
.foregroundColor(.white.opacity(0.9)) .foregroundColor(.white.opacity(0.9))
#if os(iOS)
.padding(.horizontal, 14)
.padding(.vertical, 10)
#else
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 8) .padding(.vertical, 8)
#endif
.background(Color.black.opacity(0.35)) .background(Color.black.opacity(0.35))
.clipShape(Capsule()) .clipShape(Capsule())
.overlay( .overlay(
@@ -273,6 +371,61 @@ struct LiveView: View {
} }
private func currentChannelInfo(_ channel: LiveChannelItem) -> some View { private func currentChannelInfo(_ channel: LiveChannelItem) -> some View {
#if os(iOS)
// iOS:
VStack(spacing: 12) {
HStack(spacing: 12) {
//
HStack(spacing: 8) {
Circle().fill(Color.orange).frame(width: 8, height: 8)
Text(channel.channelName)
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
.lineLimit(1)
}
Spacer()
// 线
if channel.sourceNum > 1 {
Text("线路 \(channel.sourceIndex + 1)/\(channel.sourceNum)")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white.opacity(0.6))
}
}
//
if channel.sourceNum > 1 {
HStack(spacing: 12) {
Button {
HapticManager.shared.mediumImpact()
wakeUpCurrentChannelInfo()
resetFailureTracking(for: viewModel.currentChannel)
viewModel.switchSource()
} label: {
HStack(spacing: 6) {
Image(systemName: "shuffle")
.font(.system(size: 13))
Text("切换线路")
.font(.system(size: 13, weight: .semibold))
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 40)
.background(AppTheme.accentGradient)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
}
}
.padding(16)
.glassCard(cornerRadius: 14)
.frame(maxWidth: .infinity)
.padding(.horizontal, 16)
.padding(.bottom, 8)
#else
// macOS:
HStack(spacing: 15) { HStack(spacing: 15) {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 10) { HStack(spacing: 10) {
@@ -292,7 +445,6 @@ struct LiveView: View {
Spacer() Spacer()
HStack(spacing: 12) { HStack(spacing: 12) {
// 线
if channel.sourceNum > 1 { if channel.sourceNum > 1 {
Button { Button {
wakeUpCurrentChannelInfo() wakeUpCurrentChannelInfo()
@@ -313,7 +465,6 @@ struct LiveView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
#if os(macOS)
Button { Button {
wakeUpCurrentChannelInfo() wakeUpCurrentChannelInfo()
toggleWindowFullScreen() toggleWindowFullScreen()
@@ -334,7 +485,6 @@ struct LiveView: View {
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
#endif
} }
} }
.padding(20) .padding(20)
@@ -342,6 +492,7 @@ struct LiveView: View {
.frame(maxWidth: currentChannelInfoMaxWidth) .frame(maxWidth: currentChannelInfoMaxWidth)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(20) .padding(20)
#endif
} }
private func wakeUpCurrentChannelInfo() { private func wakeUpCurrentChannelInfo() {
@@ -364,6 +515,7 @@ struct LiveView: View {
} }
private func reportUserActivity() { private func reportUserActivity() {
// VLC
wakeUpCurrentChannelInfo() wakeUpCurrentChannelInfo()
vlcInteractionToken &+= 1 vlcInteractionToken &+= 1
} }
@@ -447,7 +599,12 @@ struct LiveView: View {
cleanupPlayer() cleanupPlayer()
let playerItem = AVPlayerItem(url: url) // 使 AVURLAsset HTTP CDN User-Agent
let headers: [String: String] = [
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
]
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
let playerItem = AVPlayerItem(asset: asset)
// //
playerItem.preferredForwardBufferDuration = 3 playerItem.preferredForwardBufferDuration = 3
playerItem.canUseNetworkResourcesForLiveStreamingWhilePaused = false playerItem.canUseNetworkResourcesForLiveStreamingWhilePaused = false
@@ -459,6 +616,7 @@ struct LiveView: View {
} }
private func cleanupPlayer() { private func cleanupPlayer() {
//
if let observer = playbackFailedObserver { if let observer = playbackFailedObserver {
NotificationCenter.default.removeObserver(observer) NotificationCenter.default.removeObserver(observer)
playbackFailedObserver = nil playbackFailedObserver = nil
@@ -475,6 +633,7 @@ struct LiveView: View {
} }
private func observePlaybackFailure(for item: AVPlayerItem) { private func observePlaybackFailure(for item: AVPlayerItem) {
// KVO item
itemStatusObserver = item.observe(\.status, options: [.new]) { observedItem, _ in itemStatusObserver = item.observe(\.status, options: [.new]) { observedItem, _ in
if observedItem.status == .failed { if observedItem.status == .failed {
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -483,6 +642,7 @@ struct LiveView: View {
} }
} }
//
playbackFailedObserver = NotificationCenter.default.addObserver( playbackFailedObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemFailedToPlayToEndTime, forName: .AVPlayerItemFailedToPlayToEndTime,
object: item, object: item,
@@ -491,6 +651,7 @@ struct LiveView: View {
handlePlaybackFailure(trigger: "item_failed") handlePlaybackFailure(trigger: "item_failed")
} }
//
playbackStalledObserver = NotificationCenter.default.addObserver( playbackStalledObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemPlaybackStalled, forName: .AVPlayerItemPlaybackStalled,
object: item, object: item,
@@ -526,6 +687,7 @@ struct LiveView: View {
private func switchToNextAvailableSource(totalSources: Int) -> Bool { private func switchToNextAvailableSource(totalSources: Int) -> Bool {
guard failedSourceIndices.count < totalSources else { return false } guard failedSourceIndices.count < totalSources else { return false }
// `totalSources` 线
for _ in 0..<totalSources { for _ in 0..<totalSources {
viewModel.switchSource() viewModel.switchSource()
guard let nextIndex = viewModel.currentChannel?.sourceIndex else { return false } guard let nextIndex = viewModel.currentChannel?.sourceIndex else { return false }
@@ -0,0 +1,143 @@
#if os(iOS)
import SwiftUI
// MARK: - LivePlayerGestureLayer
/// -
/// 退
/// - /
/// - /
/// -
/// -
struct LivePlayerGestureLayer: View {
// MARK: - Callbacks
let onTogglePlayPause: () -> Void
let onToggleControls: () -> Void
let onVolumeChanged: (CGFloat) -> Void
// MARK: - State
@State private var gestureMode: LiveGestureMode = .none
@State private var showIndicator = false
@State private var dragStartX: CGFloat = 0
@State private var accumulatedBrightnessDelta: CGFloat = 0
@State private var accumulatedVolumeDelta: CGFloat = 0
enum LiveGestureMode: Equatable {
case none
case adjustingBrightness(delta: CGFloat)
case adjustingVolume(delta: CGFloat)
}
// MARK: - Body
var body: some View {
GeometryReader { geometry in
Color.clear
.contentShape(Rectangle())
.gesture(dragGesture(in: geometry))
.onTapGesture(count: 2) {
HapticManager.shared.lightImpact()
onTogglePlayPause()
}
.onTapGesture(count: 1) {
onToggleControls()
}
.overlay { gestureIndicatorOverlay }
}
}
// MARK: - Drag Gesture
private func dragGesture(in geometry: GeometryProxy) -> some Gesture {
DragGesture(minimumDistance: 15)
.onChanged { value in
if gestureMode == .none {
dragStartX = value.startLocation.x
HapticManager.shared.lightImpact()
}
let absV = abs(value.translation.height)
guard absV > 15 else { return }
let isLeftHalf = dragStartX < geometry.size.width / 2
let delta = -value.translation.height / 300.0
if isLeftHalf {
gestureMode = .adjustingBrightness(delta: delta)
// Apply brightness directly
let newBrightness = max(0, min(1, UIScreen.main.brightness + (delta - accumulatedBrightnessDelta)))
UIScreen.main.brightness = newBrightness
accumulatedBrightnessDelta = delta
} else {
gestureMode = .adjustingVolume(delta: delta)
let volumeDelta = delta - accumulatedVolumeDelta
onVolumeChanged(volumeDelta)
accumulatedVolumeDelta = delta
}
withAnimation(.easeInOut(duration: 0.2)) {
showIndicator = true
}
}
.onEnded { _ in
gestureMode = .none
accumulatedBrightnessDelta = 0
accumulatedVolumeDelta = 0
withAnimation(.easeInOut(duration: 0.2)) {
showIndicator = false
}
}
}
// MARK: - Gesture Indicator Overlay
@ViewBuilder
private var gestureIndicatorOverlay: some View {
if showIndicator {
VStack(spacing: 8) {
indicatorIcon
indicatorText
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
.opacity(showIndicator ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: showIndicator)
}
}
@ViewBuilder
private var indicatorIcon: some View {
switch gestureMode {
case .adjustingBrightness:
Image(systemName: "sun.max.fill")
.font(.title2)
.foregroundColor(.yellow)
case .adjustingVolume:
Image(systemName: "speaker.wave.2.fill")
.font(.title2)
.foregroundColor(.white)
case .none:
EmptyView()
}
}
@ViewBuilder
private var indicatorText: some View {
switch gestureMode {
case .adjustingBrightness:
let percentage = Int(UIScreen.main.brightness * 100)
Text("\(percentage)%")
.font(.caption)
.foregroundColor(.white)
case .adjustingVolume:
Text("音量")
.font(.caption)
.foregroundColor(.white)
case .none:
EmptyView()
}
}
}
#endif
+273
View File
@@ -0,0 +1,273 @@
#if os(iOS)
import SwiftUI
// MARK: - Gesture Mode Enum
/// -
enum PlayerGestureMode: Equatable {
case none
case seeking(offset: Double)
case adjustingBrightness(delta: CGFloat)
case adjustingVolume(delta: CGFloat)
}
// MARK: - PlayerGestureLayer
/// -
/// 退
/// /
struct PlayerGestureLayer: View {
// MARK: - Callbacks
let onSeek: (Double) -> Void
let onTogglePlayPause: () -> Void
let onToggleControls: () -> Void
let onZoomChanged: (CGFloat) -> Void
// MARK: - Properties
let currentTime: Double
let duration: Double
// MARK: - State
@State private var gestureMode: PlayerGestureMode = .none
@State private var zoomScale: CGFloat = 1.0
@State private var showIndicator = false
@State private var dragStartX: CGFloat = 0
// MARK: - Pure Computation Functions
///
static func classifyGesture(
startX: CGFloat,
containerWidth: CGFloat,
translation: CGSize,
threshold: CGFloat = 10
) -> PlayerGestureMode {
let absH = abs(translation.width)
let absV = abs(translation.height)
// 退
if absH > absV && absH > threshold {
//
let normalizedOffset = translation.width / containerWidth
let offset = Double(normalizedOffset) * 120.0 // = 120
return .seeking(offset: offset)
}
// = =
if absV > threshold {
let isLeftHalf = startX < containerWidth / 2
let delta = -translation.height / 300.0 //
if isLeftHalf {
return .adjustingBrightness(delta: delta)
} else {
return .adjustingVolume(delta: delta)
}
}
return .none
}
/// 退
static func computeSeekTarget(
currentTime: Double,
offset: Double,
duration: Double
) -> Double {
let target = currentTime + offset
return max(0, min(target, duration))
}
/// [0, 1]
static func clampAdjustment(
currentValue: CGFloat,
delta: CGFloat
) -> CGFloat {
return max(0.0, min(1.0, currentValue + delta))
}
/// [minZoom, maxZoom]
static func clampZoom(
scale: CGFloat,
minZoom: CGFloat = 1.0,
maxZoom: CGFloat = 3.0
) -> CGFloat {
return max(minZoom, min(maxZoom, scale))
}
// MARK: - Body
var body: some View {
GeometryReader { geometry in
Color.clear
.contentShape(Rectangle())
.gesture(dragGesture(in: geometry))
.gesture(pinchGesture)
.onTapGesture(count: 2) {
HapticManager.shared.lightImpact()
onTogglePlayPause()
}
.onTapGesture(count: 1) {
onToggleControls()
}
.overlay { gestureIndicatorOverlay }
}
}
// MARK: - Drag Gesture
private func dragGesture(in geometry: GeometryProxy) -> some Gesture {
DragGesture(minimumDistance: 5)
.onChanged { value in
if gestureMode == .none {
dragStartX = value.startLocation.x
HapticManager.shared.lightImpact()
}
let mode = Self.classifyGesture(
startX: dragStartX,
containerWidth: geometry.size.width,
translation: value.translation
)
if mode != .none {
gestureMode = mode
withAnimation(.easeInOut(duration: 0.2)) {
showIndicator = true
}
}
}
.onEnded { value in
switch gestureMode {
case .seeking(let offset):
let target = Self.computeSeekTarget(
currentTime: currentTime,
offset: offset,
duration: duration
)
onSeek(target - currentTime)
case .adjustingBrightness, .adjustingVolume:
break
case .none:
break
}
gestureMode = .none
withAnimation(.easeInOut(duration: 0.2)) {
showIndicator = false
}
}
}
// MARK: - Pinch Gesture
private var pinchGesture: some Gesture {
MagnifyGesture()
.onChanged { value in
let newScale = Self.clampZoom(scale: value.magnification * zoomScale)
onZoomChanged(newScale)
gestureMode = .none
withAnimation(.easeInOut(duration: 0.2)) {
showIndicator = true
}
}
.onEnded { value in
let finalScale = Self.clampZoom(scale: value.magnification * zoomScale)
if finalScale < 1.0 {
zoomScale = 1.0
} else {
zoomScale = finalScale
}
onZoomChanged(zoomScale)
withAnimation(.easeInOut(duration: 0.2)) {
showIndicator = false
}
}
}
// MARK: - Gesture Indicator Overlay
@ViewBuilder
private var gestureIndicatorOverlay: some View {
if showIndicator {
VStack(spacing: 8) {
indicatorIcon
indicatorText
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
.opacity(showIndicator ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: showIndicator)
}
}
@ViewBuilder
private var indicatorIcon: some View {
switch gestureMode {
case .seeking(let offset):
Image(systemName: offset >= 0 ? "forward.fill" : "backward.fill")
.font(.title2)
.foregroundColor(.white)
case .adjustingBrightness:
Image(systemName: "sun.max.fill")
.font(.title2)
.foregroundColor(.yellow)
case .adjustingVolume:
Image(systemName: "speaker.wave.2.fill")
.font(.title2)
.foregroundColor(.white)
case .none:
EmptyView()
}
}
@ViewBuilder
private var indicatorText: some View {
switch gestureMode {
case .seeking(let offset):
let target = Self.computeSeekTarget(
currentTime: currentTime,
offset: offset,
duration: duration
)
Text(formatTime(target))
.font(.caption)
.foregroundColor(.white)
case .adjustingBrightness(let delta):
let percentage = Int(Self.clampAdjustment(
currentValue: UIScreen.main.brightness,
delta: delta
) * 100)
Text("\(percentage)%")
.font(.caption)
.foregroundColor(.white)
case .adjustingVolume(let delta):
let percentage = Int(Self.clampAdjustment(
currentValue: 0.5,
delta: delta
) * 100)
Text("\(percentage)%")
.font(.caption)
.foregroundColor(.white)
case .none:
EmptyView()
}
}
// MARK: - Helpers
private func formatTime(_ seconds: Double) -> String {
let totalSeconds = Int(max(0, seconds))
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let secs = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, secs)
} else {
return String(format: "%02d:%02d", minutes, secs)
}
}
}
#endif
+316 -22
View File
@@ -26,7 +26,7 @@ private struct MacOSPlayerView: NSViewRepresentable {
func makeNSView(context: Context) -> AVPlayerView { func makeNSView(context: Context) -> AVPlayerView {
let view = AVPlayerView() let view = AVPlayerView()
view.controlsStyle = .none // view.controlsStyle = .none
view.showsFullScreenToggleButton = false view.showsFullScreenToggleButton = false
view.videoGravity = .resizeAspect view.videoGravity = .resizeAspect
view.player = player view.player = player
@@ -61,10 +61,17 @@ final class SystemPlayerSessionController: ObservableObject {
} }
func stop() { func stop() {
let stoppingPlayer = player
player?.pause() player?.pause()
player?.replaceCurrentItem(with: nil)
player = nil player = nil
mediaURLString = nil mediaURLString = nil
// AVPlayer.replaceCurrentItem(with: nil) 线
// AVPlayer dealloc
if let stoppingPlayer {
DispatchQueue.global(qos: .utility).async {
stoppingPlayer.replaceCurrentItem(with: nil)
}
}
} }
} }
@@ -144,6 +151,7 @@ struct PlayerView: View {
/// AVPlayer /// AVPlayer
struct AVPlayerContentView: View { struct AVPlayerContentView: View {
private static let supportedPlaybackRates: [Float] = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
let urlString: String let urlString: String
var startPosition: Double = 0 var startPosition: Double = 0
var onProgressChanged: ((Double, Double?) -> Void)? = nil var onProgressChanged: ((Double, Double?) -> Void)? = nil
@@ -152,6 +160,7 @@ struct AVPlayerContentView: View {
var canPlayNext: Bool = false var canPlayNext: Bool = false
var onPlayNext: (() -> Void)? = nil var onPlayNext: (() -> Void)? = nil
var sharedController: SystemPlayerSessionController? = nil var sharedController: SystemPlayerSessionController? = nil
@AppStorage(HawkConfig.PLAY_SPEED) private var savedPlaybackRate = 1.0
@State private var player: AVPlayer? @State private var player: AVPlayer?
@State private var playbackEndObserver: NSObjectProtocol? @State private var playbackEndObserver: NSObjectProtocol?
@State private var timeObserverToken: Any? @State private var timeObserverToken: Any?
@@ -163,6 +172,7 @@ struct AVPlayerContentView: View {
@State private var volume: Double = 1.0 @State private var volume: Double = 1.0
@State private var rate: Float = 1.0 @State private var rate: Float = 1.0
@State private var isPreparing = true @State private var isPreparing = true
@State private var playbackError: String?
@State private var showControls = true @State private var showControls = true
@State private var controlsTimer: Timer? @State private var controlsTimer: Timer?
@State private var osdIcon: String? @State private var osdIcon: String?
@@ -172,11 +182,16 @@ struct AVPlayerContentView: View {
@State private var draggingSeconds: Double = 0 @State private var draggingSeconds: Double = 0
@State private var playerObservers: [NSKeyValueObservation] = [] @State private var playerObservers: [NSKeyValueObservation] = []
@State private var videoZoomScale: CGFloat = 1.0
var body: some View { var body: some View {
ZStack { ZStack {
Group { Group {
if let player = player { if let player = player {
PlatformVideoPlayer(player: player) PlatformVideoPlayer(player: player)
#if os(iOS)
.scaleEffect(videoZoomScale)
#endif
} else { } else {
ZStack { ZStack {
Color.black Color.black
@@ -185,18 +200,37 @@ struct AVPlayerContentView: View {
} }
} }
} }
#if !os(iOS)
.onTapGesture(count: 2) { .onTapGesture(count: 2) {
onToggleFullScreen?() onToggleFullScreen?()
} }
.onTapGesture(count: 1) { .onTapGesture(count: 1) {
togglePlayPauseWithOSD() togglePlayPauseWithOSD()
} }
#endif
if isPreparing { if isPreparing {
ProgressView() ProgressView()
.tint(.white) .tint(.white)
} }
if let error = playbackError {
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 36))
.foregroundColor(.yellow)
Text("播放失败")
.font(.headline)
.foregroundColor(.white)
Text(error)
.font(.caption)
.foregroundColor(.white.opacity(0.7))
.multilineTextAlignment(.center)
.lineLimit(3)
}
.padding()
}
if let osdIcon = osdIcon { if let osdIcon = osdIcon {
Image(systemName: osdIcon) Image(systemName: osdIcon)
.font(.system(size: 60, weight: .semibold)) .font(.system(size: 60, weight: .semibold))
@@ -208,6 +242,22 @@ struct AVPlayerContentView: View {
.allowsHitTesting(false) .allowsHitTesting(false)
} }
} }
#if os(iOS)
.overlay {
PlayerGestureLayer(
onSeek: { offset in seek(by: offset) },
onTogglePlayPause: { togglePlayPauseWithOSD() },
onToggleControls: { wakeUpControls() },
onZoomChanged: { scale in
withAnimation(.easeInOut(duration: 0.2)) {
videoZoomScale = scale
}
},
currentTime: currentTime,
duration: duration
)
}
#endif
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
GeometryReader { proxy in GeometryReader { proxy in
if player != nil { if player != nil {
@@ -238,10 +288,12 @@ struct AVPlayerContentView: View {
} }
} }
.onAppear { .onAppear {
syncRateFromSettings()
setupPlayer() setupPlayer()
wakeUpControls() wakeUpControls()
} }
.onChange(of: urlString) { _, _ in .onChange(of: urlString) { _, _ in
syncRateFromSettings()
setupPlayer() setupPlayer()
wakeUpControls() wakeUpControls()
} }
@@ -253,14 +305,37 @@ struct AVPlayerContentView: View {
} }
private func setupPlayer() { private func setupPlayer() {
guard let url = URL(string: urlString) else { return } guard let url = Self.sanitizedURL(from: urlString) else {
print("[AVPlayer] URL sanitization failed for: \(urlString)")
return
}
let targetURLString = url.absoluteString let targetURLString = url.absoluteString
let preferredRate = normalizedSavedPlaybackRate
rate = preferredRate
playbackError = nil
if let sharedController, if let sharedController,
sharedController.mediaURLString == targetURLString, sharedController.mediaURLString == targetURLString,
let sharedPlayer = sharedController.player { let sharedPlayer = sharedController.player {
cleanupPlayer(keepSharedPlayer: true) cleanupPlayer(keepSharedPlayer: true)
// AVPlayerItem退 VideoPlayer
// SwiftUI.VideoPlayer AVPlayer
// replaceCurrentItem layer
#if os(iOS)
if let currentItem = sharedPlayer.currentItem {
let currentTime = sharedPlayer.currentTime()
let wasPlaying = sharedPlayer.rate != 0
sharedPlayer.replaceCurrentItem(with: nil)
sharedPlayer.replaceCurrentItem(with: currentItem)
sharedPlayer.seek(to: currentTime, toleranceBefore: .zero, toleranceAfter: .zero) { _ in
if wasPlaying {
sharedPlayer.playImmediately(atRate: self.normalizedSavedPlaybackRate)
}
}
}
#endif
player = sharedPlayer player = sharedPlayer
applyPreferredPlaybackRate(to: sharedPlayer)
bindPlayerObservers(for: sharedPlayer) bindPlayerObservers(for: sharedPlayer)
reportProgress(for: sharedPlayer) reportProgress(for: sharedPlayer)
return return
@@ -269,8 +344,13 @@ struct AVPlayerContentView: View {
// //
cleanupPlayer() cleanupPlayer()
let playerItem = AVPlayerItem(url: url) // 使 AVURLAsset HTTP CDN User-Agent
let asset = AVURLAsset(url: url)
asset.resourceLoader.setDelegate(nil, queue: nil)
let playerItem = AVPlayerItem(asset: asset)
playerItem.preferredForwardBufferDuration = 0
let newPlayer = AVPlayer(playerItem: playerItem) let newPlayer = AVPlayer(playerItem: playerItem)
newPlayer.defaultRate = preferredRate
if let sharedController { if let sharedController {
sharedController.setPlayer(newPlayer, urlString: targetURLString) sharedController.setPlayer(newPlayer, urlString: targetURLString)
} }
@@ -280,6 +360,20 @@ struct AVPlayerContentView: View {
startPlayback(for: newPlayer) startPlayback(for: newPlayer)
} }
/// URL URL
private static func sanitizedURL(from urlString: String) -> URL? {
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: trimmed) {
return url
}
//
if let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: encoded) {
return url
}
return nil
}
private func bindPlayerObservers(for player: AVPlayer) { private func bindPlayerObservers(for player: AVPlayer) {
playerObservers = [ playerObservers = [
player.observe(\.timeControlStatus, options: [.new]) { p, _ in player.observe(\.timeControlStatus, options: [.new]) { p, _ in
@@ -295,16 +389,40 @@ struct AVPlayerContentView: View {
DispatchQueue.main.async { volume = vol } DispatchQueue.main.async { volume = vol }
}, },
player.observe(\.rate, options: [.new]) { p, _ in player.observe(\.rate, options: [.new]) { p, _ in
let r = p.rate let currentRate = p.rate
DispatchQueue.main.async { rate = r } guard currentRate > 0 else { return }
let normalized = Self.normalizedPlaybackRate(from: currentRate)
DispatchQueue.main.async {
rate = normalized
if abs(savedPlaybackRate - Double(normalized)) > 0.001 {
savedPlaybackRate = Double(normalized)
}
}
} }
] ]
// AVPlayerItem
if let item = player.currentItem {
let itemObserver = item.observe(\.status, options: [.new]) { observedItem, _ in
if observedItem.status == .failed {
let errorDesc = observedItem.error?.localizedDescription ?? "未知错误"
DispatchQueue.main.async {
isPreparing = false
playbackError = errorDesc
}
} else if observedItem.status == .readyToPlay {
DispatchQueue.main.async {
playbackError = nil
}
}
}
playerObservers.append(itemObserver)
}
observePlaybackProgress(for: player) observePlaybackProgress(for: player)
observePlaybackEnd(for: player) observePlaybackEnd(for: player)
isPlaying = player.timeControlStatus == .playing isPlaying = player.timeControlStatus == .playing
isPreparing = player.reasonForWaitingToPlay != nil isPreparing = player.reasonForWaitingToPlay != nil
volume = Double(player.volume) volume = Double(player.volume)
rate = player.rate rate = normalizedSavedPlaybackRate
} }
private func detachPlayerObservers() { private func detachPlayerObservers() {
@@ -331,12 +449,15 @@ struct AVPlayerContentView: View {
} }
currentPlayer.pause() currentPlayer.pause()
currentPlayer.replaceCurrentItem(with: nil)
if sharedController?.player === currentPlayer { if sharedController?.player === currentPlayer {
sharedController?.player = nil sharedController?.player = nil
sharedController?.mediaURLString = nil sharedController?.mediaURLString = nil
} }
player = nil player = nil
// replaceCurrentItem(with: nil) 线
DispatchQueue.global(qos: .utility).async {
currentPlayer.replaceCurrentItem(with: nil)
}
} }
private func startPlayback(for player: AVPlayer) { private func startPlayback(for player: AVPlayer) {
@@ -346,17 +467,17 @@ struct AVPlayerContentView: View {
let seekTime = CMTime(seconds: target, preferredTimescale: 600) let seekTime = CMTime(seconds: target, preferredTimescale: 600)
player.seek(to: seekTime, toleranceBefore: .zero, toleranceAfter: .zero) { _ in player.seek(to: seekTime, toleranceBefore: .zero, toleranceAfter: .zero) { _ in
reportProgress(for: player) reportProgress(for: player)
player.play() playAtPreferredRate(player)
} }
} else { } else {
player.play() playAtPreferredRate(player)
} }
} }
private func togglePlayPause() { private func togglePlayPause() {
guard let player = player else { return } guard let player = player else { return }
if player.rate == 0 { if player.rate == 0 {
player.play() playAtPreferredRate(player)
} else { } else {
player.pause() player.pause()
} }
@@ -436,8 +557,129 @@ struct AVPlayerContentView: View {
} }
private func playbackControls(containerWidth: CGFloat) -> some View { private func playbackControls(containerWidth: CGFloat) -> some View {
VStack(spacing: 8) { #if os(iOS)
// let controlWidth = containerWidth * 1.0
#else
let controlWidth = containerWidth * 0.7
#endif
return VStack(spacing: 0) {
#if os(iOS)
// iOS:
//
HStack(spacing: 8) {
Text(currentTime.durationString)
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.8))
.lineLimit(1)
Slider(
value: Binding(
get: { isDraggingProgress ? draggingSeconds : currentTime },
set: {
draggingSeconds = $0
wakeUpControls()
}
),
in: 0...progressUpperBound,
onEditingChanged: { editing in
isDraggingProgress = editing
wakeUpControls()
if !editing {
seek(to: draggingSeconds)
}
}
)
.accentColor(.white)
.disabled(duration <= 0)
Text(duration.durationString)
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.5))
.lineLimit(1)
}
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 4)
//
HStack(spacing: 0) {
//
playbackRateMenu
.frame(minWidth: 36, alignment: .leading)
Spacer()
//
HStack(spacing: 20) {
Button {
wakeUpControls()
seek(by: -seekStep)
showOSD(icon: "gobackward.\(Int(seekStep))")
} label: {
Image(systemName: "gobackward.\(Int(seekStep))")
.font(.system(size: 16, weight: .medium))
.frame(minWidth: 36, minHeight: 36)
}
.buttonStyle(.plain)
Button {
wakeUpControls()
togglePlayPauseWithOSD()
} label: {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 20, weight: .bold))
.frame(minWidth: 36, minHeight: 36)
}
.buttonStyle(.plain)
Button {
wakeUpControls()
seek(by: seekStep)
showOSD(icon: "goforward.\(Int(seekStep))")
} label: {
Image(systemName: "goforward.\(Int(seekStep))")
.font(.system(size: 16, weight: .medium))
.frame(minWidth: 36, minHeight: 36)
}
.buttonStyle(.plain)
if let onPlayNext {
Button {
guard canPlayNext else { return }
wakeUpControls()
onPlayNext()
showOSD(icon: "forward.end.fill")
} label: {
Image(systemName: "forward.end.fill")
.font(.system(size: 16, weight: .medium))
.frame(minWidth: 36, minHeight: 36)
}
.buttonStyle(.plain)
.disabled(!canPlayNext)
.opacity(canPlayNext ? 1 : 0.4)
}
}
Spacer()
//
if let onToggleFullScreen {
Button {
wakeUpControls()
onToggleFullScreen()
} label: {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 14, weight: .bold))
.frame(minWidth: 36, minHeight: 36)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.bottom, 6)
#else
// macOS:
HStack(spacing: 12) { HStack(spacing: 12) {
Text(currentTime.durationString) Text(currentTime.durationString)
.font(.system(size: 11, weight: .semibold, design: .monospaced)) .font(.system(size: 11, weight: .semibold, design: .monospaced))
@@ -473,9 +715,7 @@ struct AVPlayerContentView: View {
} }
.padding(.horizontal, 4) .padding(.horizontal, 4)
//
HStack(spacing: 0) { HStack(spacing: 0) {
//
HStack(spacing: 16) { HStack(spacing: 16) {
playbackRateMenu playbackRateMenu
} }
@@ -483,7 +723,6 @@ struct AVPlayerContentView: View {
Spacer() Spacer()
// (退)
HStack(spacing: 24) { HStack(spacing: 24) {
Button { Button {
wakeUpControls() wakeUpControls()
@@ -538,7 +777,6 @@ struct AVPlayerContentView: View {
Spacer() Spacer()
//
HStack(spacing: 14) { HStack(spacing: 14) {
HStack(spacing: 6) { HStack(spacing: 6) {
Button { Button {
@@ -580,28 +818,44 @@ struct AVPlayerContentView: View {
} }
.frame(width: 150, alignment: .trailing) .frame(width: 150, alignment: .trailing)
} }
.padding(.top, 8)
#endif
} }
#if os(iOS)
.padding(.vertical, 2)
.foregroundColor(.white)
.background(
LinearGradient(
colors: [.clear, .black.opacity(0.6)],
startPoint: .top,
endPoint: .bottom
)
)
.padding(.bottom, 0)
.frame(width: controlWidth)
#else
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.vertical, 10) .padding(.vertical, 10)
.foregroundColor(.white) .foregroundColor(.white)
.glassCard(cornerRadius: 18) .glassCard(cornerRadius: 18)
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.bottom, 6) .padding(.bottom, 6)
.frame(width: containerWidth * 0.7) .frame(width: controlWidth)
#endif
.environment(\.colorScheme, .dark) .environment(\.colorScheme, .dark)
} }
private var playbackRateMenu: some View { private var playbackRateMenu: some View {
Menu { Menu {
ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { r in ForEach(Self.supportedPlaybackRates, id: \.self) { r in
Button { Button {
wakeUpControls() wakeUpControls()
player?.rate = Float(r) setPlaybackRate(r)
showOSD(icon: "speedometer") showOSD(icon: "speedometer")
} label: { } label: {
HStack { HStack {
Text("\(String(format: "%.1f", r))x") Text("\(String(format: "%.1f", r))x")
if Float(r) == rate { if r == rate {
Spacer() Spacer()
Image(systemName: "checkmark") Image(systemName: "checkmark")
} }
@@ -630,6 +884,46 @@ struct AVPlayerContentView: View {
return "speaker.wave.2.fill" return "speaker.wave.2.fill"
} }
private var normalizedSavedPlaybackRate: Float {
Self.normalizedPlaybackRate(from: Float(savedPlaybackRate))
}
private static func normalizedPlaybackRate(from raw: Float) -> Float {
guard !supportedPlaybackRates.isEmpty else { return 1.0 }
return supportedPlaybackRates.min(by: { abs($0 - raw) < abs($1 - raw) }) ?? 1.0
}
private func syncRateFromSettings() {
rate = normalizedSavedPlaybackRate
}
private func setPlaybackRate(_ value: Float) {
let normalized = Self.normalizedPlaybackRate(from: value)
rate = normalized
savedPlaybackRate = Double(normalized)
guard let player else { return }
player.defaultRate = normalized
if player.rate > 0 {
player.rate = normalized
}
}
private func applyPreferredPlaybackRate(to player: AVPlayer) {
let normalized = normalizedSavedPlaybackRate
rate = normalized
player.defaultRate = normalized
if player.rate > 0 {
player.rate = normalized
}
}
private func playAtPreferredRate(_ player: AVPlayer) {
let normalized = normalizedSavedPlaybackRate
rate = normalized
player.defaultRate = normalized
player.playImmediately(atRate: normalized)
}
private func seek(to seconds: Double) { private func seek(to seconds: Double) {
guard let player = player else { return } guard let player = player else { return }
let time = CMTime(seconds: seconds, preferredTimescale: 600) let time = CMTime(seconds: seconds, preferredTimescale: 600)
@@ -651,7 +945,7 @@ struct AVPlayerContentView: View {
player.seek(to: CMTime(seconds: target, preferredTimescale: 600)) { _ in player.seek(to: CMTime(seconds: target, preferredTimescale: 600)) { _ in
reportProgress(for: player) reportProgress(for: player)
if wasPlaying { if wasPlaying {
player.play() playAtPreferredRate(player)
} }
} }
} }
+288 -17
View File
@@ -32,7 +32,17 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
}() }()
private typealias LibVLCStopAsyncFunction = @convention(c) (UnsafeMutableRawPointer?) -> Void private typealias LibVLCStopAsyncFunction = @convention(c) (UnsafeMutableRawPointer?) -> Void
let mediaPlayer = VLCMediaPlayer(options: VLCPlayerController.stablePlaybackOptions) private var _mediaPlayer: VLCMediaPlayer?
var mediaPlayer: VLCMediaPlayer {
if let existing = _mediaPlayer {
return existing
}
let player = VLCMediaPlayer(options: VLCPlayerController.stablePlaybackOptions)
_mediaPlayer = player
player.delegate = self
player.drawable = persistentDrawableView
return player
}
@Published var isPreparing = true @Published var isPreparing = true
@Published var isPlaying = false @Published var isPlaying = false
@Published var currentTimeSeconds: Double = 0 @Published var currentTimeSeconds: Double = 0
@@ -107,8 +117,18 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
#else #else
persistentDrawableView.backgroundColor = .black persistentDrawableView.backgroundColor = .black
#endif #endif
mediaPlayer.delegate = self }
mediaPlayer.drawable = persistentDrawableView
deinit {
if let player = _mediaPlayer {
_mediaPlayer = nil
// VLCMediaPlayer dealloc libvlc_media_player_release dispatch_sync 线
// 线 dealloc 线
nonisolated(unsafe) let p = player
DispatchQueue.global(qos: .utility).async { [p] in
_ = p
}
}
} }
func play( func play(
@@ -167,7 +187,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
"network-caching": cacheConfig.network, "network-caching": cacheConfig.network,
"live-caching": cacheConfig.live, "live-caching": cacheConfig.live,
"file-caching": cacheConfig.file, "file-caching": cacheConfig.file,
"http-reconnect": 1 "http-reconnect": 1,
"http-user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
] ]
if !isLive { if !isLive {
mediaOptions["avcodec-hurry-up"] = 0 mediaOptions["avcodec-hurry-up"] = 0
@@ -204,8 +225,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
stopProgressTimer() stopProgressTimer()
resetPlaybackRecoveryState() resetPlaybackRecoveryState()
cancelScheduledRebinds() cancelScheduledRebinds()
stopMediaPlayer()
mediaPlayer.media = nil
onProgressChanged = nil onProgressChanged = nil
onPlaybackEnded = nil onPlaybackEnded = nil
onPlaybackFailed = nil onPlaybackFailed = nil
@@ -216,6 +235,17 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
currentMediaIsLive = false currentMediaIsLive = false
currentMediaDecodeMode = .auto currentMediaDecodeMode = .auto
currentMediaBufferMode = .defaultMode currentMediaBufferMode = .defaultMode
guard let player = _mediaPlayer else { return }
// audio.volume dispatch_sync
player.audio?.volume = 0
// mediaPlayer
// pause/stop/media=nil VLC API dispatch_sync 线
// VLCMediaPlayer dealloc 线
_mediaPlayer = nil
nonisolated(unsafe) let p = player
DispatchQueue.global(qos: .utility).async { [p] in
_ = p
}
} }
func togglePlayback() { func togglePlayback() {
@@ -358,9 +388,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
#endif #endif
private func refreshDrawableBinding() { private func refreshDrawableBinding() {
// macOS #if os(macOS)
// macOS:
mediaPlayer.drawable = nil mediaPlayer.drawable = nil
mediaPlayer.drawable = persistentDrawableView mediaPlayer.drawable = persistentDrawableView
#else
// iOS: drawable state change SwiftUI dismiss
mediaPlayer.drawable = persistentDrawableView
#endif
lastDrawableRebindAt = Date() lastDrawableRebindAt = Date()
} }
@@ -371,7 +406,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
stopAsync(playerPointer) stopAsync(playerPointer)
return return
} }
mediaPlayer.stop() // VLCKit 3.x async stop API stop 线 2-3
// UI
nonisolated(unsafe) let player = mediaPlayer
DispatchQueue.global(qos: .userInitiated).async {
player.stop()
}
} }
private func playerInstancePointer() -> UnsafeMutableRawPointer? { private func playerInstancePointer() -> UnsafeMutableRawPointer? {
@@ -825,12 +865,14 @@ struct VLCVodPlayerView: View {
ZStack { ZStack {
VLCDrawableView(controller: controller) VLCDrawableView(controller: controller)
.background(Color.black) .background(Color.black)
#if !os(iOS)
.onTapGesture(count: 2) { .onTapGesture(count: 2) {
onToggleFullScreen?() onToggleFullScreen?()
} }
.onTapGesture(count: 1) { .onTapGesture(count: 1) {
togglePlaybackWithOSD() togglePlaybackWithOSD()
} }
#endif
if controller.isPreparing { if controller.isPreparing {
ProgressView() ProgressView()
@@ -848,10 +890,24 @@ struct VLCVodPlayerView: View {
.allowsHitTesting(false) .allowsHitTesting(false)
} }
} }
#if os(iOS)
.overlay {
PlayerGestureLayer(
onSeek: { offset in controller.seek(by: offset) },
onTogglePlayPause: { togglePlaybackWithOSD() },
onToggleControls: { wakeUpControls() },
onZoomChanged: { _ in },
currentTime: controller.currentTimeSeconds,
duration: controller.durationSeconds
)
}
#endif
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
GeometryReader { proxy in GeometryReader { proxy in
playbackControls(containerWidth: proxy.size.width) playbackControls(containerWidth: proxy.size.width)
#if os(macOS)
.padding(12) .padding(12)
#endif
.opacity(showControls ? 1.0 : 0.0) .opacity(showControls ? 1.0 : 0.0)
.animation(.easeInOut(duration: 0.3), value: showControls) .animation(.easeInOut(duration: 0.3), value: showControls)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
@@ -937,7 +993,10 @@ struct VLCVodPlayerView: View {
} }
private func startPlayback() { private func startPlayback() {
guard let url = URL(string: urlString) else { return } guard let url = Self.sanitizedURL(from: urlString) else {
print("[VLC] URL sanitization failed for: \(urlString)")
return
}
let targetStartPosition = max(startPosition, 0) let targetStartPosition = max(startPosition, 0)
draggingSeconds = targetStartPosition draggingSeconds = targetStartPosition
startPlaybackTask?.cancel() startPlaybackTask?.cancel()
@@ -956,6 +1015,19 @@ struct VLCVodPlayerView: View {
} }
} }
/// URL URL
private static func sanitizedURL(from urlString: String) -> URL? {
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: trimmed) {
return url
}
if let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: encoded) {
return url
}
return nil
}
private var seekStep: Double { private var seekStep: Double {
let saved = UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TIME_STEP) let saved = UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TIME_STEP)
return Double(saved > 0 ? saved : 10) return Double(saved > 0 ? saved : 10)
@@ -976,8 +1048,128 @@ struct VLCVodPlayerView: View {
} }
private func playbackControls(containerWidth: CGFloat) -> some View { private func playbackControls(containerWidth: CGFloat) -> some View {
VStack(spacing: 8) { #if os(iOS)
// let controlWidth = containerWidth * 1.0
#else
let controlWidth = containerWidth * 0.7
#endif
return VStack(spacing: 0) {
#if os(iOS)
// iOS:
HStack(spacing: 8) {
Text(currentDisplaySeconds.durationString)
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.8))
.lineLimit(1)
Slider(
value: Binding(
get: { isDraggingProgress ? draggingSeconds : controller.currentTimeSeconds },
set: {
draggingSeconds = $0
wakeUpControls()
}
),
in: 0...progressUpperBound,
onEditingChanged: { editing in
isDraggingProgress = editing
wakeUpControls()
if !editing {
controller.seek(to: draggingSeconds)
}
}
)
.accentColor(.white)
.disabled(!controller.hasValidDuration)
Text(totalDisplayText)
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.5))
.lineLimit(1)
}
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 4)
//
HStack(spacing: 0) {
//
playbackRateMenu
.frame(minWidth: 36, alignment: .leading)
Spacer()
//
HStack(spacing: 20) {
Button {
wakeUpControls()
controller.seek(by: -seekStep)
showOSD(icon: "gobackward.\(Int(seekStep))")
} label: {
Image(systemName: "gobackward.\(Int(seekStep))")
.font(.system(size: 16, weight: .medium))
.frame(minWidth: 36, minHeight: 36)
}
.buttonStyle(.plain)
Button {
wakeUpControls()
togglePlaybackWithOSD()
} label: {
Image(systemName: controller.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 20, weight: .bold))
.frame(minWidth: 36, minHeight: 36)
}
.buttonStyle(.plain)
Button {
wakeUpControls()
controller.seek(by: seekStep)
showOSD(icon: "goforward.\(Int(seekStep))")
} label: {
Image(systemName: "goforward.\(Int(seekStep))")
.font(.system(size: 16, weight: .medium))
.frame(minWidth: 36, minHeight: 36)
}
.buttonStyle(.plain)
if let onPlayNext {
Button {
guard canPlayNext else { return }
wakeUpControls()
onPlayNext()
showOSD(icon: "forward.end.fill")
} label: {
Image(systemName: "forward.end.fill")
.font(.system(size: 16, weight: .medium))
.frame(minWidth: 36, minHeight: 36)
}
.buttonStyle(.plain)
.disabled(!canPlayNext)
.opacity(canPlayNext ? 1 : 0.4)
}
}
Spacer()
//
if let onToggleFullScreen {
Button {
wakeUpControls()
onToggleFullScreen()
} label: {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 14, weight: .bold))
.frame(minWidth: 36, minHeight: 36)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.bottom, 6)
#else
// macOS:
HStack(spacing: 12) { HStack(spacing: 12) {
Text(currentDisplaySeconds.durationString) Text(currentDisplaySeconds.durationString)
.font(.system(size: 11, weight: .semibold, design: .monospaced)) .font(.system(size: 11, weight: .semibold, design: .monospaced))
@@ -1013,9 +1205,7 @@ struct VLCVodPlayerView: View {
} }
.padding(.horizontal, 4) .padding(.horizontal, 4)
//
HStack(spacing: 0) { HStack(spacing: 0) {
//
HStack(spacing: 16) { HStack(spacing: 16) {
playbackRateMenu playbackRateMenu
} }
@@ -1023,7 +1213,6 @@ struct VLCVodPlayerView: View {
Spacer() Spacer()
//
HStack(spacing: 24) { HStack(spacing: 24) {
Button { Button {
wakeUpControls() wakeUpControls()
@@ -1078,7 +1267,6 @@ struct VLCVodPlayerView: View {
Spacer() Spacer()
//
HStack(spacing: 14) { HStack(spacing: 14) {
HStack(spacing: 6) { HStack(spacing: 6) {
Button { Button {
@@ -1120,14 +1308,30 @@ struct VLCVodPlayerView: View {
} }
.frame(width: 150, alignment: .trailing) .frame(width: 150, alignment: .trailing)
} }
.padding(.top, 8)
#endif
} }
#if os(iOS)
.padding(.vertical, 2)
.foregroundColor(.white)
.background(
LinearGradient(
colors: [.clear, .black.opacity(0.6)],
startPoint: .top,
endPoint: .bottom
)
)
.padding(.bottom, 0)
.frame(width: controlWidth)
#else
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.vertical, 10) .padding(.vertical, 10)
.foregroundColor(.white) .foregroundColor(.white)
.glassCard(cornerRadius: 18) .glassCard(cornerRadius: 18)
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.bottom, 6) .padding(.bottom, 6)
.frame(width: containerWidth * 0.7) .frame(width: controlWidth)
#endif
.environment(\.colorScheme, .dark) .environment(\.colorScheme, .dark)
} }
@@ -1197,17 +1401,21 @@ struct VLCLivePlayerView: View {
@State private var osdIcon: String? @State private var osdIcon: String?
@State private var osdOpacity: Double = 0 @State private var osdOpacity: Double = 0
@State private var osdTimer: Timer? @State private var osdTimer: Timer?
@State private var showControls = true
@State private var controlsTimer: Timer?
var body: some View { var body: some View {
ZStack { ZStack {
VLCDrawableView(controller: controller) VLCDrawableView(controller: controller)
.background(Color.black) .background(Color.black)
#if !os(iOS)
.onTapGesture(count: 2) { .onTapGesture(count: 2) {
onToggleFullScreen?() onToggleFullScreen?()
} }
.onTapGesture(count: 1) { .onTapGesture(count: 1) {
togglePlaybackWithOSD() togglePlaybackWithOSD()
} }
#endif
if controller.isPreparing { if controller.isPreparing {
ProgressView() ProgressView()
@@ -1225,6 +1433,23 @@ struct VLCLivePlayerView: View {
.allowsHitTesting(false) .allowsHitTesting(false)
} }
} }
#if os(iOS)
.overlay {
LivePlayerGestureLayer(
onTogglePlayPause: { togglePlaybackWithOSD() },
onToggleControls: { wakeUpControls() },
onVolumeChanged: { delta in
let newVol = max(0, min(200, controller.volume + Int(delta * 200)))
controller.setVolume(newVol)
}
)
}
.overlay(alignment: .bottom) {
liveControlsOverlay
.opacity(showControls ? 1.0 : 0.0)
.animation(.easeInOut(duration: 0.3), value: showControls)
}
#endif
.overlay { .overlay {
KeyboardShortcutCaptureView( KeyboardShortcutCaptureView(
onLeft: { }, onLeft: { },
@@ -1248,13 +1473,58 @@ struct VLCLivePlayerView: View {
} }
.onAppear { .onAppear {
startPlayback() startPlayback()
wakeUpControls()
} }
.onChange(of: urlString) { _, _ in .onChange(of: urlString) { _, _ in
startPlayback() startPlayback()
wakeUpControls()
}
.onChange(of: activityToken) { _, _ in
wakeUpControls()
} }
.onDisappear { .onDisappear {
controller.stop() controller.stop()
osdTimer?.invalidate() osdTimer?.invalidate()
controlsTimer?.invalidate()
}
}
// MARK: - iOS Live Controls Overlay
#if os(iOS)
private var liveControlsOverlay: some View {
HStack(spacing: 20) {
// Play/Pause button
Button {
wakeUpControls()
togglePlaybackWithOSD()
} label: {
Image(systemName: controller.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 20, weight: .bold))
.foregroundColor(.white)
.frame(width: 48, height: 48)
.background(Color.white.opacity(0.15))
.clipShape(Circle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.glassCard(cornerRadius: 14)
.padding(.bottom, 20)
.padding(.horizontal, 16)
}
#endif
// MARK: - Controls Timer
private func wakeUpControls() {
withAnimation { showControls = true }
controlsTimer?.invalidate()
controlsTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { _ in
withAnimation(.easeOut(duration: 0.5)) {
showControls = false
}
} }
} }
@@ -1275,7 +1545,8 @@ struct VLCLivePlayerView: View {
} }
private func startPlayback() { private func startPlayback() {
guard let url = URL(string: urlString) else { let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: trimmed) ?? trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed).flatMap({ URL(string: $0) }) else {
onPlaybackFailed?() onPlaybackFailed?()
return return
} }
+70
View File
@@ -0,0 +1,70 @@
#if os(iOS)
import SwiftUI
/// - Profile Hub
struct ProfileView: View {
var body: some View {
NavigationStack {
List {
// Header section
Section {
profileHeader
}
.listRowBackground(Color.clear)
// Navigation entries
Section {
NavigationLink(destination: FavoritesView()) {
profileRow(icon: "heart.fill", title: "我的收藏", color: .red)
}
NavigationLink(destination: HistoryView()) {
profileRow(icon: "clock.fill", title: "播放历史", color: .orange)
}
NavigationLink(destination: SettingsView()) {
profileRow(icon: "gearshape.fill", title: "设置", color: .gray)
}
}
}
.navigationTitle("个人中心")
.navigationBarTitleDisplayMode(.large)
.background(AppTheme.primaryGradient)
.scrollContentBackground(.hidden)
}
}
// MARK: - Profile Header
private var profileHeader: some View {
VStack(spacing: 12) {
Image("AppIcon")
.resizable()
.frame(width: 72, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 16))
Text("TVBox v\(appVersion)")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
}
// MARK: - Profile Row
private func profileRow(icon: String, title: String, color: Color) -> some View {
HStack(spacing: 14) {
Image(systemName: icon)
.foregroundColor(color)
.frame(width: 28)
Text(title)
.font(.body)
}
.frame(minHeight: 44)
}
// MARK: - App Version
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
}
}
#endif
+15
View File
@@ -2,13 +2,16 @@ import SwiftUI
/// - Android SearchActivity /// - Android SearchActivity
struct SearchView: View { struct SearchView: View {
///
@StateObject private var viewModel = SearchViewModel() @StateObject private var viewModel = SearchViewModel()
#if os(iOS) #if os(iOS)
/// iOS
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12) GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
] ]
#else #else
/// macOS
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16) GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
] ]
@@ -29,6 +32,7 @@ struct SearchView: View {
} else if !viewModel.results.isEmpty { } else if !viewModel.results.isEmpty {
searchResults searchResults
} else if viewModel.keyword.isEmpty { } else if viewModel.keyword.isEmpty {
//
searchHistorySection searchHistorySection
} else if let error = viewModel.errorMessage { } else if let error = viewModel.errorMessage {
Spacer() Spacer()
@@ -53,6 +57,7 @@ struct SearchView: View {
// MARK: - // MARK: -
///
private var searchBar: some View { private var searchBar: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
HStack(spacing: 10) { HStack(spacing: 10) {
@@ -114,6 +119,7 @@ struct SearchView: View {
// MARK: - // MARK: -
///
private var searchResults: some View { private var searchResults: some View {
ScrollView { ScrollView {
LazyVGrid(columns: columns, spacing: 16) { LazyVGrid(columns: columns, spacing: 16) {
@@ -121,7 +127,11 @@ struct SearchView: View {
NavigationLink(value: video) { NavigationLink(value: video) {
VodCardView(video: video) VodCardView(video: video)
} }
#if os(iOS)
.buttonStyle(VodCardPressStyle())
#else
.buttonStyle(.plain) .buttonStyle(.plain)
#endif
} }
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
@@ -134,6 +144,7 @@ struct SearchView: View {
// MARK: - // MARK: -
///
private var searchHistorySection: some View { private var searchHistorySection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
if !viewModel.searchHistory.isEmpty { if !viewModel.searchHistory.isEmpty {
@@ -183,13 +194,16 @@ struct SearchView: View {
/// ///
struct FlowLayout: Layout { struct FlowLayout: Layout {
///
var spacing: CGFloat = 8 var spacing: CGFloat = 8
///
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = arrangement(proposal: proposal, subviews: subviews) let result = arrangement(proposal: proposal, subviews: subviews)
return result.size return result.size
} }
///
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = arrangement(proposal: ProposedViewSize(width: bounds.width, height: bounds.height), subviews: subviews) let result = arrangement(proposal: ProposedViewSize(width: bounds.width, height: bounds.height), subviews: subviews)
for (index, position) in result.positions.enumerated() { for (index, position) in result.positions.enumerated() {
@@ -197,6 +211,7 @@ struct FlowLayout: Layout {
} }
} }
///
private func arrangement(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) { private func arrangement(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
let maxWidth = proposal.width ?? .infinity let maxWidth = proposal.width ?? .infinity
var positions: [CGPoint] = [] var positions: [CGPoint] = []
+26
View File
@@ -335,11 +335,37 @@ struct SettingsView: View {
} }
} }
} }
.overlay(multiRepoSelectionOverlay)
#if os(iOS) #if os(iOS)
.presentationDetents([.medium, .large]) .presentationDetents([.medium, .large])
#endif #endif
} }
@ViewBuilder
private var multiRepoSelectionOverlay: some View {
if let pending = viewModel.pendingMultiRepoSelection {
SelectionModal(
title: "选择\(pending.target.title)仓库",
icon: "list.bullet.rectangle.portrait.fill",
items: pending.options,
selectedItem: nil,
itemTitle: { $0.name },
onSelect: { option in
Task {
await viewModel.selectPendingMultiRepoOption(option)
if viewModel.configSuccess {
appState.applyLoadedConfigState()
showApiInput = false
}
}
},
onCancel: {
viewModel.cancelPendingMultiRepoSelection()
}
)
}
}
private var currentApiBinding: Binding<String> { private var currentApiBinding: Binding<String> {
switch editingApiType { switch editingApiType {
case .vod: case .vod:
+12
View File
@@ -0,0 +1,12 @@
<?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.app-sandbox</key>
<false/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>
+62 -1
View File
@@ -1,10 +1,18 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import Combine
///
/// SwiftData `AppState`
@main @main
struct tvboxApp: App { struct tvboxApp: App {
///
@StateObject private var appState = AppState() @StateObject private var appState = AppState()
///
@StateObject private var networkMonitor = NetworkMonitor.shared
/// SwiftData
/// Schema//使
var sharedModelContainer: ModelContainer = { var sharedModelContainer: ModelContainer = {
let schema = Schema([ let schema = Schema([
VodCollect.self, VodCollect.self,
@@ -19,10 +27,12 @@ struct tvboxApp: App {
} }
}() }()
///
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environmentObject(appState) .environmentObject(appState)
.environmentObject(networkMonitor)
} }
.modelContainer(sharedModelContainer) .modelContainer(sharedModelContainer)
#if os(macOS) #if os(macOS)
@@ -31,40 +41,90 @@ struct tvboxApp: App {
} }
} }
///
///
@MainActor @MainActor
class AppState: ObservableObject { class AppState: ObservableObject {
/// ViewModel 使
@Published var apiConfig = ApiConfig.shared @Published var apiConfig = ApiConfig.shared
/// `ContentView`
@Published var isConfigLoaded = false @Published var isConfigLoaded = false
/// key
@Published var currentSourceKey: String = "" @Published var currentSourceKey: String = ""
/// UI
@Published var configLoadError: String?
///
@Published var isRetryingConfig = false
#if os(macOS) #if os(macOS)
/// macOS //
@Published var splitViewVisibility: NavigationSplitViewVisibility = .all @Published var splitViewVisibility: NavigationSplitViewVisibility = .all
/// 退
private var splitViewVisibilityBeforePlayerFullScreen: NavigationSplitViewVisibility? private var splitViewVisibilityBeforePlayerFullScreen: NavigationSplitViewVisibility?
#endif #endif
///
private var lastVodUrl: String = ""
private var lastLiveUrl: String = ""
private var networkRestoredCancellable: AnyCancellable?
init() {
setupNetworkRestoredAutoRetry()
}
///
func loadConfig(url: String) async { func loadConfig(url: String) async {
await loadConfig(vodUrl: url, liveUrl: nil) await loadConfig(vodUrl: url, liveUrl: nil)
} }
///
/// - Parameters:
/// - vodUrl:
/// - liveUrl: 退
func loadConfig(vodUrl: String, liveUrl: String?) async { func loadConfig(vodUrl: String, liveUrl: String?) async {
let trimmedVod = vodUrl.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedVod = vodUrl.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedLive = (liveUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let trimmedLive = (liveUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedVod.isEmpty else { return } guard !trimmedVod.isEmpty else { return }
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
lastVodUrl = trimmedVod
lastLiveUrl = resolvedLive
configLoadError = nil
do { do {
try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive) try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive)
applyLoadedConfigState() applyLoadedConfigState()
} catch { } catch {
print("Failed to load config: \(error)") if !(error is CancellationError) {
configLoadError = error.localizedDescription
}
} }
} }
/// ""
///
func applyLoadedConfigState() { func applyLoadedConfigState() {
isConfigLoaded = true isConfigLoaded = true
configLoadError = nil
currentSourceKey = ApiConfig.shared.homeSourceBean?.key ?? "" currentSourceKey = ApiConfig.shared.homeSourceBean?.key ?? ""
} }
///
private func setupNetworkRestoredAutoRetry() {
networkRestoredCancellable = NetworkMonitor.shared.networkRestoredPublisher
.sink { [weak self] in
guard let self else { return }
Task { @MainActor [weak self] in
guard let self, !self.isConfigLoaded, !self.lastVodUrl.isEmpty else { return }
self.isRetryingConfig = true
await self.loadConfig(vodUrl: self.lastVodUrl, liveUrl: self.lastLiveUrl)
self.isRetryingConfig = false
}
}
}
#if os(macOS) #if os(macOS)
///
func enterPlayerFullScreen() { func enterPlayerFullScreen() {
if splitViewVisibilityBeforePlayerFullScreen == nil { if splitViewVisibilityBeforePlayerFullScreen == nil {
splitViewVisibilityBeforePlayerFullScreen = splitViewVisibility splitViewVisibilityBeforePlayerFullScreen = splitViewVisibility
@@ -72,6 +132,7 @@ class AppState: ObservableObject {
splitViewVisibility = .detailOnly splitViewVisibility = .detailOnly
} }
/// 退
func exitPlayerFullScreen() { func exitPlayerFullScreen() {
guard let previous = splitViewVisibilityBeforePlayerFullScreen else { return } guard let previous = splitViewVisibilityBeforePlayerFullScreen else { return }
splitViewVisibility = previous splitViewVisibility = previous
@@ -0,0 +1,133 @@
#if os(iOS)
import Testing
import CoreGraphics
@testable import TVBox
/// Property 7: Channel overlay group width is proportional
/// For any screen width w > 0, computeGroupWidth(screenWidth: w) returns a value in [w * 0.30, w * 0.35]
///
/// **Validates: Requirements 8.3, 8.4**
@Suite("Property 7: Channel overlay group width is proportional")
struct ChannelOverlayGroupWidthPropertyTests {
// MARK: - Parameterized tests with representative screen widths
/// Common iOS device screen widths and edge cases
static let screenWidths: [CGFloat] = [
// Small values
1.0, 10.0, 50.0,
// iPhone SE / small phones
320.0, 375.0,
// iPhone standard
390.0, 393.0,
// iPhone Plus / Max
414.0, 428.0, 430.0,
// iPad
744.0, 768.0, 810.0, 834.0, 1024.0, 1194.0,
// Large values
1366.0, 2048.0, 5000.0
]
@Test("computeGroupWidth returns value in [w*0.30, w*0.35] for common screen widths",
arguments: screenWidths)
func groupWidthInProportionalRange(screenWidth: CGFloat) {
let result = ChannelOverlayView.computeGroupWidth(screenWidth: screenWidth)
let lowerBound = screenWidth * 0.30
let upperBound = screenWidth * 0.35
#expect(result >= lowerBound,
"computeGroupWidth(\(screenWidth)) = \(result) should be >= \(lowerBound) (w * 0.30)")
#expect(result <= upperBound,
"computeGroupWidth(\(screenWidth)) = \(result) should be <= \(upperBound) (w * 0.35)")
}
// MARK: - Randomized property test
@Test("computeGroupWidth satisfies proportional invariant for random positive widths")
func groupWidthProportionalRandomized() {
for _ in 0..<1000 {
// Generate random positive screen widths across different magnitudes
let screenWidth = CGFloat.random(in: 0.001...10000.0)
let result = ChannelOverlayView.computeGroupWidth(screenWidth: screenWidth)
let lowerBound = screenWidth * 0.30
let upperBound = screenWidth * 0.35
#expect(result >= lowerBound,
"Random width \(screenWidth): result \(result) should be >= \(lowerBound)")
#expect(result <= upperBound,
"Random width \(screenWidth): result \(result) should be <= \(upperBound)")
}
}
// MARK: - Proportionality: result scales linearly with screen width
@Test("computeGroupWidth scales linearly with screen width")
func groupWidthScalesLinearly() {
let widths: [CGFloat] = [100.0, 200.0, 400.0, 800.0]
for i in 0..<(widths.count - 1) {
let w1 = widths[i]
let w2 = widths[i + 1]
let result1 = ChannelOverlayView.computeGroupWidth(screenWidth: w1)
let result2 = ChannelOverlayView.computeGroupWidth(screenWidth: w2)
let ratio = w2 / w1
let resultRatio = result2 / result1
// The ratio of results should equal the ratio of inputs (linear scaling)
#expect(abs(resultRatio - ratio) < 0.001,
"Group width should scale linearly: ratio of widths = \(ratio), ratio of results = \(resultRatio)")
}
}
// MARK: - Edge case: very small positive widths
@Test("computeGroupWidth handles very small positive widths", arguments: [
CGFloat(0.001),
CGFloat(0.01),
CGFloat(0.1),
CGFloat(0.5),
])
func groupWidthSmallPositiveWidths(screenWidth: CGFloat) {
let result = ChannelOverlayView.computeGroupWidth(screenWidth: screenWidth)
let lowerBound = screenWidth * 0.30
let upperBound = screenWidth * 0.35
#expect(result >= lowerBound,
"Small width \(screenWidth): result \(result) should be >= \(lowerBound)")
#expect(result <= upperBound,
"Small width \(screenWidth): result \(result) should be <= \(upperBound)")
}
// MARK: - Edge case: very large widths
@Test("computeGroupWidth handles very large widths", arguments: [
CGFloat(10000.0),
CGFloat(50000.0),
CGFloat(100000.0),
])
func groupWidthLargeWidths(screenWidth: CGFloat) {
let result = ChannelOverlayView.computeGroupWidth(screenWidth: screenWidth)
let lowerBound = screenWidth * 0.30
let upperBound = screenWidth * 0.35
#expect(result >= lowerBound,
"Large width \(screenWidth): result \(result) should be >= \(lowerBound)")
#expect(result <= upperBound,
"Large width \(screenWidth): result \(result) should be <= \(upperBound)")
}
// MARK: - Result is always positive for positive input
@Test("computeGroupWidth always returns positive value for positive input")
func groupWidthAlwaysPositive() {
for _ in 0..<500 {
let screenWidth = CGFloat.random(in: 0.001...10000.0)
let result = ChannelOverlayView.computeGroupWidth(screenWidth: screenWidth)
#expect(result > 0, "computeGroupWidth(\(screenWidth)) = \(result) should be > 0")
}
}
}
#endif
@@ -0,0 +1,271 @@
#if os(iOS)
import Testing
import CoreGraphics
@testable import TVBox
/// Property-based tests for PlayerGestureLayer pure computation functions.
/// These tests validate correctness properties across a wide range of inputs.
// MARK: - Property 3: Vertical gesture direction mapping preserves monotonicity
/// **Validates: Requirements 2.2, 3.2**
///
/// For any vertical translation `dy`, the computed adjustment delta SHALL be positive when
/// `dy < 0` (upward swipe) and negative when `dy > 0` (downward swipe), ensuring that
/// upward gestures always increase the controlled value and downward gestures always decrease it.
@Suite("Property 3: Vertical gesture direction mapping preserves monotonicity")
struct VerticalGestureMonotonicityPropertyTests {
// Use a container width and threshold that ensure vertical gesture classification
static let containerWidth: CGFloat = 400.0
static let threshold: CGFloat = 10.0
// MARK: - Parameterized tests with representative dy values
/// Negative dy values (upward swipes) that exceed the threshold
static let negativeDyValues: [CGFloat] = [
-11.0, -20.0, -50.0, -100.0, -300.0, -500.0, -1000.0, -1500.0
]
/// Positive dy values (downward swipes) that exceed the threshold
static let positiveDyValues: [CGFloat] = [
11.0, 20.0, 50.0, 100.0, 300.0, 500.0, 1000.0
]
/// Start positions covering both left half (brightness) and right half (volume)
static let startPositions: [CGFloat] = [0.0, 50.0, 100.0, 199.0, 200.0, 250.0, 350.0, 399.0]
@Test("Upward swipe (negative dy) produces positive delta",
arguments: negativeDyValues, startPositions)
func upwardSwipeProducesPositiveDelta(dy: CGFloat, startX: CGFloat) {
// Use dx = 0 to ensure vertical classification (|dy| > |dx|)
let translation = CGSize(width: 0, height: dy)
let result = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: Self.containerWidth,
translation: translation,
threshold: Self.threshold
)
switch result {
case .adjustingBrightness(let delta):
#expect(delta > 0,
"Upward swipe (dy=\(dy)) on left side should produce positive delta, got \(delta)")
case .adjustingVolume(let delta):
#expect(delta > 0,
"Upward swipe (dy=\(dy)) on right side should produce positive delta, got \(delta)")
default:
Issue.record("Expected brightness or volume adjustment for dy=\(dy), startX=\(startX), got \(result)")
}
}
@Test("Downward swipe (positive dy) produces negative delta",
arguments: positiveDyValues, startPositions)
func downwardSwipeProducesNegativeDelta(dy: CGFloat, startX: CGFloat) {
// Use dx = 0 to ensure vertical classification (|dy| > |dx|)
let translation = CGSize(width: 0, height: dy)
let result = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: Self.containerWidth,
translation: translation,
threshold: Self.threshold
)
switch result {
case .adjustingBrightness(let delta):
#expect(delta < 0,
"Downward swipe (dy=\(dy)) on left side should produce negative delta, got \(delta)")
case .adjustingVolume(let delta):
#expect(delta < 0,
"Downward swipe (dy=\(dy)) on right side should produce negative delta, got \(delta)")
default:
Issue.record("Expected brightness or volume adjustment for dy=\(dy), startX=\(startX), got \(result)")
}
}
// MARK: - Randomized property test
@Test("Monotonicity holds for random vertical translations")
func monotonicityRandomized() {
for _ in 0..<1000 {
let startX = CGFloat.random(in: 0.0..<Self.containerWidth)
// Generate dy that exceeds threshold (either positive or negative)
let dyMagnitude = CGFloat.random(in: (Self.threshold + 1)...1000.0)
let isUpward = Bool.random()
let dy = isUpward ? -dyMagnitude : dyMagnitude
// Use small dx to ensure vertical classification
let dx = CGFloat.random(in: -Self.threshold...Self.threshold) * 0.5
let translation = CGSize(width: dx, height: dy)
let result = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: Self.containerWidth,
translation: translation,
threshold: Self.threshold
)
switch result {
case .adjustingBrightness(let delta), .adjustingVolume(let delta):
if dy < 0 {
#expect(delta > 0,
"Upward swipe (dy=\(dy)) should produce positive delta, got \(delta)")
} else {
#expect(delta < 0,
"Downward swipe (dy=\(dy)) should produce negative delta, got \(delta)")
}
case .seeking:
// If |dx| > |dy|, it classifies as seeking - this is acceptable
// since we're testing the vertical case
break
case .none:
// Below threshold - acceptable for edge cases
break
}
}
}
// MARK: - Delta magnitude proportionality
@Test("Larger vertical displacement produces larger absolute delta")
func deltaMagnitudeProportional() {
let startX: CGFloat = 100.0 // Left half for brightness
let smallDy: CGFloat = -20.0
let largeDy: CGFloat = -200.0
let smallTranslation = CGSize(width: 0, height: smallDy)
let largeTranslation = CGSize(width: 0, height: largeDy)
let smallResult = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: Self.containerWidth,
translation: smallTranslation,
threshold: Self.threshold
)
let largeResult = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: Self.containerWidth,
translation: largeTranslation,
threshold: Self.threshold
)
if case .adjustingBrightness(let smallDelta) = smallResult,
case .adjustingBrightness(let largeDelta) = largeResult {
#expect(largeDelta > smallDelta,
"Larger upward swipe should produce larger positive delta: small=\(smallDelta), large=\(largeDelta)")
} else {
Issue.record("Expected brightness adjustments for both translations")
}
}
// MARK: - Zero dy produces no vertical gesture
@Test("Zero dy does not produce vertical adjustment")
func zeroDyNoVerticalAdjustment() {
let translation = CGSize(width: 0, height: 0)
let result = PlayerGestureLayer.classifyGesture(
startX: 100.0,
containerWidth: Self.containerWidth,
translation: translation,
threshold: Self.threshold
)
#expect(result == .none,
"Zero translation should produce .none, got \(result)")
}
}
// MARK: - Property 4: Adjustment value clamping invariant
/// **Validates: Requirements 2.4, 3.4**
///
/// For any currentValue in [0, 1] and any delta (positive, negative, very large, very small),
/// the result of `clampAdjustment(currentValue:delta:)` must always be in [0.0, 1.0].
@Suite("Property 4: Adjustment value clamping invariant")
struct AdjustmentClampingPropertyTests {
// MARK: - Parameterized boundary values
static let boundaryCurrentValues: [CGFloat] = [0.0, 0.001, 0.25, 0.5, 0.75, 0.999, 1.0]
static let boundaryDeltas: [CGFloat] = [
-1000.0, -100.0, -10.0, -1.0, -0.5, -0.001,
0.0,
0.001, 0.5, 1.0, 10.0, 100.0, 1000.0
]
@Test("Clamped result is always within [0, 1] for boundary values",
arguments: boundaryCurrentValues, boundaryDeltas)
func clampedResultInRange(currentValue: CGFloat, delta: CGFloat) {
let result = PlayerGestureLayer.clampAdjustment(currentValue: currentValue, delta: delta)
#expect(result >= 0.0, "Result \(result) should be >= 0.0 for currentValue=\(currentValue), delta=\(delta)")
#expect(result <= 1.0, "Result \(result) should be <= 1.0 for currentValue=\(currentValue), delta=\(delta)")
}
// MARK: - Randomized property test
@Test("Clamped result is always within [0, 1] for random inputs")
func clampedResultInRangeRandomized() {
// Generate many random test cases to approximate property-based testing
for _ in 0..<1000 {
let currentValue = CGFloat.random(in: 0.0...1.0)
let delta = CGFloat.random(in: -1000.0...1000.0)
let result = PlayerGestureLayer.clampAdjustment(currentValue: currentValue, delta: delta)
#expect(result >= 0.0, "Result \(result) should be >= 0.0 for currentValue=\(currentValue), delta=\(delta)")
#expect(result <= 1.0, "Result \(result) should be <= 1.0 for currentValue=\(currentValue), delta=\(delta)")
}
}
// MARK: - Edge cases with extreme deltas
@Test("Clamped result handles extreme positive deltas")
func extremePositiveDeltas() {
let extremeDeltas: [CGFloat] = [CGFloat.greatestFiniteMagnitude / 2, 1e10, 1e6]
for delta in extremeDeltas {
for currentValue in [0.0, 0.5, 1.0] as [CGFloat] {
let result = PlayerGestureLayer.clampAdjustment(currentValue: currentValue, delta: delta)
#expect(result >= 0.0 && result <= 1.0,
"Result \(result) out of range for currentValue=\(currentValue), delta=\(delta)")
}
}
}
@Test("Clamped result handles extreme negative deltas")
func extremeNegativeDeltas() {
let extremeDeltas: [CGFloat] = [-CGFloat.greatestFiniteMagnitude / 2, -1e10, -1e6]
for delta in extremeDeltas {
for currentValue in [0.0, 0.5, 1.0] as [CGFloat] {
let result = PlayerGestureLayer.clampAdjustment(currentValue: currentValue, delta: delta)
#expect(result >= 0.0 && result <= 1.0,
"Result \(result) out of range for currentValue=\(currentValue), delta=\(delta)")
}
}
}
// MARK: - Zero delta preserves value
@Test("Zero delta preserves the current value",
arguments: boundaryCurrentValues)
func zeroDeltaPreservesValue(currentValue: CGFloat) {
let result = PlayerGestureLayer.clampAdjustment(currentValue: currentValue, delta: 0.0)
#expect(result == currentValue,
"Zero delta should preserve value, got \(result) instead of \(currentValue)")
}
// MARK: - Idempotence at boundaries
@Test("Result at lower bound stays at lower bound with negative delta")
func lowerBoundIdempotence() {
let result = PlayerGestureLayer.clampAdjustment(currentValue: 0.0, delta: -0.5)
#expect(result == 0.0, "Should clamp to 0.0 at lower bound, got \(result)")
}
@Test("Result at upper bound stays at upper bound with positive delta")
func upperBoundIdempotence() {
let result = PlayerGestureLayer.clampAdjustment(currentValue: 1.0, delta: 0.5)
#expect(result == 1.0, "Should clamp to 1.0 at upper bound, got \(result)")
}
}
#endif
@@ -0,0 +1,130 @@
#if os(iOS)
import Testing
import CoreGraphics
@testable import TVBox
/// Property 6: Pinch zoom clamping invariant
/// For any scale s > 0, clampZoom returns a value in [1.0, 3.0]
///
/// **Validates: Requirements 5.2, 5.3**
@Suite("Property 6: Pinch zoom clamping invariant")
struct PlayerGestureLayerZoomClampingTests {
// MARK: - Property Test: Default bounds [1.0, 3.0]
/// Property: For any positive scale, clampZoom with default bounds always returns a value in [1.0, 3.0]
@Test("clampZoom clamps to [1.0, 3.0] for various positive scales", arguments: [
// Boundary values
CGFloat(0.001), // very small positive
CGFloat(0.5), // below min
CGFloat(0.99), // just below min
CGFloat(1.0), // exactly min
CGFloat(1.001), // just above min
CGFloat(1.5), // mid-range
CGFloat(2.0), // mid-range
CGFloat(2.5), // mid-range
CGFloat(2.99), // just below max
CGFloat(3.0), // exactly max
CGFloat(3.001), // just above max
CGFloat(3.5), // above max
CGFloat(5.0), // well above max
CGFloat(10.0), // far above max
CGFloat(100.0), // extreme above max
CGFloat(1000.0), // very extreme
CGFloat(0.0001), // near-zero positive
])
func clampZoomDefaultBounds(scale: CGFloat) {
let result = PlayerGestureLayer.clampZoom(scale: scale)
#expect(result >= 1.0, "clampZoom(\(scale)) = \(result) should be >= 1.0")
#expect(result <= 3.0, "clampZoom(\(scale)) = \(result) should be <= 3.0")
}
// MARK: - Property Test: Custom bounds
/// Property: For any positive scale and valid custom bounds, clampZoom returns a value in [minZoom, maxZoom]
@Test("clampZoom respects custom min/max bounds", arguments: [
(CGFloat(0.5), CGFloat(0.5), CGFloat(2.0)), // scale below custom min
(CGFloat(1.0), CGFloat(0.5), CGFloat(2.0)), // scale within custom range
(CGFloat(2.5), CGFloat(0.5), CGFloat(2.0)), // scale above custom max
(CGFloat(0.1), CGFloat(0.2), CGFloat(5.0)), // scale below wide range min
(CGFloat(3.0), CGFloat(0.2), CGFloat(5.0)), // scale within wide range
(CGFloat(6.0), CGFloat(0.2), CGFloat(5.0)), // scale above wide range max
(CGFloat(2.0), CGFloat(2.0), CGFloat(2.0)), // min equals max
(CGFloat(1.0), CGFloat(2.0), CGFloat(2.0)), // below when min equals max
(CGFloat(3.0), CGFloat(2.0), CGFloat(2.0)), // above when min equals max
(CGFloat(0.01), CGFloat(0.5), CGFloat(10.0)), // very small scale, wide range
(CGFloat(100.0), CGFloat(0.5), CGFloat(10.0)), // very large scale, wide range
])
func clampZoomCustomBounds(scale: CGFloat, minZoom: CGFloat, maxZoom: CGFloat) {
let result = PlayerGestureLayer.clampZoom(scale: scale, minZoom: minZoom, maxZoom: maxZoom)
#expect(result >= minZoom, "clampZoom(\(scale), min: \(minZoom), max: \(maxZoom)) = \(result) should be >= \(minZoom)")
#expect(result <= maxZoom, "clampZoom(\(scale), min: \(minZoom), max: \(maxZoom)) = \(result) should be <= \(maxZoom)")
}
// MARK: - Property Test: Idempotency
/// Property: Clamping an already-clamped value should return the same value
@Test("clampZoom is idempotent - clamping twice gives same result", arguments: [
CGFloat(0.5),
CGFloat(1.0),
CGFloat(2.0),
CGFloat(3.0),
CGFloat(5.0),
])
func clampZoomIdempotent(scale: CGFloat) {
let firstClamp = PlayerGestureLayer.clampZoom(scale: scale)
let secondClamp = PlayerGestureLayer.clampZoom(scale: firstClamp)
#expect(firstClamp == secondClamp, "clampZoom should be idempotent: clampZoom(clampZoom(\(scale))) = \(secondClamp) != clampZoom(\(scale)) = \(firstClamp)")
}
// MARK: - Property Test: Values within range are unchanged
/// Property: If scale is already within [minZoom, maxZoom], it should be returned unchanged
@Test("clampZoom preserves values already within bounds", arguments: [
CGFloat(1.0),
CGFloat(1.5),
CGFloat(2.0),
CGFloat(2.5),
CGFloat(3.0),
])
func clampZoomPreservesValidValues(scale: CGFloat) {
let result = PlayerGestureLayer.clampZoom(scale: scale)
#expect(result == scale, "clampZoom(\(scale)) should return \(scale) unchanged, got \(result)")
}
// MARK: - Randomized Property Test
/// Property: Randomized test with many generated values to approximate property-based testing
@Test("clampZoom satisfies clamping invariant for generated positive scales")
func clampZoomRandomizedProperty() {
// Generate a wide range of positive scale values to test the property
var rng = SystemRandomNumberGenerator()
for _ in 0..<200 {
// Generate random positive CGFloat values across different magnitudes
let magnitude = CGFloat.random(in: 0..<4, using: &rng) // 0 to 4 for exponent
let scale = CGFloat(pow(10.0, Double(magnitude))) * CGFloat.random(in: 0.001..<1.0, using: &rng)
let result = PlayerGestureLayer.clampZoom(scale: scale)
#expect(result >= 1.0, "Random scale \(scale): result \(result) should be >= 1.0")
#expect(result <= 3.0, "Random scale \(scale): result \(result) should be <= 3.0")
}
}
/// Property: Randomized test with custom bounds
@Test("clampZoom satisfies clamping invariant for generated scales with custom bounds")
func clampZoomRandomizedCustomBounds() {
var rng = SystemRandomNumberGenerator()
for _ in 0..<200 {
// Generate random bounds where minZoom <= maxZoom
let minZoom = CGFloat.random(in: 0.1..<5.0, using: &rng)
let maxZoom = minZoom + CGFloat.random(in: 0.0..<10.0, using: &rng)
let scale = CGFloat.random(in: 0.001..<20.0, using: &rng)
let result = PlayerGestureLayer.clampZoom(scale: scale, minZoom: minZoom, maxZoom: maxZoom)
#expect(result >= minZoom, "scale=\(scale), min=\(minZoom), max=\(maxZoom): result \(result) should be >= \(minZoom)")
#expect(result <= maxZoom, "scale=\(scale), min=\(minZoom), max=\(maxZoom): result \(result) should be <= \(maxZoom)")
}
}
}
#endif