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