Compare commits
9
Commits
Release1.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdde6c51ce | ||
|
|
63e29be346 | ||
|
|
fac70d7496 | ||
|
|
76ed32ccf3 | ||
|
|
aebf17dfb5 | ||
|
|
dc1a010e05 | ||
|
|
e58322339f | ||
|
|
ee8c71dcd4 | ||
|
|
99a8cc799b |
+7
-1
@@ -8,4 +8,10 @@ DerivedData/
|
||||
.netrc
|
||||
/build
|
||||
TVBox-*.dmg
|
||||
*.py
|
||||
*.py
|
||||
/IOS-key
|
||||
/TVBox.ipa
|
||||
/ExportOptions.plist
|
||||
.kiro
|
||||
package_ios.sh
|
||||
image_cache
|
||||
|
||||
+5
-3
@@ -3,13 +3,15 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>development</string> <!-- 可选: ad-hoc, enterprise, app-store, development -->
|
||||
<string>enterprise</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
<string>manual</string>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>teamID</key>
|
||||
<string></string> <!-- 此处填写您的 Apple Team ID -->
|
||||
<string>ZDV8SQG3XZ</string>
|
||||
<key>signingCertificate</key>
|
||||
<string>Apple Distribution</string>
|
||||
<key>thinning</key>
|
||||
<string><none></string>
|
||||
</dict>
|
||||
|
||||
+31
-1
@@ -9,6 +9,31 @@ rm -rf build
|
||||
rm -rf tvbox.xcarchive
|
||||
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"
|
||||
CONFIGURATION="Release"
|
||||
ARCHIVE_PATH="build/tvbox.xcarchive"
|
||||
@@ -21,7 +46,12 @@ xcodebuild archive \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration "$CONFIGURATION" \
|
||||
-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
|
||||
echo "发现 $EXPORT_OPTIONS,尝试导出 IPA..."
|
||||
|
||||
+66
-5
@@ -8,7 +8,7 @@ options:
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.9"
|
||||
DEVELOPMENT_TEAM: ""
|
||||
DEVELOPMENT_TEAM: WP3M36TK8M
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
|
||||
packages:
|
||||
@@ -30,7 +30,7 @@ targets:
|
||||
- Info.plist
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.tvbox.app
|
||||
PRODUCT_BUNDLE_IDENTIFIER: xyz.appinstall.nov.carbon.lam
|
||||
INFOPLIST_FILE: tvbox/Info.plist
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
@@ -39,7 +39,7 @@ targets:
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
MARKETING_VERSION: "1.0.0"
|
||||
MARKETING_VERSION: "1.0.3"
|
||||
PRODUCT_NAME: TVBox
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||
@@ -52,6 +52,47 @@ targets:
|
||||
Release:
|
||||
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:
|
||||
type: application
|
||||
platform: macOS
|
||||
@@ -69,13 +110,33 @@ targets:
|
||||
INFOPLIST_FILE: tvbox/Info.plist
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
MARKETING_VERSION: "1.0.0"
|
||||
MARKETING_VERSION: "1.0.3"
|
||||
PRODUCT_NAME: TVBox
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||
SWIFT_EMIT_LOC_STRINGS: YES
|
||||
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:
|
||||
Debug:
|
||||
SWIFT_OPTIMIZATION_LEVEL: "-Onone"
|
||||
|
||||
@@ -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
@@ -13,12 +13,15 @@
|
||||
058BC2FBB08D016A6FCF4C13 /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CD8B07440DBB1EF2807795 /* FavoritesView.swift */; };
|
||||
073BA70E29280BDF914166CF /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD980DF9D6ABDD040AC805 /* SearchView.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 */; };
|
||||
100ED189186C10410BE683C5 /* PlayerEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */; };
|
||||
12314BE9C595577FEE1839BB /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016DC7547EE0DE345E510288 /* Extensions.swift */; };
|
||||
16D0E98EA5DF105764396919 /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE08247136FF788C621DA20 /* DesignSystem.swift */; };
|
||||
17BAC049400FFF5487ECD23B /* SourceBean.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617855BC3AE2B293C71A0DA4 /* SourceBean.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 */; };
|
||||
205509C747774AFBAB4F9645 /* VodCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F905D1244D5DE13DBCEE170F /* VodCardView.swift */; };
|
||||
211847D0D4F382678F5EDE96 /* VLCKitSPM in Frameworks */ = {isa = PBXBuildFile; productRef = A8E4ADD9D85C0FCB9811382F /* VLCKitSPM */; };
|
||||
@@ -28,6 +31,7 @@
|
||||
2C85CE605FC864EB479F3CCC /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE08247136FF788C621DA20 /* DesignSystem.swift */; };
|
||||
2F841DB8CE3BFB7C4F391B52 /* PlayerEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.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 */; };
|
||||
3F69F139D892A91CA7DA6C73 /* ApiConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F94E23C5085A1C8ACB4856 /* ApiConfig.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 */; };
|
||||
59DB9B1D12BAAA2F654579FE /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD980DF9D6ABDD040AC805 /* SearchView.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 */; };
|
||||
5FE9E153523D77AB24B16A7E /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016DC7547EE0DE345E510288 /* Extensions.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 */; };
|
||||
7C95C692E1A80145DED49DD3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3EE159FC0B6328166F56CE36 /* Assets.xcassets */; };
|
||||
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 */; };
|
||||
83A7D5C99EF218624DBBCAA2 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.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 */; };
|
||||
969EE3F9ACF8E1A3026CC37F /* DetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8B3DC7E0B9C01A294C47FC /* DetailViewModel.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 */; };
|
||||
9FCA2C5CC08A9369EA53B130 /* LiveViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21306B53D5CA8BB88593A55D /* LiveViewModel.swift */; };
|
||||
B06E33645C9D8322D7474962 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE52456C613016065CF59F9C /* HistoryView.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 */; };
|
||||
BB36351815C53AC6EACB475A /* VodInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA608E781D094B187D3034FD /* VodInfo.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 */; };
|
||||
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 */; };
|
||||
D8412DB044659CA4507DE219 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE52456C613016065CF59F9C /* HistoryView.swift */; };
|
||||
DB893501C654D6CC2D71D198 /* CacheStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD32454522C32D876921A30 /* CacheStore.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 */; };
|
||||
F6E863F58C52916EAB766B60 /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */; };
|
||||
F911F241653C76EABDA844A9 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.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 */
|
||||
|
||||
/* 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 */
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -112,6 +159,10 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -139,15 +190,26 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
016DC7547EE0DE345E510288 /* Extensions.swift */,
|
||||
EF32540F2107786AF4ECD571 /* HapticManager.swift */,
|
||||
);
|
||||
path = Utils;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0F55DB9D84FD47BF11F99DE5 /* tvbox-Tests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9777D260D16B089DA2DACB22 /* PlayerGestureLayerTests.swift */,
|
||||
5EDFB004DA6642926C611BC9 /* PlayerGestureLayerZoneClassificationTests.swift */,
|
||||
);
|
||||
path = "tvbox-Tests";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
22CB65DE627DB94747387944 /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */,
|
||||
D1006DD8209F63DC3AC3C718 /* NetworkManager.swift */,
|
||||
7752B731B0A015EB4B85BFB1 /* NetworkMonitor.swift */,
|
||||
D5DFDD8C597739AD4B443C03 /* SourceService.swift */,
|
||||
);
|
||||
path = Services;
|
||||
@@ -156,6 +218,8 @@
|
||||
2990EE415D2440DE11757F52 /* Player */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7A12D4549628C3FFBA736748 /* LivePlayerGestureLayer.swift */,
|
||||
E7491D05031A04661718FF52 /* PlayerGestureLayer.swift */,
|
||||
BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */,
|
||||
DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */,
|
||||
);
|
||||
@@ -170,6 +234,24 @@
|
||||
path = Search;
|
||||
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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -202,6 +284,7 @@
|
||||
FD7DFF54835B377CB9BF5A82 /* Home */,
|
||||
D3F1B37CB4D7643D7F7639CE /* Live */,
|
||||
2990EE415D2440DE11757F52 /* Player */,
|
||||
2D13F96D465DAF4A57E7F402 /* Profile */,
|
||||
2B5161C9D7D5ECC250A7FFC0 /* Search */,
|
||||
395C32C174B961E32AF81E4F /* Settings */,
|
||||
6F4341B27BB1A860CB183467 /* ContentView.swift */,
|
||||
@@ -214,6 +297,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8B5798117EA20DF680EFD3CA /* tvbox */,
|
||||
0F55DB9D84FD47BF11F99DE5 /* tvbox-Tests */,
|
||||
2CDB960D512B40AF659C0811 /* tvboxTests */,
|
||||
AF26E3B8CC541C336E1ECDDB /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -261,6 +346,7 @@
|
||||
E1FD870365F6D4FD67730DBD /* ViewModels */,
|
||||
4D47ACD45B1BF337CCB04FCC /* Views */,
|
||||
3EE159FC0B6328166F56CE36 /* Assets.xcassets */,
|
||||
98A5ADBA89C07920FE7DE302 /* tvbox-macOS.entitlements */,
|
||||
54D163D4F6A45E9ACA696071 /* tvboxApp.swift */,
|
||||
);
|
||||
path = tvbox;
|
||||
@@ -270,7 +356,9 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */,
|
||||
E64444D4CBE0502858CBD7E4 /* tvbox-Tests.xctest */,
|
||||
E5234A75D14EAE9141633FCA /* tvbox.app */,
|
||||
09FEA232F8084173AAC649D4 /* tvboxTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -278,6 +366,7 @@
|
||||
D3F1B37CB4D7643D7F7639CE /* Live */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
74B2E6B616F6AE034C7CC242 /* ChannelOverlayView.swift */,
|
||||
B3D8D6F9D894DBE0594084F9 /* LiveView.swift */,
|
||||
);
|
||||
path = Live;
|
||||
@@ -335,6 +424,24 @@
|
||||
productReference = E5234A75D14EAE9141633FCA /* tvbox.app */;
|
||||
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 */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = B562A6D461B4EAA4BD297CA0 /* Build configuration list for PBXNativeTarget "tvbox-macOS" */;
|
||||
@@ -355,6 +462,24 @@
|
||||
productReference = 7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */;
|
||||
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 */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -365,11 +490,19 @@
|
||||
LastUpgradeCheck = 1500;
|
||||
TargetAttributes = {
|
||||
11562C2D9CA08B82105BC7B6 = {
|
||||
DevelopmentTeam = "";
|
||||
DevelopmentTeam = WP3M36TK8M;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
6654D9217FBAAC8D637C69A8 = {
|
||||
DevelopmentTeam = WP3M36TK8M;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
793E3BB44A68A9F75C89F0C1 = {
|
||||
DevelopmentTeam = "";
|
||||
DevelopmentTeam = WP3M36TK8M;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
A8BE1A152DDC63C329EA702B = {
|
||||
DevelopmentTeam = WP3M36TK8M;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
@@ -392,7 +525,9 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
11562C2D9CA08B82105BC7B6 /* tvbox */,
|
||||
6654D9217FBAAC8D637C69A8 /* tvbox-Tests */,
|
||||
793E3BB44A68A9F75C89F0C1 /* tvbox-macOS */,
|
||||
A8BE1A152DDC63C329EA702B /* tvboxTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -417,6 +552,28 @@
|
||||
/* End PBXResourcesBuildPhase 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 */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -424,6 +581,7 @@
|
||||
633ECC16BBEA9AB61841F8A3 /* ApiConfig.swift in Sources */,
|
||||
3E399383D92192CDA8840FEB /* AppConfig.swift in Sources */,
|
||||
DB893501C654D6CC2D71D198 /* CacheStore.swift in Sources */,
|
||||
F19D2281D427FEE5B1349769 /* ChannelOverlayView.swift in Sources */,
|
||||
0E9ABBA046E84A91B5DC3BBB /* ContentView.swift in Sources */,
|
||||
2C85CE605FC864EB479F3CCC /* DesignSystem.swift in Sources */,
|
||||
4E84E7083C81F5A8E3C8FFD8 /* DetailView.swift in Sources */,
|
||||
@@ -432,17 +590,22 @@
|
||||
69BDB52A3ADDA43EAE59DE4B /* EpisodeListView.swift in Sources */,
|
||||
12314BE9C595577FEE1839BB /* Extensions.swift in Sources */,
|
||||
F49BBFD0CCDE27C63FD97180 /* FavoritesView.swift in Sources */,
|
||||
0D5200B31179039E1BB61DE5 /* HapticManager.swift in Sources */,
|
||||
D8412DB044659CA4507DE219 /* HistoryView.swift in Sources */,
|
||||
97B3904616111CE93232423E /* HomeView.swift in Sources */,
|
||||
5D208E481C6F97783B92E78C /* HomeViewModel.swift in Sources */,
|
||||
E983FD4D6902C9F359528F2E /* LiveModels.swift in Sources */,
|
||||
7D0F71AA151C3F8CE8EB962B /* LivePlayerGestureLayer.swift in Sources */,
|
||||
267A5751434568E302E0C902 /* LiveView.swift in Sources */,
|
||||
19291FACD401CDE0E6E2963E /* LiveViewModel.swift in Sources */,
|
||||
0B78C3CA1D93FA29531ABD0B /* Movie.swift in Sources */,
|
||||
68A7BF3D3ECDA73637B644C8 /* MovieSort.swift in Sources */,
|
||||
5931E2182CBA71FD467BC61B /* NetworkManager.swift in Sources */,
|
||||
F01490300EAFDF38F8BD5D46 /* NetworkMonitor.swift in Sources */,
|
||||
2F841DB8CE3BFB7C4F391B52 /* PlayerEngine.swift in Sources */,
|
||||
0CE56FEF7BEDF2D9AF2DD1AD /* PlayerGestureLayer.swift in Sources */,
|
||||
00DC0B0A8D261A9015CE9308 /* PlayerView.swift in Sources */,
|
||||
5E87760B3FDD9FCA2A532221 /* ProfileView.swift in Sources */,
|
||||
073BA70E29280BDF914166CF /* SearchView.swift in Sources */,
|
||||
1D0C0D4DD4E22EC319637BDF /* SearchViewModel.swift in Sources */,
|
||||
FA5621E49A1014D014A5906E /* SettingsView.swift in Sources */,
|
||||
@@ -463,6 +626,7 @@
|
||||
3F69F139D892A91CA7DA6C73 /* ApiConfig.swift in Sources */,
|
||||
D6F4B32786BF79CBF6AB77EA /* AppConfig.swift in Sources */,
|
||||
6EC44AD626C64BC902CD9D1B /* CacheStore.swift in Sources */,
|
||||
B42C3B637B6082ECD044DBFE /* ChannelOverlayView.swift in Sources */,
|
||||
01259DBB84CEFA52CB5BC8C1 /* ContentView.swift in Sources */,
|
||||
16D0E98EA5DF105764396919 /* DesignSystem.swift in Sources */,
|
||||
6765DB90DA62DBEE185104F7 /* DetailView.swift in Sources */,
|
||||
@@ -471,17 +635,22 @@
|
||||
72A1006A1A66F1721DD1618E /* EpisodeListView.swift in Sources */,
|
||||
5FE9E153523D77AB24B16A7E /* Extensions.swift in Sources */,
|
||||
058BC2FBB08D016A6FCF4C13 /* FavoritesView.swift in Sources */,
|
||||
329F390FCD9530CE2B19B397 /* HapticManager.swift in Sources */,
|
||||
B06E33645C9D8322D7474962 /* HistoryView.swift in Sources */,
|
||||
5EB3F1571D44835F105ED6E9 /* HomeView.swift in Sources */,
|
||||
32340EF75714EF664DC9512D /* HomeViewModel.swift in Sources */,
|
||||
7CC2D33B7B438A89B03990EC /* LiveModels.swift in Sources */,
|
||||
ED9018DE19A38BEF616B71A3 /* LivePlayerGestureLayer.swift in Sources */,
|
||||
530B86138F2A404353CE37AB /* LiveView.swift in Sources */,
|
||||
9FCA2C5CC08A9369EA53B130 /* LiveViewModel.swift in Sources */,
|
||||
485F358B964A4CB6E5AC4E03 /* Movie.swift in Sources */,
|
||||
7B76864A9BC631F1A86E8104 /* MovieSort.swift in Sources */,
|
||||
5230AE350F993A4792B03F74 /* NetworkManager.swift in Sources */,
|
||||
B86C3ACE754B5F52B70588A9 /* NetworkMonitor.swift in Sources */,
|
||||
100ED189186C10410BE683C5 /* PlayerEngine.swift in Sources */,
|
||||
19FCB4D2A58F2CA96183E67A /* PlayerGestureLayer.swift in Sources */,
|
||||
F6E863F58C52916EAB766B60 /* PlayerView.swift in Sources */,
|
||||
E9DE1232C207925E93343E15 /* ProfileView.swift in Sources */,
|
||||
59DB9B1D12BAAA2F654579FE /* SearchView.swift in Sources */,
|
||||
F911F241653C76EABDA844A9 /* SearchViewModel.swift in Sources */,
|
||||
020C5667E8D08A0C9B03EC8E /* SettingsView.swift in Sources */,
|
||||
@@ -497,13 +666,26 @@
|
||||
};
|
||||
/* 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 */
|
||||
05A4C2FBFE824459DAF6F032 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "";
|
||||
CODE_SIGN_ENTITLEMENTS = "tvbox/tvbox-macOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -514,7 +696,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.0.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.macos;
|
||||
PRODUCT_NAME = TVBox;
|
||||
SDKROOT = macosx;
|
||||
@@ -559,7 +741,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = WP3M36TK8M;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -584,7 +766,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "";
|
||||
CODE_SIGN_ENTITLEMENTS = "tvbox/tvbox-macOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -595,7 +777,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.0.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.macos;
|
||||
PRODUCT_NAME = TVBox;
|
||||
SDKROOT = macosx;
|
||||
@@ -604,6 +786,63 @@
|
||||
};
|
||||
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 */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -624,8 +863,8 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app;
|
||||
MARKETING_VERSION = 1.0.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.appinstall.nov.carbon.lam;
|
||||
PRODUCT_NAME = TVBox;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
@@ -635,6 +874,25 @@
|
||||
};
|
||||
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 */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -671,7 +929,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = WP3M36TK8M;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -718,8 +976,8 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app;
|
||||
MARKETING_VERSION = 1.0.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.appinstall.nov.carbon.lam;
|
||||
PRODUCT_NAME = TVBox;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
@@ -759,6 +1017,24 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
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 */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
|
||||
@@ -214,3 +214,31 @@ struct AppConfigData: Codable {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,15 @@ import Foundation
|
||||
|
||||
/// 直播频道分组
|
||||
struct LiveChannelGroup: Codable, Identifiable, Hashable {
|
||||
/// 以分组名作为稳定标识,便于 SwiftUI 列表 diff。
|
||||
var id: String { groupName }
|
||||
/// 分组名称(如“央视”“卫视”)。
|
||||
var groupName: String = ""
|
||||
/// 分组在列表中的顺序索引。
|
||||
var groupIndex: Int = 0
|
||||
/// 分组下频道列表。
|
||||
var channels: [LiveChannelItem] = []
|
||||
/// 是否为加密分组(当前实现仅保留字段,未启用密码校验)。
|
||||
var isPassword: Bool = false
|
||||
|
||||
init(groupName: String = "", groupIndex: Int = 0) {
|
||||
@@ -18,12 +23,19 @@ struct LiveChannelGroup: Codable, Identifiable, Hashable {
|
||||
|
||||
/// 直播频道
|
||||
struct LiveChannelItem: Codable, Identifiable, Hashable {
|
||||
/// 频道标识由名称+索引组成,规避同名频道冲突。
|
||||
var id: String { "\(channelName)_\(channelIndex)" }
|
||||
/// 频道名。
|
||||
var channelName: String = ""
|
||||
/// 频道在分组内的顺序索引。
|
||||
var channelIndex: Int = 0
|
||||
/// 多线路播放地址。
|
||||
var channelUrls: [String] = []
|
||||
/// 当前选中的线路索引。
|
||||
var sourceIndex: Int = 0
|
||||
/// 可用线路总数。
|
||||
var sourceNum: Int { channelUrls.count }
|
||||
/// 台标地址(预留)。
|
||||
var logo: String = ""
|
||||
|
||||
init(channelName: String = "", channelIndex: Int = 0) {
|
||||
@@ -31,11 +43,14 @@ struct LiveChannelItem: Codable, Identifiable, Hashable {
|
||||
self.channelIndex = channelIndex
|
||||
}
|
||||
|
||||
/// 当前线路对应的播放地址。
|
||||
/// 当索引越界时兜底返回第一条线路,避免直接播放失败。
|
||||
var currentUrl: String? {
|
||||
guard sourceIndex >= 0, sourceIndex < channelUrls.count else { return channelUrls.first }
|
||||
return channelUrls[sourceIndex]
|
||||
}
|
||||
|
||||
/// 轮换到下一条线路。
|
||||
mutating func nextSource() {
|
||||
if channelUrls.count > 0 {
|
||||
sourceIndex = (sourceIndex + 1) % channelUrls.count
|
||||
@@ -51,6 +66,7 @@ struct Epginfo: Codable, Identifiable, Hashable {
|
||||
var endTime: String = ""
|
||||
var index: Int = 0
|
||||
|
||||
/// 根据 `HH:mm` 时间段判断节目是否正在播出。
|
||||
var isLive: Bool {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm"
|
||||
@@ -65,8 +81,12 @@ struct Epginfo: Codable, Identifiable, Hashable {
|
||||
/// EPG 日期分组
|
||||
struct LiveEpgDate: Codable, Identifiable, Hashable {
|
||||
var id: String { datePresent }
|
||||
var datePresent: String = "" // 显示日期
|
||||
var date: String = "" // 查询日期
|
||||
/// 供 UI 展示的日期文案。
|
||||
var datePresent: String = ""
|
||||
/// 供接口查询的原始日期值。
|
||||
var date: String = ""
|
||||
/// 在日期列表中的位置索引。
|
||||
var index: Int = 0
|
||||
/// 是否被当前 UI 选中。
|
||||
var isSelected: Bool = false
|
||||
}
|
||||
|
||||
@@ -2,28 +2,47 @@ import Foundation
|
||||
|
||||
/// 电影/视频数据模型 - 对应 Android 版 Movie.java
|
||||
struct Movie: Codable {
|
||||
/// 列表数据主体。
|
||||
var videoList: [Video] = []
|
||||
/// 总页数。
|
||||
var pagecount: Int = 0
|
||||
/// 当前页码。
|
||||
var page: Int = 0
|
||||
/// 总条数。
|
||||
var total: Int = 0
|
||||
/// 每页条数。
|
||||
var limit: Int = 0
|
||||
|
||||
/// 单个视频条目
|
||||
struct Video: Codable, Identifiable, Hashable {
|
||||
/// 视频唯一 ID(接口可能返回 Int 或 String,见自定义解码)。
|
||||
var id: String
|
||||
/// 片名。
|
||||
var name: String = ""
|
||||
/// 海报地址。
|
||||
var pic: String = ""
|
||||
var note: String = "" // 备注(如"更新至第20集")
|
||||
/// 备注(如“更新至第20集”)。
|
||||
var note: String = ""
|
||||
/// 年份。
|
||||
var year: String = ""
|
||||
/// 地区。
|
||||
var area: String = ""
|
||||
var type: String = "" // 类型/分类名
|
||||
/// 类型/分类名。
|
||||
var type: String = ""
|
||||
/// 导演。
|
||||
var director: String = ""
|
||||
/// 演员。
|
||||
var actor: String = ""
|
||||
var des: String = "" // 简介
|
||||
var sourceKey: String = "" // 来源站点 key
|
||||
var tid: String = "" // 分类 ID
|
||||
var last: String = "" // 最后更新
|
||||
var dt: String = "" // 日期
|
||||
/// 简介。
|
||||
var des: String = ""
|
||||
/// 来源站点 key,用于跨源隔离收藏与历史。
|
||||
var sourceKey: String = ""
|
||||
/// 分类 ID。
|
||||
var tid: String = ""
|
||||
/// 最后更新时间。
|
||||
var last: String = ""
|
||||
/// 播放来源信息(部分接口会复用该字段)。
|
||||
var dt: String = ""
|
||||
|
||||
init(id: String = UUID().uuidString, name: String = "", pic: String = "",
|
||||
note: String = "", sourceKey: String = "") {
|
||||
@@ -51,6 +70,7 @@ struct Movie: Codable {
|
||||
case sourceKey
|
||||
}
|
||||
|
||||
/// 自定义解码以兼容多源字段类型差异(如 `vod_id` / `type_id` 可能是 Int 或 String)。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// 支持 String 或 Int 类型的 id
|
||||
|
||||
@@ -2,13 +2,18 @@ import Foundation
|
||||
|
||||
/// 分类排序模型 - 对应 Android 版 MovieSort.java
|
||||
struct MovieSort: Codable {
|
||||
/// 分类列表(包含首页推荐、影视分类等)。
|
||||
var sortList: [SortData] = []
|
||||
|
||||
/// 单个分类数据
|
||||
struct SortData: Codable, Identifiable, Hashable {
|
||||
/// 分类唯一标识(接口字段通常为 type_id)。
|
||||
var id: String
|
||||
/// 分类显示名。
|
||||
var name: String = ""
|
||||
/// 标记位(不同源可定义不同语义,常用于首页/推荐标识)。
|
||||
var flag: String = ""
|
||||
/// 分类下可选筛选项(年份、地区、类型等)。
|
||||
var filters: [SortFilter] = []
|
||||
|
||||
init(id: String = "", name: String = "", flag: String = "") {
|
||||
@@ -17,6 +22,8 @@ struct MovieSort: Codable {
|
||||
self.flag = flag
|
||||
}
|
||||
|
||||
/// 生成首页推荐占位分类。
|
||||
/// 该分类不走常规分类接口,直接渲染首页推荐列表。
|
||||
static func home() -> SortData {
|
||||
SortData(id: "home", name: "推荐", flag: "1")
|
||||
}
|
||||
@@ -24,13 +31,18 @@ struct MovieSort: Codable {
|
||||
|
||||
/// 筛选条件
|
||||
struct SortFilter: Codable, Hashable {
|
||||
/// 接口参数键,例如 `year`、`area`。
|
||||
var key: String = ""
|
||||
/// UI 展示名称。
|
||||
var name: String = ""
|
||||
/// 可选值集合。
|
||||
var values: [SortFilterValue] = []
|
||||
|
||||
struct SortFilterValue: Codable, Hashable {
|
||||
var n: String = "" // 显示名
|
||||
var v: String = "" // 值
|
||||
/// 展示名。
|
||||
var n: String = ""
|
||||
/// 真实参数值。
|
||||
var v: String = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ import Foundation
|
||||
|
||||
/// 播放器引擎类型
|
||||
enum PlayerEngine: Int, CaseIterable, Identifiable {
|
||||
/// 系统 AVPlayer 内核。
|
||||
case system = 0
|
||||
/// VLC 内核(需编译时可导入 VLCKitSPM)。
|
||||
case vlc = 10
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
/// UI 展示名。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .system:
|
||||
@@ -16,6 +19,7 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前构建产物是否包含 VLC 能力。
|
||||
static var isVLCAvailable: Bool {
|
||||
#if canImport(VLCKitSPM)
|
||||
return true
|
||||
@@ -24,6 +28,8 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 实际可供用户选择的播放器列表。
|
||||
/// 当 VLC 不可用时,仅暴露系统播放器,避免无效配置。
|
||||
static var availableEngines: [PlayerEngine] {
|
||||
var engines: [PlayerEngine] = [.system]
|
||||
if isVLCAvailable {
|
||||
@@ -32,6 +38,7 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
|
||||
return engines
|
||||
}
|
||||
|
||||
/// 从持久化值恢复播放器选项,并自动兜底到可用引擎。
|
||||
static func fromStoredValue(_ rawValue: Int) -> PlayerEngine {
|
||||
guard let engine = PlayerEngine(rawValue: rawValue) else {
|
||||
return .system
|
||||
@@ -47,8 +54,11 @@ enum PlayerEngine: Int, CaseIterable, Identifiable {
|
||||
|
||||
/// 视频解码模式
|
||||
enum VideoDecodeMode: Int, CaseIterable, Identifiable {
|
||||
/// 自动策略,优先硬解,失败时用户可切换。
|
||||
case auto = 0
|
||||
/// 强制硬解。
|
||||
case hardware = 1
|
||||
/// 强制软解。
|
||||
case software = 2
|
||||
|
||||
var id: Int { rawValue }
|
||||
@@ -69,6 +79,7 @@ enum VideoDecodeMode: Int, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
/// VLC 媒体选项
|
||||
/// 返回 `avcodec-hw` 对应的值。
|
||||
var vlcHardwareDecodeOption: String? {
|
||||
switch self {
|
||||
case .auto:
|
||||
@@ -84,8 +95,11 @@ enum VideoDecodeMode: Int, CaseIterable, Identifiable {
|
||||
|
||||
/// VLC 缓冲策略
|
||||
enum VLCBufferMode: Int, CaseIterable, Identifiable {
|
||||
/// 低延迟优先,适合直播但容错较低。
|
||||
case lowLatency = 0
|
||||
/// 兼顾延迟与稳定性,作为默认策略。
|
||||
case balanced = 1
|
||||
/// 稳定流畅优先,允许更高缓冲。
|
||||
case smooth = 2
|
||||
|
||||
var id: Int { rawValue }
|
||||
@@ -111,6 +125,10 @@ enum VLCBufferMode: Int, CaseIterable, Identifiable {
|
||||
self == .lowLatency
|
||||
}
|
||||
|
||||
/// 根据直播/点播场景输出三类缓存值(单位毫秒)。
|
||||
/// - Parameters:
|
||||
/// - isLive: 是否直播场景
|
||||
/// - Returns: network/live/file 三类缓存配置
|
||||
func cacheConfig(isLive: Bool) -> (network: Int, live: Int, file: Int) {
|
||||
switch self {
|
||||
case .lowLatency:
|
||||
|
||||
@@ -2,25 +2,37 @@ import Foundation
|
||||
|
||||
/// 视频源站点配置 - 对应 Android 版 SourceBean.java
|
||||
struct SourceBean: Codable, Identifiable, Hashable {
|
||||
/// 以源 key 作为稳定标识。
|
||||
var id: String { key }
|
||||
|
||||
/// 源唯一键。
|
||||
let key: String
|
||||
/// 源显示名。
|
||||
let name: String
|
||||
/// 源接口地址。
|
||||
let api: String
|
||||
let searchable: Int // 0:关闭搜索 1:启用搜索
|
||||
let filterable: Int // 0:首页不可选 1:首页可选
|
||||
let playerType: Int // 0:系统 1:IJK 2:EXO
|
||||
let type: Int // 0:xml 1:json 3:jar 4:remote
|
||||
/// 搜索开关:0 关闭,1 开启。
|
||||
let searchable: Int
|
||||
/// 是否允许出现在首页分类:0 不可选,1 可选。
|
||||
let filterable: Int
|
||||
/// 快速搜索开关:0 关闭,1 开启(主要用于 remote 源 quick 参数)。
|
||||
let quickSearch: Int
|
||||
/// 源声明的播放器类型(历史字段,Swift 端目前主要走统一播放器策略)。
|
||||
let playerType: Int
|
||||
/// 源协议类型:0 XML,1 JSON,3 JAR,4 Remote。
|
||||
let type: Int
|
||||
/// 扩展参数(remote 源常用)。
|
||||
let ext: 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) {
|
||||
self.key = key
|
||||
self.name = name
|
||||
self.api = api
|
||||
self.searchable = searchable
|
||||
self.filterable = filterable
|
||||
self.quickSearch = quickSearch
|
||||
self.playerType = playerType
|
||||
self.type = type
|
||||
self.ext = ext
|
||||
@@ -28,6 +40,7 @@ struct SourceBean: Codable, Identifiable, Hashable {
|
||||
|
||||
var isSearchable: Bool { searchable == 1 }
|
||||
var isFilterable: Bool { filterable == 1 }
|
||||
var isQuickSearchEnabled: Bool { quickSearch == 1 }
|
||||
|
||||
/// 是否在 Swift 版中受支持(type=3 为 JAR/Spider,需要 Java 运行时,暂不支持)
|
||||
var isSupportedInSwift: Bool {
|
||||
|
||||
@@ -2,16 +2,27 @@ import Foundation
|
||||
|
||||
/// 视频详情模型 - 对应 Android 版 VodInfo.java
|
||||
struct VodInfo: Codable, Identifiable {
|
||||
/// 视频唯一 ID。
|
||||
var id: String
|
||||
/// 标题。
|
||||
var name: String = ""
|
||||
/// 海报地址。
|
||||
var pic: String = ""
|
||||
/// 备注(更新状态等)。
|
||||
var note: String = ""
|
||||
/// 年份。
|
||||
var year: String = ""
|
||||
/// 地区。
|
||||
var area: String = ""
|
||||
/// 类型名。
|
||||
var typeName: String = ""
|
||||
/// 导演。
|
||||
var director: String = ""
|
||||
/// 演员。
|
||||
var actor: String = ""
|
||||
/// 简介。
|
||||
var des: String = ""
|
||||
/// 来源站点 key。
|
||||
var sourceKey: String = ""
|
||||
|
||||
/// 播放来源(线路)列表
|
||||
@@ -19,13 +30,17 @@ struct VodInfo: Codable, Identifiable {
|
||||
/// key: flag名称, value: 剧集列表
|
||||
var playUrlMap: [String: [Episode]] = [:]
|
||||
|
||||
var playFlag: String = "" // 当前选中线路
|
||||
var playIndex: Int = 0 // 当前播放剧集索引
|
||||
/// 当前选中线路。
|
||||
var playFlag: String = ""
|
||||
/// 当前播放剧集索引。
|
||||
var playIndex: Int = 0
|
||||
|
||||
/// 单集信息
|
||||
struct Episode: Codable, Identifiable, Hashable {
|
||||
var id: String { name }
|
||||
/// 集标题。
|
||||
let name: String
|
||||
/// 集播放地址。
|
||||
let 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.sourceKey = video.sourceKey
|
||||
|
||||
// 解析播放列表
|
||||
// 解析播放列表:
|
||||
// playFrom 格式: "线路1$$$线路2$$$线路3"
|
||||
// playUrl 格式: "第1集$url1#第2集$url2$$$第1集$url3#第2集$url4"
|
||||
let flags = playFrom.components(separatedBy: "$$$").filter { !$0.isEmpty }
|
||||
@@ -73,10 +88,12 @@ struct VodInfo: Codable, Identifiable {
|
||||
return info
|
||||
}
|
||||
|
||||
/// 当前线路下的剧集。
|
||||
var currentEpisodes: [Episode] {
|
||||
playUrlMap[playFlag] ?? []
|
||||
}
|
||||
|
||||
/// 当前线路 + 当前索引对应的剧集对象。
|
||||
var currentEpisode: Episode? {
|
||||
let eps = currentEpisodes
|
||||
guard playIndex >= 0, playIndex < eps.count else { return nil }
|
||||
|
||||
@@ -3,23 +3,41 @@ import SwiftData
|
||||
|
||||
/// 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 {
|
||||
/// 当前播放线路标识。
|
||||
var flag: String
|
||||
/// 剧集索引。
|
||||
var episodeIndex: Int
|
||||
/// 播放进度(秒)。
|
||||
var progressSeconds: Double
|
||||
}
|
||||
|
||||
/// 视频收藏
|
||||
@Model
|
||||
final class VodCollect {
|
||||
/// 业务唯一键(sourceKey + vodId)。
|
||||
var bizKey: String = ""
|
||||
/// 视频 ID(与 sourceKey 组成唯一语义键)。
|
||||
var vodId: String = ""
|
||||
/// 片名。
|
||||
var vodName: String = ""
|
||||
/// 海报地址。
|
||||
var vodPic: String = ""
|
||||
/// 来源站点 key。
|
||||
var sourceKey: String = ""
|
||||
/// 最近更新时间(收藏创建/刷新时间)。
|
||||
var updateTime: Date = Date()
|
||||
|
||||
init(vodId: String, vodName: String, vodPic: String, sourceKey: String) {
|
||||
self.bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
|
||||
self.vodId = vodId
|
||||
self.vodName = vodName
|
||||
self.vodPic = vodPic
|
||||
@@ -31,15 +49,25 @@ final class VodCollect {
|
||||
/// 播放历史记录
|
||||
@Model
|
||||
final class VodRecord {
|
||||
/// 业务唯一键(sourceKey + vodId)。
|
||||
var bizKey: String = ""
|
||||
/// 视频 ID。
|
||||
var vodId: String = ""
|
||||
/// 片名。
|
||||
var vodName: String = ""
|
||||
/// 海报地址。
|
||||
var vodPic: String = ""
|
||||
/// 来源站点 key。
|
||||
var sourceKey: String = ""
|
||||
var playNote: String = "" // 如 "第5集 03:45"
|
||||
var dataJson: String = "" // 续播状态 JSON(VodPlaybackState)
|
||||
/// 播放标记,如“第5集 03:45”。
|
||||
var playNote: String = ""
|
||||
/// 续播状态 JSON(`VodPlaybackState` 编码结果)。
|
||||
var dataJson: String = ""
|
||||
/// 最近播放时间。
|
||||
var updateTime: Date = Date()
|
||||
|
||||
init(vodId: String, vodName: String, vodPic: String, sourceKey: String, playNote: String = "") {
|
||||
self.bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
|
||||
self.vodId = vodId
|
||||
self.vodName = vodName
|
||||
self.vodPic = vodPic
|
||||
@@ -52,8 +80,11 @@ final class VodRecord {
|
||||
/// 通用缓存
|
||||
@Model
|
||||
final class CacheItem {
|
||||
/// 唯一缓存键。
|
||||
@Attribute(.unique) var key: String = ""
|
||||
/// 缓存值(字符串形式)。
|
||||
var value: String = ""
|
||||
/// 更新时间。
|
||||
var updateTime: Date = Date()
|
||||
|
||||
init(key: String, value: String) {
|
||||
@@ -71,49 +102,64 @@ actor CacheStore {
|
||||
|
||||
@MainActor
|
||||
func addCollect(_ video: Movie.Video, context: ModelContext) {
|
||||
// 先检查是否已存在
|
||||
let vodId = video.id
|
||||
let sourceKey = video.sourceKey
|
||||
let predicate = #Predicate<VodCollect> { item in
|
||||
item.vodId == vodId && item.sourceKey == sourceKey
|
||||
}
|
||||
let descriptor = FetchDescriptor<VodCollect>(predicate: predicate)
|
||||
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
|
||||
|
||||
if let existing = try? context.fetch(descriptor), !existing.isEmpty {
|
||||
return // 已收藏
|
||||
do {
|
||||
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
|
||||
func removeCollect(vodId: String, sourceKey: String, context: ModelContext) {
|
||||
let predicate = #Predicate<VodCollect> { item in
|
||||
item.vodId == vodId && item.sourceKey == sourceKey
|
||||
}
|
||||
let descriptor = FetchDescriptor<VodCollect>(predicate: predicate)
|
||||
if let items = try? context.fetch(descriptor) {
|
||||
do {
|
||||
let items = try fetchCollects(vodId: vodId, sourceKey: sourceKey, context: context)
|
||||
guard !items.isEmpty else { return }
|
||||
for item in items {
|
||||
context.delete(item)
|
||||
}
|
||||
try? context.save()
|
||||
try context.save()
|
||||
} catch {
|
||||
print("删除收藏失败: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func isCollected(vodId: String, sourceKey: String, context: ModelContext) -> Bool {
|
||||
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
|
||||
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)
|
||||
return (try? context.fetchCount(descriptor)) ?? 0 > 0
|
||||
|
||||
do {
|
||||
let count = try context.fetchCount(descriptor)
|
||||
return count > 0
|
||||
} catch {
|
||||
print("查询收藏状态失败: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -125,38 +171,55 @@ actor CacheStore {
|
||||
) {
|
||||
let vodId = video.id
|
||||
let sourceKey = video.sourceKey
|
||||
let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context)
|
||||
let encodedState = Self.encodePlaybackState(playbackState)
|
||||
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
|
||||
|
||||
// 更新或插入
|
||||
if let record {
|
||||
record.playNote = playNote
|
||||
if let encodedState {
|
||||
record.dataJson = encodedState
|
||||
do {
|
||||
let matched = try fetchRecords(vodId: vodId, sourceKey: sourceKey, context: context)
|
||||
|
||||
// 更新或插入
|
||||
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 {
|
||||
let record = VodRecord(
|
||||
vodId: video.id,
|
||||
vodName: video.name,
|
||||
vodPic: video.pic,
|
||||
sourceKey: video.sourceKey,
|
||||
playNote: playNote
|
||||
)
|
||||
if let encodedState {
|
||||
record.dataJson = encodedState
|
||||
}
|
||||
context.insert(record)
|
||||
|
||||
try context.save()
|
||||
} catch {
|
||||
print("写入播放记录失败: \(error)")
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
/// 读取续播状态(若无记录或 JSON 无法解码则返回 `nil`)。
|
||||
@MainActor
|
||||
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 Self.decodePlaybackState(record.dataJson)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -170,13 +233,47 @@ actor CacheStore {
|
||||
}
|
||||
|
||||
@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
|
||||
item.vodId == vodId && item.sourceKey == sourceKey
|
||||
item.bizKey == bizKey || (item.bizKey == "" && item.vodId == vodId && item.sourceKey == sourceKey)
|
||||
}
|
||||
let descriptor = FetchDescriptor<VodRecord>(predicate: predicate)
|
||||
guard let records = try? context.fetch(descriptor) else { return nil }
|
||||
return records.first
|
||||
let records = try context.fetch(descriptor)
|
||||
|
||||
// 兼容旧数据:命中 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? {
|
||||
@@ -185,6 +282,7 @@ actor CacheStore {
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// 从 JSON 字符串反序列化续播状态。
|
||||
private nonisolated static func decodePlaybackState(_ json: String) -> VodPlaybackState? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(VodPlaybackState.self, from: data)
|
||||
|
||||
+517
-33
@@ -5,6 +5,17 @@ import Foundation
|
||||
@MainActor
|
||||
class ApiConfig: ObservableObject {
|
||||
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 homeSourceBean: SourceBean?
|
||||
@@ -17,6 +28,13 @@ class ApiConfig: ObservableObject {
|
||||
@Published var wallpaper: String = ""
|
||||
|
||||
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() {}
|
||||
|
||||
@@ -32,27 +50,98 @@ class ApiConfig: ObservableObject {
|
||||
guard !trimmedVod.isEmpty else {
|
||||
throw ConfigError.parseError("点播接口地址不能为空")
|
||||
}
|
||||
let loadToken = UUID()
|
||||
activeLoadToken = loadToken
|
||||
liveParseTask?.cancel()
|
||||
liveParseTask = nil
|
||||
|
||||
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
|
||||
self.configUrl = trimmedVod
|
||||
self.liveConfigUrl = resolvedLive
|
||||
|
||||
if trimmedVod == resolvedLive {
|
||||
let config = try await fetchConfig(from: trimmedVod)
|
||||
parseConfig(config, apiUrl: trimmedVod, includeSources: true, includeLive: true)
|
||||
let configResult = try await fetchConfig(from: trimmedVod)
|
||||
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 {
|
||||
let vodConfig = try await fetchConfig(from: trimmedVod)
|
||||
parseConfig(vodConfig, apiUrl: trimmedVod, includeSources: true, includeLive: false)
|
||||
|
||||
let liveConfig = try await fetchConfig(from: resolvedLive)
|
||||
parseConfig(liveConfig, apiUrl: resolvedLive, includeSources: false, includeLive: true)
|
||||
async let vodConfigTask = fetchConfig(from: trimmedVod)
|
||||
async let liveConfigTask = fetchConfig(from: resolvedLive)
|
||||
let (vodConfig, liveConfig) = try await (vodConfigTask, liveConfigTask)
|
||||
guard activeLoadToken == loadToken else { return }
|
||||
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
|
||||
}
|
||||
|
||||
/// 直播分组改为后台解析,避免阻塞首页首屏进入。
|
||||
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 -> AppConfigData {
|
||||
let jsonStr = try await network.getString(from: apiUrl)
|
||||
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)
|
||||
|
||||
// 清理非标准 JSON(Android 端 Gson 默认支持注释,Swift 需要手动处理)
|
||||
let cleanedJson = Self.stripJsonComments(jsonStr)
|
||||
@@ -61,7 +150,311 @@ class ApiConfig: ObservableObject {
|
||||
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: "&", 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 配置文件格式
|
||||
@@ -124,13 +517,64 @@ class ApiConfig: ObservableObject {
|
||||
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(
|
||||
_ config: AppConfigData,
|
||||
apiUrl: String,
|
||||
includeSources: Bool,
|
||||
includeLive: Bool
|
||||
) {
|
||||
includeLive: Bool,
|
||||
loadToken: UUID
|
||||
) async {
|
||||
guard activeLoadToken == loadToken else { return }
|
||||
|
||||
if includeSources {
|
||||
// 解析站点列表
|
||||
var sources: [SourceBean] = []
|
||||
@@ -142,6 +586,7 @@ class ApiConfig: ObservableObject {
|
||||
api: site.api ?? "",
|
||||
searchable: site.searchable?.value ?? 1,
|
||||
filterable: site.filterable?.value ?? 1,
|
||||
quickSearch: site.quickSearch?.value ?? 0,
|
||||
playerType: site.playerType?.value ?? 0,
|
||||
type: site.type?.value ?? 1,
|
||||
ext: site.ext?.stringValue
|
||||
@@ -165,6 +610,8 @@ class ApiConfig: ObservableObject {
|
||||
self.parseBeanList = parses.map { p in
|
||||
ParseBean(name: p.name ?? "", url: p.url ?? "", type: p.type?.value ?? 0)
|
||||
}
|
||||
} else {
|
||||
self.parseBeanList = []
|
||||
}
|
||||
|
||||
// 解析 DoH 列表
|
||||
@@ -173,6 +620,8 @@ class ApiConfig: ObservableObject {
|
||||
guard let name = d.name, let url = d.url else { return nil }
|
||||
return (name: name, url: url)
|
||||
}
|
||||
} else {
|
||||
self.dohList = []
|
||||
}
|
||||
|
||||
// 壁纸
|
||||
@@ -181,7 +630,9 @@ class ApiConfig: ObservableObject {
|
||||
|
||||
if includeLive {
|
||||
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 {
|
||||
liveChannelGroupList = []
|
||||
}
|
||||
@@ -189,32 +640,65 @@ class ApiConfig: ObservableObject {
|
||||
}
|
||||
|
||||
/// 解析直播列表
|
||||
private func parseLives(_ lives: [AppConfigData.LiveConfig], apiUrl: String) {
|
||||
Task {
|
||||
var mergedGroups: [String: LiveChannelGroup] = [:]
|
||||
private func parseLives(
|
||||
_ lives: [AppConfigData.LiveConfig],
|
||||
apiUrl: String,
|
||||
loadToken: UUID
|
||||
) async -> [LiveChannelGroup] {
|
||||
var mergedGroups: [String: LiveChannelGroup] = [:]
|
||||
var remoteLiveTargets: [(order: Int, url: String)] = []
|
||||
|
||||
for (index, live) in lives.enumerated() {
|
||||
guard activeLoadToken == loadToken else { return [] }
|
||||
|
||||
for live in lives {
|
||||
// 如果有 url,从远程加载
|
||||
if let liveUrl = live.url, !liveUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
let resolvedUrl = resolveLiveUrl(liveUrl, baseConfigUrl: apiUrl)
|
||||
do {
|
||||
let content = try await network.getString(from: resolvedUrl)
|
||||
let groups = parseLiveContent(content)
|
||||
mergeLiveGroups(groups, into: &mergedGroups)
|
||||
} catch {
|
||||
print("加载直播源失败: \(resolvedUrl), error: \(error)")
|
||||
// 如果有 url,从远程加载
|
||||
if let liveUrl = live.url, !liveUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
let resolvedUrl = resolveLiveUrl(liveUrl, baseConfigUrl: apiUrl)
|
||||
remoteLiveTargets.append((order: index, url: resolvedUrl))
|
||||
}
|
||||
|
||||
// 如果有内嵌频道
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有内嵌频道
|
||||
if let channels = live.channels {
|
||||
let inlineGroups = parseInlineLiveChannels(channels)
|
||||
mergeLiveGroups(inlineGroups, into: &mergedGroups)
|
||||
var results: [(Int, String)] = []
|
||||
for await (order, content) in group {
|
||||
if let content {
|
||||
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 格式的直播内容
|
||||
@@ -389,13 +873,13 @@ class ApiConfig: ObservableObject {
|
||||
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return trimmed }
|
||||
if let url = URL(string: trimmed), url.scheme != nil {
|
||||
return trimmed
|
||||
return Self.normalizeConfigUrl(trimmed)
|
||||
}
|
||||
guard let baseUrl = URL(string: baseConfigUrl),
|
||||
let resolved = URL(string: trimmed, relativeTo: baseUrl)?.absoluteURL else {
|
||||
return trimmed
|
||||
}
|
||||
return resolved.absoluteString
|
||||
return Self.normalizeConfigUrl(resolved.absoluteString)
|
||||
}
|
||||
|
||||
private static func uniqueLiveUrls(_ urls: [String]) -> [String] {
|
||||
|
||||
@@ -2,21 +2,64 @@ import Foundation
|
||||
|
||||
/// 网络请求封装 - 对应 Android 版 OkGo
|
||||
class 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
|
||||
/// JSON 解码器。
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
/// 私有初始化,防止外部创建多个请求管理器。
|
||||
private init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 15
|
||||
config.timeoutIntervalForResource = 30
|
||||
config.httpMaximumConnectionsPerHost = 5
|
||||
config.waitsForConnectivity = true
|
||||
self.session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
/// GET 请求获取字符串
|
||||
func getString(from urlString: String, headers: [String: String]? = nil) async throws -> String {
|
||||
/// GET 请求获取字符串,支持自动重试。
|
||||
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 {
|
||||
throw NetworkError.invalidURL(urlString)
|
||||
}
|
||||
@@ -25,48 +68,188 @@ class NetworkManager {
|
||||
request.httpMethod = "GET"
|
||||
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 {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
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 解码失败")
|
||||
guard let str = Self.decodeString(data: data, response: httpResponse) else {
|
||||
throw NetworkError.decodingError("文本解码失败")
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
/// GET 请求解码 JSON
|
||||
func getJSON<T: Decodable>(from urlString: String, type: T.Type, headers: [String: String]? = nil) async throws -> T {
|
||||
let str = try await getString(from: urlString, headers: headers)
|
||||
/// GET 请求解码 JSON,支持自动重试。
|
||||
func getJSON<T: Decodable>(
|
||||
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 {
|
||||
throw NetworkError.decodingError("字符串转 Data 失败")
|
||||
}
|
||||
return try decoder.decode(T.self, from: data)
|
||||
}
|
||||
|
||||
/// GET 请求获取原始 Data
|
||||
func getData(from urlString: String) async throws -> Data {
|
||||
/// GET 请求获取原始 Data,支持自动重试和 HTTP 状态码校验。
|
||||
func getData(
|
||||
from urlString: String,
|
||||
maxRetries: Int = NetworkManager.defaultMaxRetries
|
||||
) async throws -> Data {
|
||||
guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
case invalidURL(String)
|
||||
case invalidResponse
|
||||
case httpError(Int)
|
||||
case decodingError(String)
|
||||
|
||||
/// 面向 UI/日志的错误描述。
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL(let url): return "无效的URL: \(url)"
|
||||
@@ -75,4 +258,33 @@ enum NetworkError: LocalizedError {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -34,19 +34,24 @@ class SourceService {
|
||||
jsonStr = try await network.getString(from: api)
|
||||
} else if sourceBean.type == 4 {
|
||||
// Type 4: 远程接口,需要 extend 和 filter 参数
|
||||
var url = api.contains("?") ? "\(api)&filter=true" : "\(api)?filter=true"
|
||||
var queryItems: [URLQueryItem] = [
|
||||
URLQueryItem(name: "filter", value: "true")
|
||||
]
|
||||
// 加载 extend
|
||||
if let ext = sourceBean.ext, !ext.isEmpty {
|
||||
let extend = await resolveExtend(ext)
|
||||
if !extend.isEmpty {
|
||||
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend
|
||||
url += "&extend=\(encoded)"
|
||||
queryItems.append(URLQueryItem(name: "extend", value: extend))
|
||||
}
|
||||
}
|
||||
let url = try buildURL(base: api, queryItems: queryItems)
|
||||
jsonStr = try await network.getString(from: url)
|
||||
} else {
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -62,12 +67,24 @@ class SourceService {
|
||||
if sourceBean.type == 4 {
|
||||
// type=4 用 ac=detail 格式,与 getList 保持一致
|
||||
let ext = Data("{}".utf8).base64EncodedString()
|
||||
listUrl = api.contains("?")
|
||||
? "\(api)&ac=detail&filter=true&pg=1&ext=\(ext)"
|
||||
: "\(api)?ac=detail&filter=true&pg=1&ext=\(ext)"
|
||||
listUrl = try buildURL(
|
||||
base: api,
|
||||
queryItems: [
|
||||
URLQueryItem(name: "ac", value: "detail"),
|
||||
URLQueryItem(name: "filter", value: "true"),
|
||||
URLQueryItem(name: "pg", value: "1"),
|
||||
URLQueryItem(name: "ext", value: ext)
|
||||
]
|
||||
)
|
||||
} else {
|
||||
// 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) {
|
||||
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.isHttpApi else { throw SourceError.invalidApiUrl(api) }
|
||||
|
||||
var url: String
|
||||
let url: String
|
||||
if sourceBean.type == 0 {
|
||||
// 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 {
|
||||
// Type 4: 远程接口
|
||||
url = api.contains("?")
|
||||
? "\(api)&ac=detail&filter=true&t=\(sortData.id)&pg=\(page)"
|
||||
: "\(api)?ac=detail&filter=true&t=\(sortData.id)&pg=\(page)"
|
||||
var queryItems: [URLQueryItem] = [
|
||||
URLQueryItem(name: "ac", value: "detail"),
|
||||
URLQueryItem(name: "filter", value: "true"),
|
||||
URLQueryItem(name: "t", value: sortData.id),
|
||||
URLQueryItem(name: "pg", value: String(page))
|
||||
]
|
||||
|
||||
// 附加筛选参数(base64 编码)
|
||||
if let filters = filters, !filters.isEmpty {
|
||||
if let filterData = try? JSONSerialization.data(withJSONObject: filters),
|
||||
let filterStr = String(data: filterData, encoding: .utf8) {
|
||||
let ext = Data(filterStr.utf8).base64EncodedString()
|
||||
url += "&ext=\(ext)"
|
||||
queryItems.append(URLQueryItem(name: "ext", value: ext))
|
||||
}
|
||||
} else {
|
||||
let ext = Data("{}".utf8).base64EncodedString()
|
||||
url += "&ext=\(ext)"
|
||||
queryItems.append(URLQueryItem(name: "ext", value: ext))
|
||||
}
|
||||
|
||||
// 加载 extend
|
||||
if let ext = sourceBean.ext, !ext.isEmpty {
|
||||
let extend = await resolveExtend(ext)
|
||||
if !extend.isEmpty {
|
||||
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend
|
||||
url += "&extend=\(encoded)"
|
||||
queryItems.append(URLQueryItem(name: "extend", value: extend))
|
||||
}
|
||||
}
|
||||
url = try buildURL(base: api, queryItems: queryItems)
|
||||
} else {
|
||||
// JSON 接口 (type=1)
|
||||
url = api.contains("?")
|
||||
? "\(api)&ac=videolist&t=\(sortData.id)&pg=\(page)"
|
||||
: "\(api)?ac=videolist&t=\(sortData.id)&pg=\(page)"
|
||||
var queryItems: [URLQueryItem] = [
|
||||
URLQueryItem(name: "ac", value: "videolist"),
|
||||
URLQueryItem(name: "t", value: sortData.id),
|
||||
URLQueryItem(name: "pg", value: String(page))
|
||||
]
|
||||
|
||||
// 附加筛选参数
|
||||
if let filters = 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)
|
||||
@@ -255,28 +285,39 @@ class SourceService {
|
||||
guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) }
|
||||
guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) }
|
||||
|
||||
var url: String
|
||||
let url: String
|
||||
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 {
|
||||
// Type 4: 远程接口
|
||||
url = api.contains("?")
|
||||
? "\(api)&ac=detail&ids=\(vodId)"
|
||||
: "\(api)?ac=detail&ids=\(vodId)"
|
||||
var queryItems: [URLQueryItem] = [
|
||||
URLQueryItem(name: "ac", value: "detail"),
|
||||
URLQueryItem(name: "ids", value: vodId)
|
||||
]
|
||||
|
||||
// 加载 extend
|
||||
if let ext = sourceBean.ext, !ext.isEmpty {
|
||||
let extend = await resolveExtend(ext)
|
||||
if !extend.isEmpty {
|
||||
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend
|
||||
url += "&extend=\(encoded)"
|
||||
queryItems.append(URLQueryItem(name: "extend", value: extend))
|
||||
}
|
||||
}
|
||||
url = try buildURL(base: api, queryItems: queryItems)
|
||||
} else {
|
||||
// JSON 接口 (type=1)
|
||||
url = api.contains("?")
|
||||
? "\(api)&ac=detail&ids=\(vodId)"
|
||||
: "\(api)?ac=detail&ids=\(vodId)"
|
||||
url = try buildURL(
|
||||
base: api,
|
||||
queryItems: [
|
||||
URLQueryItem(name: "ac", value: "detail"),
|
||||
URLQueryItem(name: "ids", value: vodId)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
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? {
|
||||
if type == 0 {
|
||||
return parseXMLDetail(jsonStr, sourceKey: sourceKey)
|
||||
}
|
||||
|
||||
guard let data = jsonStr.data(using: .utf8) else {
|
||||
throw SourceError.parseError("无法解析数据")
|
||||
}
|
||||
|
||||
if type == 1 || type == 4 {
|
||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let list = json["list"] as? [[String: Any]],
|
||||
let first = list.first {
|
||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let list = json["list"] as? [[String: Any]],
|
||||
let first = list.first {
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
if let itemData = try? JSONSerialization.data(withJSONObject: first),
|
||||
var video = try? decoder.decode(Movie.Video.self, from: itemData) {
|
||||
video.sourceKey = sourceKey
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
if let itemData = try? JSONSerialization.data(withJSONObject: first),
|
||||
var video = try? decoder.decode(Movie.Video.self, from: itemData) {
|
||||
video.sourceKey = sourceKey
|
||||
|
||||
let playFrom = first["vod_play_from"] as? String ?? ""
|
||||
let playUrl = first["vod_play_url"] as? String ?? ""
|
||||
|
||||
return VodInfo.from(video: video, playFrom: playFrom, playUrl: playUrl)
|
||||
}
|
||||
let playFrom = first["vod_play_from"] as? String ?? ""
|
||||
let playUrl = first["vod_play_url"] as? String ?? ""
|
||||
|
||||
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.isHttpApi else { throw SourceError.invalidApiUrl(api) }
|
||||
|
||||
let encodedKeyword = keyword.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? keyword
|
||||
|
||||
var url: String
|
||||
let url: String
|
||||
if sourceBean.type == 0 {
|
||||
url = "\(api)?wd=\(encodedKeyword)"
|
||||
url = try buildURL(
|
||||
base: api,
|
||||
queryItems: [URLQueryItem(name: "wd", value: keyword)]
|
||||
)
|
||||
} else if sourceBean.type == 4 {
|
||||
// Type 4: 远程接口
|
||||
url = api.contains("?")
|
||||
? "\(api)&wd=\(encodedKeyword)&ac=detail&quick=false"
|
||||
: "\(api)?wd=\(encodedKeyword)&ac=detail&quick=false"
|
||||
let quickValue = sourceBean.isQuickSearchEnabled ? "true" : "false"
|
||||
var queryItems: [URLQueryItem] = [
|
||||
URLQueryItem(name: "wd", value: keyword),
|
||||
URLQueryItem(name: "ac", value: "detail"),
|
||||
URLQueryItem(name: "quick", value: quickValue)
|
||||
]
|
||||
|
||||
// 加载 extend
|
||||
if let ext = sourceBean.ext, !ext.isEmpty {
|
||||
let extend = await resolveExtend(ext)
|
||||
if !extend.isEmpty {
|
||||
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend
|
||||
url += "&extend=\(encoded)"
|
||||
queryItems.append(URLQueryItem(name: "extend", value: extend))
|
||||
}
|
||||
}
|
||||
url = try buildURL(base: api, queryItems: queryItems)
|
||||
} else {
|
||||
// JSON 接口 (type=1)
|
||||
url = api.contains("?")
|
||||
? "\(api)&wd=\(encodedKeyword)"
|
||||
: "\(api)?wd=\(encodedKeyword)"
|
||||
url = try buildURL(
|
||||
base: api,
|
||||
queryItems: [URLQueryItem(name: "wd", value: keyword)]
|
||||
)
|
||||
}
|
||||
|
||||
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 解析
|
||||
|
||||
/// 解析 extend 参数(对应 Android 端 getFixUrl)
|
||||
@@ -398,6 +484,128 @@ class SourceService {
|
||||
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: "&", with: "&")
|
||||
value = value.replacingOccurrences(of: "<", with: "<")
|
||||
value = value.replacingOccurrences(of: ">", with: ">")
|
||||
value = value.replacingOccurrences(of: """, with: "\"")
|
||||
value = value.replacingOccurrences(of: "'", 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 {
|
||||
|
||||
@@ -218,6 +218,9 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
||||
@ViewBuilder let placeholder: () -> Placeholder
|
||||
|
||||
@State private var loadedImage: PlatformImage?
|
||||
@State private var loadFailed = false
|
||||
|
||||
private static var delayedRetryDelay: TimeInterval { 3.0 }
|
||||
|
||||
init(
|
||||
url: URL?,
|
||||
@@ -246,6 +249,7 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
||||
}
|
||||
.onDisappear {
|
||||
loadedImage = nil
|
||||
loadFailed = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,9 +269,17 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
||||
guard !Task.isCancelled else { return }
|
||||
ImageCache.shared.set(image, for: url)
|
||||
loadedImage = image
|
||||
loadFailed = false
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
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)
|
||||
}
|
||||
|
||||
private static let maxImageRetries = 1
|
||||
private static let imageRetryDelay: TimeInterval = 1.0
|
||||
|
||||
func load(url: URL) async throws -> PlatformImage {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
// 设置常见的浏览器 User-Agent,避免部分服务器拒绝请求
|
||||
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",
|
||||
forHTTPHeaderField: "User-Agent"
|
||||
)
|
||||
// 部分图片服务器检查 Referer
|
||||
if let host = url.host {
|
||||
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 状态码
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
!(200...299).contains(httpResponse.statusCode) {
|
||||
throw ImageLoadError.httpError(httpResponse.statusCode)
|
||||
for attempt in 0..<totalAttempts {
|
||||
do {
|
||||
try Task.checkCancellation()
|
||||
|
||||
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
|
||||
guard let image = Self.decodeImage(data: data, maxPixelSize: maxPixelSize) else {
|
||||
throw ImageLoadError.invalidData
|
||||
}
|
||||
|
||||
return image
|
||||
throw lastError
|
||||
}
|
||||
|
||||
private static func isRetryableImageError(_ error: Error, statusCode: Int) -> Bool {
|
||||
[408, 429, 500, 502, 503, 504].contains(statusCode)
|
||||
}
|
||||
|
||||
var cacheUsage: (memory: Int, disk: Int) {
|
||||
|
||||
@@ -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
|
||||
@@ -2,9 +2,13 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct PlaybackQualityOption: Identifiable, Hashable {
|
||||
/// “自动”选项固定标识。
|
||||
static let autoIdentifier = "auto"
|
||||
/// 选项唯一标识(这里直接使用播放地址或固定 auto id)。
|
||||
let id: String
|
||||
/// UI 展示名(如 1080p / 720p / 自动)。
|
||||
let name: String
|
||||
/// 对应播放地址。
|
||||
let url: String
|
||||
|
||||
var isAuto: Bool {
|
||||
@@ -19,23 +23,39 @@ struct PlaybackQualityOption: Identifiable, Hashable {
|
||||
/// 详情页 ViewModel
|
||||
@MainActor
|
||||
class DetailViewModel: ObservableObject {
|
||||
/// 详情信息主体。
|
||||
@Published var vodInfo: VodInfo?
|
||||
/// 加载状态。
|
||||
@Published var isLoading = false
|
||||
/// 错误提示。
|
||||
@Published var errorMessage: String?
|
||||
/// 当前选中线路。
|
||||
@Published var selectedFlag: String = ""
|
||||
/// 当前选中剧集索引。
|
||||
@Published var selectedEpisodeIndex: Int = 0
|
||||
/// 是否处于播放态。
|
||||
@Published var isPlaying = false
|
||||
/// 当前实际播放地址(可能是原始地址,也可能是清晰度切换后的子流地址)。
|
||||
@Published var playUrl: String?
|
||||
/// 续播起始位置(秒)。
|
||||
@Published var resumeSeconds: Double = 0
|
||||
/// 当前可选清晰度列表。
|
||||
@Published var qualityOptions: [PlaybackQualityOption] = []
|
||||
/// 当前选中的清晰度 id。
|
||||
@Published var selectedQualityId: String = PlaybackQualityOption.autoIdentifier
|
||||
/// 播放器高频回调进度,不直接绑定 UI,避免高频刷新引发性能问题。
|
||||
private var realtimeProgressSeconds: Double = 0
|
||||
|
||||
/// 数据服务与网络服务。
|
||||
private let sourceService = SourceService.shared
|
||||
private let network = NetworkManager.shared
|
||||
/// 当前清晰度列表对应的基础剧集地址。
|
||||
private var qualityBaseEpisodeURL: String = ""
|
||||
/// 清晰度解析缓存,key 为原始剧集 URL。
|
||||
private var qualityOptionCache: [String: [PlaybackQualityOption]] = [:]
|
||||
/// 清晰度解析任务,用于取消旧请求。
|
||||
private var qualityResolveTask: Task<Void, Never>?
|
||||
/// 解析令牌,防止异步结果回写到过期状态。
|
||||
private var qualityResolveToken = UUID()
|
||||
|
||||
/// 加载视频详情
|
||||
@@ -105,6 +125,7 @@ class DetailViewModel: ObservableObject {
|
||||
realtimeProgressSeconds = 0
|
||||
|
||||
if let episode = vodInfo?.currentEpisode {
|
||||
// 仅当剧集 URL 变化时重置清晰度选择。
|
||||
let shouldResetQuality = qualityBaseEpisodeURL != episode.url
|
||||
updateQualityOptions(for: episode.url, resetSelection: shouldResetQuality)
|
||||
playUrl = selectedPlayableURL(fallback: episode.url)
|
||||
@@ -144,6 +165,7 @@ class DetailViewModel: ObservableObject {
|
||||
selectedQualityId = option.id
|
||||
guard isPlaying else { return }
|
||||
|
||||
// “自动”使用基础剧集地址;其他选项使用对应变体地址。
|
||||
let targetURL = option.url.isEmpty ? qualityBaseEpisodeURL : option.url
|
||||
guard !targetURL.isEmpty, playUrl != targetURL else { return }
|
||||
|
||||
@@ -208,6 +230,7 @@ class DetailViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func selectedPlayableURL(fallback: String) -> String {
|
||||
// 若当前清晰度存在有效 URL,则优先使用;否则回退剧集原始地址。
|
||||
let selected = qualityOptions.first(where: { $0.id == selectedQualityId })?.url
|
||||
if let selected, !selected.isEmpty {
|
||||
return selected
|
||||
@@ -215,6 +238,7 @@ class DetailViewModel: ObservableObject {
|
||||
return fallback
|
||||
}
|
||||
|
||||
/// 重置清晰度解析与选择状态。
|
||||
private func resetQualityState() {
|
||||
qualityResolveTask?.cancel()
|
||||
qualityResolveTask = nil
|
||||
@@ -231,6 +255,7 @@ class DetailViewModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
// 切换剧集时先取消旧任务,避免异步回写错位。
|
||||
qualityResolveTask?.cancel()
|
||||
qualityResolveTask = nil
|
||||
|
||||
@@ -245,6 +270,7 @@ class DetailViewModel: ObservableObject {
|
||||
qualityOptions = [autoOption]
|
||||
|
||||
if let cached = qualityOptionCache[trimmedEpisodeURL] {
|
||||
// 缓存命中时直接复用,避免重复网络解析。
|
||||
qualityOptions = cached
|
||||
if resetSelection || !cached.contains(where: { $0.id == selectedQualityId }) {
|
||||
selectedQualityId = PlaybackQualityOption.autoIdentifier
|
||||
@@ -277,12 +303,14 @@ class DetailViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// 尝试从 HLS 主播放列表解析多清晰度选项。
|
||||
private func resolveQualityOptions(for episodeURL: String) async -> [PlaybackQualityOption] {
|
||||
guard let url = URL(string: episodeURL), Self.looksLikeHLSURL(url) else { return [] }
|
||||
guard let playlist = try? await network.getString(from: episodeURL) else { return [] }
|
||||
return Self.parseMasterPlaylist(playlist, masterURL: url)
|
||||
}
|
||||
|
||||
/// HLS 变体流中间模型。
|
||||
private struct HLSVariant {
|
||||
let url: String
|
||||
let name: String?
|
||||
@@ -290,6 +318,7 @@ class DetailViewModel: ObservableObject {
|
||||
let bandwidth: Int?
|
||||
}
|
||||
|
||||
/// 轻量判断 URL 是否可能是 HLS 播放列表。
|
||||
private static func looksLikeHLSURL(_ url: URL) -> Bool {
|
||||
let lowercased = url.absoluteString.lowercased()
|
||||
if lowercased.contains(".m3u8") { return true }
|
||||
@@ -297,6 +326,8 @@ class DetailViewModel: ObservableObject {
|
||||
return ext == "m3u8" || ext == "m3u"
|
||||
}
|
||||
|
||||
/// 解析 HLS 主播放列表并生成清晰度选项。
|
||||
/// 仅当解析出 2 个及以上有效变体时才返回(否则不显示清晰度切换)。
|
||||
private static func parseMasterPlaylist(_ content: String, masterURL: URL) -> [PlaybackQualityOption] {
|
||||
guard content.localizedCaseInsensitiveContains("#EXT-X-STREAM-INF") else { return [] }
|
||||
|
||||
@@ -407,6 +438,7 @@ class DetailViewModel: ObservableObject {
|
||||
return merged
|
||||
}
|
||||
|
||||
/// 解析 `EXT-X-STREAM-INF` 的属性串为键值字典。
|
||||
private static func parseAttributeMap(_ raw: String) -> [String: String] {
|
||||
var result: [String: String] = [:]
|
||||
let pairs = splitAttributes(raw)
|
||||
@@ -426,6 +458,7 @@ class DetailViewModel: ObservableObject {
|
||||
return result
|
||||
}
|
||||
|
||||
/// 按逗号分隔属性,但保留引号内逗号。
|
||||
private static func splitAttributes(_ raw: String) -> [String] {
|
||||
var parts: [String] = []
|
||||
var buffer = ""
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// 首页 ViewModel
|
||||
@MainActor
|
||||
class HomeViewModel: ObservableObject {
|
||||
/// 分类列表(包含手动注入的"推荐"分类)。
|
||||
@Published var sorts: [MovieSort.SortData] = []
|
||||
/// 当前选中的分类。
|
||||
@Published var selectedSort: MovieSort.SortData?
|
||||
/// 首页推荐内容(对应"推荐"分类)。
|
||||
@Published var homeVideos: [Movie.Video] = []
|
||||
/// 普通分类的视频列表(分页加载)。
|
||||
@Published var categoryVideos: [Movie.Video] = []
|
||||
/// 页面加载状态(分类加载与分页共用)。
|
||||
@Published var isLoading = false
|
||||
/// 当前分类的分页页码。
|
||||
@Published var currentPage = 1
|
||||
/// 是否还有下一页。
|
||||
@Published var hasMore = true
|
||||
/// 错误提示文案。
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 源数据访问服务。
|
||||
private let sourceService = SourceService.shared
|
||||
/// 标记上次加载是否因网络错误失败(用于网络恢复自动重试)。
|
||||
private var lastLoadFailedDueToNetwork = false
|
||||
private var networkRestoredCancellable: AnyCancellable?
|
||||
|
||||
init() {
|
||||
setupNetworkRestoredAutoRetry()
|
||||
}
|
||||
|
||||
/// 加载分类列表
|
||||
func loadSorts() async {
|
||||
@@ -24,25 +41,41 @@ class HomeViewModel: ObservableObject {
|
||||
do {
|
||||
let result = try await sourceService.getSort(sourceBean: source)
|
||||
|
||||
// 添加"推荐"到首位
|
||||
// 插入本地"推荐"分类,保持 UI 与 Android 版本习惯一致。
|
||||
var allSorts = [MovieSort.SortData.home()]
|
||||
allSorts.append(contentsOf: result.sorts)
|
||||
|
||||
self.sorts = allSorts
|
||||
self.homeVideos = result.homeVideos
|
||||
lastLoadFailedDueToNetwork = false
|
||||
|
||||
if selectedSort == nil {
|
||||
selectedSort = allSorts.first
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
lastLoadFailedDueToNetwork = error.isNetworkConnectionError
|
||||
}
|
||||
|
||||
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) {
|
||||
// 切分类时先重置分页状态,避免旧分类残留数据闪烁。
|
||||
selectedSort = sort
|
||||
errorMessage = nil
|
||||
categoryVideos = []
|
||||
@@ -62,6 +95,7 @@ class HomeViewModel: ObservableObject {
|
||||
private func loadCategoryVideos(page: Int, sort: MovieSort.SortData) async {
|
||||
guard sort.id != "home" else { return }
|
||||
guard let source = ApiConfig.shared.homeSourceBean else { return }
|
||||
// 防重复并发加载,避免分页错序。
|
||||
guard !isLoading else { return }
|
||||
|
||||
isLoading = true
|
||||
@@ -78,6 +112,7 @@ class HomeViewModel: ObservableObject {
|
||||
} else {
|
||||
categoryVideos.append(contentsOf: videos)
|
||||
}
|
||||
// 以"返回非空"作为是否继续分页的轻量判断。
|
||||
currentPage = page
|
||||
hasMore = !videos.isEmpty
|
||||
} catch {
|
||||
@@ -105,6 +140,7 @@ class HomeViewModel: ObservableObject {
|
||||
|
||||
/// 刷新
|
||||
func refresh() async {
|
||||
// 全量刷新时重置分页与错误态,再重新拉分类与当前分类内容。
|
||||
currentPage = 1
|
||||
hasMore = true
|
||||
categoryVideos = []
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// 直播 ViewModel
|
||||
@MainActor
|
||||
class LiveViewModel: ObservableObject {
|
||||
/// 全部频道分组。
|
||||
@Published var channelGroups: [LiveChannelGroup] = []
|
||||
/// 当前选中分组索引。
|
||||
@Published var selectedGroupIndex: Int = 0
|
||||
/// 当前选中频道索引(相对于当前分组)。
|
||||
@Published var selectedChannelIndex: Int = 0
|
||||
/// 当前播放频道。
|
||||
@Published var currentChannel: LiveChannelItem?
|
||||
/// 当前频道节目单(预留)。
|
||||
@Published var epgList: [Epginfo] = []
|
||||
/// 加载状态(预留,便于后续接入远程 EPG)。
|
||||
@Published var isLoading = false
|
||||
/// 是否显示频道列表(预留给 TV 遥控交互)。
|
||||
@Published var showChannelList = false
|
||||
|
||||
/// 订阅配置更新,支持直播频道列表实时刷新。
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
init() {
|
||||
bindLiveChannelGroups()
|
||||
}
|
||||
|
||||
/// 加载直播频道
|
||||
func loadChannels() {
|
||||
self.channelGroups = ApiConfig.shared.liveChannelGroupList
|
||||
if let firstGroup = channelGroups.first, let firstChannel = firstGroup.channels.first {
|
||||
currentChannel = firstChannel
|
||||
}
|
||||
applyChannelGroups(ApiConfig.shared.liveChannelGroupList)
|
||||
}
|
||||
|
||||
/// 选择频道分组
|
||||
@@ -67,6 +79,7 @@ class LiveViewModel: ObservableObject {
|
||||
|
||||
/// 切换线路
|
||||
func switchSource() {
|
||||
// `currentChannel` 为值类型,调用 mutating 方法会触发 @Published 重新发布。
|
||||
currentChannel?.nextSource()
|
||||
}
|
||||
|
||||
@@ -78,13 +91,69 @@ class LiveViewModel: ObservableObject {
|
||||
|
||||
/// 加载 EPG 节目单
|
||||
private func loadEPG(for channel: LiveChannelItem) {
|
||||
// EPG 加载 - 简化实现
|
||||
// 预留:后续可在此按频道名/频道 ID 请求远程 EPG。
|
||||
// 当前版本先清空,避免展示过期节目单。
|
||||
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 {
|
||||
/// 安全下标读取,越界时返回 `nil`。
|
||||
subscript(safe index: Index) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
|
||||
@@ -4,14 +4,23 @@ import SwiftUI
|
||||
/// 搜索 ViewModel
|
||||
@MainActor
|
||||
class SearchViewModel: ObservableObject {
|
||||
/// 搜索关键词输入。
|
||||
@Published var keyword: String = ""
|
||||
/// 当前结果列表。
|
||||
@Published var results: [Movie.Video] = []
|
||||
/// 搜索加载状态,用于控制进度指示器。
|
||||
@Published var isSearching = false
|
||||
/// 本地搜索历史(最近在前)。
|
||||
@Published var searchHistory: [String] = []
|
||||
/// 搜索失败或空结果提示。
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 源数据服务(负责多源并发搜索)。
|
||||
private let sourceService = SourceService.shared
|
||||
/// 搜索请求序号(用于丢弃过期异步结果)。
|
||||
private var latestSearchRequestId: UUID = UUID()
|
||||
|
||||
/// 初始化时同步加载本地历史记录,确保搜索页首次渲染即可展示。
|
||||
init() {
|
||||
loadSearchHistory()
|
||||
}
|
||||
@@ -20,15 +29,19 @@ class SearchViewModel: ObservableObject {
|
||||
func search() async {
|
||||
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let requestId = UUID()
|
||||
latestSearchRequestId = requestId
|
||||
|
||||
isSearching = true
|
||||
errorMessage = nil
|
||||
results = []
|
||||
|
||||
// 保存搜索历史
|
||||
// 搜索一旦触发就先落历史,保持行为与移动端常见搜索体验一致。
|
||||
addToHistory(trimmed)
|
||||
|
||||
// 走多源并发搜索,返回聚合后的影片列表。
|
||||
let videos = await sourceService.searchAll(keyword: trimmed)
|
||||
guard requestId == latestSearchRequestId else { return }
|
||||
self.results = videos
|
||||
|
||||
if videos.isEmpty {
|
||||
@@ -42,13 +55,18 @@ class SearchViewModel: ObservableObject {
|
||||
func searchInSource(_ source: SourceBean) async {
|
||||
let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let requestId = UUID()
|
||||
latestSearchRequestId = requestId
|
||||
|
||||
isSearching = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let videos = try await sourceService.search(sourceBean: source, keyword: trimmed)
|
||||
guard requestId == latestSearchRequestId else { return }
|
||||
self.results = videos
|
||||
} catch {
|
||||
guard requestId == latestSearchRequestId else { return }
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
@@ -57,10 +75,12 @@ class SearchViewModel: ObservableObject {
|
||||
|
||||
// MARK: - 搜索历史
|
||||
|
||||
/// 从本地读取历史。
|
||||
private func loadSearchHistory() {
|
||||
searchHistory = UserDefaults.standard.stringArray(forKey: HawkConfig.SEARCH_HISTORY) ?? []
|
||||
}
|
||||
|
||||
/// 新增历史项并去重,最多保留 20 条。
|
||||
private func addToHistory(_ keyword: String) {
|
||||
searchHistory.removeAll { $0 == keyword }
|
||||
searchHistory.insert(keyword, at: 0)
|
||||
@@ -70,11 +90,13 @@ class SearchViewModel: ObservableObject {
|
||||
UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY)
|
||||
}
|
||||
|
||||
/// 清空历史。
|
||||
func clearHistory() {
|
||||
searchHistory = []
|
||||
UserDefaults.standard.removeObject(forKey: HawkConfig.SEARCH_HISTORY)
|
||||
}
|
||||
|
||||
/// 删除单条历史。
|
||||
func removeFromHistory(_ keyword: String) {
|
||||
searchHistory.removeAll { $0 == keyword }
|
||||
UserDefaults.standard.set(searchHistory, forKey: HawkConfig.SEARCH_HISTORY)
|
||||
|
||||
@@ -4,24 +4,70 @@ import SwiftUI
|
||||
/// 设置 ViewModel
|
||||
@MainActor
|
||||
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 liveApiUrl: String = ""
|
||||
/// 配置加载中状态。
|
||||
@Published var isLoadingConfig = false
|
||||
/// 配置错误提示。
|
||||
@Published var configError: String?
|
||||
/// 配置是否加载成功(供 UI 执行后续跳转/收起流程)。
|
||||
@Published var configSuccess = false
|
||||
/// 多仓库待选状态,为 nil 表示无需弹窗。
|
||||
@Published var pendingMultiRepoSelection: PendingMultiRepoSelection?
|
||||
/// 最近输入过的 API 历史。
|
||||
@Published var apiHistory: [String] = []
|
||||
/// 点播播放器内核选择。
|
||||
@Published var vodPlayerEngine: PlayerEngine = .system
|
||||
/// 直播播放器内核选择。
|
||||
@Published var livePlayerEngine: PlayerEngine = .system
|
||||
/// 解码模式选择。
|
||||
@Published var decodeMode: VideoDecodeMode = .auto
|
||||
/// VLC 缓冲策略。
|
||||
@Published var vlcBufferMode: VLCBufferMode = .defaultMode
|
||||
/// 快进/快退步长(秒)。
|
||||
@Published var playTimeStep: Int = 10
|
||||
/// 缓存占用展示文本。
|
||||
@Published var cacheSizeString: String = "0 KB"
|
||||
|
||||
/// 快进步长候选项。
|
||||
let playTimeStepOptions: [Int] = [5, 10, 15, 30, 60]
|
||||
/// 当前构建可用播放器列表。
|
||||
let playerEngineOptions: [PlayerEngine] = PlayerEngine.availableEngines
|
||||
/// 解码模式候选。
|
||||
let decodeModeOptions: [VideoDecodeMode] = VideoDecodeMode.allCases
|
||||
/// VLC 缓冲模式候选。
|
||||
let vlcBufferModeOptions: [VLCBufferMode] = VLCBufferMode.allCases
|
||||
|
||||
/// 初始化时完成三件事:
|
||||
/// 1) 回填已保存的配置地址
|
||||
/// 2) 兼容老版本单一播放器字段到新字段
|
||||
/// 3) 回填播放/缓存相关设置
|
||||
init() {
|
||||
let defaults = UserDefaults.standard
|
||||
let savedVod = defaults.string(forKey: HawkConfig.API_URL) ?? ""
|
||||
@@ -74,10 +120,23 @@ class SettingsViewModel: ObservableObject {
|
||||
isLoadingConfig = true
|
||||
configError = nil
|
||||
configSuccess = false
|
||||
pendingMultiRepoSelection = nil
|
||||
|
||||
do {
|
||||
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)
|
||||
// 保存用户输入(live 允许空值,表示跟随点播地址)。
|
||||
UserDefaults.standard.set(trimmedVod, forKey: HawkConfig.API_URL)
|
||||
UserDefaults.standard.set(trimmedLive, forKey: HawkConfig.LIVE_API_URL)
|
||||
vodApiUrl = trimmedVod
|
||||
@@ -92,12 +151,80 @@ class SettingsViewModel: ObservableObject {
|
||||
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 历史
|
||||
|
||||
/// 读取 API 历史。
|
||||
private func loadApiHistory() {
|
||||
apiHistory = UserDefaults.standard.stringArray(forKey: "api_history") ?? []
|
||||
}
|
||||
|
||||
/// 新增历史并去重,最多保留 10 条。
|
||||
private func addToApiHistory(_ url: String) {
|
||||
apiHistory.removeAll { $0 == url }
|
||||
apiHistory.insert(url, at: 0)
|
||||
@@ -107,6 +234,7 @@ class SettingsViewModel: ObservableObject {
|
||||
UserDefaults.standard.set(apiHistory, forKey: "api_history")
|
||||
}
|
||||
|
||||
/// 删除单条 API 历史。
|
||||
func removeApiHistory(_ url: String) {
|
||||
apiHistory.removeAll { $0 == url }
|
||||
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)
|
||||
}
|
||||
|
||||
/// 统计并刷新缓存占用展示(网络缓存 + 图片缓存磁盘占用)。
|
||||
private func refreshCacheSize() {
|
||||
let sharedDisk = URLCache.shared.currentDiskUsage
|
||||
let imageDisk = ImageLoader.shared.cacheUsage.disk
|
||||
cacheSizeString = Self.formatSize(bytes: sharedDisk + imageDisk)
|
||||
}
|
||||
|
||||
/// 格式化字节大小。
|
||||
private static func formatSize(bytes: Int) -> String {
|
||||
let size = max(0, bytes)
|
||||
if size < 1024 * 1024 {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 通用空状态组件。
|
||||
/// 在收藏、历史等页面复用,统一空页面视觉风格。
|
||||
struct EmptyStateView: View {
|
||||
/// SF Symbol 图标名。
|
||||
let icon: String
|
||||
/// 主标题。
|
||||
let title: String
|
||||
/// 可选说明文字,为空时不渲染副文案区域。
|
||||
var message: String? = nil
|
||||
|
||||
var body: some View {
|
||||
@@ -23,11 +28,13 @@ struct EmptyStateView: View {
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// 标题强调当前页面状态,例如“暂无收藏”“暂无播放记录”。
|
||||
Text(title)
|
||||
.font(.title3.bold())
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.tracking(1)
|
||||
|
||||
// 副文案用于提供下一步引导,不参与核心逻辑判断。
|
||||
if let message = message {
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
|
||||
@@ -2,15 +2,23 @@ import SwiftUI
|
||||
|
||||
/// 根视图 - 对应 Android 版 HomeActivity 的 TabView 导航
|
||||
struct ContentView: View {
|
||||
/// 首次配置页点击“最近使用”时,当前要写入的输入框目标。
|
||||
private enum ApiInputTarget {
|
||||
case vod
|
||||
case live
|
||||
}
|
||||
|
||||
/// 全局状态(配置加载、分栏状态等)。
|
||||
@EnvironmentObject var appState: AppState
|
||||
/// 网络连接状态。
|
||||
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||
/// 设置页 ViewModel。根视图复用它处理首次配置与多仓库选择。
|
||||
@StateObject private var settingsVM = SettingsViewModel()
|
||||
/// 当前主标签索引。
|
||||
@State private var selectedTab = 0
|
||||
/// 预留:控制首次配置页显隐(当前逻辑由 `appState.isConfigLoaded` 驱动)。
|
||||
@State private var showSetup = false
|
||||
/// 首次配置页历史回填目标输入框。
|
||||
@State private var setupInputTarget: ApiInputTarget = .vod
|
||||
|
||||
var body: some View {
|
||||
@@ -21,6 +29,10 @@ struct ContentView: View {
|
||||
setupView
|
||||
}
|
||||
}
|
||||
.overlay(multiRepoSelectionOverlay)
|
||||
.overlay(alignment: .top) {
|
||||
networkStatusBanner
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.onAppear {
|
||||
// 自动加载已保存的配置
|
||||
@@ -28,6 +40,7 @@ struct ContentView: View {
|
||||
let savedVodUrl = defaults.string(forKey: HawkConfig.API_URL) ?? ""
|
||||
let savedLiveUrl = defaults.string(forKey: HawkConfig.LIVE_API_URL) ?? ""
|
||||
if !savedVodUrl.isEmpty {
|
||||
// 启动自动恢复配置,避免每次重启都回到首次配置页。
|
||||
Task {
|
||||
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: - 主界面
|
||||
|
||||
/// 主体导航容器:iOS 使用 TabView,macOS 使用 NavigationSplitView。
|
||||
private var mainTabView: some View {
|
||||
#if os(iOS)
|
||||
TabView(selection: $selectedTab) {
|
||||
@@ -46,7 +85,9 @@ struct ContentView: View {
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
LiveView()
|
||||
LiveView(onExit: {
|
||||
selectedTab = 0
|
||||
})
|
||||
.tabItem {
|
||||
Label("直播", systemImage: "tv.fill")
|
||||
}
|
||||
@@ -58,19 +99,16 @@ struct ContentView: View {
|
||||
}
|
||||
.tag(2)
|
||||
|
||||
FavoritesView()
|
||||
ProfileView()
|
||||
.tabItem {
|
||||
Label("收藏", systemImage: "heart.fill")
|
||||
Label("个人中心", systemImage: "person.fill")
|
||||
}
|
||||
.tag(3)
|
||||
|
||||
SettingsView()
|
||||
.tabItem {
|
||||
Label("设置", systemImage: "gearshape.fill")
|
||||
}
|
||||
.tag(4)
|
||||
}
|
||||
.tint(.orange)
|
||||
.onChange(of: selectedTab) { _, _ in
|
||||
HapticManager.shared.selection()
|
||||
}
|
||||
#else
|
||||
NavigationSplitView(columnVisibility: $appState.splitViewVisibility) {
|
||||
List(selection: $selectedTab) {
|
||||
@@ -94,9 +132,15 @@ struct ContentView: View {
|
||||
case 0: HomeView()
|
||||
case 1: LiveView()
|
||||
case 2: SearchView()
|
||||
case 3: FavoritesView()
|
||||
case 3:
|
||||
NavigationStack {
|
||||
FavoritesView()
|
||||
}
|
||||
case 4: SettingsView()
|
||||
case 5: HistoryView()
|
||||
case 5:
|
||||
NavigationStack {
|
||||
HistoryView()
|
||||
}
|
||||
default: HomeView()
|
||||
}
|
||||
}
|
||||
@@ -105,6 +149,7 @@ struct ContentView: View {
|
||||
|
||||
// MARK: - 首次配置页面
|
||||
|
||||
/// 首次启动或未加载配置时的引导页面。
|
||||
private var setupView: some View {
|
||||
ZStack {
|
||||
// 背景装饰
|
||||
@@ -254,6 +299,7 @@ struct ContentView: View {
|
||||
.clipShape(Capsule())
|
||||
.shadow(color: .red.opacity(0.4), radius: 12, x: 0, y: 6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(
|
||||
settingsVM.isLoadingConfig
|
||||
|| 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? {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string
|
||||
#else
|
||||
// macOS 下通过 NSPasteboard 读取纯文本。
|
||||
NSPasteboard.general.string(forType: .string)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
// This file has been moved to tvbox/Utils/Extensions.swift to ensure compatibility across targets.
|
||||
// 该文件已迁移至 `tvbox/Utils/Extensions.swift`。
|
||||
// 保留此占位文件仅为兼容历史引用,避免旧路径导入时报错。
|
||||
|
||||
@@ -13,6 +13,8 @@ struct DetailView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@State private var showFullScreen = false
|
||||
/// VLC 全屏退出动画期间为 true,防止内联播放器与全屏播放器同时争抢 drawable
|
||||
@State private var isFullScreenDismissing = false
|
||||
#if os(macOS)
|
||||
@State private var pendingMacWindowFullScreen = false
|
||||
#endif
|
||||
@@ -23,7 +25,7 @@ struct DetailView: View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// 播放器区域
|
||||
if !showFullScreen, viewModel.isPlaying, let url = viewModel.playUrl {
|
||||
if !showFullScreen, !isFullScreenDismissing, viewModel.isPlaying, let url = viewModel.playUrl {
|
||||
PlayerView(
|
||||
urlString: url,
|
||||
startPosition: viewModel.currentPlaybackSeconds(),
|
||||
@@ -136,7 +138,9 @@ struct DetailView: View {
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.fullScreenCover(isPresented: $showFullScreen) {
|
||||
.fullScreenCover(isPresented: $showFullScreen, onDismiss: {
|
||||
isFullScreenDismissing = false
|
||||
}) {
|
||||
if let url = viewModel.playUrl {
|
||||
FullScreenPlayerView(
|
||||
urlString: url,
|
||||
@@ -146,7 +150,11 @@ struct DetailView: View {
|
||||
canPlayNext: canPlayNextEpisode,
|
||||
onPlayNext: playNextEpisodeIfNeeded,
|
||||
systemController: sharedSystemController,
|
||||
vlcController: sharedVLCController
|
||||
vlcController: sharedVLCController,
|
||||
onCloseRequested: {
|
||||
isFullScreenDismissing = true
|
||||
showFullScreen = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -155,6 +163,35 @@ struct DetailView: View {
|
||||
|
||||
// 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
|
||||
private var videoInfoSection: some View {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
@@ -167,6 +204,7 @@ struct DetailView: View {
|
||||
.padding(15)
|
||||
.glassCard(cornerRadius: AppTheme.glassRadius)
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
private var videoPoster: some View {
|
||||
@@ -201,12 +239,14 @@ struct DetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
Spacer(minLength: 10)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
playButton
|
||||
collectButton
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,17 +2,26 @@ import SwiftUI
|
||||
|
||||
/// 剧集列表组件 - 对应 Android 版 SeriesAdapter
|
||||
struct EpisodeListView: View {
|
||||
/// 当前线路下的全部剧集。
|
||||
let episodes: [VodInfo.Episode]
|
||||
/// 外部传入的当前选中集索引(绝对索引)。
|
||||
let selectedIndex: Int
|
||||
/// 点击某一集后的回调(返回绝对索引)。
|
||||
let onSelect: (Int) -> Void
|
||||
|
||||
/// 当前分组索引(每 50 集一个分组,避免超长列表影响渲染与选择体验)。
|
||||
@State private var currentGroup = 0
|
||||
/// 每个分组展示的剧集数量。
|
||||
private let groupSize = 50
|
||||
/// 自适应列:根据可用宽度自动换行,避免集数过多时右侧被截断。
|
||||
private let gridColumns = [GridItem(.adaptive(minimum: 78), spacing: 10)]
|
||||
|
||||
/// 分组总数,至少为 1,避免空数组时出现 0 组的边界问题。
|
||||
private var groupCount: Int {
|
||||
max(1, (episodes.count + groupSize - 1) / groupSize)
|
||||
}
|
||||
|
||||
/// 当前分组对应的切片数据。
|
||||
private var currentEpisodes: [VodInfo.Episode] {
|
||||
let start = currentGroup * groupSize
|
||||
let end = min(start + groupSize, episodes.count)
|
||||
@@ -56,43 +65,40 @@ struct EpisodeListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 剧集网格
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHGrid(rows: [
|
||||
GridItem(.fixed(44)),
|
||||
GridItem(.fixed(44))
|
||||
], spacing: 10) {
|
||||
ForEach(Array(currentEpisodes.enumerated()), id: \.offset) { index, episode in
|
||||
let actualIndex = currentGroup * groupSize + index
|
||||
Button {
|
||||
onSelect(actualIndex)
|
||||
} label: {
|
||||
Text(episode.name)
|
||||
.font(.system(size: 13, weight: actualIndex == selectedIndex ? .bold : .medium))
|
||||
.foregroundColor(actualIndex == selectedIndex ? .white : .white.opacity(0.7))
|
||||
.frame(minWidth: 70)
|
||||
.frame(height: 44)
|
||||
.background(
|
||||
ZStack {
|
||||
if actualIndex == selectedIndex {
|
||||
AppTheme.accentGradient
|
||||
} else {
|
||||
Color.white.opacity(0.05)
|
||||
}
|
||||
// 剧集网格:自适应换行,避免横向裁切。
|
||||
LazyVGrid(columns: gridColumns, alignment: .leading, spacing: 10) {
|
||||
// 这里使用当前组内索引 + 组偏移,换算成全局索引以便外部状态一致。
|
||||
ForEach(Array(currentEpisodes.enumerated()), id: \.offset) { index, episode in
|
||||
let actualIndex = currentGroup * groupSize + index
|
||||
Button {
|
||||
onSelect(actualIndex)
|
||||
} label: {
|
||||
Text(episode.name)
|
||||
.font(.system(size: 13, weight: actualIndex == selectedIndex ? .bold : .medium))
|
||||
.foregroundColor(actualIndex == selectedIndex ? .white : .white.opacity(0.7))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.background(
|
||||
ZStack {
|
||||
if actualIndex == selectedIndex {
|
||||
AppTheme.accentGradient
|
||||
} else {
|
||||
Color.white.opacity(0.05)
|
||||
}
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(actualIndex == selectedIndex ? Color.clear : Color.white.opacity(0.1), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(actualIndex == selectedIndex ? Color.clear : Color.white.opacity(0.1), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
.frame(height: 100)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,59 +3,71 @@ import SwiftData
|
||||
|
||||
/// 收藏页 - 对应 Android 版 CollectActivity
|
||||
struct FavoritesView: View {
|
||||
/// 按更新时间倒序展示收藏,最近收藏/更新的内容靠前。
|
||||
@Query(sort: \VodCollect.updateTime, order: .reverse)
|
||||
private var favorites: [VodCollect]
|
||||
/// SwiftData 上下文,用于删除收藏并持久化。
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
#if os(iOS)
|
||||
/// iOS 下卡片尺寸更紧凑,适配手机竖屏。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
||||
]
|
||||
#else
|
||||
/// macOS 下卡片适度放大,提升桌面端可读性。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
|
||||
]
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if favorites.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
ForEach(favorites) { item in
|
||||
NavigationLink(value: movieVideo(from: item)) {
|
||||
favoriteCard(item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
modelContext.delete(item)
|
||||
try? modelContext.save()
|
||||
} label: {
|
||||
Label("取消收藏", systemImage: "heart.slash")
|
||||
favoritesContent
|
||||
}
|
||||
|
||||
/// 收藏内容视图(不含 NavigationStack 包裹)。
|
||||
/// iOS 下由外层 ProfileView/SettingsView 的 NavigationStack 管理导航;
|
||||
/// macOS 下由 ContentView 的 NavigationSplitView detail 区域使用独立 NavigationStack。
|
||||
private var favoritesContent: some View {
|
||||
Group {
|
||||
if favorites.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
// 每个收藏项都可直接跳转详情,并支持右键取消收藏。
|
||||
ForEach(favorites) { item in
|
||||
NavigationLink(destination: DetailView(video: movieVideo(from: item))) {
|
||||
favoriteCard(item)
|
||||
}
|
||||
.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 {
|
||||
EmptyStateView(
|
||||
icon: "heart.text.square",
|
||||
@@ -65,6 +77,8 @@ struct FavoritesView: View {
|
||||
.padding(40)
|
||||
}
|
||||
|
||||
/// 收藏卡片。
|
||||
/// 只展示海报与标题,保持网格信息密度一致。
|
||||
private func favoriteCard(_ item: VodCollect) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
CachedAsyncImage(url: URL.posterURL(from: item.vodPic)) { image in
|
||||
@@ -83,6 +97,7 @@ struct FavoritesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将收藏记录映射成详情页可识别的视频对象。
|
||||
private func movieVideo(from item: VodCollect) -> Movie.Video {
|
||||
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
|
||||
}
|
||||
|
||||
@@ -3,73 +3,86 @@ import SwiftData
|
||||
|
||||
/// 历史记录页 - 对应 Android 版 HistoryActivity
|
||||
struct HistoryView: View {
|
||||
/// 按最近播放时间倒序展示历史记录。
|
||||
@Query(sort: \VodRecord.updateTime, order: .reverse)
|
||||
private var records: [VodRecord]
|
||||
/// SwiftData 上下文,用于删除单条记录或清空历史。
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
#if os(iOS)
|
||||
/// iOS 网格配置。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
||||
]
|
||||
#else
|
||||
/// macOS 网格配置。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
|
||||
]
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if records.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
ForEach(records) { item in
|
||||
NavigationLink(value: movieVideo(from: item)) {
|
||||
recordCard(item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
modelContext.delete(item)
|
||||
try? modelContext.save()
|
||||
} label: {
|
||||
Label("删除记录", systemImage: "trash")
|
||||
historyContent
|
||||
}
|
||||
|
||||
/// 历史记录内容视图(不含 NavigationStack 包裹)。
|
||||
/// iOS 下由外层 ProfileView/SettingsView 的 NavigationStack 管理导航;
|
||||
/// macOS 下由 ContentView 的 NavigationSplitView detail 区域使用独立 NavigationStack。
|
||||
private var historyContent: some View {
|
||||
Group {
|
||||
if records.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
// 记录卡片支持跳转详情与右键删除。
|
||||
ForEach(records) { item in
|
||||
NavigationLink(destination: DetailView(video: movieVideo(from: item))) {
|
||||
recordCard(item)
|
||||
}
|
||||
.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("历史记录")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
if !records.isEmpty {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
Button {
|
||||
Task {
|
||||
CacheStore.shared.clearHistory(context: modelContext)
|
||||
}
|
||||
} label: {
|
||||
Text("清空")
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
.background(Color(red: 0.08, green: 0.08, blue: 0.1))
|
||||
.navigationTitle("历史记录")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
if !records.isEmpty {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
// 清空历史使用统一缓存服务,确保行为与其他入口一致。
|
||||
Button {
|
||||
Task {
|
||||
CacheStore.shared.clearHistory(context: modelContext)
|
||||
}
|
||||
} label: {
|
||||
Text("清空")
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: Movie.Video.self) { video in
|
||||
DetailView(video: video)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 无历史时的占位视图。
|
||||
private var emptyState: some View {
|
||||
EmptyStateView(
|
||||
icon: "clock.arrow.circlepath",
|
||||
@@ -79,6 +92,8 @@ struct HistoryView: View {
|
||||
.padding(40)
|
||||
}
|
||||
|
||||
/// 历史卡片。
|
||||
/// 除海报和标题外,额外显示播放进度与更新时间,便于快速续播。
|
||||
private func recordCard(_ item: VodRecord) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
@@ -115,6 +130,7 @@ struct HistoryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将历史记录转换为详情页的入参模型。
|
||||
private func movieVideo(from item: VodRecord) -> Movie.Video {
|
||||
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
|
||||
}
|
||||
|
||||
+55
-147
@@ -5,12 +5,11 @@ struct HomeView: View {
|
||||
@StateObject private var viewModel = HomeViewModel()
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State private var categoryScrollAnchorId: String?
|
||||
@State private var categoryDragTranslation: CGFloat = 0
|
||||
|
||||
// 网格布局
|
||||
#if os(iOS)
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
||||
GridItem(.adaptive(minimum: 100, maximum: 140), spacing: 10)
|
||||
]
|
||||
#else
|
||||
private let columns = [
|
||||
@@ -42,11 +41,11 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 顶部栏
|
||||
// MARK: - 顶部栏(源选择器)
|
||||
|
||||
private var headerBar: some View {
|
||||
HStack(spacing: 15) {
|
||||
// 应用名(可切换源)
|
||||
HStack(spacing: 12) {
|
||||
// 源切换按钮
|
||||
Menu {
|
||||
ForEach(ApiConfig.shared.sourceBeanList.filter { $0.isSupportedInSwift }) { source in
|
||||
Button {
|
||||
@@ -63,132 +62,77 @@ struct HomeView: View {
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "sparkles")
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.orange)
|
||||
Text(ApiConfig.shared.homeSourceBean?.name ?? "TVBox")
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.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)
|
||||
.fixedSize()
|
||||
|
||||
Spacer()
|
||||
|
||||
// 日期时间
|
||||
HomeClockView()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 15)
|
||||
.padding(.bottom, 10)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
// MARK: - 分类标签栏
|
||||
|
||||
private var categoryTabBar: some View {
|
||||
ScrollViewReader { proxy in
|
||||
HStack(spacing: 8) {
|
||||
categoryMoveButton(
|
||||
systemName: "chevron.left",
|
||||
enabled: canMoveCategory(by: -1)
|
||||
) {
|
||||
moveCategoryTabs(by: -3, proxy: proxy)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(viewModel.sorts) { sort in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
viewModel.selectSort(sort)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.id(sort.id)
|
||||
categoryScrollAnchorId = 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)
|
||||
}
|
||||
|
||||
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
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
private func categoryIndex(for id: String?) -> Int? {
|
||||
@@ -216,17 +160,6 @@ struct HomeView: View {
|
||||
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) {
|
||||
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: - 内容区
|
||||
|
||||
private var contentArea: some View {
|
||||
@@ -309,7 +225,11 @@ struct HomeView: View {
|
||||
NavigationLink(value: video) {
|
||||
VodCardView(video: video)
|
||||
}
|
||||
#if os(iOS)
|
||||
.buttonStyle(VodCardPressStyle())
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
.onAppear {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
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 {
|
||||
/// 卡片对应的视频数据。
|
||||
let video: Movie.Video
|
||||
/// 悬停状态(主要用于 macOS 悬停放大动效)。
|
||||
@State private var isHovered = false
|
||||
|
||||
var body: some View {
|
||||
@@ -44,6 +59,7 @@ struct VodCardView: View {
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
// 悬停缩放只增强视觉反馈,不影响点击命中区域。
|
||||
.scaleEffect(isHovered ? 1.05 : 1.0)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isHovered)
|
||||
.onHover { hovering in
|
||||
@@ -65,8 +81,13 @@ struct VodCardView: View {
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
#if os(iOS)
|
||||
.frame(minWidth: 44, minHeight: 44)
|
||||
.contentShape(Rectangle())
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 海报占位图,避免图片加载失败导致卡片高度塌陷。
|
||||
private var placeholderImage: some View {
|
||||
RoundedRectangle(cornerRadius: AppTheme.cardRadius)
|
||||
.fill(Color.white.opacity(0.05))
|
||||
|
||||
@@ -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
|
||||
@@ -6,24 +6,49 @@ import AppKit
|
||||
|
||||
/// 直播页 - 对应 Android 版 LivePlayActivity
|
||||
struct LiveView: View {
|
||||
/// 退出直播回调(iOS 用于返回首页并显示 TabBar)。
|
||||
var onExit: (() -> Void)? = nil
|
||||
/// 直播频道与选中状态管理。
|
||||
@StateObject private var viewModel = LiveViewModel()
|
||||
/// 全局应用状态(用于 macOS 全屏时调整分栏布局)。
|
||||
@EnvironmentObject var appState: AppState
|
||||
/// 系统播放器实例(仅在选择系统内核时使用)。
|
||||
@State private var avPlayer: AVPlayer?
|
||||
/// 直播播放器内核配置(新字段)。
|
||||
@AppStorage(HawkConfig.PLAY_TYPE_LIVE) private var livePlayTypeRaw = -1
|
||||
/// 兼容旧版本单播放器字段。
|
||||
@AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue
|
||||
/// 是否展示左侧频道抽屉。
|
||||
@State private var showChannelDrawer = true
|
||||
#if os(iOS)
|
||||
/// iOS 全屏频道覆盖层是否显示。
|
||||
@State private var showChannelOverlay = false
|
||||
#endif
|
||||
/// 当前窗口是否处于全屏。
|
||||
@State private var isWindowFullScreen = false
|
||||
/// 底部频道信息卡最大宽度。
|
||||
private let currentChannelInfoMaxWidth: CGFloat = 600
|
||||
/// AVPlayer 状态观察者。
|
||||
@State private var itemStatusObserver: NSKeyValueObservation?
|
||||
/// 播放失败通知观察者。
|
||||
@State private var playbackFailedObserver: NSObjectProtocol?
|
||||
/// 播放卡顿通知观察者。
|
||||
@State private var playbackStalledObserver: NSObjectProtocol?
|
||||
/// 当前频道已失败的线路索引,用于自动切线去重。
|
||||
@State private var failedSourceIndices: Set<Int> = []
|
||||
/// 当前跟踪的频道 ID(频道切换时重置失败状态)。
|
||||
@State private var trackedChannelId: String = ""
|
||||
/// 底部频道信息是否显示。
|
||||
@State private var showCurrentChannelInfo = true
|
||||
/// 自动隐藏频道信息的定时器。
|
||||
@State private var channelInfoTimer: Timer?
|
||||
/// 频道信息自动隐藏延迟(秒)。
|
||||
private let channelInfoAutoHideDelay: TimeInterval = 3.0
|
||||
/// 用户交互令牌,递增后可通知 VLC 子视图重置自动隐藏逻辑。
|
||||
@State private var vlcInteractionToken = 0
|
||||
|
||||
/// 当前实际生效的播放器内核。
|
||||
/// 优先读取直播专用字段,再回退老字段,最后使用默认值。
|
||||
private var selectedEngine: PlayerEngine {
|
||||
let defaults = UserDefaults.standard
|
||||
let rawValue: Int
|
||||
@@ -78,8 +103,12 @@ struct LiveView: View {
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(true)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
#endif
|
||||
.onAppear {
|
||||
// 首次进入时加载频道并展示频道信息卡。
|
||||
viewModel.loadChannels()
|
||||
wakeUpCurrentChannelInfo()
|
||||
}
|
||||
@@ -92,6 +121,7 @@ struct LiveView: View {
|
||||
wakeUpCurrentChannelInfo()
|
||||
}
|
||||
.onChange(of: viewModel.currentChannel?.id) { _, _ in
|
||||
// 切台后清空失败线路记录,避免复用上个频道的失败状态。
|
||||
resetFailureTracking(for: viewModel.currentChannel)
|
||||
wakeUpCurrentChannelInfo()
|
||||
}
|
||||
@@ -149,6 +179,7 @@ struct LiveView: View {
|
||||
|
||||
private var overlayUI: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
#if os(macOS)
|
||||
if showChannelDrawer {
|
||||
Color.black.opacity(0.22)
|
||||
.ignoresSafeArea()
|
||||
@@ -159,9 +190,13 @@ struct LiveView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
#if os(iOS)
|
||||
liveBackButton
|
||||
#endif
|
||||
channelDrawerToggleButton
|
||||
Spacer()
|
||||
}
|
||||
@@ -177,19 +212,44 @@ struct LiveView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
if showChannelDrawer {
|
||||
channelDrawer
|
||||
.padding(.leading, 12)
|
||||
.padding(.vertical, 20)
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: showCurrentChannelInfo)
|
||||
#if os(macOS)
|
||||
.animation(.easeInOut(duration: 0.2), value: showChannelDrawer)
|
||||
#endif
|
||||
.simultaneousGesture(
|
||||
TapGesture().onEnded {
|
||||
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)
|
||||
.onContinuousHover { phase in
|
||||
switch phase {
|
||||
@@ -245,20 +305,58 @@ struct LiveView: View {
|
||||
.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 {
|
||||
Button {
|
||||
#if os(iOS)
|
||||
HapticManager.shared.lightImpact()
|
||||
showChannelOverlay = true
|
||||
#else
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showChannelDrawer.toggle()
|
||||
}
|
||||
#endif
|
||||
} label: {
|
||||
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")
|
||||
Text(showChannelDrawer ? "收起菜单" : "频道菜单")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
#endif
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
#if os(iOS)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
#else
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
#endif
|
||||
.background(Color.black.opacity(0.35))
|
||||
.clipShape(Capsule())
|
||||
.overlay(
|
||||
@@ -273,6 +371,61 @@ struct LiveView: 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) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 10) {
|
||||
@@ -292,7 +445,6 @@ struct LiveView: View {
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
// 切换线路按钮
|
||||
if channel.sourceNum > 1 {
|
||||
Button {
|
||||
wakeUpCurrentChannelInfo()
|
||||
@@ -313,7 +465,6 @@ struct LiveView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
Button {
|
||||
wakeUpCurrentChannelInfo()
|
||||
toggleWindowFullScreen()
|
||||
@@ -334,7 +485,6 @@ struct LiveView: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
@@ -342,6 +492,7 @@ struct LiveView: View {
|
||||
.frame(maxWidth: currentChannelInfoMaxWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(20)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func wakeUpCurrentChannelInfo() {
|
||||
@@ -364,6 +515,7 @@ struct LiveView: View {
|
||||
}
|
||||
|
||||
private func reportUserActivity() {
|
||||
// 任意交互都刷新显示时间,并触发 VLC 子层同步交互状态。
|
||||
wakeUpCurrentChannelInfo()
|
||||
vlcInteractionToken &+= 1
|
||||
}
|
||||
@@ -447,7 +599,12 @@ struct LiveView: View {
|
||||
|
||||
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.canUseNetworkResourcesForLiveStreamingWhilePaused = false
|
||||
@@ -459,6 +616,7 @@ struct LiveView: View {
|
||||
}
|
||||
|
||||
private func cleanupPlayer() {
|
||||
// 先移除观察者再释放播放器,避免悬空回调。
|
||||
if let observer = playbackFailedObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
playbackFailedObserver = nil
|
||||
@@ -475,6 +633,7 @@ struct LiveView: View {
|
||||
}
|
||||
|
||||
private func observePlaybackFailure(for item: AVPlayerItem) {
|
||||
// KVO 监听 item 状态失败。
|
||||
itemStatusObserver = item.observe(\.status, options: [.new]) { observedItem, _ in
|
||||
if observedItem.status == .failed {
|
||||
DispatchQueue.main.async {
|
||||
@@ -483,6 +642,7 @@ struct LiveView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 播放到结尾失败回调。
|
||||
playbackFailedObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemFailedToPlayToEndTime,
|
||||
object: item,
|
||||
@@ -491,6 +651,7 @@ struct LiveView: View {
|
||||
handlePlaybackFailure(trigger: "item_failed")
|
||||
}
|
||||
|
||||
// 播放卡顿回调。
|
||||
playbackStalledObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemPlaybackStalled,
|
||||
object: item,
|
||||
@@ -526,6 +687,7 @@ struct LiveView: View {
|
||||
private func switchToNextAvailableSource(totalSources: Int) -> Bool {
|
||||
guard failedSourceIndices.count < totalSources else { return false }
|
||||
|
||||
// 最多轮询 `totalSources` 次,找到一条尚未失败的线路即返回。
|
||||
for _ in 0..<totalSources {
|
||||
viewModel.switchSource()
|
||||
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
|
||||
@@ -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
|
||||
@@ -26,7 +26,7 @@ private struct MacOSPlayerView: NSViewRepresentable {
|
||||
|
||||
func makeNSView(context: Context) -> AVPlayerView {
|
||||
let view = AVPlayerView()
|
||||
view.controlsStyle = .none // 禁用系统默认控制栏
|
||||
view.controlsStyle = .none
|
||||
view.showsFullScreenToggleButton = false
|
||||
view.videoGravity = .resizeAspect
|
||||
view.player = player
|
||||
@@ -61,10 +61,17 @@ final class SystemPlayerSessionController: ObservableObject {
|
||||
}
|
||||
|
||||
func stop() {
|
||||
let stoppingPlayer = player
|
||||
player?.pause()
|
||||
player?.replaceCurrentItem(with: nil)
|
||||
player = 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 的点播播放器实现
|
||||
struct AVPlayerContentView: View {
|
||||
private static let supportedPlaybackRates: [Float] = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
|
||||
let urlString: String
|
||||
var startPosition: Double = 0
|
||||
var onProgressChanged: ((Double, Double?) -> Void)? = nil
|
||||
@@ -152,6 +160,7 @@ struct AVPlayerContentView: View {
|
||||
var canPlayNext: Bool = false
|
||||
var onPlayNext: (() -> Void)? = nil
|
||||
var sharedController: SystemPlayerSessionController? = nil
|
||||
@AppStorage(HawkConfig.PLAY_SPEED) private var savedPlaybackRate = 1.0
|
||||
@State private var player: AVPlayer?
|
||||
@State private var playbackEndObserver: NSObjectProtocol?
|
||||
@State private var timeObserverToken: Any?
|
||||
@@ -163,6 +172,7 @@ struct AVPlayerContentView: View {
|
||||
@State private var volume: Double = 1.0
|
||||
@State private var rate: Float = 1.0
|
||||
@State private var isPreparing = true
|
||||
@State private var playbackError: String?
|
||||
@State private var showControls = true
|
||||
@State private var controlsTimer: Timer?
|
||||
@State private var osdIcon: String?
|
||||
@@ -172,11 +182,16 @@ struct AVPlayerContentView: View {
|
||||
@State private var draggingSeconds: Double = 0
|
||||
@State private var playerObservers: [NSKeyValueObservation] = []
|
||||
|
||||
@State private var videoZoomScale: CGFloat = 1.0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Group {
|
||||
if let player = player {
|
||||
PlatformVideoPlayer(player: player)
|
||||
#if os(iOS)
|
||||
.scaleEffect(videoZoomScale)
|
||||
#endif
|
||||
} else {
|
||||
ZStack {
|
||||
Color.black
|
||||
@@ -185,17 +200,36 @@ struct AVPlayerContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#if !os(iOS)
|
||||
.onTapGesture(count: 2) {
|
||||
onToggleFullScreen?()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
togglePlayPauseWithOSD()
|
||||
}
|
||||
#endif
|
||||
|
||||
if isPreparing {
|
||||
ProgressView()
|
||||
.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 {
|
||||
Image(systemName: osdIcon)
|
||||
@@ -208,6 +242,22 @@ struct AVPlayerContentView: View {
|
||||
.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) {
|
||||
GeometryReader { proxy in
|
||||
if player != nil {
|
||||
@@ -238,10 +288,12 @@ struct AVPlayerContentView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
syncRateFromSettings()
|
||||
setupPlayer()
|
||||
wakeUpControls()
|
||||
}
|
||||
.onChange(of: urlString) { _, _ in
|
||||
syncRateFromSettings()
|
||||
setupPlayer()
|
||||
wakeUpControls()
|
||||
}
|
||||
@@ -253,14 +305,37 @@ struct AVPlayerContentView: View {
|
||||
}
|
||||
|
||||
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 preferredRate = normalizedSavedPlaybackRate
|
||||
rate = preferredRate
|
||||
playbackError = nil
|
||||
|
||||
if let sharedController,
|
||||
sharedController.mediaURLString == targetURLString,
|
||||
let sharedPlayer = sharedController.player {
|
||||
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
|
||||
applyPreferredPlaybackRate(to: sharedPlayer)
|
||||
bindPlayerObservers(for: sharedPlayer)
|
||||
reportProgress(for: sharedPlayer)
|
||||
return
|
||||
@@ -269,8 +344,13 @@ struct AVPlayerContentView: View {
|
||||
// 清理旧播放器
|
||||
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)
|
||||
newPlayer.defaultRate = preferredRate
|
||||
if let sharedController {
|
||||
sharedController.setPlayer(newPlayer, urlString: targetURLString)
|
||||
}
|
||||
@@ -280,6 +360,20 @@ struct AVPlayerContentView: View {
|
||||
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) {
|
||||
playerObservers = [
|
||||
player.observe(\.timeControlStatus, options: [.new]) { p, _ in
|
||||
@@ -295,16 +389,40 @@ struct AVPlayerContentView: View {
|
||||
DispatchQueue.main.async { volume = vol }
|
||||
},
|
||||
player.observe(\.rate, options: [.new]) { p, _ in
|
||||
let r = p.rate
|
||||
DispatchQueue.main.async { rate = r }
|
||||
let currentRate = p.rate
|
||||
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)
|
||||
observePlaybackEnd(for: player)
|
||||
isPlaying = player.timeControlStatus == .playing
|
||||
isPreparing = player.reasonForWaitingToPlay != nil
|
||||
volume = Double(player.volume)
|
||||
rate = player.rate
|
||||
rate = normalizedSavedPlaybackRate
|
||||
}
|
||||
|
||||
private func detachPlayerObservers() {
|
||||
@@ -331,12 +449,15 @@ struct AVPlayerContentView: View {
|
||||
}
|
||||
|
||||
currentPlayer.pause()
|
||||
currentPlayer.replaceCurrentItem(with: nil)
|
||||
if sharedController?.player === currentPlayer {
|
||||
sharedController?.player = nil
|
||||
sharedController?.mediaURLString = nil
|
||||
}
|
||||
player = nil
|
||||
// replaceCurrentItem(with: nil) 在播放网络流时会阻塞主线程,移到后台
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
currentPlayer.replaceCurrentItem(with: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func startPlayback(for player: AVPlayer) {
|
||||
@@ -346,17 +467,17 @@ struct AVPlayerContentView: View {
|
||||
let seekTime = CMTime(seconds: target, preferredTimescale: 600)
|
||||
player.seek(to: seekTime, toleranceBefore: .zero, toleranceAfter: .zero) { _ in
|
||||
reportProgress(for: player)
|
||||
player.play()
|
||||
playAtPreferredRate(player)
|
||||
}
|
||||
} else {
|
||||
player.play()
|
||||
playAtPreferredRate(player)
|
||||
}
|
||||
}
|
||||
|
||||
private func togglePlayPause() {
|
||||
guard let player = player else { return }
|
||||
if player.rate == 0 {
|
||||
player.play()
|
||||
playAtPreferredRate(player)
|
||||
} else {
|
||||
player.pause()
|
||||
}
|
||||
@@ -436,8 +557,129 @@ struct AVPlayerContentView: 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) {
|
||||
Text(currentTime.durationString)
|
||||
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||||
@@ -473,9 +715,7 @@ struct AVPlayerContentView: View {
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
// 第二行:控制按钮
|
||||
HStack(spacing: 0) {
|
||||
// 左侧区:倍速
|
||||
HStack(spacing: 16) {
|
||||
playbackRateMenu
|
||||
}
|
||||
@@ -483,7 +723,6 @@ struct AVPlayerContentView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// 中间区:主控 (这里集成了您原本右下角的快进快退和下一集按钮)
|
||||
HStack(spacing: 24) {
|
||||
Button {
|
||||
wakeUpControls()
|
||||
@@ -538,7 +777,6 @@ struct AVPlayerContentView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// 右侧区:音量和全屏
|
||||
HStack(spacing: 14) {
|
||||
HStack(spacing: 6) {
|
||||
Button {
|
||||
@@ -580,28 +818,44 @@ struct AVPlayerContentView: View {
|
||||
}
|
||||
.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(.vertical, 10)
|
||||
.foregroundColor(.white)
|
||||
.glassCard(cornerRadius: 18)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 6)
|
||||
.frame(width: containerWidth * 0.7)
|
||||
.frame(width: controlWidth)
|
||||
#endif
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
|
||||
private var playbackRateMenu: some View {
|
||||
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 {
|
||||
wakeUpControls()
|
||||
player?.rate = Float(r)
|
||||
setPlaybackRate(r)
|
||||
showOSD(icon: "speedometer")
|
||||
} label: {
|
||||
HStack {
|
||||
Text("\(String(format: "%.1f", r))x")
|
||||
if Float(r) == rate {
|
||||
if r == rate {
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
@@ -630,6 +884,46 @@ struct AVPlayerContentView: View {
|
||||
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) {
|
||||
guard let player = player else { return }
|
||||
let time = CMTime(seconds: seconds, preferredTimescale: 600)
|
||||
@@ -651,7 +945,7 @@ struct AVPlayerContentView: View {
|
||||
player.seek(to: CMTime(seconds: target, preferredTimescale: 600)) { _ in
|
||||
reportProgress(for: player)
|
||||
if wasPlaying {
|
||||
player.play()
|
||||
playAtPreferredRate(player)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,17 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
}()
|
||||
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 isPlaying = false
|
||||
@Published var currentTimeSeconds: Double = 0
|
||||
@@ -107,8 +117,18 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
#else
|
||||
persistentDrawableView.backgroundColor = .black
|
||||
#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(
|
||||
@@ -167,7 +187,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
"network-caching": cacheConfig.network,
|
||||
"live-caching": cacheConfig.live,
|
||||
"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 {
|
||||
mediaOptions["avcodec-hurry-up"] = 0
|
||||
@@ -204,8 +225,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
stopProgressTimer()
|
||||
resetPlaybackRecoveryState()
|
||||
cancelScheduledRebinds()
|
||||
stopMediaPlayer()
|
||||
mediaPlayer.media = nil
|
||||
onProgressChanged = nil
|
||||
onPlaybackEnded = nil
|
||||
onPlaybackFailed = nil
|
||||
@@ -216,6 +235,17 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
currentMediaIsLive = false
|
||||
currentMediaDecodeMode = .auto
|
||||
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() {
|
||||
@@ -358,9 +388,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
#endif
|
||||
|
||||
private func refreshDrawableBinding() {
|
||||
// 强制重绑视频输出,规避 macOS 切全屏后偶发“有声音无画面”
|
||||
#if os(macOS)
|
||||
// macOS: 强制重绑视频输出,规避切全屏后偶发“有声音无画面”
|
||||
mediaPlayer.drawable = nil
|
||||
mediaPlayer.drawable = persistentDrawableView
|
||||
#else
|
||||
// iOS: 直接赋值,不置空 drawable,避免触发 state change 导致 SwiftUI dismiss 动画中断
|
||||
mediaPlayer.drawable = persistentDrawableView
|
||||
#endif
|
||||
lastDrawableRebindAt = Date()
|
||||
}
|
||||
|
||||
@@ -371,7 +406,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
stopAsync(playerPointer)
|
||||
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? {
|
||||
@@ -825,12 +865,14 @@ struct VLCVodPlayerView: View {
|
||||
ZStack {
|
||||
VLCDrawableView(controller: controller)
|
||||
.background(Color.black)
|
||||
#if !os(iOS)
|
||||
.onTapGesture(count: 2) {
|
||||
onToggleFullScreen?()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
togglePlaybackWithOSD()
|
||||
}
|
||||
#endif
|
||||
|
||||
if controller.isPreparing {
|
||||
ProgressView()
|
||||
@@ -848,10 +890,24 @@ struct VLCVodPlayerView: View {
|
||||
.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) {
|
||||
GeometryReader { proxy in
|
||||
playbackControls(containerWidth: proxy.size.width)
|
||||
#if os(macOS)
|
||||
.padding(12)
|
||||
#endif
|
||||
.opacity(showControls ? 1.0 : 0.0)
|
||||
.animation(.easeInOut(duration: 0.3), value: showControls)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
@@ -937,7 +993,10 @@ struct VLCVodPlayerView: View {
|
||||
}
|
||||
|
||||
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)
|
||||
draggingSeconds = targetStartPosition
|
||||
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 {
|
||||
let saved = UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TIME_STEP)
|
||||
return Double(saved > 0 ? saved : 10)
|
||||
@@ -976,8 +1048,128 @@ struct VLCVodPlayerView: 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) {
|
||||
Text(currentDisplaySeconds.durationString)
|
||||
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||||
@@ -1013,9 +1205,7 @@ struct VLCVodPlayerView: View {
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
// 第二行:控制按钮
|
||||
HStack(spacing: 0) {
|
||||
// 左侧区:倍速
|
||||
HStack(spacing: 16) {
|
||||
playbackRateMenu
|
||||
}
|
||||
@@ -1023,7 +1213,6 @@ struct VLCVodPlayerView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// 中间区:主控
|
||||
HStack(spacing: 24) {
|
||||
Button {
|
||||
wakeUpControls()
|
||||
@@ -1078,7 +1267,6 @@ struct VLCVodPlayerView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// 右侧区:音量和全屏
|
||||
HStack(spacing: 14) {
|
||||
HStack(spacing: 6) {
|
||||
Button {
|
||||
@@ -1120,14 +1308,30 @@ struct VLCVodPlayerView: View {
|
||||
}
|
||||
.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(.vertical, 10)
|
||||
.foregroundColor(.white)
|
||||
.glassCard(cornerRadius: 18)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 6)
|
||||
.frame(width: containerWidth * 0.7)
|
||||
.frame(width: controlWidth)
|
||||
#endif
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
|
||||
@@ -1197,17 +1401,21 @@ struct VLCLivePlayerView: View {
|
||||
@State private var osdIcon: String?
|
||||
@State private var osdOpacity: Double = 0
|
||||
@State private var osdTimer: Timer?
|
||||
@State private var showControls = true
|
||||
@State private var controlsTimer: Timer?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VLCDrawableView(controller: controller)
|
||||
.background(Color.black)
|
||||
#if !os(iOS)
|
||||
.onTapGesture(count: 2) {
|
||||
onToggleFullScreen?()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
togglePlaybackWithOSD()
|
||||
}
|
||||
#endif
|
||||
|
||||
if controller.isPreparing {
|
||||
ProgressView()
|
||||
@@ -1225,6 +1433,23 @@ struct VLCLivePlayerView: View {
|
||||
.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 {
|
||||
KeyboardShortcutCaptureView(
|
||||
onLeft: { },
|
||||
@@ -1248,13 +1473,58 @@ struct VLCLivePlayerView: View {
|
||||
}
|
||||
.onAppear {
|
||||
startPlayback()
|
||||
wakeUpControls()
|
||||
}
|
||||
.onChange(of: urlString) { _, _ in
|
||||
startPlayback()
|
||||
wakeUpControls()
|
||||
}
|
||||
.onChange(of: activityToken) { _, _ in
|
||||
wakeUpControls()
|
||||
}
|
||||
.onDisappear {
|
||||
controller.stop()
|
||||
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() {
|
||||
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?()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -2,13 +2,16 @@ import SwiftUI
|
||||
|
||||
/// 搜索页 - 对应 Android 版 SearchActivity
|
||||
struct SearchView: View {
|
||||
/// 搜索状态与结果管理。
|
||||
@StateObject private var viewModel = SearchViewModel()
|
||||
|
||||
#if os(iOS)
|
||||
/// iOS 卡片网格参数。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
||||
]
|
||||
#else
|
||||
/// macOS 卡片网格参数。
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
|
||||
]
|
||||
@@ -29,6 +32,7 @@ struct SearchView: View {
|
||||
} else if !viewModel.results.isEmpty {
|
||||
searchResults
|
||||
} else if viewModel.keyword.isEmpty {
|
||||
// 输入为空时显示历史;输入非空但无结果时显示提示文案。
|
||||
searchHistorySection
|
||||
} else if let error = viewModel.errorMessage {
|
||||
Spacer()
|
||||
@@ -53,6 +57,7 @@ struct SearchView: View {
|
||||
|
||||
// MARK: - 搜索栏
|
||||
|
||||
/// 顶部搜索输入区。
|
||||
private var searchBar: some View {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
@@ -114,6 +119,7 @@ struct SearchView: View {
|
||||
|
||||
// MARK: - 搜索结果
|
||||
|
||||
/// 搜索结果网格。
|
||||
private var searchResults: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
@@ -121,7 +127,11 @@ struct SearchView: View {
|
||||
NavigationLink(value: video) {
|
||||
VodCardView(video: video)
|
||||
}
|
||||
#if os(iOS)
|
||||
.buttonStyle(VodCardPressStyle())
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
@@ -134,6 +144,7 @@ struct SearchView: View {
|
||||
|
||||
// MARK: - 搜索历史
|
||||
|
||||
/// 搜索历史区域,支持复用历史关键词与一键清空。
|
||||
private var searchHistorySection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if !viewModel.searchHistory.isEmpty {
|
||||
@@ -183,13 +194,16 @@ struct SearchView: View {
|
||||
|
||||
/// 流式布局
|
||||
struct FlowLayout: Layout {
|
||||
/// 子项间距。
|
||||
var spacing: CGFloat = 8
|
||||
|
||||
/// 计算整体尺寸。
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let result = arrangement(proposal: proposal, subviews: subviews)
|
||||
return result.size
|
||||
}
|
||||
|
||||
/// 按计算结果放置子视图。
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let result = arrangement(proposal: ProposedViewSize(width: bounds.width, height: bounds.height), subviews: subviews)
|
||||
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]) {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var positions: [CGPoint] = []
|
||||
|
||||
@@ -335,11 +335,37 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(multiRepoSelectionOverlay)
|
||||
#if os(iOS)
|
||||
.presentationDetents([.medium, .large])
|
||||
#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> {
|
||||
switch editingApiType {
|
||||
case .vod:
|
||||
|
||||
@@ -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
@@ -1,10 +1,18 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import Combine
|
||||
|
||||
/// 应用入口。
|
||||
/// 负责初始化 SwiftData 容器,并将全局状态 `AppState` 注入到根视图。
|
||||
@main
|
||||
struct tvboxApp: App {
|
||||
/// 全局运行时状态(配置加载状态、当前源、分栏布局状态等)。
|
||||
@StateObject private var appState = AppState()
|
||||
/// 网络状态监控。
|
||||
@StateObject private var networkMonitor = NetworkMonitor.shared
|
||||
|
||||
/// 全局共享的 SwiftData 容器。
|
||||
/// 这里显式声明 Schema,确保收藏/历史/缓存三类数据使用同一持久化存储。
|
||||
var sharedModelContainer: ModelContainer = {
|
||||
let schema = Schema([
|
||||
VodCollect.self,
|
||||
@@ -19,10 +27,12 @@ struct tvboxApp: App {
|
||||
}
|
||||
}()
|
||||
|
||||
/// 应用窗口与根视图。
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(appState)
|
||||
.environmentObject(networkMonitor)
|
||||
}
|
||||
.modelContainer(sharedModelContainer)
|
||||
#if os(macOS)
|
||||
@@ -31,40 +41,90 @@ struct tvboxApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用级状态容器。
|
||||
/// 统一管理配置加载与页面共享状态,避免在各页面重复拉取配置。
|
||||
@MainActor
|
||||
class AppState: ObservableObject {
|
||||
/// 解析后的配置单例,提供给所有页面与 ViewModel 使用。
|
||||
@Published var apiConfig = ApiConfig.shared
|
||||
/// 配置是否已经成功加载。控制 `ContentView` 显示主界面或首次配置页。
|
||||
@Published var isConfigLoaded = false
|
||||
/// 当前首页选中的视频源 key(用于跨页面同步)。
|
||||
@Published var currentSourceKey: String = ""
|
||||
/// 配置加载错误信息,供 UI 展示。
|
||||
@Published var configLoadError: String?
|
||||
/// 是否正在重试加载配置。
|
||||
@Published var isRetryingConfig = false
|
||||
|
||||
#if os(macOS)
|
||||
/// macOS 三栏布局可见性(侧栏/内容/详情)。
|
||||
@Published var splitViewVisibility: NavigationSplitViewVisibility = .all
|
||||
/// 进入播放器全屏前的分栏状态快照,用于退出全屏后恢复。
|
||||
private var splitViewVisibilityBeforePlayerFullScreen: NavigationSplitViewVisibility?
|
||||
#endif
|
||||
|
||||
/// 上次尝试加载的配置地址(用于自动重试)。
|
||||
private var lastVodUrl: String = ""
|
||||
private var lastLiveUrl: String = ""
|
||||
private var networkRestoredCancellable: AnyCancellable?
|
||||
|
||||
init() {
|
||||
setupNetworkRestoredAutoRetry()
|
||||
}
|
||||
|
||||
/// 仅提供点播地址时的快捷加载入口(直播地址默认与点播一致)。
|
||||
func loadConfig(url: String) async {
|
||||
await loadConfig(vodUrl: url, liveUrl: nil)
|
||||
}
|
||||
|
||||
/// 加载点播与直播配置。
|
||||
/// - Parameters:
|
||||
/// - vodUrl: 点播配置地址
|
||||
/// - liveUrl: 直播配置地址;为空时自动回退到点播地址
|
||||
func loadConfig(vodUrl: String, liveUrl: String?) async {
|
||||
let trimmedVod = vodUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedLive = (liveUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedVod.isEmpty else { return }
|
||||
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
|
||||
|
||||
lastVodUrl = trimmedVod
|
||||
lastLiveUrl = resolvedLive
|
||||
configLoadError = nil
|
||||
|
||||
do {
|
||||
try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive)
|
||||
applyLoadedConfigState()
|
||||
} catch {
|
||||
print("Failed to load config: \(error)")
|
||||
if !(error is CancellationError) {
|
||||
configLoadError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 将"配置已加载"的统一状态写回全局。
|
||||
/// 该方法会在设置页和启动自动加载两个入口中复用。
|
||||
func applyLoadedConfigState() {
|
||||
isConfigLoaded = true
|
||||
configLoadError = nil
|
||||
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)
|
||||
/// 进入播放器全屏时隐藏侧栏,减少播放器可视区域干扰。
|
||||
func enterPlayerFullScreen() {
|
||||
if splitViewVisibilityBeforePlayerFullScreen == nil {
|
||||
splitViewVisibilityBeforePlayerFullScreen = splitViewVisibility
|
||||
@@ -72,6 +132,7 @@ class AppState: ObservableObject {
|
||||
splitViewVisibility = .detailOnly
|
||||
}
|
||||
|
||||
/// 退出播放器全屏时恢复之前的分栏状态。
|
||||
func exitPlayerFullScreen() {
|
||||
guard let previous = splitViewVisibilityBeforePlayerFullScreen else { return }
|
||||
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
|
||||
Reference in New Issue
Block a user