fix iOS
This commit is contained in:
+4
-1
@@ -11,4 +11,7 @@ TVBox-*.dmg
|
|||||||
*.py
|
*.py
|
||||||
/IOS-key
|
/IOS-key
|
||||||
/TVBox.ipa
|
/TVBox.ipa
|
||||||
/ExportOptions.plist
|
/ExportOptions.plist
|
||||||
|
.kiro
|
||||||
|
package_ios.sh
|
||||||
|
image_cache
|
||||||
|
|||||||
+3
-6
@@ -9,12 +9,9 @@
|
|||||||
<key>stripSwiftSymbols</key>
|
<key>stripSwiftSymbols</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>teamID</key>
|
<key>teamID</key>
|
||||||
<string>ZDV8SQG3XZ</string>
|
<string>ZDV8SQG3XZ</string>
|
||||||
<key>provisioningProfiles</key>
|
<key>signingCertificate</key>
|
||||||
<dict>
|
<string>Apple Distribution</string>
|
||||||
<key>xyz.appinstall.nov.carbon.lam</key>
|
|
||||||
<string>CarbonLAM_Dist_Jan262026</string>
|
|
||||||
</dict>
|
|
||||||
<key>thinning</key>
|
<key>thinning</key>
|
||||||
<string><none></string>
|
<string><none></string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
+64
-3
@@ -8,7 +8,7 @@ options:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_VERSION: "5.9"
|
SWIFT_VERSION: "5.9"
|
||||||
DEVELOPMENT_TEAM: ""
|
DEVELOPMENT_TEAM: WP3M36TK8M
|
||||||
CODE_SIGN_STYLE: Automatic
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
@@ -30,7 +30,7 @@ targets:
|
|||||||
- Info.plist
|
- Info.plist
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.tvbox.app
|
PRODUCT_BUNDLE_IDENTIFIER: xyz.appinstall.nov.carbon.lam
|
||||||
INFOPLIST_FILE: tvbox/Info.plist
|
INFOPLIST_FILE: tvbox/Info.plist
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
@@ -52,6 +52,47 @@ targets:
|
|||||||
Release:
|
Release:
|
||||||
SWIFT_OPTIMIZATION_LEVEL: "-O"
|
SWIFT_OPTIMIZATION_LEVEL: "-O"
|
||||||
|
|
||||||
|
tvbox-Tests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: iOS
|
||||||
|
deploymentTarget: "17.0"
|
||||||
|
sources:
|
||||||
|
- path: tvbox-Tests
|
||||||
|
- path: tvboxTests
|
||||||
|
dependencies:
|
||||||
|
- target: tvbox
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.tvbox.app.tests
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/TVBox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TVBox"
|
||||||
|
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||||
|
configs:
|
||||||
|
Debug:
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL: "-Onone"
|
||||||
|
Release:
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL: "-O"
|
||||||
|
|
||||||
|
tvboxTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: iOS
|
||||||
|
deploymentTarget: "17.0"
|
||||||
|
dependencies:
|
||||||
|
- target: tvbox
|
||||||
|
sources:
|
||||||
|
- path: tvboxTests
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.tvbox.app.tests
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/TVBox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TVBox"
|
||||||
|
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||||
|
configs:
|
||||||
|
Debug:
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL: "-Onone"
|
||||||
|
Release:
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL: "-O"
|
||||||
|
|
||||||
tvbox-macOS:
|
tvbox-macOS:
|
||||||
type: application
|
type: application
|
||||||
platform: macOS
|
platform: macOS
|
||||||
@@ -75,7 +116,27 @@ targets:
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||||
SWIFT_EMIT_LOC_STRINGS: YES
|
SWIFT_EMIT_LOC_STRINGS: YES
|
||||||
CODE_SIGN_IDENTITY: "-"
|
CODE_SIGN_IDENTITY: "-"
|
||||||
CODE_SIGN_ENTITLEMENTS: ""
|
CODE_SIGN_ENTITLEMENTS: tvbox/tvbox-macOS.entitlements
|
||||||
|
configs:
|
||||||
|
Debug:
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL: "-Onone"
|
||||||
|
Release:
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL: "-O"
|
||||||
|
|
||||||
|
tvboxTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: iOS
|
||||||
|
deploymentTarget: "17.0"
|
||||||
|
dependencies:
|
||||||
|
- target: tvbox
|
||||||
|
sources:
|
||||||
|
- path: tvboxTests
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.tvbox.app.tests
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/TVBox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TVBox"
|
||||||
|
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||||
configs:
|
configs:
|
||||||
Debug:
|
Debug:
|
||||||
SWIFT_OPTIMIZATION_LEVEL: "-Onone"
|
SWIFT_OPTIMIZATION_LEVEL: "-Onone"
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+282
-12
@@ -13,12 +13,15 @@
|
|||||||
058BC2FBB08D016A6FCF4C13 /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CD8B07440DBB1EF2807795 /* FavoritesView.swift */; };
|
058BC2FBB08D016A6FCF4C13 /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CD8B07440DBB1EF2807795 /* FavoritesView.swift */; };
|
||||||
073BA70E29280BDF914166CF /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD980DF9D6ABDD040AC805 /* SearchView.swift */; };
|
073BA70E29280BDF914166CF /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD980DF9D6ABDD040AC805 /* SearchView.swift */; };
|
||||||
0B78C3CA1D93FA29531ABD0B /* Movie.swift in Sources */ = {isa = PBXBuildFile; fileRef = D791457269BE952C496ECE55 /* Movie.swift */; };
|
0B78C3CA1D93FA29531ABD0B /* Movie.swift in Sources */ = {isa = PBXBuildFile; fileRef = D791457269BE952C496ECE55 /* Movie.swift */; };
|
||||||
|
0CE56FEF7BEDF2D9AF2DD1AD /* PlayerGestureLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7491D05031A04661718FF52 /* PlayerGestureLayer.swift */; };
|
||||||
|
0D5200B31179039E1BB61DE5 /* HapticManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF32540F2107786AF4ECD571 /* HapticManager.swift */; };
|
||||||
0E9ABBA046E84A91B5DC3BBB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F4341B27BB1A860CB183467 /* ContentView.swift */; };
|
0E9ABBA046E84A91B5DC3BBB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F4341B27BB1A860CB183467 /* ContentView.swift */; };
|
||||||
100ED189186C10410BE683C5 /* PlayerEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */; };
|
100ED189186C10410BE683C5 /* PlayerEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */; };
|
||||||
12314BE9C595577FEE1839BB /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016DC7547EE0DE345E510288 /* Extensions.swift */; };
|
12314BE9C595577FEE1839BB /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016DC7547EE0DE345E510288 /* Extensions.swift */; };
|
||||||
16D0E98EA5DF105764396919 /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE08247136FF788C621DA20 /* DesignSystem.swift */; };
|
16D0E98EA5DF105764396919 /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE08247136FF788C621DA20 /* DesignSystem.swift */; };
|
||||||
17BAC049400FFF5487ECD23B /* SourceBean.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617855BC3AE2B293C71A0DA4 /* SourceBean.swift */; };
|
17BAC049400FFF5487ECD23B /* SourceBean.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617855BC3AE2B293C71A0DA4 /* SourceBean.swift */; };
|
||||||
19291FACD401CDE0E6E2963E /* LiveViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21306B53D5CA8BB88593A55D /* LiveViewModel.swift */; };
|
19291FACD401CDE0E6E2963E /* LiveViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21306B53D5CA8BB88593A55D /* LiveViewModel.swift */; };
|
||||||
|
19FCB4D2A58F2CA96183E67A /* PlayerGestureLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7491D05031A04661718FF52 /* PlayerGestureLayer.swift */; };
|
||||||
1D0C0D4DD4E22EC319637BDF /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */; };
|
1D0C0D4DD4E22EC319637BDF /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */; };
|
||||||
205509C747774AFBAB4F9645 /* VodCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F905D1244D5DE13DBCEE170F /* VodCardView.swift */; };
|
205509C747774AFBAB4F9645 /* VodCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F905D1244D5DE13DBCEE170F /* VodCardView.swift */; };
|
||||||
211847D0D4F382678F5EDE96 /* VLCKitSPM in Frameworks */ = {isa = PBXBuildFile; productRef = A8E4ADD9D85C0FCB9811382F /* VLCKitSPM */; };
|
211847D0D4F382678F5EDE96 /* VLCKitSPM in Frameworks */ = {isa = PBXBuildFile; productRef = A8E4ADD9D85C0FCB9811382F /* VLCKitSPM */; };
|
||||||
@@ -28,6 +31,7 @@
|
|||||||
2C85CE605FC864EB479F3CCC /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE08247136FF788C621DA20 /* DesignSystem.swift */; };
|
2C85CE605FC864EB479F3CCC /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE08247136FF788C621DA20 /* DesignSystem.swift */; };
|
||||||
2F841DB8CE3BFB7C4F391B52 /* PlayerEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */; };
|
2F841DB8CE3BFB7C4F391B52 /* PlayerEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */; };
|
||||||
32340EF75714EF664DC9512D /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */; };
|
32340EF75714EF664DC9512D /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */; };
|
||||||
|
329F390FCD9530CE2B19B397 /* HapticManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF32540F2107786AF4ECD571 /* HapticManager.swift */; };
|
||||||
3E399383D92192CDA8840FEB /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6B7E1747D5E2C29B54066 /* AppConfig.swift */; };
|
3E399383D92192CDA8840FEB /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6B7E1747D5E2C29B54066 /* AppConfig.swift */; };
|
||||||
3F69F139D892A91CA7DA6C73 /* ApiConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */; };
|
3F69F139D892A91CA7DA6C73 /* ApiConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */; };
|
||||||
45D7A96C98872769AE01C131 /* tvboxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D163D4F6A45E9ACA696071 /* tvboxApp.swift */; };
|
45D7A96C98872769AE01C131 /* tvboxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D163D4F6A45E9ACA696071 /* tvboxApp.swift */; };
|
||||||
@@ -40,6 +44,7 @@
|
|||||||
5931E2182CBA71FD467BC61B /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1006DD8209F63DC3AC3C718 /* NetworkManager.swift */; };
|
5931E2182CBA71FD467BC61B /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1006DD8209F63DC3AC3C718 /* NetworkManager.swift */; };
|
||||||
59DB9B1D12BAAA2F654579FE /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD980DF9D6ABDD040AC805 /* SearchView.swift */; };
|
59DB9B1D12BAAA2F654579FE /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD980DF9D6ABDD040AC805 /* SearchView.swift */; };
|
||||||
5D208E481C6F97783B92E78C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */; };
|
5D208E481C6F97783B92E78C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */; };
|
||||||
|
5E87760B3FDD9FCA2A532221 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81C1B0D20EE3EFB6B58418B /* ProfileView.swift */; };
|
||||||
5EB3F1571D44835F105ED6E9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90F26F870D1B587C1292A189 /* HomeView.swift */; };
|
5EB3F1571D44835F105ED6E9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90F26F870D1B587C1292A189 /* HomeView.swift */; };
|
||||||
5FE9E153523D77AB24B16A7E /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016DC7547EE0DE345E510288 /* Extensions.swift */; };
|
5FE9E153523D77AB24B16A7E /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016DC7547EE0DE345E510288 /* Extensions.swift */; };
|
||||||
633ECC16BBEA9AB61841F8A3 /* ApiConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */; };
|
633ECC16BBEA9AB61841F8A3 /* ApiConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F94E23C5085A1C8ACB4856 /* ApiConfig.swift */; };
|
||||||
@@ -53,53 +58,92 @@
|
|||||||
7B76864A9BC631F1A86E8104 /* MovieSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0212ABEA698460948737CABE /* MovieSort.swift */; };
|
7B76864A9BC631F1A86E8104 /* MovieSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0212ABEA698460948737CABE /* MovieSort.swift */; };
|
||||||
7C95C692E1A80145DED49DD3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3EE159FC0B6328166F56CE36 /* Assets.xcassets */; };
|
7C95C692E1A80145DED49DD3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3EE159FC0B6328166F56CE36 /* Assets.xcassets */; };
|
||||||
7CC2D33B7B438A89B03990EC /* LiveModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE83E7D21400ACFE3BC6ED38 /* LiveModels.swift */; };
|
7CC2D33B7B438A89B03990EC /* LiveModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE83E7D21400ACFE3BC6ED38 /* LiveModels.swift */; };
|
||||||
|
7D0F71AA151C3F8CE8EB962B /* LivePlayerGestureLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A12D4549628C3FFBA736748 /* LivePlayerGestureLayer.swift */; };
|
||||||
80886C0A593DE429B8A62C4C /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7F257F6B68F93D58E8C032 /* SettingsViewModel.swift */; };
|
80886C0A593DE429B8A62C4C /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7F257F6B68F93D58E8C032 /* SettingsViewModel.swift */; };
|
||||||
83A7D5C99EF218624DBBCAA2 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */; };
|
83A7D5C99EF218624DBBCAA2 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */; };
|
||||||
85094ADB7683C758B5F5F835 /* VLCPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */; };
|
85094ADB7683C758B5F5F835 /* VLCPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */; };
|
||||||
|
9231BB5D5B5D6E7172B46775 /* ChannelOverlayGroupWidthPropertyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FAB47D447BF6A9FC1A80BE9 /* ChannelOverlayGroupWidthPropertyTests.swift */; };
|
||||||
960791F95D922E104CD855F8 /* VLCKitSPM in Frameworks */ = {isa = PBXBuildFile; productRef = 1C6BDE11E85522F286D15861 /* VLCKitSPM */; };
|
960791F95D922E104CD855F8 /* VLCKitSPM in Frameworks */ = {isa = PBXBuildFile; productRef = 1C6BDE11E85522F286D15861 /* VLCKitSPM */; };
|
||||||
969EE3F9ACF8E1A3026CC37F /* DetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8B3DC7E0B9C01A294C47FC /* DetailViewModel.swift */; };
|
969EE3F9ACF8E1A3026CC37F /* DetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8B3DC7E0B9C01A294C47FC /* DetailViewModel.swift */; };
|
||||||
97B3904616111CE93232423E /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90F26F870D1B587C1292A189 /* HomeView.swift */; };
|
97B3904616111CE93232423E /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90F26F870D1B587C1292A189 /* HomeView.swift */; };
|
||||||
|
9995EE797BCD482A8D371F26 /* PlayerGestureLayerZoomClampingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5767F0D984BBEFBB9101110 /* PlayerGestureLayerZoomClampingTests.swift */; };
|
||||||
9B53D7CD9BD54DE0834D7FC7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3EE159FC0B6328166F56CE36 /* Assets.xcassets */; };
|
9B53D7CD9BD54DE0834D7FC7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3EE159FC0B6328166F56CE36 /* Assets.xcassets */; };
|
||||||
9FCA2C5CC08A9369EA53B130 /* LiveViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21306B53D5CA8BB88593A55D /* LiveViewModel.swift */; };
|
9FCA2C5CC08A9369EA53B130 /* LiveViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21306B53D5CA8BB88593A55D /* LiveViewModel.swift */; };
|
||||||
B06E33645C9D8322D7474962 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE52456C613016065CF59F9C /* HistoryView.swift */; };
|
B06E33645C9D8322D7474962 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE52456C613016065CF59F9C /* HistoryView.swift */; };
|
||||||
B271873FE89F02840EAC6975 /* VLCPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */; };
|
B271873FE89F02840EAC6975 /* VLCPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */; };
|
||||||
|
B42C3B637B6082ECD044DBFE /* ChannelOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B2E6B616F6AE034C7CC242 /* ChannelOverlayView.swift */; };
|
||||||
B86C3ACE754B5F52B70588A9 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7752B731B0A015EB4B85BFB1 /* NetworkMonitor.swift */; };
|
B86C3ACE754B5F52B70588A9 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7752B731B0A015EB4B85BFB1 /* NetworkMonitor.swift */; };
|
||||||
BB051B00E5F63DCFF9BC013A /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */; };
|
BB051B00E5F63DCFF9BC013A /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */; };
|
||||||
BB36351815C53AC6EACB475A /* VodInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA608E781D094B187D3034FD /* VodInfo.swift */; };
|
BB36351815C53AC6EACB475A /* VodInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA608E781D094B187D3034FD /* VodInfo.swift */; };
|
||||||
BD7E2108F8C884E1DD6EC1B9 /* tvboxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D163D4F6A45E9ACA696071 /* tvboxApp.swift */; };
|
BD7E2108F8C884E1DD6EC1B9 /* tvboxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D163D4F6A45E9ACA696071 /* tvboxApp.swift */; };
|
||||||
|
C04126B16A89AF84E7EAC441 /* PlayerGestureLayerZoneClassificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EDFB004DA6642926C611BC9 /* PlayerGestureLayerZoneClassificationTests.swift */; };
|
||||||
C13E02E9A6451F57D21590A3 /* SourceBean.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617855BC3AE2B293C71A0DA4 /* SourceBean.swift */; };
|
C13E02E9A6451F57D21590A3 /* SourceBean.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617855BC3AE2B293C71A0DA4 /* SourceBean.swift */; };
|
||||||
|
CF28A416A2BCEF2E37892899 /* ChannelOverlayGroupWidthPropertyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FAB47D447BF6A9FC1A80BE9 /* ChannelOverlayGroupWidthPropertyTests.swift */; };
|
||||||
|
CFF1FF7DF205B2E3CFBF9DD0 /* PlayerGestureLayerZoomClampingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5767F0D984BBEFBB9101110 /* PlayerGestureLayerZoomClampingTests.swift */; };
|
||||||
|
D3B09E23F0DFA6298CCE6B40 /* PlayerGestureLayerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9777D260D16B089DA2DACB22 /* PlayerGestureLayerTests.swift */; };
|
||||||
D6F4B32786BF79CBF6AB77EA /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6B7E1747D5E2C29B54066 /* AppConfig.swift */; };
|
D6F4B32786BF79CBF6AB77EA /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6B7E1747D5E2C29B54066 /* AppConfig.swift */; };
|
||||||
D8412DB044659CA4507DE219 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE52456C613016065CF59F9C /* HistoryView.swift */; };
|
D8412DB044659CA4507DE219 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE52456C613016065CF59F9C /* HistoryView.swift */; };
|
||||||
DB893501C654D6CC2D71D198 /* CacheStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD32454522C32D876921A30 /* CacheStore.swift */; };
|
DB893501C654D6CC2D71D198 /* CacheStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD32454522C32D876921A30 /* CacheStore.swift */; };
|
||||||
E983FD4D6902C9F359528F2E /* LiveModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE83E7D21400ACFE3BC6ED38 /* LiveModels.swift */; };
|
E983FD4D6902C9F359528F2E /* LiveModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE83E7D21400ACFE3BC6ED38 /* LiveModels.swift */; };
|
||||||
|
E9DE1232C207925E93343E15 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81C1B0D20EE3EFB6B58418B /* ProfileView.swift */; };
|
||||||
|
ED9018DE19A38BEF616B71A3 /* LivePlayerGestureLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A12D4549628C3FFBA736748 /* LivePlayerGestureLayer.swift */; };
|
||||||
F01490300EAFDF38F8BD5D46 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7752B731B0A015EB4B85BFB1 /* NetworkMonitor.swift */; };
|
F01490300EAFDF38F8BD5D46 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7752B731B0A015EB4B85BFB1 /* NetworkMonitor.swift */; };
|
||||||
|
F19D2281D427FEE5B1349769 /* ChannelOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B2E6B616F6AE034C7CC242 /* ChannelOverlayView.swift */; };
|
||||||
|
F351B1ECC80D0C2A15DE2BEC /* PlayerGestureLayerPropertyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269FD665F80615609817E428 /* PlayerGestureLayerPropertyTests.swift */; };
|
||||||
F49BBFD0CCDE27C63FD97180 /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CD8B07440DBB1EF2807795 /* FavoritesView.swift */; };
|
F49BBFD0CCDE27C63FD97180 /* FavoritesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CD8B07440DBB1EF2807795 /* FavoritesView.swift */; };
|
||||||
F6E863F58C52916EAB766B60 /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */; };
|
F6E863F58C52916EAB766B60 /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */; };
|
||||||
F911F241653C76EABDA844A9 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */; };
|
F911F241653C76EABDA844A9 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */; };
|
||||||
FA5621E49A1014D014A5906E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10969CAA236B75C35062BCE /* SettingsView.swift */; };
|
FA5621E49A1014D014A5906E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10969CAA236B75C35062BCE /* SettingsView.swift */; };
|
||||||
|
FFA89F351987A1217DBC56B2 /* PlayerGestureLayerPropertyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269FD665F80615609817E428 /* PlayerGestureLayerPropertyTests.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
7239DE519CCBFDEB377CA655 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 4FDAB104B1C6110A770CC389 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 11562C2D9CA08B82105BC7B6;
|
||||||
|
remoteInfo = tvbox;
|
||||||
|
};
|
||||||
|
A86A49D753626DA6D45004CE /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 4FDAB104B1C6110A770CC389 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 11562C2D9CA08B82105BC7B6;
|
||||||
|
remoteInfo = tvbox;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
016DC7547EE0DE345E510288 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; };
|
016DC7547EE0DE345E510288 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; };
|
||||||
0212ABEA698460948737CABE /* MovieSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieSort.swift; sourceTree = "<group>"; };
|
0212ABEA698460948737CABE /* MovieSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieSort.swift; sourceTree = "<group>"; };
|
||||||
06CD8B07440DBB1EF2807795 /* FavoritesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesView.swift; sourceTree = "<group>"; };
|
06CD8B07440DBB1EF2807795 /* FavoritesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesView.swift; sourceTree = "<group>"; };
|
||||||
|
09FEA232F8084173AAC649D4 /* tvboxTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = tvboxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
0A7F257F6B68F93D58E8C032 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
|
0A7F257F6B68F93D58E8C032 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
|
||||||
0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = "<group>"; };
|
0C0217E00DE1B92A89B4D7C3 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = "<group>"; };
|
||||||
131429F267F6A418C282831A /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = "<group>"; };
|
131429F267F6A418C282831A /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = "<group>"; };
|
||||||
21306B53D5CA8BB88593A55D /* LiveViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveViewModel.swift; sourceTree = "<group>"; };
|
21306B53D5CA8BB88593A55D /* LiveViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
269FD665F80615609817E428 /* PlayerGestureLayerPropertyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerGestureLayerPropertyTests.swift; sourceTree = "<group>"; };
|
||||||
2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
2C92A5E4E28B59BF75D39706 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||||
3EE159FC0B6328166F56CE36 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
3EE159FC0B6328166F56CE36 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
54D163D4F6A45E9ACA696071 /* tvboxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvboxApp.swift; sourceTree = "<group>"; };
|
54D163D4F6A45E9ACA696071 /* tvboxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvboxApp.swift; sourceTree = "<group>"; };
|
||||||
|
5EDFB004DA6642926C611BC9 /* PlayerGestureLayerZoneClassificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerGestureLayerZoneClassificationTests.swift; sourceTree = "<group>"; };
|
||||||
617855BC3AE2B293C71A0DA4 /* SourceBean.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceBean.swift; sourceTree = "<group>"; };
|
617855BC3AE2B293C71A0DA4 /* SourceBean.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceBean.swift; sourceTree = "<group>"; };
|
||||||
6D8B3DC7E0B9C01A294C47FC /* DetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailViewModel.swift; sourceTree = "<group>"; };
|
6D8B3DC7E0B9C01A294C47FC /* DetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailViewModel.swift; sourceTree = "<group>"; };
|
||||||
6EE08247136FF788C621DA20 /* DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignSystem.swift; sourceTree = "<group>"; };
|
6EE08247136FF788C621DA20 /* DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignSystem.swift; sourceTree = "<group>"; };
|
||||||
6F4341B27BB1A860CB183467 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
6F4341B27BB1A860CB183467 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "tvbox-macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "tvbox-macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
74B2E6B616F6AE034C7CC242 /* ChannelOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelOverlayView.swift; sourceTree = "<group>"; };
|
||||||
7752B731B0A015EB4B85BFB1 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
|
7752B731B0A015EB4B85BFB1 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
|
||||||
|
7A12D4549628C3FFBA736748 /* LivePlayerGestureLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivePlayerGestureLayer.swift; sourceTree = "<group>"; };
|
||||||
|
7FAB47D447BF6A9FC1A80BE9 /* ChannelOverlayGroupWidthPropertyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelOverlayGroupWidthPropertyTests.swift; sourceTree = "<group>"; };
|
||||||
8AAD980DF9D6ABDD040AC805 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
8AAD980DF9D6ABDD040AC805 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||||
8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateView.swift; sourceTree = "<group>"; };
|
8E7FB1EF8D1D9B109BFE7981 /* EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateView.swift; sourceTree = "<group>"; };
|
||||||
90F26F870D1B587C1292A189 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
90F26F870D1B587C1292A189 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
||||||
|
9777D260D16B089DA2DACB22 /* PlayerGestureLayerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerGestureLayerTests.swift; sourceTree = "<group>"; };
|
||||||
|
98A5ADBA89C07920FE7DE302 /* tvbox-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "tvbox-macOS.entitlements"; sourceTree = "<group>"; };
|
||||||
A10969CAA236B75C35062BCE /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
A10969CAA236B75C35062BCE /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
A5767F0D984BBEFBB9101110 /* PlayerGestureLayerZoomClampingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerGestureLayerZoomClampingTests.swift; sourceTree = "<group>"; };
|
||||||
AA608E781D094B187D3034FD /* VodInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VodInfo.swift; sourceTree = "<group>"; };
|
AA608E781D094B187D3034FD /* VodInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VodInfo.swift; sourceTree = "<group>"; };
|
||||||
B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerEngine.swift; sourceTree = "<group>"; };
|
B2BF545AAFFFFD0B6F7B33E8 /* PlayerEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerEngine.swift; sourceTree = "<group>"; };
|
||||||
B3D8D6F9D894DBE0594084F9 /* LiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveView.swift; sourceTree = "<group>"; };
|
B3D8D6F9D894DBE0594084F9 /* LiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveView.swift; sourceTree = "<group>"; };
|
||||||
@@ -115,6 +159,10 @@
|
|||||||
DE52456C613016065CF59F9C /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
DE52456C613016065CF59F9C /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||||
DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerView.swift; sourceTree = "<group>"; };
|
DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerView.swift; sourceTree = "<group>"; };
|
||||||
E5234A75D14EAE9141633FCA /* tvbox.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = tvbox.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
E5234A75D14EAE9141633FCA /* tvbox.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = tvbox.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
E64444D4CBE0502858CBD7E4 /* tvbox-Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "tvbox-Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
E7491D05031A04661718FF52 /* PlayerGestureLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerGestureLayer.swift; sourceTree = "<group>"; };
|
||||||
|
E81C1B0D20EE3EFB6B58418B /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
|
||||||
|
EF32540F2107786AF4ECD571 /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = "<group>"; };
|
||||||
F905D1244D5DE13DBCEE170F /* VodCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VodCardView.swift; sourceTree = "<group>"; };
|
F905D1244D5DE13DBCEE170F /* VodCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VodCardView.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@@ -142,10 +190,20 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
016DC7547EE0DE345E510288 /* Extensions.swift */,
|
016DC7547EE0DE345E510288 /* Extensions.swift */,
|
||||||
|
EF32540F2107786AF4ECD571 /* HapticManager.swift */,
|
||||||
);
|
);
|
||||||
path = Utils;
|
path = Utils;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
0F55DB9D84FD47BF11F99DE5 /* tvbox-Tests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9777D260D16B089DA2DACB22 /* PlayerGestureLayerTests.swift */,
|
||||||
|
5EDFB004DA6642926C611BC9 /* PlayerGestureLayerZoneClassificationTests.swift */,
|
||||||
|
);
|
||||||
|
path = "tvbox-Tests";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
22CB65DE627DB94747387944 /* Services */ = {
|
22CB65DE627DB94747387944 /* Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -160,6 +218,8 @@
|
|||||||
2990EE415D2440DE11757F52 /* Player */ = {
|
2990EE415D2440DE11757F52 /* Player */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
7A12D4549628C3FFBA736748 /* LivePlayerGestureLayer.swift */,
|
||||||
|
E7491D05031A04661718FF52 /* PlayerGestureLayer.swift */,
|
||||||
BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */,
|
BB87D0D74C7CB27B06EBBC4A /* PlayerView.swift */,
|
||||||
DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */,
|
DEDBFF884A656042E3EF74AA /* VLCPlayerView.swift */,
|
||||||
);
|
);
|
||||||
@@ -174,6 +234,24 @@
|
|||||||
path = Search;
|
path = Search;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
2CDB960D512B40AF659C0811 /* tvboxTests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7FAB47D447BF6A9FC1A80BE9 /* ChannelOverlayGroupWidthPropertyTests.swift */,
|
||||||
|
269FD665F80615609817E428 /* PlayerGestureLayerPropertyTests.swift */,
|
||||||
|
A5767F0D984BBEFBB9101110 /* PlayerGestureLayerZoomClampingTests.swift */,
|
||||||
|
);
|
||||||
|
path = tvboxTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
2D13F96D465DAF4A57E7F402 /* Profile */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E81C1B0D20EE3EFB6B58418B /* ProfileView.swift */,
|
||||||
|
);
|
||||||
|
path = Profile;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
2E56FD5A2A716ED24B655259 /* Models */ = {
|
2E56FD5A2A716ED24B655259 /* Models */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -206,6 +284,7 @@
|
|||||||
FD7DFF54835B377CB9BF5A82 /* Home */,
|
FD7DFF54835B377CB9BF5A82 /* Home */,
|
||||||
D3F1B37CB4D7643D7F7639CE /* Live */,
|
D3F1B37CB4D7643D7F7639CE /* Live */,
|
||||||
2990EE415D2440DE11757F52 /* Player */,
|
2990EE415D2440DE11757F52 /* Player */,
|
||||||
|
2D13F96D465DAF4A57E7F402 /* Profile */,
|
||||||
2B5161C9D7D5ECC250A7FFC0 /* Search */,
|
2B5161C9D7D5ECC250A7FFC0 /* Search */,
|
||||||
395C32C174B961E32AF81E4F /* Settings */,
|
395C32C174B961E32AF81E4F /* Settings */,
|
||||||
6F4341B27BB1A860CB183467 /* ContentView.swift */,
|
6F4341B27BB1A860CB183467 /* ContentView.swift */,
|
||||||
@@ -218,6 +297,8 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
8B5798117EA20DF680EFD3CA /* tvbox */,
|
8B5798117EA20DF680EFD3CA /* tvbox */,
|
||||||
|
0F55DB9D84FD47BF11F99DE5 /* tvbox-Tests */,
|
||||||
|
2CDB960D512B40AF659C0811 /* tvboxTests */,
|
||||||
AF26E3B8CC541C336E1ECDDB /* Products */,
|
AF26E3B8CC541C336E1ECDDB /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -265,6 +346,7 @@
|
|||||||
E1FD870365F6D4FD67730DBD /* ViewModels */,
|
E1FD870365F6D4FD67730DBD /* ViewModels */,
|
||||||
4D47ACD45B1BF337CCB04FCC /* Views */,
|
4D47ACD45B1BF337CCB04FCC /* Views */,
|
||||||
3EE159FC0B6328166F56CE36 /* Assets.xcassets */,
|
3EE159FC0B6328166F56CE36 /* Assets.xcassets */,
|
||||||
|
98A5ADBA89C07920FE7DE302 /* tvbox-macOS.entitlements */,
|
||||||
54D163D4F6A45E9ACA696071 /* tvboxApp.swift */,
|
54D163D4F6A45E9ACA696071 /* tvboxApp.swift */,
|
||||||
);
|
);
|
||||||
path = tvbox;
|
path = tvbox;
|
||||||
@@ -274,7 +356,9 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */,
|
7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */,
|
||||||
|
E64444D4CBE0502858CBD7E4 /* tvbox-Tests.xctest */,
|
||||||
E5234A75D14EAE9141633FCA /* tvbox.app */,
|
E5234A75D14EAE9141633FCA /* tvbox.app */,
|
||||||
|
09FEA232F8084173AAC649D4 /* tvboxTests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -282,6 +366,7 @@
|
|||||||
D3F1B37CB4D7643D7F7639CE /* Live */ = {
|
D3F1B37CB4D7643D7F7639CE /* Live */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
74B2E6B616F6AE034C7CC242 /* ChannelOverlayView.swift */,
|
||||||
B3D8D6F9D894DBE0594084F9 /* LiveView.swift */,
|
B3D8D6F9D894DBE0594084F9 /* LiveView.swift */,
|
||||||
);
|
);
|
||||||
path = Live;
|
path = Live;
|
||||||
@@ -339,6 +424,24 @@
|
|||||||
productReference = E5234A75D14EAE9141633FCA /* tvbox.app */;
|
productReference = E5234A75D14EAE9141633FCA /* tvbox.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
|
6654D9217FBAAC8D637C69A8 /* tvbox-Tests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = E2BCF6682D0CE82B69B74411 /* Build configuration list for PBXNativeTarget "tvbox-Tests" */;
|
||||||
|
buildPhases = (
|
||||||
|
0B9604C19AD94C5FF651FC38 /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
AEB1CA8F6E9D3FE63870B42F /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = "tvbox-Tests";
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = "tvbox-Tests";
|
||||||
|
productReference = E64444D4CBE0502858CBD7E4 /* tvbox-Tests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
793E3BB44A68A9F75C89F0C1 /* tvbox-macOS */ = {
|
793E3BB44A68A9F75C89F0C1 /* tvbox-macOS */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = B562A6D461B4EAA4BD297CA0 /* Build configuration list for PBXNativeTarget "tvbox-macOS" */;
|
buildConfigurationList = B562A6D461B4EAA4BD297CA0 /* Build configuration list for PBXNativeTarget "tvbox-macOS" */;
|
||||||
@@ -359,6 +462,24 @@
|
|||||||
productReference = 7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */;
|
productReference = 7077934707CEA1AEB06F1EF7 /* tvbox-macOS.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
|
A8BE1A152DDC63C329EA702B /* tvboxTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = E226A42B8E14F285EA8D5D9A /* Build configuration list for PBXNativeTarget "tvboxTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
7FF57815F26F4F3DCFA4F2EC /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
A407E73109D81FAE254645AA /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = tvboxTests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = tvboxTests;
|
||||||
|
productReference = 09FEA232F8084173AAC649D4 /* tvboxTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -369,11 +490,19 @@
|
|||||||
LastUpgradeCheck = 1500;
|
LastUpgradeCheck = 1500;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
11562C2D9CA08B82105BC7B6 = {
|
11562C2D9CA08B82105BC7B6 = {
|
||||||
DevelopmentTeam = "";
|
DevelopmentTeam = WP3M36TK8M;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
|
6654D9217FBAAC8D637C69A8 = {
|
||||||
|
DevelopmentTeam = WP3M36TK8M;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
793E3BB44A68A9F75C89F0C1 = {
|
793E3BB44A68A9F75C89F0C1 = {
|
||||||
DevelopmentTeam = "";
|
DevelopmentTeam = WP3M36TK8M;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
|
A8BE1A152DDC63C329EA702B = {
|
||||||
|
DevelopmentTeam = WP3M36TK8M;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -396,7 +525,9 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
11562C2D9CA08B82105BC7B6 /* tvbox */,
|
11562C2D9CA08B82105BC7B6 /* tvbox */,
|
||||||
|
6654D9217FBAAC8D637C69A8 /* tvbox-Tests */,
|
||||||
793E3BB44A68A9F75C89F0C1 /* tvbox-macOS */,
|
793E3BB44A68A9F75C89F0C1 /* tvbox-macOS */,
|
||||||
|
A8BE1A152DDC63C329EA702B /* tvboxTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -421,6 +552,28 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
0B9604C19AD94C5FF651FC38 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
CF28A416A2BCEF2E37892899 /* ChannelOverlayGroupWidthPropertyTests.swift in Sources */,
|
||||||
|
F351B1ECC80D0C2A15DE2BEC /* PlayerGestureLayerPropertyTests.swift in Sources */,
|
||||||
|
D3B09E23F0DFA6298CCE6B40 /* PlayerGestureLayerTests.swift in Sources */,
|
||||||
|
C04126B16A89AF84E7EAC441 /* PlayerGestureLayerZoneClassificationTests.swift in Sources */,
|
||||||
|
CFF1FF7DF205B2E3CFBF9DD0 /* PlayerGestureLayerZoomClampingTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
7FF57815F26F4F3DCFA4F2EC /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
9231BB5D5B5D6E7172B46775 /* ChannelOverlayGroupWidthPropertyTests.swift in Sources */,
|
||||||
|
FFA89F351987A1217DBC56B2 /* PlayerGestureLayerPropertyTests.swift in Sources */,
|
||||||
|
9995EE797BCD482A8D371F26 /* PlayerGestureLayerZoomClampingTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
89EE06EE9942A5C42303E3C1 /* Sources */ = {
|
89EE06EE9942A5C42303E3C1 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -428,6 +581,7 @@
|
|||||||
633ECC16BBEA9AB61841F8A3 /* ApiConfig.swift in Sources */,
|
633ECC16BBEA9AB61841F8A3 /* ApiConfig.swift in Sources */,
|
||||||
3E399383D92192CDA8840FEB /* AppConfig.swift in Sources */,
|
3E399383D92192CDA8840FEB /* AppConfig.swift in Sources */,
|
||||||
DB893501C654D6CC2D71D198 /* CacheStore.swift in Sources */,
|
DB893501C654D6CC2D71D198 /* CacheStore.swift in Sources */,
|
||||||
|
F19D2281D427FEE5B1349769 /* ChannelOverlayView.swift in Sources */,
|
||||||
0E9ABBA046E84A91B5DC3BBB /* ContentView.swift in Sources */,
|
0E9ABBA046E84A91B5DC3BBB /* ContentView.swift in Sources */,
|
||||||
2C85CE605FC864EB479F3CCC /* DesignSystem.swift in Sources */,
|
2C85CE605FC864EB479F3CCC /* DesignSystem.swift in Sources */,
|
||||||
4E84E7083C81F5A8E3C8FFD8 /* DetailView.swift in Sources */,
|
4E84E7083C81F5A8E3C8FFD8 /* DetailView.swift in Sources */,
|
||||||
@@ -436,10 +590,12 @@
|
|||||||
69BDB52A3ADDA43EAE59DE4B /* EpisodeListView.swift in Sources */,
|
69BDB52A3ADDA43EAE59DE4B /* EpisodeListView.swift in Sources */,
|
||||||
12314BE9C595577FEE1839BB /* Extensions.swift in Sources */,
|
12314BE9C595577FEE1839BB /* Extensions.swift in Sources */,
|
||||||
F49BBFD0CCDE27C63FD97180 /* FavoritesView.swift in Sources */,
|
F49BBFD0CCDE27C63FD97180 /* FavoritesView.swift in Sources */,
|
||||||
|
0D5200B31179039E1BB61DE5 /* HapticManager.swift in Sources */,
|
||||||
D8412DB044659CA4507DE219 /* HistoryView.swift in Sources */,
|
D8412DB044659CA4507DE219 /* HistoryView.swift in Sources */,
|
||||||
97B3904616111CE93232423E /* HomeView.swift in Sources */,
|
97B3904616111CE93232423E /* HomeView.swift in Sources */,
|
||||||
5D208E481C6F97783B92E78C /* HomeViewModel.swift in Sources */,
|
5D208E481C6F97783B92E78C /* HomeViewModel.swift in Sources */,
|
||||||
E983FD4D6902C9F359528F2E /* LiveModels.swift in Sources */,
|
E983FD4D6902C9F359528F2E /* LiveModels.swift in Sources */,
|
||||||
|
7D0F71AA151C3F8CE8EB962B /* LivePlayerGestureLayer.swift in Sources */,
|
||||||
267A5751434568E302E0C902 /* LiveView.swift in Sources */,
|
267A5751434568E302E0C902 /* LiveView.swift in Sources */,
|
||||||
19291FACD401CDE0E6E2963E /* LiveViewModel.swift in Sources */,
|
19291FACD401CDE0E6E2963E /* LiveViewModel.swift in Sources */,
|
||||||
0B78C3CA1D93FA29531ABD0B /* Movie.swift in Sources */,
|
0B78C3CA1D93FA29531ABD0B /* Movie.swift in Sources */,
|
||||||
@@ -447,7 +603,9 @@
|
|||||||
5931E2182CBA71FD467BC61B /* NetworkManager.swift in Sources */,
|
5931E2182CBA71FD467BC61B /* NetworkManager.swift in Sources */,
|
||||||
F01490300EAFDF38F8BD5D46 /* NetworkMonitor.swift in Sources */,
|
F01490300EAFDF38F8BD5D46 /* NetworkMonitor.swift in Sources */,
|
||||||
2F841DB8CE3BFB7C4F391B52 /* PlayerEngine.swift in Sources */,
|
2F841DB8CE3BFB7C4F391B52 /* PlayerEngine.swift in Sources */,
|
||||||
|
0CE56FEF7BEDF2D9AF2DD1AD /* PlayerGestureLayer.swift in Sources */,
|
||||||
00DC0B0A8D261A9015CE9308 /* PlayerView.swift in Sources */,
|
00DC0B0A8D261A9015CE9308 /* PlayerView.swift in Sources */,
|
||||||
|
5E87760B3FDD9FCA2A532221 /* ProfileView.swift in Sources */,
|
||||||
073BA70E29280BDF914166CF /* SearchView.swift in Sources */,
|
073BA70E29280BDF914166CF /* SearchView.swift in Sources */,
|
||||||
1D0C0D4DD4E22EC319637BDF /* SearchViewModel.swift in Sources */,
|
1D0C0D4DD4E22EC319637BDF /* SearchViewModel.swift in Sources */,
|
||||||
FA5621E49A1014D014A5906E /* SettingsView.swift in Sources */,
|
FA5621E49A1014D014A5906E /* SettingsView.swift in Sources */,
|
||||||
@@ -468,6 +626,7 @@
|
|||||||
3F69F139D892A91CA7DA6C73 /* ApiConfig.swift in Sources */,
|
3F69F139D892A91CA7DA6C73 /* ApiConfig.swift in Sources */,
|
||||||
D6F4B32786BF79CBF6AB77EA /* AppConfig.swift in Sources */,
|
D6F4B32786BF79CBF6AB77EA /* AppConfig.swift in Sources */,
|
||||||
6EC44AD626C64BC902CD9D1B /* CacheStore.swift in Sources */,
|
6EC44AD626C64BC902CD9D1B /* CacheStore.swift in Sources */,
|
||||||
|
B42C3B637B6082ECD044DBFE /* ChannelOverlayView.swift in Sources */,
|
||||||
01259DBB84CEFA52CB5BC8C1 /* ContentView.swift in Sources */,
|
01259DBB84CEFA52CB5BC8C1 /* ContentView.swift in Sources */,
|
||||||
16D0E98EA5DF105764396919 /* DesignSystem.swift in Sources */,
|
16D0E98EA5DF105764396919 /* DesignSystem.swift in Sources */,
|
||||||
6765DB90DA62DBEE185104F7 /* DetailView.swift in Sources */,
|
6765DB90DA62DBEE185104F7 /* DetailView.swift in Sources */,
|
||||||
@@ -476,10 +635,12 @@
|
|||||||
72A1006A1A66F1721DD1618E /* EpisodeListView.swift in Sources */,
|
72A1006A1A66F1721DD1618E /* EpisodeListView.swift in Sources */,
|
||||||
5FE9E153523D77AB24B16A7E /* Extensions.swift in Sources */,
|
5FE9E153523D77AB24B16A7E /* Extensions.swift in Sources */,
|
||||||
058BC2FBB08D016A6FCF4C13 /* FavoritesView.swift in Sources */,
|
058BC2FBB08D016A6FCF4C13 /* FavoritesView.swift in Sources */,
|
||||||
|
329F390FCD9530CE2B19B397 /* HapticManager.swift in Sources */,
|
||||||
B06E33645C9D8322D7474962 /* HistoryView.swift in Sources */,
|
B06E33645C9D8322D7474962 /* HistoryView.swift in Sources */,
|
||||||
5EB3F1571D44835F105ED6E9 /* HomeView.swift in Sources */,
|
5EB3F1571D44835F105ED6E9 /* HomeView.swift in Sources */,
|
||||||
32340EF75714EF664DC9512D /* HomeViewModel.swift in Sources */,
|
32340EF75714EF664DC9512D /* HomeViewModel.swift in Sources */,
|
||||||
7CC2D33B7B438A89B03990EC /* LiveModels.swift in Sources */,
|
7CC2D33B7B438A89B03990EC /* LiveModels.swift in Sources */,
|
||||||
|
ED9018DE19A38BEF616B71A3 /* LivePlayerGestureLayer.swift in Sources */,
|
||||||
530B86138F2A404353CE37AB /* LiveView.swift in Sources */,
|
530B86138F2A404353CE37AB /* LiveView.swift in Sources */,
|
||||||
9FCA2C5CC08A9369EA53B130 /* LiveViewModel.swift in Sources */,
|
9FCA2C5CC08A9369EA53B130 /* LiveViewModel.swift in Sources */,
|
||||||
485F358B964A4CB6E5AC4E03 /* Movie.swift in Sources */,
|
485F358B964A4CB6E5AC4E03 /* Movie.swift in Sources */,
|
||||||
@@ -487,7 +648,9 @@
|
|||||||
5230AE350F993A4792B03F74 /* NetworkManager.swift in Sources */,
|
5230AE350F993A4792B03F74 /* NetworkManager.swift in Sources */,
|
||||||
B86C3ACE754B5F52B70588A9 /* NetworkMonitor.swift in Sources */,
|
B86C3ACE754B5F52B70588A9 /* NetworkMonitor.swift in Sources */,
|
||||||
100ED189186C10410BE683C5 /* PlayerEngine.swift in Sources */,
|
100ED189186C10410BE683C5 /* PlayerEngine.swift in Sources */,
|
||||||
|
19FCB4D2A58F2CA96183E67A /* PlayerGestureLayer.swift in Sources */,
|
||||||
F6E863F58C52916EAB766B60 /* PlayerView.swift in Sources */,
|
F6E863F58C52916EAB766B60 /* PlayerView.swift in Sources */,
|
||||||
|
E9DE1232C207925E93343E15 /* ProfileView.swift in Sources */,
|
||||||
59DB9B1D12BAAA2F654579FE /* SearchView.swift in Sources */,
|
59DB9B1D12BAAA2F654579FE /* SearchView.swift in Sources */,
|
||||||
F911F241653C76EABDA844A9 /* SearchViewModel.swift in Sources */,
|
F911F241653C76EABDA844A9 /* SearchViewModel.swift in Sources */,
|
||||||
020C5667E8D08A0C9B03EC8E /* SettingsView.swift in Sources */,
|
020C5667E8D08A0C9B03EC8E /* SettingsView.swift in Sources */,
|
||||||
@@ -503,13 +666,26 @@
|
|||||||
};
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
A407E73109D81FAE254645AA /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 11562C2D9CA08B82105BC7B6 /* tvbox */;
|
||||||
|
targetProxy = A86A49D753626DA6D45004CE /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
AEB1CA8F6E9D3FE63870B42F /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 11562C2D9CA08B82105BC7B6 /* tvbox */;
|
||||||
|
targetProxy = 7239DE519CCBFDEB377CA655 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
05A4C2FBFE824459DAF6F032 /* Release */ = {
|
05A4C2FBFE824459DAF6F032 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "";
|
CODE_SIGN_ENTITLEMENTS = "tvbox/tvbox-macOS.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "-";
|
CODE_SIGN_IDENTITY = "-";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -520,7 +696,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.macos;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.macos;
|
||||||
PRODUCT_NAME = TVBox;
|
PRODUCT_NAME = TVBox;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
@@ -565,7 +741,7 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = WP3M36TK8M;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
@@ -590,7 +766,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "";
|
CODE_SIGN_ENTITLEMENTS = "tvbox/tvbox-macOS.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "-";
|
CODE_SIGN_IDENTITY = "-";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -601,7 +777,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.macos;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.macos;
|
||||||
PRODUCT_NAME = TVBox;
|
PRODUCT_NAME = TVBox;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
@@ -610,6 +786,63 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
43D3C5B79428BA37ABA0A3D1 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.tests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TVBox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TVBox";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
600A01562B84DD8644C72B26 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.tests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TVBox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TVBox";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
6D108BA4F95E6E4B71DB1AD4 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.tests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TVBox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TVBox";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
8A2175BF0264F53B0EF674FF /* Debug */ = {
|
8A2175BF0264F53B0EF674FF /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -630,8 +863,8 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app;
|
PRODUCT_BUNDLE_IDENTIFIER = xyz.appinstall.nov.carbon.lam;
|
||||||
PRODUCT_NAME = TVBox;
|
PRODUCT_NAME = TVBox;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTS_MACCATALYST = YES;
|
SUPPORTS_MACCATALYST = YES;
|
||||||
@@ -641,6 +874,25 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
C2C422BFD7EA2EA2983FF28C /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app.tests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TVBox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TVBox";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
D7359975052DCED591BCFD09 /* Debug */ = {
|
D7359975052DCED591BCFD09 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -677,7 +929,7 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = WP3M36TK8M;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
@@ -724,8 +976,8 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tvbox.app;
|
PRODUCT_BUNDLE_IDENTIFIER = xyz.appinstall.nov.carbon.lam;
|
||||||
PRODUCT_NAME = TVBox;
|
PRODUCT_NAME = TVBox;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTS_MACCATALYST = YES;
|
SUPPORTS_MACCATALYST = YES;
|
||||||
@@ -765,6 +1017,24 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Debug;
|
defaultConfigurationName = Debug;
|
||||||
};
|
};
|
||||||
|
E226A42B8E14F285EA8D5D9A /* Build configuration list for PBXNativeTarget "tvboxTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
C2C422BFD7EA2EA2983FF28C /* Debug */,
|
||||||
|
43D3C5B79428BA37ABA0A3D1 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
E2BCF6682D0CE82B69B74411 /* Build configuration list for PBXNativeTarget "tvbox-Tests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
6D108BA4F95E6E4B71DB1AD4 /* Debug */,
|
||||||
|
600A01562B84DD8644C72B26 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -85,7 +85,9 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.tag(0)
|
.tag(0)
|
||||||
|
|
||||||
LiveView()
|
LiveView(onExit: {
|
||||||
|
selectedTab = 0
|
||||||
|
})
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("直播", systemImage: "tv.fill")
|
Label("直播", systemImage: "tv.fill")
|
||||||
}
|
}
|
||||||
@@ -97,19 +99,16 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.tag(2)
|
.tag(2)
|
||||||
|
|
||||||
FavoritesView()
|
ProfileView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("收藏", systemImage: "heart.fill")
|
Label("个人中心", systemImage: "person.fill")
|
||||||
}
|
}
|
||||||
.tag(3)
|
.tag(3)
|
||||||
|
|
||||||
SettingsView()
|
|
||||||
.tabItem {
|
|
||||||
Label("设置", systemImage: "gearshape.fill")
|
|
||||||
}
|
|
||||||
.tag(4)
|
|
||||||
}
|
}
|
||||||
.tint(.orange)
|
.tint(.orange)
|
||||||
|
.onChange(of: selectedTab) { _, _ in
|
||||||
|
HapticManager.shared.selection()
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
NavigationSplitView(columnVisibility: $appState.splitViewVisibility) {
|
NavigationSplitView(columnVisibility: $appState.splitViewVisibility) {
|
||||||
List(selection: $selectedTab) {
|
List(selection: $selectedTab) {
|
||||||
@@ -133,9 +132,15 @@ struct ContentView: View {
|
|||||||
case 0: HomeView()
|
case 0: HomeView()
|
||||||
case 1: LiveView()
|
case 1: LiveView()
|
||||||
case 2: SearchView()
|
case 2: SearchView()
|
||||||
case 3: FavoritesView()
|
case 3:
|
||||||
|
NavigationStack {
|
||||||
|
FavoritesView()
|
||||||
|
}
|
||||||
case 4: SettingsView()
|
case 4: SettingsView()
|
||||||
case 5: HistoryView()
|
case 5:
|
||||||
|
NavigationStack {
|
||||||
|
HistoryView()
|
||||||
|
}
|
||||||
default: HomeView()
|
default: HomeView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ struct DetailView: View {
|
|||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@State private var showFullScreen = false
|
@State private var showFullScreen = false
|
||||||
|
/// VLC 全屏退出动画期间为 true,防止内联播放器与全屏播放器同时争抢 drawable
|
||||||
|
@State private var isFullScreenDismissing = false
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@State private var pendingMacWindowFullScreen = false
|
@State private var pendingMacWindowFullScreen = false
|
||||||
#endif
|
#endif
|
||||||
@@ -23,7 +25,7 @@ struct DetailView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// 播放器区域
|
// 播放器区域
|
||||||
if !showFullScreen, viewModel.isPlaying, let url = viewModel.playUrl {
|
if !showFullScreen, !isFullScreenDismissing, viewModel.isPlaying, let url = viewModel.playUrl {
|
||||||
PlayerView(
|
PlayerView(
|
||||||
urlString: url,
|
urlString: url,
|
||||||
startPosition: viewModel.currentPlaybackSeconds(),
|
startPosition: viewModel.currentPlaybackSeconds(),
|
||||||
@@ -136,7 +138,9 @@ struct DetailView: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.fullScreenCover(isPresented: $showFullScreen) {
|
.fullScreenCover(isPresented: $showFullScreen, onDismiss: {
|
||||||
|
isFullScreenDismissing = false
|
||||||
|
}) {
|
||||||
if let url = viewModel.playUrl {
|
if let url = viewModel.playUrl {
|
||||||
FullScreenPlayerView(
|
FullScreenPlayerView(
|
||||||
urlString: url,
|
urlString: url,
|
||||||
@@ -146,7 +150,11 @@ struct DetailView: View {
|
|||||||
canPlayNext: canPlayNextEpisode,
|
canPlayNext: canPlayNextEpisode,
|
||||||
onPlayNext: playNextEpisodeIfNeeded,
|
onPlayNext: playNextEpisodeIfNeeded,
|
||||||
systemController: sharedSystemController,
|
systemController: sharedSystemController,
|
||||||
vlcController: sharedVLCController
|
vlcController: sharedVLCController,
|
||||||
|
onCloseRequested: {
|
||||||
|
isFullScreenDismissing = true
|
||||||
|
showFullScreen = false
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,6 +163,35 @@ struct DetailView: View {
|
|||||||
|
|
||||||
// MARK: - 视频信息
|
// MARK: - 视频信息
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
@ViewBuilder
|
||||||
|
private var videoInfoSection: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Poster centered, height capped to 30% of screen
|
||||||
|
let posterHeight = UIScreen.main.bounds.height * 0.30
|
||||||
|
CachedAsyncImage(url: URL.posterURL(from: video.pic)) { image in
|
||||||
|
image.resizable().aspectRatio(2/3, contentMode: .fit)
|
||||||
|
} placeholder: {
|
||||||
|
Color.white.opacity(0.05)
|
||||||
|
.aspectRatio(2/3, contentMode: .fit)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: posterHeight)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardRadius))
|
||||||
|
|
||||||
|
// Info below poster
|
||||||
|
videoDetails
|
||||||
|
|
||||||
|
// Action buttons with 48pt height
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
playButton
|
||||||
|
collectButton
|
||||||
|
}
|
||||||
|
.frame(minHeight: 48)
|
||||||
|
}
|
||||||
|
.padding(15)
|
||||||
|
.glassCard(cornerRadius: AppTheme.glassRadius)
|
||||||
|
}
|
||||||
|
#else
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var videoInfoSection: some View {
|
private var videoInfoSection: some View {
|
||||||
HStack(alignment: .top, spacing: 20) {
|
HStack(alignment: .top, spacing: 20) {
|
||||||
@@ -167,6 +204,7 @@ struct DetailView: View {
|
|||||||
.padding(15)
|
.padding(15)
|
||||||
.glassCard(cornerRadius: AppTheme.glassRadius)
|
.glassCard(cornerRadius: AppTheme.glassRadius)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var videoPoster: some View {
|
private var videoPoster: some View {
|
||||||
@@ -201,12 +239,14 @@ struct DetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
Spacer(minLength: 10)
|
Spacer(minLength: 10)
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
playButton
|
playButton
|
||||||
collectButton
|
collectButton
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,48 +22,49 @@ struct FavoritesView: View {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
favoritesContent
|
||||||
Group {
|
}
|
||||||
if favorites.isEmpty {
|
|
||||||
emptyState
|
/// 收藏内容视图(不含 NavigationStack 包裹)。
|
||||||
} else {
|
/// iOS 下由外层 ProfileView/SettingsView 的 NavigationStack 管理导航;
|
||||||
ScrollView {
|
/// macOS 下由 ContentView 的 NavigationSplitView detail 区域使用独立 NavigationStack。
|
||||||
LazyVGrid(columns: columns, spacing: 16) {
|
private var favoritesContent: some View {
|
||||||
// 每个收藏项都可直接跳转详情,并支持右键取消收藏。
|
Group {
|
||||||
ForEach(favorites) { item in
|
if favorites.isEmpty {
|
||||||
NavigationLink(value: movieVideo(from: item)) {
|
emptyState
|
||||||
favoriteCard(item)
|
} else {
|
||||||
}
|
ScrollView {
|
||||||
.buttonStyle(.plain)
|
LazyVGrid(columns: columns, spacing: 16) {
|
||||||
.contextMenu {
|
// 每个收藏项都可直接跳转详情,并支持右键取消收藏。
|
||||||
Button(role: .destructive) {
|
ForEach(favorites) { item in
|
||||||
modelContext.delete(item)
|
NavigationLink(destination: DetailView(video: movieVideo(from: item))) {
|
||||||
do {
|
favoriteCard(item)
|
||||||
try modelContext.save()
|
}
|
||||||
} catch {
|
.buttonStyle(.plain)
|
||||||
print("删除收藏失败: \(error)")
|
.contextMenu {
|
||||||
}
|
Button(role: .destructive) {
|
||||||
} label: {
|
modelContext.delete(item)
|
||||||
Label("取消收藏", systemImage: "heart.slash")
|
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
|
|
||||||
// 通过 Movie.Video 作为路由载体,保持与首页/搜索页一致的详情入口。
|
|
||||||
.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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 空列表占位。
|
/// 空列表占位。
|
||||||
|
|||||||
@@ -22,62 +22,63 @@ struct HistoryView: View {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
historyContent
|
||||||
Group {
|
}
|
||||||
if records.isEmpty {
|
|
||||||
emptyState
|
/// 历史记录内容视图(不含 NavigationStack 包裹)。
|
||||||
} else {
|
/// iOS 下由外层 ProfileView/SettingsView 的 NavigationStack 管理导航;
|
||||||
ScrollView {
|
/// macOS 下由 ContentView 的 NavigationSplitView detail 区域使用独立 NavigationStack。
|
||||||
LazyVGrid(columns: columns, spacing: 16) {
|
private var historyContent: some View {
|
||||||
// 记录卡片支持跳转详情与右键删除。
|
Group {
|
||||||
ForEach(records) { item in
|
if records.isEmpty {
|
||||||
NavigationLink(value: movieVideo(from: item)) {
|
emptyState
|
||||||
recordCard(item)
|
} else {
|
||||||
}
|
ScrollView {
|
||||||
.buttonStyle(.plain)
|
LazyVGrid(columns: columns, spacing: 16) {
|
||||||
.contextMenu {
|
// 记录卡片支持跳转详情与右键删除。
|
||||||
Button(role: .destructive) {
|
ForEach(records) { item in
|
||||||
modelContext.delete(item)
|
NavigationLink(destination: DetailView(video: movieVideo(from: item))) {
|
||||||
do {
|
recordCard(item)
|
||||||
try modelContext.save()
|
}
|
||||||
} catch {
|
.buttonStyle(.plain)
|
||||||
print("删除历史记录失败: \(error)")
|
.contextMenu {
|
||||||
}
|
Button(role: .destructive) {
|
||||||
} label: {
|
modelContext.delete(item)
|
||||||
Label("删除记录", systemImage: "trash")
|
do {
|
||||||
|
try modelContext.save()
|
||||||
|
} catch {
|
||||||
|
print("删除历史记录失败: \(error)")
|
||||||
}
|
}
|
||||||
|
} label: {
|
||||||
|
Label("删除记录", systemImage: "trash")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color(red: 0.08, green: 0.08, blue: 0.1))
|
}
|
||||||
.navigationTitle("历史记录")
|
.background(Color(red: 0.08, green: 0.08, blue: 0.1))
|
||||||
#if os(iOS)
|
.navigationTitle("历史记录")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
#if os(iOS)
|
||||||
#endif
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
#endif
|
||||||
if !records.isEmpty {
|
.toolbar {
|
||||||
ToolbarItem(placement: .automatic) {
|
if !records.isEmpty {
|
||||||
// 清空历史使用统一缓存服务,确保行为与其他入口一致。
|
ToolbarItem(placement: .automatic) {
|
||||||
Button {
|
// 清空历史使用统一缓存服务,确保行为与其他入口一致。
|
||||||
Task {
|
Button {
|
||||||
CacheStore.shared.clearHistory(context: modelContext)
|
Task {
|
||||||
}
|
CacheStore.shared.clearHistory(context: modelContext)
|
||||||
} label: {
|
|
||||||
Text("清空")
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
}
|
}
|
||||||
|
} label: {
|
||||||
|
Text("清空")
|
||||||
|
.foregroundColor(.orange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 与首页/搜索/收藏共用同一种详情路由模型。
|
|
||||||
.navigationDestination(for: Movie.Video.self) { video in
|
|
||||||
DetailView(video: video)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+55
-147
@@ -5,12 +5,11 @@ struct HomeView: View {
|
|||||||
@StateObject private var viewModel = HomeViewModel()
|
@StateObject private var viewModel = HomeViewModel()
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@State private var categoryScrollAnchorId: String?
|
@State private var categoryScrollAnchorId: String?
|
||||||
@State private var categoryDragTranslation: CGFloat = 0
|
|
||||||
|
|
||||||
// 网格布局
|
// 网格布局
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private let columns = [
|
private let columns = [
|
||||||
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
|
GridItem(.adaptive(minimum: 100, maximum: 140), spacing: 10)
|
||||||
]
|
]
|
||||||
#else
|
#else
|
||||||
private let columns = [
|
private let columns = [
|
||||||
@@ -42,11 +41,11 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 顶部栏
|
// MARK: - 顶部栏(源选择器)
|
||||||
|
|
||||||
private var headerBar: some View {
|
private var headerBar: some View {
|
||||||
HStack(spacing: 15) {
|
HStack(spacing: 12) {
|
||||||
// 应用名(可切换源)
|
// 源切换按钮
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(ApiConfig.shared.sourceBeanList.filter { $0.isSupportedInSwift }) { source in
|
ForEach(ApiConfig.shared.sourceBeanList.filter { $0.isSupportedInSwift }) { source in
|
||||||
Button {
|
Button {
|
||||||
@@ -63,132 +62,77 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "sparkles")
|
Image(systemName: "play.tv.fill")
|
||||||
|
.font(.system(size: 14))
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
Text(ApiConfig.shared.homeSourceBean?.name ?? "TVBox")
|
Text(ApiConfig.shared.homeSourceBean?.name ?? "TVBox")
|
||||||
.font(.system(size: 15, weight: .bold))
|
.font(.system(size: 15, weight: .semibold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Image(systemName: "chevron.up.chevron.down")
|
Image(systemName: "chevron.down")
|
||||||
.font(.system(size: 9, weight: .bold))
|
.font(.system(size: 10, weight: .semibold))
|
||||||
.foregroundColor(.white.opacity(0.5))
|
.foregroundColor(.white.opacity(0.5))
|
||||||
.padding(.leading, 2)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(Color.white.opacity(0.1))
|
|
||||||
.clipShape(Capsule())
|
|
||||||
.overlay(Capsule().stroke(Color.white.opacity(0.1), lineWidth: 0.5))
|
|
||||||
}
|
}
|
||||||
.menuStyle(.borderlessButton)
|
.menuStyle(.borderlessButton)
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// 日期时间
|
|
||||||
HomeClockView()
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 15)
|
.padding(.top, 10)
|
||||||
.padding(.bottom, 10)
|
.padding(.bottom, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 分类标签栏
|
// MARK: - 分类标签栏
|
||||||
|
|
||||||
private var categoryTabBar: some View {
|
private var categoryTabBar: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
HStack(spacing: 8) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
categoryMoveButton(
|
HStack(spacing: 0) {
|
||||||
systemName: "chevron.left",
|
ForEach(viewModel.sorts) { sort in
|
||||||
enabled: canMoveCategory(by: -1)
|
Button {
|
||||||
) {
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
moveCategoryTabs(by: -3, proxy: proxy)
|
viewModel.selectSort(sort)
|
||||||
}
|
|
||||||
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
ForEach(viewModel.sorts) { sort in
|
|
||||||
Button {
|
|
||||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
|
||||||
viewModel.selectSort(sort)
|
|
||||||
}
|
|
||||||
categoryScrollAnchorId = sort.id
|
|
||||||
scrollCategoryBar(to: sort.id, proxy: proxy)
|
|
||||||
} label: {
|
|
||||||
Text(sort.name)
|
|
||||||
.font(.system(size: 14, weight: viewModel.selectedSort?.id == sort.id ? .bold : .medium))
|
|
||||||
.foregroundColor(viewModel.selectedSort?.id == sort.id ? .orange : .white.opacity(0.8))
|
|
||||||
.padding(.horizontal, 18)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(
|
|
||||||
ZStack {
|
|
||||||
if viewModel.selectedSort?.id == sort.id {
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(Color.orange.opacity(0.15))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(Color.orange.opacity(0.5), lineWidth: 1)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(Color.white.opacity(0.05))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
categoryScrollAnchorId = sort.id
|
||||||
.id(sort.id)
|
scrollCategoryBar(to: sort.id, proxy: proxy)
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text(sort.name)
|
||||||
|
.font(.system(size: 14, weight: viewModel.selectedSort?.id == sort.id ? .bold : .regular))
|
||||||
|
.foregroundColor(viewModel.selectedSort?.id == sort.id ? .white : .white.opacity(0.6))
|
||||||
|
|
||||||
|
// 底部指示条
|
||||||
|
RoundedRectangle(cornerRadius: 1.5)
|
||||||
|
.fill(Color.orange)
|
||||||
|
.frame(width: 20, height: 3)
|
||||||
|
.opacity(viewModel.selectedSort?.id == sort.id ? 1 : 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.id(sort.id)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 8)
|
|
||||||
}
|
|
||||||
.simultaneousGesture(categoryDragGesture(proxy: proxy))
|
|
||||||
.onAppear {
|
|
||||||
syncCategoryScrollAnchorIfNeeded()
|
|
||||||
scrollCategoryBar(to: categoryScrollAnchorId, proxy: proxy, animated: false)
|
|
||||||
}
|
|
||||||
.onChange(of: viewModel.sorts.map(\.id)) { oldValue, newValue in
|
|
||||||
syncCategoryScrollAnchorIfNeeded()
|
|
||||||
scrollCategoryBar(to: categoryScrollAnchorId, proxy: proxy, animated: false)
|
|
||||||
}
|
|
||||||
.onChange(of: viewModel.selectedSort?.id) { oldId, newId in
|
|
||||||
guard let newId else { return }
|
|
||||||
categoryScrollAnchorId = newId
|
|
||||||
scrollCategoryBar(to: newId, proxy: proxy)
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryMoveButton(
|
|
||||||
systemName: "chevron.right",
|
|
||||||
enabled: canMoveCategory(by: 1)
|
|
||||||
) {
|
|
||||||
moveCategoryTabs(by: 3, proxy: proxy)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
syncCategoryScrollAnchorIfNeeded()
|
||||||
|
scrollCategoryBar(to: categoryScrollAnchorId, proxy: proxy, animated: false)
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.sorts.map(\.id)) { oldValue, newValue in
|
||||||
|
syncCategoryScrollAnchorIfNeeded()
|
||||||
|
scrollCategoryBar(to: categoryScrollAnchorId, proxy: proxy, animated: false)
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.selectedSort?.id) { oldId, newId in
|
||||||
|
guard let newId else { return }
|
||||||
|
categoryScrollAnchorId = newId
|
||||||
|
scrollCategoryBar(to: newId, proxy: proxy)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.bottom, 4)
|
||||||
}
|
|
||||||
|
|
||||||
private func categoryMoveButton(systemName: String, enabled: Bool, action: @escaping () -> Void) -> some View {
|
|
||||||
Button(action: action) {
|
|
||||||
Image(systemName: systemName)
|
|
||||||
.font(.system(size: 12, weight: .semibold))
|
|
||||||
.foregroundColor(.white.opacity(enabled ? 0.9 : 0.35))
|
|
||||||
.frame(width: 26, height: 26)
|
|
||||||
.background(Color.white.opacity(0.08))
|
|
||||||
.clipShape(Circle())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(!enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func canMoveCategory(by direction: Int) -> Bool {
|
|
||||||
guard !viewModel.sorts.isEmpty else { return false }
|
|
||||||
let currentIndex = categoryIndex(for: categoryScrollAnchorId) ?? 0
|
|
||||||
if direction < 0 {
|
|
||||||
return currentIndex > 0
|
|
||||||
}
|
|
||||||
return currentIndex < viewModel.sorts.count - 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func categoryIndex(for id: String?) -> Int? {
|
private func categoryIndex(for id: String?) -> Int? {
|
||||||
@@ -216,17 +160,6 @@ struct HomeView: View {
|
|||||||
categoryScrollAnchorId = viewModel.sorts.first?.id
|
categoryScrollAnchorId = viewModel.sorts.first?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
private func moveCategoryTabs(by delta: Int, proxy: ScrollViewProxy) {
|
|
||||||
guard !viewModel.sorts.isEmpty else { return }
|
|
||||||
let currentIndex = categoryIndex(for: categoryScrollAnchorId) ?? 0
|
|
||||||
let newIndex = min(max(0, currentIndex + delta), viewModel.sorts.count - 1)
|
|
||||||
guard newIndex != currentIndex else { return }
|
|
||||||
|
|
||||||
let targetId = viewModel.sorts[newIndex].id
|
|
||||||
categoryScrollAnchorId = targetId
|
|
||||||
scrollCategoryBar(to: targetId, proxy: proxy)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scrollCategoryBar(to id: String?, proxy: ScrollViewProxy, animated: Bool = true) {
|
private func scrollCategoryBar(to id: String?, proxy: ScrollViewProxy, animated: Bool = true) {
|
||||||
guard let id else { return }
|
guard let id else { return }
|
||||||
|
|
||||||
@@ -239,23 +172,6 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func categoryDragGesture(proxy: ScrollViewProxy) -> some Gesture {
|
|
||||||
DragGesture(minimumDistance: 12)
|
|
||||||
.onChanged { value in
|
|
||||||
let delta = value.translation.width - categoryDragTranslation
|
|
||||||
if delta <= -28 {
|
|
||||||
moveCategoryTabs(by: 1, proxy: proxy)
|
|
||||||
categoryDragTranslation = value.translation.width
|
|
||||||
} else if delta >= 28 {
|
|
||||||
moveCategoryTabs(by: -1, proxy: proxy)
|
|
||||||
categoryDragTranslation = value.translation.width
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEnded { _ in
|
|
||||||
categoryDragTranslation = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 内容区
|
// MARK: - 内容区
|
||||||
|
|
||||||
private var contentArea: some View {
|
private var contentArea: some View {
|
||||||
@@ -309,7 +225,11 @@ struct HomeView: View {
|
|||||||
NavigationLink(value: video) {
|
NavigationLink(value: video) {
|
||||||
VodCardView(video: video)
|
VodCardView(video: video)
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.buttonStyle(VodCardPressStyle())
|
||||||
|
#else
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
#endif
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task { await viewModel.loadMoreIfNeeded(currentItem: video) }
|
Task { await viewModel.loadMoreIfNeeded(currentItem: video) }
|
||||||
}
|
}
|
||||||
@@ -335,15 +255,3 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct HomeClockView: View {
|
|
||||||
var body: some View {
|
|
||||||
TimelineView(.periodic(from: .now, by: 1)) { timeline in
|
|
||||||
Text(timeline.date.homeDateString)
|
|
||||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
|
||||||
.foregroundColor(.white.opacity(0.6))
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.glassCard(cornerRadius: 12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - VodCard Press Style (iOS only)
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// 按压缩放动画样式 - 为 VodCard 提供触觉反馈式的按压效果
|
||||||
|
struct VodCardPressStyle: ButtonStyle {
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// 视频卡片组件
|
/// 视频卡片组件
|
||||||
struct VodCardView: View {
|
struct VodCardView: View {
|
||||||
/// 卡片对应的视频数据。
|
/// 卡片对应的视频数据。
|
||||||
@@ -68,6 +81,10 @@ struct VodCardView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.frame(minWidth: 44, minHeight: 44)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 海报占位图,避免图片加载失败导致卡片高度塌陷。
|
/// 海报占位图,避免图片加载失败导致卡片高度塌陷。
|
||||||
|
|||||||
@@ -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,6 +6,8 @@ import AppKit
|
|||||||
|
|
||||||
/// 直播页 - 对应 Android 版 LivePlayActivity
|
/// 直播页 - 对应 Android 版 LivePlayActivity
|
||||||
struct LiveView: View {
|
struct LiveView: View {
|
||||||
|
/// 退出直播回调(iOS 用于返回首页并显示 TabBar)。
|
||||||
|
var onExit: (() -> Void)? = nil
|
||||||
/// 直播频道与选中状态管理。
|
/// 直播频道与选中状态管理。
|
||||||
@StateObject private var viewModel = LiveViewModel()
|
@StateObject private var viewModel = LiveViewModel()
|
||||||
/// 全局应用状态(用于 macOS 全屏时调整分栏布局)。
|
/// 全局应用状态(用于 macOS 全屏时调整分栏布局)。
|
||||||
@@ -18,6 +20,10 @@ struct LiveView: View {
|
|||||||
@AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue
|
@AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue
|
||||||
/// 是否展示左侧频道抽屉。
|
/// 是否展示左侧频道抽屉。
|
||||||
@State private var showChannelDrawer = true
|
@State private var showChannelDrawer = true
|
||||||
|
#if os(iOS)
|
||||||
|
/// iOS 全屏频道覆盖层是否显示。
|
||||||
|
@State private var showChannelOverlay = false
|
||||||
|
#endif
|
||||||
/// 当前窗口是否处于全屏。
|
/// 当前窗口是否处于全屏。
|
||||||
@State private var isWindowFullScreen = false
|
@State private var isWindowFullScreen = false
|
||||||
/// 底部频道信息卡最大宽度。
|
/// 底部频道信息卡最大宽度。
|
||||||
@@ -97,6 +103,9 @@ struct LiveView: View {
|
|||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
|
.toolbar(.hidden, for: .tabBar)
|
||||||
#endif
|
#endif
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// 首次进入时加载频道并展示频道信息卡。
|
// 首次进入时加载频道并展示频道信息卡。
|
||||||
@@ -170,6 +179,7 @@ struct LiveView: View {
|
|||||||
|
|
||||||
private var overlayUI: some View {
|
private var overlayUI: some View {
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
|
#if os(macOS)
|
||||||
if showChannelDrawer {
|
if showChannelDrawer {
|
||||||
Color.black.opacity(0.22)
|
Color.black.opacity(0.22)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
@@ -180,9 +190,13 @@ struct LiveView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
|
#if os(iOS)
|
||||||
|
liveBackButton
|
||||||
|
#endif
|
||||||
channelDrawerToggleButton
|
channelDrawerToggleButton
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -198,19 +212,44 @@ struct LiveView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
if showChannelDrawer {
|
if showChannelDrawer {
|
||||||
channelDrawer
|
channelDrawer
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
.padding(.vertical, 20)
|
.padding(.vertical, 20)
|
||||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.2), value: showCurrentChannelInfo)
|
.animation(.easeInOut(duration: 0.2), value: showCurrentChannelInfo)
|
||||||
|
#if os(macOS)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: showChannelDrawer)
|
||||||
|
#endif
|
||||||
.simultaneousGesture(
|
.simultaneousGesture(
|
||||||
TapGesture().onEnded {
|
TapGesture().onEnded {
|
||||||
reportUserActivity()
|
reportUserActivity()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
#if os(iOS)
|
||||||
|
.fullScreenCover(isPresented: $showChannelOverlay) {
|
||||||
|
ChannelOverlayView(
|
||||||
|
channelGroups: viewModel.channelGroups,
|
||||||
|
selectedGroupIndex: $viewModel.selectedGroupIndex,
|
||||||
|
currentChannels: viewModel.currentChannels,
|
||||||
|
currentChannel: viewModel.currentChannel,
|
||||||
|
onSelectGroup: { viewModel.selectGroup($0) },
|
||||||
|
onSelectChannel: { channel in
|
||||||
|
viewModel.selectChannel(channel)
|
||||||
|
HapticManager.shared.mediumImpact()
|
||||||
|
showChannelOverlay = false
|
||||||
|
},
|
||||||
|
onDismiss: { showChannelOverlay = false }
|
||||||
|
)
|
||||||
|
.presentationBackground(.clear)
|
||||||
|
.transition(.move(edge: .bottom))
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: showChannelOverlay)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.onContinuousHover { phase in
|
.onContinuousHover { phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
@@ -266,20 +305,58 @@ struct LiveView: View {
|
|||||||
.glassCard(cornerRadius: 14)
|
.glassCard(cornerRadius: 14)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private var liveBackButton: some View {
|
||||||
|
Button {
|
||||||
|
HapticManager.shared.lightImpact()
|
||||||
|
onExit?()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
.frame(width: 38, height: 38)
|
||||||
|
.background(Color.black.opacity(0.35))
|
||||||
|
.clipShape(Circle())
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white.opacity(0.15), lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private var channelDrawerToggleButton: some View {
|
private var channelDrawerToggleButton: some View {
|
||||||
Button {
|
Button {
|
||||||
|
#if os(iOS)
|
||||||
|
HapticManager.shared.lightImpact()
|
||||||
|
showChannelOverlay = true
|
||||||
|
#else
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
showChannelDrawer.toggle()
|
showChannelDrawer.toggle()
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
|
#if os(iOS)
|
||||||
|
Image(systemName: "list.bullet")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
Text("频道")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
#else
|
||||||
Image(systemName: showChannelDrawer ? "sidebar.left" : "sidebar.right")
|
Image(systemName: showChannelDrawer ? "sidebar.left" : "sidebar.right")
|
||||||
Text(showChannelDrawer ? "收起菜单" : "频道菜单")
|
Text(showChannelDrawer ? "收起菜单" : "频道菜单")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.foregroundColor(.white.opacity(0.9))
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
#if os(iOS)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
#else
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
#endif
|
||||||
.background(Color.black.opacity(0.35))
|
.background(Color.black.opacity(0.35))
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
.overlay(
|
.overlay(
|
||||||
@@ -294,6 +371,61 @@ struct LiveView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func currentChannelInfo(_ channel: LiveChannelItem) -> some View {
|
private func currentChannelInfo(_ channel: LiveChannelItem) -> some View {
|
||||||
|
#if os(iOS)
|
||||||
|
// iOS: 紧凑的底部信息栏,适合单手操作
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// 频道名称
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle().fill(Color.orange).frame(width: 8, height: 8)
|
||||||
|
Text(channel.channelName)
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 线路信息
|
||||||
|
if channel.sourceNum > 1 {
|
||||||
|
Text("线路 \(channel.sourceIndex + 1)/\(channel.sourceNum)")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 操作按钮行
|
||||||
|
if channel.sourceNum > 1 {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
HapticManager.shared.mediumImpact()
|
||||||
|
wakeUpCurrentChannelInfo()
|
||||||
|
resetFailureTracking(for: viewModel.currentChannel)
|
||||||
|
viewModel.switchSource()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "shuffle")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
Text("切换线路")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 40)
|
||||||
|
.background(AppTheme.accentGradient)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.glassCard(cornerRadius: 14)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
#else
|
||||||
|
// macOS: 保持原有宽屏布局
|
||||||
HStack(spacing: 15) {
|
HStack(spacing: 15) {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
@@ -313,7 +445,6 @@ struct LiveView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
// 切换线路按钮
|
|
||||||
if channel.sourceNum > 1 {
|
if channel.sourceNum > 1 {
|
||||||
Button {
|
Button {
|
||||||
wakeUpCurrentChannelInfo()
|
wakeUpCurrentChannelInfo()
|
||||||
@@ -334,7 +465,6 @@ struct LiveView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
Button {
|
Button {
|
||||||
wakeUpCurrentChannelInfo()
|
wakeUpCurrentChannelInfo()
|
||||||
toggleWindowFullScreen()
|
toggleWindowFullScreen()
|
||||||
@@ -355,7 +485,6 @@ struct LiveView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
@@ -363,6 +492,7 @@ struct LiveView: View {
|
|||||||
.frame(maxWidth: currentChannelInfoMaxWidth)
|
.frame(maxWidth: currentChannelInfoMaxWidth)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(20)
|
.padding(20)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func wakeUpCurrentChannelInfo() {
|
private func wakeUpCurrentChannelInfo() {
|
||||||
@@ -469,7 +599,12 @@ struct LiveView: View {
|
|||||||
|
|
||||||
cleanupPlayer()
|
cleanupPlayer()
|
||||||
|
|
||||||
let playerItem = AVPlayerItem(url: url)
|
// 使用 AVURLAsset 并设置自定义 HTTP 头,解决部分 CDN 拒绝无 User-Agent 请求的问题
|
||||||
|
let headers: [String: String] = [
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
]
|
||||||
|
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
|
||||||
|
let playerItem = AVPlayerItem(asset: asset)
|
||||||
// 直播场景优先实时性,避免过多缓冲导致内存上涨
|
// 直播场景优先实时性,避免过多缓冲导致内存上涨
|
||||||
playerItem.preferredForwardBufferDuration = 3
|
playerItem.preferredForwardBufferDuration = 3
|
||||||
playerItem.canUseNetworkResourcesForLiveStreamingWhilePaused = false
|
playerItem.canUseNetworkResourcesForLiveStreamingWhilePaused = false
|
||||||
|
|||||||
@@ -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 {
|
func makeNSView(context: Context) -> AVPlayerView {
|
||||||
let view = AVPlayerView()
|
let view = AVPlayerView()
|
||||||
view.controlsStyle = .none // 禁用系统默认控制栏
|
view.controlsStyle = .none
|
||||||
view.showsFullScreenToggleButton = false
|
view.showsFullScreenToggleButton = false
|
||||||
view.videoGravity = .resizeAspect
|
view.videoGravity = .resizeAspect
|
||||||
view.player = player
|
view.player = player
|
||||||
@@ -61,10 +61,17 @@ final class SystemPlayerSessionController: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
|
let stoppingPlayer = player
|
||||||
player?.pause()
|
player?.pause()
|
||||||
player?.replaceCurrentItem(with: nil)
|
|
||||||
player = nil
|
player = nil
|
||||||
mediaURLString = nil
|
mediaURLString = nil
|
||||||
|
// AVPlayer.replaceCurrentItem(with: nil) 在播放网络流时会同步阻塞主线程数秒。
|
||||||
|
// 将其移到后台执行,闭包持有 AVPlayer 强引用防止提前 dealloc。
|
||||||
|
if let stoppingPlayer {
|
||||||
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
stoppingPlayer.replaceCurrentItem(with: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +172,7 @@ struct AVPlayerContentView: View {
|
|||||||
@State private var volume: Double = 1.0
|
@State private var volume: Double = 1.0
|
||||||
@State private var rate: Float = 1.0
|
@State private var rate: Float = 1.0
|
||||||
@State private var isPreparing = true
|
@State private var isPreparing = true
|
||||||
|
@State private var playbackError: String?
|
||||||
@State private var showControls = true
|
@State private var showControls = true
|
||||||
@State private var controlsTimer: Timer?
|
@State private var controlsTimer: Timer?
|
||||||
@State private var osdIcon: String?
|
@State private var osdIcon: String?
|
||||||
@@ -174,11 +182,16 @@ struct AVPlayerContentView: View {
|
|||||||
@State private var draggingSeconds: Double = 0
|
@State private var draggingSeconds: Double = 0
|
||||||
@State private var playerObservers: [NSKeyValueObservation] = []
|
@State private var playerObservers: [NSKeyValueObservation] = []
|
||||||
|
|
||||||
|
@State private var videoZoomScale: CGFloat = 1.0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Group {
|
Group {
|
||||||
if let player = player {
|
if let player = player {
|
||||||
PlatformVideoPlayer(player: player)
|
PlatformVideoPlayer(player: player)
|
||||||
|
#if os(iOS)
|
||||||
|
.scaleEffect(videoZoomScale)
|
||||||
|
#endif
|
||||||
} else {
|
} else {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black
|
Color.black
|
||||||
@@ -187,17 +200,36 @@ struct AVPlayerContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if !os(iOS)
|
||||||
.onTapGesture(count: 2) {
|
.onTapGesture(count: 2) {
|
||||||
onToggleFullScreen?()
|
onToggleFullScreen?()
|
||||||
}
|
}
|
||||||
.onTapGesture(count: 1) {
|
.onTapGesture(count: 1) {
|
||||||
togglePlayPauseWithOSD()
|
togglePlayPauseWithOSD()
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
if isPreparing {
|
if isPreparing {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.tint(.white)
|
.tint(.white)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let error = playbackError {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundColor(.yellow)
|
||||||
|
Text("播放失败")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(3)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
if let osdIcon = osdIcon {
|
if let osdIcon = osdIcon {
|
||||||
Image(systemName: osdIcon)
|
Image(systemName: osdIcon)
|
||||||
@@ -210,6 +242,22 @@ struct AVPlayerContentView: View {
|
|||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.overlay {
|
||||||
|
PlayerGestureLayer(
|
||||||
|
onSeek: { offset in seek(by: offset) },
|
||||||
|
onTogglePlayPause: { togglePlayPauseWithOSD() },
|
||||||
|
onToggleControls: { wakeUpControls() },
|
||||||
|
onZoomChanged: { scale in
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
videoZoomScale = scale
|
||||||
|
}
|
||||||
|
},
|
||||||
|
currentTime: currentTime,
|
||||||
|
duration: duration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
GeometryReader { proxy in
|
GeometryReader { proxy in
|
||||||
if player != nil {
|
if player != nil {
|
||||||
@@ -257,15 +305,35 @@ struct AVPlayerContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func setupPlayer() {
|
private func setupPlayer() {
|
||||||
guard let url = URL(string: urlString) else { return }
|
guard let url = Self.sanitizedURL(from: urlString) else {
|
||||||
|
print("[AVPlayer] URL sanitization failed for: \(urlString)")
|
||||||
|
return
|
||||||
|
}
|
||||||
let targetURLString = url.absoluteString
|
let targetURLString = url.absoluteString
|
||||||
let preferredRate = normalizedSavedPlaybackRate
|
let preferredRate = normalizedSavedPlaybackRate
|
||||||
rate = preferredRate
|
rate = preferredRate
|
||||||
|
playbackError = nil
|
||||||
|
|
||||||
if let sharedController,
|
if let sharedController,
|
||||||
sharedController.mediaURLString == targetURLString,
|
sharedController.mediaURLString == targetURLString,
|
||||||
let sharedPlayer = sharedController.player {
|
let sharedPlayer = sharedController.player {
|
||||||
cleanupPlayer(keepSharedPlayer: true)
|
cleanupPlayer(keepSharedPlayer: true)
|
||||||
|
// 强制重新关联 AVPlayerItem,修复从全屏退出后 VideoPlayer 黑屏问题。
|
||||||
|
// SwiftUI.VideoPlayer 在复用已有 AVPlayer 时可能无法正确连接视频渲染层,
|
||||||
|
// 通过 replaceCurrentItem 触发内部 layer 重新绑定。
|
||||||
|
#if os(iOS)
|
||||||
|
if let currentItem = sharedPlayer.currentItem {
|
||||||
|
let currentTime = sharedPlayer.currentTime()
|
||||||
|
let wasPlaying = sharedPlayer.rate != 0
|
||||||
|
sharedPlayer.replaceCurrentItem(with: nil)
|
||||||
|
sharedPlayer.replaceCurrentItem(with: currentItem)
|
||||||
|
sharedPlayer.seek(to: currentTime, toleranceBefore: .zero, toleranceAfter: .zero) { _ in
|
||||||
|
if wasPlaying {
|
||||||
|
sharedPlayer.playImmediately(atRate: self.normalizedSavedPlaybackRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
player = sharedPlayer
|
player = sharedPlayer
|
||||||
applyPreferredPlaybackRate(to: sharedPlayer)
|
applyPreferredPlaybackRate(to: sharedPlayer)
|
||||||
bindPlayerObservers(for: sharedPlayer)
|
bindPlayerObservers(for: sharedPlayer)
|
||||||
@@ -276,7 +344,11 @@ struct AVPlayerContentView: View {
|
|||||||
// 清理旧播放器
|
// 清理旧播放器
|
||||||
cleanupPlayer()
|
cleanupPlayer()
|
||||||
|
|
||||||
let playerItem = AVPlayerItem(url: url)
|
// 使用 AVURLAsset 并设置自定义 HTTP 头,解决部分 CDN 拒绝无 User-Agent 请求的问题
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
asset.resourceLoader.setDelegate(nil, queue: nil)
|
||||||
|
let playerItem = AVPlayerItem(asset: asset)
|
||||||
|
playerItem.preferredForwardBufferDuration = 0
|
||||||
let newPlayer = AVPlayer(playerItem: playerItem)
|
let newPlayer = AVPlayer(playerItem: playerItem)
|
||||||
newPlayer.defaultRate = preferredRate
|
newPlayer.defaultRate = preferredRate
|
||||||
if let sharedController {
|
if let sharedController {
|
||||||
@@ -288,6 +360,20 @@ struct AVPlayerContentView: View {
|
|||||||
startPlayback(for: newPlayer)
|
startPlayback(for: newPlayer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将原始 URL 字符串转换为合法的 URL,处理未编码的特殊字符。
|
||||||
|
private static func sanitizedURL(from urlString: String) -> URL? {
|
||||||
|
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let url = URL(string: trimmed) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
// 尝试对整个字符串进行百分号编码(保留已编码部分)
|
||||||
|
if let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||||
|
let url = URL(string: encoded) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
private func bindPlayerObservers(for player: AVPlayer) {
|
private func bindPlayerObservers(for player: AVPlayer) {
|
||||||
playerObservers = [
|
playerObservers = [
|
||||||
player.observe(\.timeControlStatus, options: [.new]) { p, _ in
|
player.observe(\.timeControlStatus, options: [.new]) { p, _ in
|
||||||
@@ -314,6 +400,23 @@ struct AVPlayerContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
// 监听 AVPlayerItem 状态,捕获加载失败的具体原因
|
||||||
|
if let item = player.currentItem {
|
||||||
|
let itemObserver = item.observe(\.status, options: [.new]) { observedItem, _ in
|
||||||
|
if observedItem.status == .failed {
|
||||||
|
let errorDesc = observedItem.error?.localizedDescription ?? "未知错误"
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
isPreparing = false
|
||||||
|
playbackError = errorDesc
|
||||||
|
}
|
||||||
|
} else if observedItem.status == .readyToPlay {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
playbackError = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
playerObservers.append(itemObserver)
|
||||||
|
}
|
||||||
observePlaybackProgress(for: player)
|
observePlaybackProgress(for: player)
|
||||||
observePlaybackEnd(for: player)
|
observePlaybackEnd(for: player)
|
||||||
isPlaying = player.timeControlStatus == .playing
|
isPlaying = player.timeControlStatus == .playing
|
||||||
@@ -346,12 +449,15 @@ struct AVPlayerContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentPlayer.pause()
|
currentPlayer.pause()
|
||||||
currentPlayer.replaceCurrentItem(with: nil)
|
|
||||||
if sharedController?.player === currentPlayer {
|
if sharedController?.player === currentPlayer {
|
||||||
sharedController?.player = nil
|
sharedController?.player = nil
|
||||||
sharedController?.mediaURLString = nil
|
sharedController?.mediaURLString = nil
|
||||||
}
|
}
|
||||||
player = nil
|
player = nil
|
||||||
|
// replaceCurrentItem(with: nil) 在播放网络流时会阻塞主线程,移到后台
|
||||||
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
currentPlayer.replaceCurrentItem(with: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startPlayback(for player: AVPlayer) {
|
private func startPlayback(for player: AVPlayer) {
|
||||||
@@ -451,8 +557,129 @@ struct AVPlayerContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func playbackControls(containerWidth: CGFloat) -> some View {
|
private func playbackControls(containerWidth: CGFloat) -> some View {
|
||||||
VStack(spacing: 8) {
|
#if os(iOS)
|
||||||
// 第一行:进度条和时间
|
let controlWidth = containerWidth * 1.0
|
||||||
|
#else
|
||||||
|
let controlWidth = containerWidth * 0.7
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return VStack(spacing: 0) {
|
||||||
|
#if os(iOS)
|
||||||
|
// iOS: 紧凑单行布局 — 进度条在上,按钮在下紧贴
|
||||||
|
// 进度条行
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(currentTime.durationString)
|
||||||
|
.font(.system(size: 10, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { isDraggingProgress ? draggingSeconds : currentTime },
|
||||||
|
set: {
|
||||||
|
draggingSeconds = $0
|
||||||
|
wakeUpControls()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
in: 0...progressUpperBound,
|
||||||
|
onEditingChanged: { editing in
|
||||||
|
isDraggingProgress = editing
|
||||||
|
wakeUpControls()
|
||||||
|
if !editing {
|
||||||
|
seek(to: draggingSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.accentColor(.white)
|
||||||
|
.disabled(duration <= 0)
|
||||||
|
|
||||||
|
Text(duration.durationString)
|
||||||
|
.font(.system(size: 10, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(.white.opacity(0.5))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
// 控制按钮行 — 紧凑排列
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
// 左:倍速
|
||||||
|
playbackRateMenu
|
||||||
|
.frame(minWidth: 36, alignment: .leading)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 中间:主控按钮
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
Button {
|
||||||
|
wakeUpControls()
|
||||||
|
seek(by: -seekStep)
|
||||||
|
showOSD(icon: "gobackward.\(Int(seekStep))")
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "gobackward.\(Int(seekStep))")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.frame(minWidth: 36, minHeight: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
wakeUpControls()
|
||||||
|
togglePlayPauseWithOSD()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
||||||
|
.font(.system(size: 20, weight: .bold))
|
||||||
|
.frame(minWidth: 36, minHeight: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
wakeUpControls()
|
||||||
|
seek(by: seekStep)
|
||||||
|
showOSD(icon: "goforward.\(Int(seekStep))")
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "goforward.\(Int(seekStep))")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.frame(minWidth: 36, minHeight: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if let onPlayNext {
|
||||||
|
Button {
|
||||||
|
guard canPlayNext else { return }
|
||||||
|
wakeUpControls()
|
||||||
|
onPlayNext()
|
||||||
|
showOSD(icon: "forward.end.fill")
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "forward.end.fill")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.frame(minWidth: 36, minHeight: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!canPlayNext)
|
||||||
|
.opacity(canPlayNext ? 1 : 0.4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 右:全屏
|
||||||
|
if let onToggleFullScreen {
|
||||||
|
Button {
|
||||||
|
wakeUpControls()
|
||||||
|
onToggleFullScreen()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.frame(minWidth: 36, minHeight: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
#else
|
||||||
|
// macOS: 保持两行布局
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Text(currentTime.durationString)
|
Text(currentTime.durationString)
|
||||||
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||||||
@@ -488,9 +715,7 @@ struct AVPlayerContentView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
|
|
||||||
// 第二行:控制按钮
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
// 左侧区:倍速
|
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
playbackRateMenu
|
playbackRateMenu
|
||||||
}
|
}
|
||||||
@@ -498,7 +723,6 @@ struct AVPlayerContentView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// 中间区:主控 (这里集成了您原本右下角的快进快退和下一集按钮)
|
|
||||||
HStack(spacing: 24) {
|
HStack(spacing: 24) {
|
||||||
Button {
|
Button {
|
||||||
wakeUpControls()
|
wakeUpControls()
|
||||||
@@ -553,7 +777,6 @@ struct AVPlayerContentView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// 右侧区:音量和全屏
|
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Button {
|
Button {
|
||||||
@@ -595,14 +818,30 @@ struct AVPlayerContentView: View {
|
|||||||
}
|
}
|
||||||
.frame(width: 150, alignment: .trailing)
|
.frame(width: 150, alignment: .trailing)
|
||||||
}
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.clear, .black.opacity(0.6)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(.bottom, 0)
|
||||||
|
.frame(width: controlWidth)
|
||||||
|
#else
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.glassCard(cornerRadius: 18)
|
.glassCard(cornerRadius: 18)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, 6)
|
||||||
.frame(width: containerWidth * 0.7)
|
.frame(width: controlWidth)
|
||||||
|
#endif
|
||||||
.environment(\.colorScheme, .dark)
|
.environment(\.colorScheme, .dark)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,17 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
|||||||
}()
|
}()
|
||||||
private typealias LibVLCStopAsyncFunction = @convention(c) (UnsafeMutableRawPointer?) -> Void
|
private typealias LibVLCStopAsyncFunction = @convention(c) (UnsafeMutableRawPointer?) -> Void
|
||||||
|
|
||||||
let mediaPlayer = VLCMediaPlayer(options: VLCPlayerController.stablePlaybackOptions)
|
private var _mediaPlayer: VLCMediaPlayer?
|
||||||
|
var mediaPlayer: VLCMediaPlayer {
|
||||||
|
if let existing = _mediaPlayer {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
let player = VLCMediaPlayer(options: VLCPlayerController.stablePlaybackOptions)
|
||||||
|
_mediaPlayer = player
|
||||||
|
player.delegate = self
|
||||||
|
player.drawable = persistentDrawableView
|
||||||
|
return player
|
||||||
|
}
|
||||||
@Published var isPreparing = true
|
@Published var isPreparing = true
|
||||||
@Published var isPlaying = false
|
@Published var isPlaying = false
|
||||||
@Published var currentTimeSeconds: Double = 0
|
@Published var currentTimeSeconds: Double = 0
|
||||||
@@ -107,8 +117,18 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
|||||||
#else
|
#else
|
||||||
persistentDrawableView.backgroundColor = .black
|
persistentDrawableView.backgroundColor = .black
|
||||||
#endif
|
#endif
|
||||||
mediaPlayer.delegate = self
|
}
|
||||||
mediaPlayer.drawable = persistentDrawableView
|
|
||||||
|
deinit {
|
||||||
|
if let player = _mediaPlayer {
|
||||||
|
_mediaPlayer = nil
|
||||||
|
// VLCMediaPlayer 的 dealloc 会阻塞(libvlc_media_player_release 内部 dispatch_sync 到主线程)。
|
||||||
|
// 将引用移到后台线程,让 dealloc 在后台发生,避免阻塞主线程。
|
||||||
|
nonisolated(unsafe) let p = player
|
||||||
|
DispatchQueue.global(qos: .utility).async { [p] in
|
||||||
|
_ = p
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func play(
|
func play(
|
||||||
@@ -167,7 +187,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
|||||||
"network-caching": cacheConfig.network,
|
"network-caching": cacheConfig.network,
|
||||||
"live-caching": cacheConfig.live,
|
"live-caching": cacheConfig.live,
|
||||||
"file-caching": cacheConfig.file,
|
"file-caching": cacheConfig.file,
|
||||||
"http-reconnect": 1
|
"http-reconnect": 1,
|
||||||
|
"http-user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
]
|
]
|
||||||
if !isLive {
|
if !isLive {
|
||||||
mediaOptions["avcodec-hurry-up"] = 0
|
mediaOptions["avcodec-hurry-up"] = 0
|
||||||
@@ -204,8 +225,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
|||||||
stopProgressTimer()
|
stopProgressTimer()
|
||||||
resetPlaybackRecoveryState()
|
resetPlaybackRecoveryState()
|
||||||
cancelScheduledRebinds()
|
cancelScheduledRebinds()
|
||||||
stopMediaPlayer()
|
|
||||||
mediaPlayer.media = nil
|
|
||||||
onProgressChanged = nil
|
onProgressChanged = nil
|
||||||
onPlaybackEnded = nil
|
onPlaybackEnded = nil
|
||||||
onPlaybackFailed = nil
|
onPlaybackFailed = nil
|
||||||
@@ -216,6 +235,17 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
|||||||
currentMediaIsLive = false
|
currentMediaIsLive = false
|
||||||
currentMediaDecodeMode = .auto
|
currentMediaDecodeMode = .auto
|
||||||
currentMediaBufferMode = .defaultMode
|
currentMediaBufferMode = .defaultMode
|
||||||
|
guard let player = _mediaPlayer else { return }
|
||||||
|
// 立即静音(audio.volume 是轻量属性设置,不会 dispatch_sync)
|
||||||
|
player.audio?.volume = 0
|
||||||
|
// 将 mediaPlayer 引用移到后台释放。
|
||||||
|
// 不调用 pause/stop/media=nil 等 VLC API — 它们会 dispatch_sync 回主线程导致卡顿。
|
||||||
|
// VLCMediaPlayer 的 dealloc 会在后台线程自然完成清理。
|
||||||
|
_mediaPlayer = nil
|
||||||
|
nonisolated(unsafe) let p = player
|
||||||
|
DispatchQueue.global(qos: .utility).async { [p] in
|
||||||
|
_ = p
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func togglePlayback() {
|
func togglePlayback() {
|
||||||
@@ -358,9 +388,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
private func refreshDrawableBinding() {
|
private func refreshDrawableBinding() {
|
||||||
// 强制重绑视频输出,规避 macOS 切全屏后偶发“有声音无画面”
|
#if os(macOS)
|
||||||
|
// macOS: 强制重绑视频输出,规避切全屏后偶发“有声音无画面”
|
||||||
mediaPlayer.drawable = nil
|
mediaPlayer.drawable = nil
|
||||||
mediaPlayer.drawable = persistentDrawableView
|
mediaPlayer.drawable = persistentDrawableView
|
||||||
|
#else
|
||||||
|
// iOS: 直接赋值,不置空 drawable,避免触发 state change 导致 SwiftUI dismiss 动画中断
|
||||||
|
mediaPlayer.drawable = persistentDrawableView
|
||||||
|
#endif
|
||||||
lastDrawableRebindAt = Date()
|
lastDrawableRebindAt = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +406,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
|||||||
stopAsync(playerPointer)
|
stopAsync(playerPointer)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mediaPlayer.stop()
|
// VLCKit 3.x 没有 async stop API,同步 stop 会阻塞主线程 2-3 秒。
|
||||||
|
// 将其放到后台队列执行,避免 UI 卡顿。
|
||||||
|
nonisolated(unsafe) let player = mediaPlayer
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playerInstancePointer() -> UnsafeMutableRawPointer? {
|
private func playerInstancePointer() -> UnsafeMutableRawPointer? {
|
||||||
@@ -825,12 +865,14 @@ struct VLCVodPlayerView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
VLCDrawableView(controller: controller)
|
VLCDrawableView(controller: controller)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
|
#if !os(iOS)
|
||||||
.onTapGesture(count: 2) {
|
.onTapGesture(count: 2) {
|
||||||
onToggleFullScreen?()
|
onToggleFullScreen?()
|
||||||
}
|
}
|
||||||
.onTapGesture(count: 1) {
|
.onTapGesture(count: 1) {
|
||||||
togglePlaybackWithOSD()
|
togglePlaybackWithOSD()
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
if controller.isPreparing {
|
if controller.isPreparing {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -848,10 +890,24 @@ struct VLCVodPlayerView: View {
|
|||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.overlay {
|
||||||
|
PlayerGestureLayer(
|
||||||
|
onSeek: { offset in controller.seek(by: offset) },
|
||||||
|
onTogglePlayPause: { togglePlaybackWithOSD() },
|
||||||
|
onToggleControls: { wakeUpControls() },
|
||||||
|
onZoomChanged: { _ in },
|
||||||
|
currentTime: controller.currentTimeSeconds,
|
||||||
|
duration: controller.durationSeconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
GeometryReader { proxy in
|
GeometryReader { proxy in
|
||||||
playbackControls(containerWidth: proxy.size.width)
|
playbackControls(containerWidth: proxy.size.width)
|
||||||
|
#if os(macOS)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
|
#endif
|
||||||
.opacity(showControls ? 1.0 : 0.0)
|
.opacity(showControls ? 1.0 : 0.0)
|
||||||
.animation(.easeInOut(duration: 0.3), value: showControls)
|
.animation(.easeInOut(duration: 0.3), value: showControls)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||||
@@ -937,7 +993,10 @@ struct VLCVodPlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startPlayback() {
|
private func startPlayback() {
|
||||||
guard let url = URL(string: urlString) else { return }
|
guard let url = Self.sanitizedURL(from: urlString) else {
|
||||||
|
print("[VLC] URL sanitization failed for: \(urlString)")
|
||||||
|
return
|
||||||
|
}
|
||||||
let targetStartPosition = max(startPosition, 0)
|
let targetStartPosition = max(startPosition, 0)
|
||||||
draggingSeconds = targetStartPosition
|
draggingSeconds = targetStartPosition
|
||||||
startPlaybackTask?.cancel()
|
startPlaybackTask?.cancel()
|
||||||
@@ -956,6 +1015,19 @@ struct VLCVodPlayerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将原始 URL 字符串转换为合法的 URL,处理未编码的特殊字符。
|
||||||
|
private static func sanitizedURL(from urlString: String) -> URL? {
|
||||||
|
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let url = URL(string: trimmed) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||||
|
let url = URL(string: encoded) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
private var seekStep: Double {
|
private var seekStep: Double {
|
||||||
let saved = UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TIME_STEP)
|
let saved = UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TIME_STEP)
|
||||||
return Double(saved > 0 ? saved : 10)
|
return Double(saved > 0 ? saved : 10)
|
||||||
@@ -976,8 +1048,128 @@ struct VLCVodPlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func playbackControls(containerWidth: CGFloat) -> some View {
|
private func playbackControls(containerWidth: CGFloat) -> some View {
|
||||||
VStack(spacing: 8) {
|
#if os(iOS)
|
||||||
// 第一行:进度条和时间
|
let controlWidth = containerWidth * 1.0
|
||||||
|
#else
|
||||||
|
let controlWidth = containerWidth * 0.7
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return VStack(spacing: 0) {
|
||||||
|
#if os(iOS)
|
||||||
|
// iOS: 紧凑单行布局 — 进度条在上,按钮在下紧贴
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(currentDisplaySeconds.durationString)
|
||||||
|
.font(.system(size: 10, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { isDraggingProgress ? draggingSeconds : controller.currentTimeSeconds },
|
||||||
|
set: {
|
||||||
|
draggingSeconds = $0
|
||||||
|
wakeUpControls()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
in: 0...progressUpperBound,
|
||||||
|
onEditingChanged: { editing in
|
||||||
|
isDraggingProgress = editing
|
||||||
|
wakeUpControls()
|
||||||
|
if !editing {
|
||||||
|
controller.seek(to: draggingSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.accentColor(.white)
|
||||||
|
.disabled(!controller.hasValidDuration)
|
||||||
|
|
||||||
|
Text(totalDisplayText)
|
||||||
|
.font(.system(size: 10, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(.white.opacity(0.5))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
// 控制按钮行 — 紧凑排列
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
// 左:倍速
|
||||||
|
playbackRateMenu
|
||||||
|
.frame(minWidth: 36, alignment: .leading)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 中间:主控按钮
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
Button {
|
||||||
|
wakeUpControls()
|
||||||
|
controller.seek(by: -seekStep)
|
||||||
|
showOSD(icon: "gobackward.\(Int(seekStep))")
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "gobackward.\(Int(seekStep))")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.frame(minWidth: 36, minHeight: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
wakeUpControls()
|
||||||
|
togglePlaybackWithOSD()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: controller.isPlaying ? "pause.fill" : "play.fill")
|
||||||
|
.font(.system(size: 20, weight: .bold))
|
||||||
|
.frame(minWidth: 36, minHeight: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
wakeUpControls()
|
||||||
|
controller.seek(by: seekStep)
|
||||||
|
showOSD(icon: "goforward.\(Int(seekStep))")
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "goforward.\(Int(seekStep))")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.frame(minWidth: 36, minHeight: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if let onPlayNext {
|
||||||
|
Button {
|
||||||
|
guard canPlayNext else { return }
|
||||||
|
wakeUpControls()
|
||||||
|
onPlayNext()
|
||||||
|
showOSD(icon: "forward.end.fill")
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "forward.end.fill")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.frame(minWidth: 36, minHeight: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!canPlayNext)
|
||||||
|
.opacity(canPlayNext ? 1 : 0.4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 右:全屏
|
||||||
|
if let onToggleFullScreen {
|
||||||
|
Button {
|
||||||
|
wakeUpControls()
|
||||||
|
onToggleFullScreen()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.frame(minWidth: 36, minHeight: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
#else
|
||||||
|
// macOS: 保持两行布局
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Text(currentDisplaySeconds.durationString)
|
Text(currentDisplaySeconds.durationString)
|
||||||
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||||||
@@ -1013,9 +1205,7 @@ struct VLCVodPlayerView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
|
|
||||||
// 第二行:控制按钮
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
// 左侧区:倍速
|
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
playbackRateMenu
|
playbackRateMenu
|
||||||
}
|
}
|
||||||
@@ -1023,7 +1213,6 @@ struct VLCVodPlayerView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// 中间区:主控
|
|
||||||
HStack(spacing: 24) {
|
HStack(spacing: 24) {
|
||||||
Button {
|
Button {
|
||||||
wakeUpControls()
|
wakeUpControls()
|
||||||
@@ -1078,7 +1267,6 @@ struct VLCVodPlayerView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// 右侧区:音量和全屏
|
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Button {
|
Button {
|
||||||
@@ -1120,14 +1308,30 @@ struct VLCVodPlayerView: View {
|
|||||||
}
|
}
|
||||||
.frame(width: 150, alignment: .trailing)
|
.frame(width: 150, alignment: .trailing)
|
||||||
}
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.clear, .black.opacity(0.6)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(.bottom, 0)
|
||||||
|
.frame(width: controlWidth)
|
||||||
|
#else
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.glassCard(cornerRadius: 18)
|
.glassCard(cornerRadius: 18)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, 6)
|
||||||
.frame(width: containerWidth * 0.7)
|
.frame(width: controlWidth)
|
||||||
|
#endif
|
||||||
.environment(\.colorScheme, .dark)
|
.environment(\.colorScheme, .dark)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1197,17 +1401,21 @@ struct VLCLivePlayerView: View {
|
|||||||
@State private var osdIcon: String?
|
@State private var osdIcon: String?
|
||||||
@State private var osdOpacity: Double = 0
|
@State private var osdOpacity: Double = 0
|
||||||
@State private var osdTimer: Timer?
|
@State private var osdTimer: Timer?
|
||||||
|
@State private var showControls = true
|
||||||
|
@State private var controlsTimer: Timer?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
VLCDrawableView(controller: controller)
|
VLCDrawableView(controller: controller)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
|
#if !os(iOS)
|
||||||
.onTapGesture(count: 2) {
|
.onTapGesture(count: 2) {
|
||||||
onToggleFullScreen?()
|
onToggleFullScreen?()
|
||||||
}
|
}
|
||||||
.onTapGesture(count: 1) {
|
.onTapGesture(count: 1) {
|
||||||
togglePlaybackWithOSD()
|
togglePlaybackWithOSD()
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
if controller.isPreparing {
|
if controller.isPreparing {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -1225,6 +1433,23 @@ struct VLCLivePlayerView: View {
|
|||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.overlay {
|
||||||
|
LivePlayerGestureLayer(
|
||||||
|
onTogglePlayPause: { togglePlaybackWithOSD() },
|
||||||
|
onToggleControls: { wakeUpControls() },
|
||||||
|
onVolumeChanged: { delta in
|
||||||
|
let newVol = max(0, min(200, controller.volume + Int(delta * 200)))
|
||||||
|
controller.setVolume(newVol)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
liveControlsOverlay
|
||||||
|
.opacity(showControls ? 1.0 : 0.0)
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: showControls)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
.overlay {
|
.overlay {
|
||||||
KeyboardShortcutCaptureView(
|
KeyboardShortcutCaptureView(
|
||||||
onLeft: { },
|
onLeft: { },
|
||||||
@@ -1248,13 +1473,58 @@ struct VLCLivePlayerView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
startPlayback()
|
startPlayback()
|
||||||
|
wakeUpControls()
|
||||||
}
|
}
|
||||||
.onChange(of: urlString) { _, _ in
|
.onChange(of: urlString) { _, _ in
|
||||||
startPlayback()
|
startPlayback()
|
||||||
|
wakeUpControls()
|
||||||
|
}
|
||||||
|
.onChange(of: activityToken) { _, _ in
|
||||||
|
wakeUpControls()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
controller.stop()
|
controller.stop()
|
||||||
osdTimer?.invalidate()
|
osdTimer?.invalidate()
|
||||||
|
controlsTimer?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iOS Live Controls Overlay
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private var liveControlsOverlay: some View {
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
// Play/Pause button
|
||||||
|
Button {
|
||||||
|
wakeUpControls()
|
||||||
|
togglePlaybackWithOSD()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: controller.isPlaying ? "pause.fill" : "play.fill")
|
||||||
|
.font(.system(size: 20, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
.background(Color.white.opacity(0.15))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.glassCard(cornerRadius: 14)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - Controls Timer
|
||||||
|
|
||||||
|
private func wakeUpControls() {
|
||||||
|
withAnimation { showControls = true }
|
||||||
|
controlsTimer?.invalidate()
|
||||||
|
controlsTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { _ in
|
||||||
|
withAnimation(.easeOut(duration: 0.5)) {
|
||||||
|
showControls = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1275,7 +1545,8 @@ struct VLCLivePlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startPlayback() {
|
private func startPlayback() {
|
||||||
guard let url = URL(string: urlString) else {
|
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let url = URL(string: trimmed) ?? trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed).flatMap({ URL(string: $0) }) else {
|
||||||
onPlaybackFailed?()
|
onPlaybackFailed?()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -127,7 +127,11 @@ struct SearchView: View {
|
|||||||
NavigationLink(value: video) {
|
NavigationLink(value: video) {
|
||||||
VodCardView(video: video)
|
VodCardView(video: video)
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.buttonStyle(VodCardPressStyle())
|
||||||
|
#else
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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