diff --git a/README.md b/README.md
index bdefdda..04ce52f 100644
--- a/README.md
+++ b/README.md
@@ -1,30 +1,40 @@
-# SwiftUI video player iOS 14+, macOS 11+, tvOS 14+
-### *Please star the repository if you believe continuing the development of this package is worthwhile. This will help me understand which package deserves more effort.*
+# Coming soon: Metal shaders for video
+iOS 14+, macOS 11+, tvOS 14+
+
+## ⭐ Star it, please — so I know it’s worth improving further.
+
+Support for applying **Metal shaders** directly to **video frames**.
+Real-time effects on the timeline, no extra copies or heavy post-processing.
[](https://swiftpackageindex.com/swiftuiux/swiftui-loop-videoplayer)
[](https://swiftpackageindex.com/swiftuiux/swiftui-loop-videoPlayer)
-## Why if we have Apple’s VideoPlayer ?!
-Apple’s VideoPlayer offers a quick setup for video playback in SwiftUI but for example it doesn’t allow you to hide or customize the default video controls UI, limiting its use for custom scenarios. In contrast, this solution provides full control over playback, including the ability to disable or hide UI elements, making it suitable for background videos, tooltips, and video hints etc. Additionally, it supports advanced features like subtitles, seamless looping and real-time filter application, adding vector graphics upon the video stream etc. This package uses a declarative approach to declare parameters for the video component based on building blocks. This implementation might give some insights into how SwiftUI works under the hood. You can also pass parameters in the common way.
It is a pure package without any third-party libraries. My main focus was on performance. Especially if you need to add a video in the background as a design element, in such cases, you’d want a lightweight component without a lot of unnecessary features. **I hope it serves you well**.
-*If you profile the package, do it on a real device. There’s an enormous difference in results compared to the simulator.*
+### 🟩 Demo project showing video player usage and features: [follow the link](https://github.com/swiftuiux/swiftui-video-player-example)
+
-## SwiftUI app example [follow the link](https://github.com/swiftuiux/swiftui-video-player-example)
+## Why if we have Apple’s VideoPlayer ?!
+
+Apple’s VideoPlayer offers a quick setup for video playback in SwiftUI. However, it does not allow you to hide or customize the default video controls UI, limiting its use in custom scenarios. Alternatively, you would need to use AVPlayerViewController, which includes a lot of functionality just to disable the controls. In contrast, this solution acts as a modular framework, allowing you to integrate only the functionalities you need while keeping the core component lightweight. It provides full control over playback, including the ability to add custom UI elements, making it ideal for background videos, tooltips, video hints, and other custom scenarios. Additionally, it supports advanced features such as subtitles, seamless looping, and real-time filter application. It also allows adding vector graphics to the video stream, accessing frame data for custom filtering, and **applying ML or AI algorithms to video processing**, enabling a wide range of enhancements and modifications etc.
+
+*This package uses a declarative approach to declare parameters for the video component based on building blocks. This implementation might give some insights into how SwiftUI works under the hood. You can also pass parameters in the common way. If you profile the package, do it on a real device.*
```swift
ExtVideoPlayer{
VideoSettings{
- SourceName("swipe")
+ SourceName("swipe")
}
}
```
-
+or
-## [Documentation(API)](https://swiftpackageindex.com/swiftuiux/swiftui-loop-videoplayer/main/documentation/swiftui_loop_videoplayer)
+ ```swift
+ ExtVideoPlayer(fileName: 'swipe')
+```
## Philosophy of Player Dynamics
@@ -32,9 +42,14 @@ The player's functionality is designed around a dual ⇆ interaction model:
- **Commands and Settings**: Through these, you instruct the player on what to do and how to do it. Settings define the environment and initial state, while commands offer real-time control. As for now, you can conveniently pass command by command; perhaps later I’ll add support for batch commands
-- **Event Feedback**: Through event handling, the player communicates back to the application, informing it of internal changes that may need attention. Due to the nature of media players, especially in environments with dynamic content or user interactions, the flow of events can become flooded. To manage this effectively and prevent the application from being overwhelmed by the volume of incoming events, the **system collects these events every second and returns them as a batch**
+- **Event Feedback**: Through event handling, the player communicates back to the application, informing it of internal changes that may need attention. Due to the nature of media players, especially in environments with dynamic content or user interactions, the flow of events can become flooded. To manage this effectively and prevent the application from being overwhelmed by the volume of incoming events, the **system collects these events every second and returns them as a batch**. Pass `Events()` in the `settings` to enable event mechanism.
+
+## [Documentation(API)](https://swiftpackageindex.com/swiftuiux/swiftui-loop-videoplayer/main/documentation/swiftui_loop_videoplayer)
-## Specs
+
+
+## Implemented Specs
+*The [sample app](https://github.com/swiftuiux/swiftui-video-player-example) demonstrates the majority of the specs implemented in the component*
| **Feature Category** | **Feature Name** | **Description** |
|----------------------------|---------------------------------------------|----------------------------------------------------------------------------------------------------------|
@@ -89,7 +104,10 @@ You can reach out the effect simply via mask modifier
```swift
ExtVideoPlayer(
settings : $settings,
- command: $playbackCommand
+ command: $playbackCommand,
+ VideoSettings{
+ SourceName("swipe")
+ }
)
.mask{
RoundedRectangle(cornerRadius: 25)
@@ -102,6 +120,8 @@ You can reach out the effect simply via mask modifier
[Perhaps that might be enough for your needs](https://github.com/swiftuiux/swiftui-loop-videoPlayer/issues/7#issuecomment-2341268743)
+
+
## Testing
The package includes unit tests that cover key functionality. While not exhaustive, these tests help ensure the core components work as expected. UI tests are in progress and are being developed [in the example application](https://github.com/swiftuiux/swiftui-video-player-example). The run_tests.sh is an example script that automates testing by encapsulating test commands into a single executable file, simplifying the execution process. You can configure the script to run specific testing environment relevant to your projects.
@@ -124,16 +144,17 @@ Please note that using videos from URLs requires ensuring that you have the righ
| Name | Description | Default |
|---------------|-----------------------------------------------------------------------------------------------------|---------|
-| **SourceName** | **Direct URL String** If the name represents a valid URL ( HTTP etc). **Local File URL** If the name is a valid local file path (file:// scheme). **Bundle Resource** It tries to locate the file in the main bundle using Bundle.main.url(forResource:withExtension:) | - |
+| **SourceName** | **Direct URL String** If the name represents a valid URL ( HTTP etc).
**Local File URL** If the name is a valid local file path (file:// scheme).
**Bundle Resource** It tries to locate the file in the main bundle using Bundle.main.url(forResource:withExtension:) | - |
| **Ext** | File extension for the video, used when loading from local resources. This is optional when a URL is provided and the URL ends with the video file extension. | "mp4" |
-| **Subtitles** | The URL or local filename of the WebVTT (.vtt) subtitles file to be merged with the video. With a AVMutableComposition approach that is used currently in the package, you cannot directly change the position or size of subtitles. AVFoundation’s built-in handling of “text” tracks simply renders them in a default style, without allowing additional layout options. Take a look on the implementation in the example app *Video8.swift* | - |
| **Gravity** | How the video content should be resized to fit the player's bounds. | .resizeAspect |
-| **TimePublishing** | Specifies the interval at which the player publishes the current playback time. | - |
| **Loop** | Whether the video should automatically restart when it reaches the end. If not explicitly passed, the video will not loop. | false |
| **Mute** | Indicates if the video should play without sound. | false |
| **NotAutoPlay** | Indicates if the video should not play after initialization. Notice that if you use `command` as a control flow for the player the start command should be `.idle` | false |
+| **TimePublishing** | Specifies the interval at which the player publishes the current playback time. | Not Enabled |
+| **Subtitles** | The URL or local filename of the WebVTT (.vtt) subtitles file to be merged with the video. With a AVMutableComposition approach that is used currently in the package, you cannot directly change the position or size of subtitles. AVFoundation’s built-in handling of “text” tracks simply renders them in a default style, without allowing additional layout options. Take a look on the implementation in the example app *Video8.swift* | Not Enabled |
| **EnableVector** | Use this struct to activate settings that allow the addition of vector-based overlays via commands. If it is not passed via settings, any commands to `addVector` or `removeAllVectors` will have no effect. | Not Enabled |
-|**PictureInPicture**| Enable Picture-in-Picture (PiP) support. If not passed than any command like `startPiP` or `stopPiP` have no effect. Take a look the example app *Video11.swift*. It does not work on simulator. You can observe the feature only on real devices.|
+|**PictureInPicture**| Enable Picture-in-Picture (PiP) support. If not passed than any command like `startPiP` or `stopPiP` have no effect. Take a look the example app *Video11.swift*. It does not work on simulator. You can observe the feature only on real devices.| Not Enabled |
+| **Events([.durationAny, .itemStatusChangedAny])** | If `Events()` is not passed in the `settings: VideoSettings`, the event mechanism is disabled. You can specify exactly which events you want to receive (e.g., .itemStatusChangedAny) or simply pass `Events()` to receive all available events. This setting was added to improve performance because events are emitted via @State changes, which trigger view updates. If you don’t need to observe any events, disabling them can significantly boost performance. Take a look on the implementation in the example app *Video8.swift* | Not Enabled |
### Additional Notes on Settings
@@ -146,9 +167,40 @@ Please note that using videos from URLs requires ensuring that you have the righ
## Commands
+### Handling Commands
+ ```swift
+ @State public var playbackCommand: PlaybackCommand = .idle
+ ```
+`@State` updates are asynchronous and batched in SwiftUI. When you assign:
+ ```swift
+ playbackCommand = .play
+ playbackCommand = .pause
+ ```
+SwiftUI only registers the last assignment (`.pause`) in the same run loop cycle, ignoring `.play`.
+To ensure .play is applied before .pause, you can use `Task` to schedule the second update on the next run loop iteration:
+
+**.play → .pause**
+ ```swift
+ playbackCommand = .play
+
+ Task {
+ playbackCommand = .pause
+ }
+```
+**.play → .pause → .play**
+
+```swift
+ playbackCommand = .play
+
+ Task {
+ playbackCommand = .pause
+ Task { playbackCommand = .play } // This runs AFTER `.pause`
+ }
+```
+
### Handling Sequential Similar Commands
-When using the video player controls in your SwiftUI application, it's important to understand how command processing works. Specifically, issuing two identical commands consecutively will result in the second command being ignored. This is due to the underlying implementation in SwiftUI that prevents redundant command execution to optimize performance and user experience.
+When using the video player controls in your SwiftUI application, it's important to understand how command processing works. Specifically, issuing two identical commands consecutively will result in the second command being ignored. This is due to the underlying implementation that prevents redundant command execution to optimize performance and user experience in terms of UI updates.
### Common Scenario
@@ -158,6 +210,17 @@ For example, if you attempt to pause the video player twice in a row, the second
In cases where you need to re-issue a command that might appear redundant but is necessary under specific conditions, you must insert an `idle` command between the two similar commands. The `idle` command resets the command state of the player, allowing subsequent commands to be processed as new actions.
+**.play → .idle → .play**
+
+```swift
+ playbackCommand = .play
+
+ Task {
+ playbackCommand = .idle
+ Task { playbackCommand = .play } // This runs AFTER `.idle`
+ }
+```
+
### Playback Commands
| Command | Description |
@@ -232,6 +295,8 @@ video_main.m3u8
## Player Events
+ *If `Events()` is not passed in the `settings: VideoSettings`, the event mechanism is disabled. You can specify exactly which events you want to receive (e.g., .itemStatusChangedAny) or simply pass .all to receive all available events. This setting was added to improve performance because events are emitted via @State changes, which trigger view updates. If you don’t need to observe any events, disabling them can significantly boost performance.*
+
| Event | Description |
|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|
| `seek(Bool, currentTime: Double)` | Represents an end seek action within the player. The first parameter (`Bool`) indicates whether the seek was successful, and the second parameter (`currentTime`) provides the time (in seconds) to which the player is seeking. |
@@ -248,6 +313,60 @@ video_main.m3u8
| `itemStatusChanged(AVPlayerItem.Status)` | Indicates that the AVPlayerItem's status has changed. Possible statuses: `.unknown`, `.readyToPlay`, `.failed`. |
| `duration(CMTime)` | Provides the duration of the AVPlayerItem when it is ready to play. The duration is given in `CMTime`. |
+## Player event filter
+`PlayerEventFilter` - this enum provides a structured way to filter `PlayerEvent` cases. I decided to introduce an additional structure that corresponds to `PlayerEvent`. All cases with parameters are replicated as *eventName***Any** to match any variation of that event, regardless of its associated values. If you need specific events that match certain event parameters, let me know, and I will add them.
+
+```swift
+ ExtVideoPlayer{
+ VideoSettings{
+ SourceName("swipe")
+ Events([.durationAny, .itemStatusChangedAny]) // *Events([PlayerEventFilter])*
+ }
+ }
+ .onPlayerEventChange { events in
+ // Here come only events [.durationAny, .itemStatusChangedAny]: any duration and any item status change events.
+ }
+```
+
+### Event filter table
+
+| **Filter** | **Description** |
+|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|
+| `seekAny` | Matches any `.seek(...)` case, regardless of whether the seek was successful (`Bool`) or the target seek time (`currentTime: Double`). |
+| `paused` | Matches exactly the `.paused` event, which indicates that playback has been paused by the user or programmatically. |
+| `waitingToPlayAtSpecifiedRate` | Matches exactly the `.waitingToPlayAtSpecifiedRate` event, which occurs when the player is buffering or waiting for sufficient data. |
+| `playing` | Matches exactly the `.playing` event, indicating that the player is actively playing media. |
+| `currentItemChangedAny` | Matches any `.currentItemChanged(...)` case, triggered when the player's `currentItem` is updated to a new media item. |
+| `currentItemRemoved` | Matches exactly the `.currentItemRemoved` event, occurring when the player's `currentItem` is set to `nil`. |
+| `errorAny` | Matches any `.error(...)` case, representing an error within the player, with a `VPErrors` enum indicating the specific issue. |
+| `volumeChangedAny` | Matches any `.volumeChanged(...)` case, triggered when the player's volume level is adjusted. |
+| `boundsChangedAny` | Matches any `.boundsChanged(...)` case, triggered when the bounds of the main layer change. |
+| `startedPiP` | Matches exactly the `.startedPiP` event, triggered when Picture-in-Picture (PiP) mode starts. |
+| `stoppedPiP` | Matches exactly the `.stoppedPiP` event, triggered when Picture-in-Picture (PiP) mode stops. |
+| `itemStatusChangedAny` | Matches any `.itemStatusChanged(...)` case, indicating that the AVPlayerItem's status has changed (e.g., `.unknown`, `.readyToPlay`, `.failed`). |
+| `durationAny` | Matches any `.duration(...)` case, which provides the duration of the media item when ready to play. |
+
+
+### Additional Notes on Errors
+When the URL is syntactically valid but the resource does not actually exist (e.g., a 404 response or an unreachable server), AVPlayerItem.status can remain .unknown indefinitely. It may never transition to .failed, and the .AVPlayerItemFailedToPlayToEndTime notification won’t fire if playback never starts.
+
+**Workarounds and Best Practices**
+*Pre-Check the URL With HEAD*
+
+If you want to ensure that a URL is valid before passing it to the component (AVPlayerItem), use for example a simple HEAD request via URLSession to check for a valid 2xx response.
+
+```swift
+func checkURLExists(_ url: URL) async throws -> Bool {
+ var request = URLRequest(url: url)
+ request.httpMethod = "HEAD"
+
+ let (_, response) = try await URLSession.shared.data(for: request)
+ if let httpResponse = response as? HTTPURLResponse {
+ return (200...299).contains(httpResponse.statusCode)
+ }
+ return false
+}
+```
### Additional Notes on Adding and Removing Vector Graphics
@@ -272,7 +391,7 @@ Integrating vector graphics into SwiftUI views, particularly during lifecycle ev
### 1. Create LoopPlayerView
```swift
-ExtVideoPlayer(fileName: 'swipe')
+ ExtVideoPlayer(fileName: 'swipe')
```
or in a declarative way
@@ -285,6 +404,7 @@ or in a declarative way
Ext("mp8") // Set default extension here If not provided then mp4 is default
Gravity(.resizeAspectFill)
TimePublishing()
+ Events([.durationAny, .itemStatusChangedAny])
}
}
.onPlayerTimeChange { newTime in
@@ -293,8 +413,7 @@ or in a declarative way
.onPlayerEventChange { events in
// Player events
}
-```
-
+```
```swift
ExtVideoPlayer{
@@ -344,27 +463,6 @@ ExtVideoPlayer{
| HLS Streams | Yes | HLS streams are supported and can be used for live streaming purposes. |
-## New Functionality: Playback Commands
-
-The package now supports playback commands, allowing you to control video playback actions such as play, pause, and seek to specific times.
-
-```swift
-struct VideoView: View {
- @State private var playbackCommand: PlaybackCommand = .play
-
- var body: some View {
- ExtVideoPlayer(
- {
- VideoSettings {
- SourceName("swipe")
- }
- },
- command: $playbackCommand
- )
- }
-}
-```
-
## Practical ideas for the package
You can introduce video hints about some functionality into the app, for example how to add positions to favorites. Put loop video hint into background or open as popup.
diff --git a/Sources/swiftui-loop-videoplayer/ExtVideoPlayer.swift b/Sources/swiftui-loop-videoplayer/ExtVideoPlayer.swift
index 1abb91a..a0421f5 100644
--- a/Sources/swiftui-loop-videoplayer/ExtVideoPlayer.swift
+++ b/Sources/swiftui-loop-videoplayer/ExtVideoPlayer.swift
@@ -105,10 +105,37 @@ public struct ExtVideoPlayer: View{
.onReceive(timePublisher.receive(on: DispatchQueue.main), perform: { time in
currentTime = time
})
- .onReceive(eventPublisher.collect(.byTime(DispatchQueue.main, .seconds(1))), perform: { event in
- playerEvent = event
+ .onReceive(eventPublisher
+ .collect(.byTime(DispatchQueue.main, .seconds(1))), perform: { event in
+ playerEvent = filterEvents(with: settings, for: event)
})
.preference(key: CurrentTimePreferenceKey.self, value: currentTime)
.preference(key: PlayerEventPreferenceKey.self, value: playerEvent)
}
}
+
+// MARK: - Fileprivate
+
+/// Filters a list of `PlayerEvent` instances based on the provided `VideoSettings`.
+///
+/// - Parameters:
+/// - settings: The video settings containing event filters.
+/// - events: The list of events to be filtered.
+/// - Returns: A filtered list of `PlayerEvent` that match at least one filter in `settings`.
+fileprivate func filterEvents(with settings: VideoSettings, for events: [PlayerEvent]) -> [PlayerEvent] {
+ let filters = settings.events // `[PlayerEventFilter]`
+
+ // If no filters are provided, return an empty array.
+ guard let filters else {
+ return []
+ }
+
+ guard !filters.isEmpty else{
+ return events
+ }
+
+ // Keep each `PlayerEvent` only if it matches *at least* one filter in `filters`.
+ return events.filter { event in
+ filters.contains(event)
+ }
+}
diff --git a/Sources/swiftui-loop-videoplayer/enum/PlayerEvent.swift b/Sources/swiftui-loop-videoplayer/enum/PlayerEvent.swift
index 6a1a169..0398f7b 100644
--- a/Sources/swiftui-loop-videoplayer/enum/PlayerEvent.swift
+++ b/Sources/swiftui-loop-videoplayer/enum/PlayerEvent.swift
@@ -11,7 +11,7 @@ import AVFoundation
/// An enumeration representing various events that can occur within a media player.
@available(iOS 14.0, macOS 11.0, tvOS 14.0, *)
public enum PlayerEvent: Equatable {
-
+
/// Represents an end seek action within the player.
/// - Parameters:
/// - Bool: Indicates whether the seek was successful.
diff --git a/Sources/swiftui-loop-videoplayer/enum/PlayerEventFilter.swift b/Sources/swiftui-loop-videoplayer/enum/PlayerEventFilter.swift
new file mode 100644
index 0000000..bb0b711
--- /dev/null
+++ b/Sources/swiftui-loop-videoplayer/enum/PlayerEventFilter.swift
@@ -0,0 +1,108 @@
+//
+// PlayerEventFilter.swift
+// swiftui-loop-videoplayer
+//
+// Created by Igor Shelopaev on 12.02.25.
+//
+
+import Foundation
+
+/// A "parallel" structure for filtering PlayerEvent.
+/// Each case here:
+/// 1) Either ignores associated values (xxxAny)
+/// 2) Or matches cases that have no associated values in PlayerEvent.
+@available(iOS 14.0, macOS 11.0, tvOS 14.0, *)
+public enum PlayerEventFilter {
+ /// Matches any `.seek(...)` case, regardless of Bool or currentTime
+ case seekAny
+
+ /// Matches `.paused` exactly (no associated values)
+ case paused
+
+ /// Matches `.waitingToPlayAtSpecifiedRate` (no associated values)
+ case waitingToPlayAtSpecifiedRate
+
+ /// Matches `.playing` (no associated values)
+ case playing
+
+ /// Matches any `.currentItemChanged(...)` case
+ case currentItemChangedAny
+
+ /// Matches `.currentItemRemoved` exactly (no associated values)
+ case currentItemRemoved
+
+ /// Matches any `.volumeChanged(...)` case
+ case volumeChangedAny
+
+ /// Matches any `.error(...)` case
+ case errorAny
+
+ /// Matches any `.boundsChanged(...)` case
+ case boundsChangedAny
+
+ /// Matches `.startedPiP` (no associated values)
+ case startedPiP
+
+ /// Matches `.stoppedPiP` (no associated values)
+ case stoppedPiP
+
+ /// Matches any `.itemStatusChanged(...)` case
+ case itemStatusChangedAny
+
+ /// Matches any `.duration(...)` case
+ case durationAny
+}
+
+extension PlayerEventFilter {
+ /// Checks whether a given `PlayerEvent` matches this filter.
+ ///
+ /// - Parameter event: The `PlayerEvent` to inspect.
+ /// - Returns: `true` if the event belongs to this case (ignoring parameters), `false` otherwise.
+ func matches(_ event: PlayerEvent) -> Bool {
+ switch (self, event) {
+ // Compare by case name only, ignoring associated values
+ case (.seekAny, .seek):
+ return true
+ case (.paused, .paused):
+ return true
+ case (.waitingToPlayAtSpecifiedRate, .waitingToPlayAtSpecifiedRate):
+ return true
+ case (.playing, .playing):
+ return true
+ case (.currentItemChangedAny, .currentItemChanged):
+ return true
+ case (.currentItemRemoved, .currentItemRemoved):
+ return true
+ case (.volumeChangedAny, .volumeChanged):
+ return true
+ case (.errorAny, .error):
+ return true
+ case (.boundsChangedAny, .boundsChanged):
+ return true
+ case (.startedPiP, .startedPiP):
+ return true
+ case (.stoppedPiP, .stoppedPiP):
+ return true
+ case (.itemStatusChangedAny, .itemStatusChanged):
+ return true
+ case (.durationAny, .duration):
+ return true
+
+ // Default fallback: no match
+ default:
+ return false
+ }
+ }
+}
+
+extension Collection where Element == PlayerEventFilter {
+ /// Checks whether any filter in this collection matches the given `PlayerEvent`.
+ ///
+ /// - Parameter event: The `PlayerEvent` to test.
+ /// - Returns: `true` if at least one `PlayerEventFilter` in this collection matches the `event`; otherwise, `false`.
+ func contains(_ event: PlayerEvent) -> Bool {
+ return self.contains { filter in
+ filter.matches(event)
+ }
+ }
+}
diff --git a/Sources/swiftui-loop-videoplayer/enum/Setting.swift b/Sources/swiftui-loop-videoplayer/enum/Setting.swift
index d4c57a0..4c7c341 100644
--- a/Sources/swiftui-loop-videoplayer/enum/Setting.swift
+++ b/Sources/swiftui-loop-videoplayer/enum/Setting.swift
@@ -11,59 +11,60 @@ import SwiftUI
import AVKit
#endif
-/// Settings for loop video player
+/// Configuration settings for a loop video player.
+/// These settings control various playback and UI behaviors.
@available(iOS 14.0, macOS 11.0, tvOS 14.0, *)
-public enum Setting: Equatable, SettingsConvertible{
-
- /// Converts the current setting to an array containing only this setting.
- /// - Returns: An array of `Setting` containing the single instance of this setting.
+public enum Setting: Equatable, SettingsConvertible {
+
+ /// Converts the current setting into an array containing itself.
+ /// - Returns: An array with a single instance of `Setting`.
public func asSettings() -> [Setting] {
[self]
}
-
- ///Enable vector layer to add overlay vector graphics
+
+ /// Event filters to monitor specific player events.
+ case events([PlayerEventFilter]?)
+
+ /// Enables a vector layer for overlaying vector graphics.
case vector
-
- /// Loop video
+
+ /// Enables looping of the video playback.
case loop
-
- /// Mute video
+
+ /// Mutes the video.
case mute
-
- /// Don't auto play video after initialization
+
+ /// Prevents automatic playback after initialization.
case notAutoPlay
-
- /// File name
+
+ /// Specifies the file name of the video.
case name(String)
- /// File extension
+ /// Specifies the file extension of the video.
case ext(String)
-
- /// Subtitles
+
+ /// Sets subtitles for the video.
case subtitles(String)
-
- /// Support Picture-in-Picture
+
+ /// Enables Picture-in-Picture (PiP) mode support.
case pictureInPicture
-
- /// A CMTime value representing the interval at which the player's current time should be published.
- /// If set, the player will publish periodic time updates based on this interval.
+
+ /// Defines the interval at which the player's current time should be published.
case timePublishing(CMTime)
- /// Video gravity
+ /// Sets the video gravity (e.g., aspect fit, aspect fill).
case gravity(AVLayerVideoGravity = .resizeAspect)
- /// Case name
+ /// Retrieves the name of the current case.
var caseName: String {
Mirror(reflecting: self).children.first?.label ?? "\(self)"
}
-
- /// Associated value
+
+ /// Retrieves the associated value of the case, if any.
var associatedValue: Any? {
-
guard let firstChild = Mirror(reflecting: self).children.first else {
return nil
}
-
return firstChild.value
}
}
diff --git a/Sources/swiftui-loop-videoplayer/enum/VPErrors.swift b/Sources/swiftui-loop-videoplayer/enum/VPErrors.swift
index a350354..0adb444 100644
--- a/Sources/swiftui-loop-videoplayer/enum/VPErrors.swift
+++ b/Sources/swiftui-loop-videoplayer/enum/VPErrors.swift
@@ -22,29 +22,30 @@ public enum VPErrors: Error, CustomStringConvertible, Sendable {
/// Error case for when settings are not unique.
case settingsNotUnique
- /// Picture-in-Picture (PiP) is not supported
+ /// Picture-in-Picture (PiP) is not supported.
case notSupportedPiP
- /// Failed to load
- case failedToLoad
+ /// Failed to load.
+ /// - Parameter error: The error encountered during loading.
+ case failedToLoad(Error?)
/// A description of the error, suitable for display.
public var description: String {
switch self {
- case .sourceNotFound(let name):
- return "Source not found: \(name)"
+ case .sourceNotFound(let name):
+ return "Source not found: \(name)"
- case .notSupportedPiP:
- return "Picture-in-Picture (PiP) is not supported on this device."
+ case .notSupportedPiP:
+ return "Picture-in-Picture (PiP) is not supported on this device."
- case .settingsNotUnique:
- return "Settings are not unique"
+ case .settingsNotUnique:
+ return "Settings are not unique."
- case .remoteVideoError(let error):
- return "Playback error: \(String(describing: error?.localizedDescription))"
+ case .remoteVideoError(let error):
+ return "Playback error: \(error?.localizedDescription ?? "Unknown error.")"
- case .failedToLoad:
- return "Failed to load the video."
+ case .failedToLoad(let error):
+ return "Failed to load the video: \(error?.localizedDescription ?? "Unknown error.")"
}
}
}
@@ -57,12 +58,19 @@ extension VPErrors: Equatable {
switch (lhs, rhs) {
case (.remoteVideoError(let a), .remoteVideoError(let b)):
return a?.localizedDescription == b?.localizedDescription
+
case (.sourceNotFound(let a), .sourceNotFound(let b)):
return a == b
+
case (.settingsNotUnique, .settingsNotUnique):
return true
- case (.failedToLoad, .failedToLoad):
+
+ case (.notSupportedPiP, .notSupportedPiP):
return true
+
+ case (.failedToLoad(let a), .failedToLoad(let b)):
+ return a?.localizedDescription == b?.localizedDescription
+
default:
return false
}
diff --git a/Sources/swiftui-loop-videoplayer/ext+/Array+.swift b/Sources/swiftui-loop-videoplayer/ext+/Array+.swift
index 0b172a2..606cd14 100644
--- a/Sources/swiftui-loop-videoplayer/ext+/Array+.swift
+++ b/Sources/swiftui-loop-videoplayer/ext+/Array+.swift
@@ -16,7 +16,6 @@ extension Array where Element == Setting{
self.first(where: { $0.caseName == name })
}
-
/// Fetch associated value
/// - Parameters:
/// - name: Case name
diff --git a/Sources/swiftui-loop-videoplayer/ext+/URL+.swift b/Sources/swiftui-loop-videoplayer/ext+/URL+.swift
index f797374..0186998 100644
--- a/Sources/swiftui-loop-videoplayer/ext+/URL+.swift
+++ b/Sources/swiftui-loop-videoplayer/ext+/URL+.swift
@@ -10,23 +10,26 @@ import Foundation
extension URL {
- /// Validates a string as a well-formed HTTP or HTTPS URL and returns a URL object if valid.
- ///
- /// - Parameter urlString: The string to validate as a URL.
- /// - Returns: An optional URL object if the string is a valid URL.
- /// - Throws: An error if the URL is not valid or cannot be created.
- static func validURLFromString(_ string: String) -> URL? {
- let pattern = "^(https?:\\/\\/)(([a-zA-Z0-9-]+\\.)*[a-zA-Z0-9-]+\\.[a-zA-Z]{2,})(:\\d{1,5})?(\\/[\\S]*)?$"
- let regex = try? NSRegularExpression(pattern: pattern, options: [])
-
- let matches = regex?.matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count))
-
- guard let _ = matches, !matches!.isEmpty else {
- // If no matches are found, the URL is not valid
- return nil
+ /// Validates and returns an HTTP/HTTPS URL or nil.
+ /// Strategy:
+ /// 1) Parse once to detect an existing scheme (mailto, ftp, etc.).
+ /// 2) If a scheme exists and it's not http/https -> reject.
+ static func validURLFromString(from raw: String) -> URL? {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ // First parse to detect an existing scheme.
+ if let pre = URLComponents(string: trimmed), let scheme = pre.scheme?.lowercased() {
+ // Reject anything that is not http/https.
+ guard scheme == "http" || scheme == "https" else { return nil }
+
+ let comps = pre
+ // Require a host
+ guard let host = comps.host, !host.isEmpty else { return nil }
+ // Validate port range
+ if let port = comps.port, !(1...65535).contains(port) { return nil }
+ return comps.url
}
- // If a match is found, attempt to create a URL object
- return URL(string: string)
+ return nil
}
}
diff --git a/Sources/swiftui-loop-videoplayer/fn/fn+.swift b/Sources/swiftui-loop-videoplayer/fn/fn+.swift
index dd287fe..a9204a3 100644
--- a/Sources/swiftui-loop-videoplayer/fn/fn+.swift
+++ b/Sources/swiftui-loop-videoplayer/fn/fn+.swift
@@ -44,7 +44,7 @@ func subtitlesAssetFor(_ settings: VideoSettings) -> AVURLAsset? {
/// - Returns: An optional `AVURLAsset`, or `nil` if neither a valid URL nor a local resource file is found.
fileprivate func assetFrom(name: String, fileExtension: String?) -> AVURLAsset? {
// Attempt to create a valid URL from the provided string.
- if let url = URL.validURLFromString(name) {
+ if let url = URL.validURLFromString(from: name) {
return AVURLAsset(url: url)
}
@@ -66,7 +66,7 @@ fileprivate func assetFrom(name: String, fileExtension: String?) -> AVURLAsset?
/// Attempts to create a valid `URL` from a string that starts with `"file://"`.
/// - Parameter rawString: A file URL string, e.g. `"file:///Users/igor/My Folder/File.mp4"`.
/// - Returns: A `URL` if successfully parsed; otherwise `nil`.
-func fileURL(from rawString: String) -> URL? {
+public func fileURL(from rawString: String) -> URL? {
guard rawString.hasPrefix("file://") else {
// Not a file URL scheme
return nil
@@ -110,7 +110,7 @@ fileprivate func extractExtension(from name: String) -> String? {
/// - contrast: A Float value representing the contrast adjustment to apply.
///
/// - Returns: An array of CIFilter objects, including the original filters and the added brightness and contrast adjustments.
-internal func combineFilters(_ filters: [CIFilter],_ brightness: Float,_ contrast: Float) -> [CIFilter] {
+func combineFilters(_ filters: [CIFilter],_ brightness: Float,_ contrast: Float) -> [CIFilter] {
var allFilters = filters
if let filter = CIFilter(name: "CIColorControls", parameters: [kCIInputBrightnessKey: brightness]) {
allFilters.append(filter)
@@ -130,7 +130,7 @@ internal func combineFilters(_ filters: [CIFilter],_ brightness: Float,_ contra
///
/// The function starts by clamping the source image to ensure coordinates remain within the image bounds,
/// applies each filter in the provided array, and completes by returning the modified image to the composition request.
-internal func handleVideoComposition(request: AVAsynchronousCIImageFilteringRequest, filters: [CIFilter]) {
+func handleVideoComposition(request: AVAsynchronousCIImageFilteringRequest, filters: [CIFilter]) {
// Start with the source image, ensuring it's clamped to avoid any coordinate issues
var currentImage = request.sourceImage.clampedToExtent()
@@ -243,27 +243,22 @@ func mergeAssetWithSubtitles(videoAsset: AVURLAsset, subtitleAsset: AVURLAsset)
/// - duration: A `CMTime` value representing the total duration of the media.
/// This value must be valid for the calculation to work correctly.
/// - Returns: A `CMTime` value representing the resolved seek position within the media.
-func getSeekTime(for time: Double, duration : CMTime) -> CMTime?{
-
- guard duration.value != 0 else{ return nil }
-
- let endTime = CMTimeGetSeconds(duration)
- let seekTime : CMTime
-
- if time < 0 {
- // If the time is negative, seek to the start of the video
- seekTime = .zero
- } else if time >= endTime {
- // If the time exceeds the video duration, seek to the end of the video
- let endCMTime = CMTime(seconds: endTime, preferredTimescale: duration.timescale)
- seekTime = endCMTime
- } else {
- // Otherwise, seek to the specified time
- let seekCMTime = CMTime(seconds: time, preferredTimescale: duration.timescale)
- seekTime = seekCMTime
- }
-
- return seekTime
+func getSeekTime(for time: Double, duration: CMTime) -> CMTime? {
+ guard duration.isValid,
+ duration.isNumeric,
+ duration.timescale != 0
+ else { return nil }
+
+ let endSeconds = CMTimeGetSeconds(duration)
+ guard endSeconds.isFinite, endSeconds >= 0 else { return nil }
+
+ if time <= 0 { return .zero }
+ if time >= endSeconds { return duration }
+
+ let clamped = max(0, min(time, endSeconds))
+
+ let scale = max(Int32(600), duration.timescale)
+ return CMTime(seconds: clamped, preferredTimescale: scale)
}
/// Creates an `AVPlayerItem` with optional subtitle merging.
diff --git a/Sources/swiftui-loop-videoplayer/protocol/player/ExtPlayerProtocol.swift b/Sources/swiftui-loop-videoplayer/protocol/player/ExtPlayerProtocol.swift
index 28bccce..f7c04e7 100644
--- a/Sources/swiftui-loop-videoplayer/protocol/player/ExtPlayerProtocol.swift
+++ b/Sources/swiftui-loop-videoplayer/protocol/player/ExtPlayerProtocol.swift
@@ -186,6 +186,7 @@ internal extension ExtPlayerProtocol {
}
observeItemStatus(newItem)
+
insert(newItem)
if settings.loop{
@@ -210,7 +211,7 @@ internal extension ExtPlayerProtocol {
self?.onError(.sourceNotFound(name))
}
}
-
+
/// Observes the status of an AVPlayerItem and notifies the delegate when the status changes.
/// - Parameter item: The AVPlayerItem whose status should be observed.
private func observeItemStatus(_ item: AVPlayerItem) {
@@ -223,20 +224,22 @@ internal extension ExtPlayerProtocol {
}
switch item.status {
- case .unknown: break
- case .readyToPlay:
- Task { @MainActor in
- self?.delegate?.duration(item.duration)
- }
- case .failed:
- Task { @MainActor in
- self?.onError(.failedToLoad)
+ case .unknown: break
+ case .readyToPlay:
+ Task { @MainActor in
+ self?.delegate?.duration(item.duration)
+ }
+ case .failed:
+ Task { @MainActor in
+ let error = self?.currentItem?.error
+ self?.onError(.failedToLoad(error))
+ }
+ @unknown default:
+ Task { @MainActor in
+ let error = self?.currentItem?.error
+ self?.onError(.failedToLoad(error))
+ }
}
- @unknown default:
- Task { @MainActor in
- self?.onError(.failedToLoad)
- }
- }
}
}
@@ -261,27 +264,27 @@ internal extension ExtPlayerProtocol {
timeControlObserver = player.observe(\.timeControlStatus, options: [.new, .old]) { [weak self] player, change in
switch player.timeControlStatus {
- case .paused:
- // This could mean playback has stopped, but it's not specific to end of playback
- Task { @MainActor in
- self?.delegate?.didPausePlayback()
- }
- case .waitingToPlayAtSpecifiedRate:
- // Player is waiting to play (e.g., buffering)
- Task { @MainActor in
- self?.delegate?.isWaitingToPlay()
+ case .paused:
+ // This could mean playback has stopped, but it's not specific to end of playback
+ Task { @MainActor in
+ self?.delegate?.didPausePlayback()
+ }
+ case .waitingToPlayAtSpecifiedRate:
+ // Player is waiting to play (e.g., buffering)
+ Task { @MainActor in
+ self?.delegate?.isWaitingToPlay()
+ }
+ case .playing:
+ // Player is currently playing
+ Task { @MainActor in
+ self?.delegate?.didStartPlaying()
+ }
+ @unknown default:
+ break
}
- case .playing:
- // Player is currently playing
- Task { @MainActor in
- self?.delegate?.didStartPlaying()
- }
- @unknown default:
- break
- }
}
- currentItemObserver = player.observe(\.currentItem, options: [.new, .old, .initial]) { [weak self] player, change in
+ currentItemObserver = player.observe(\.currentItem, options: [.new]) { [weak self] player, change in
// Detecting when the current item is changed
if let newItem = change.newValue as? AVPlayerItem {
Task { @MainActor in
@@ -342,26 +345,45 @@ internal extension ExtPlayerProtocol {
}
/// Sets the playback command for the video player.
- /// - Parameter value: The `PlaybackCommand` to set. This can be one of the following:
- /// - `play`: Command to play the video.
- /// - `pause`: Command to pause the video.
- /// - `seek(to:)`: Command to seek to a specific time in the video.
- /// - `begin`: Command to position the video at the beginning.
- /// - `end`: Command to position the video at the end.
- /// - `mute`: Command to mute the video.
- /// - `unmute`: Command to unmute the video.
- /// - `volume`: Command to adjust the volume of the video playback.
- /// - `subtitles`: Command to set subtitles to a specified language or turn them off.
- /// - `playbackSpeed`: Command to adjust the playback speed of the video.
- /// - `loop`: Command to enable looping of the video playback.
- /// - `unloop`: Command to disable looping of the video playback.
- /// - `brightness`: Command to adjust the brightness of the video playback.
- /// - `contrast`: Command to adjust the contrast of the video playback.
- /// - `filter`: Command to apply a specific Core Image filter to the video.
- /// - `removeAllFilters`: Command to remove all applied filters from the video playback.
- /// - `audioTrack`: Command to select a specific audio track based on language code.
- /// - `vector`: Sets a vector graphic operation on the video player.
- /// - `removeAllVectors`: Clears all vector graphics from the video player.
+ ///
+ /// - Parameter value: The `PlaybackCommand` to set. Available commands include:
+ ///
+ /// ### Playback Controls
+ /// - `play`: Starts video playback.
+ /// - `pause`: Pauses video playback.
+ /// - `seek(to:play:)`: Moves to a specified time in the video, with an option to start playing.
+ /// - `begin`: Moves the video to the beginning.
+ /// - `end`: Moves the video to the end.
+ ///
+ /// ### Audio & Volume
+ /// - `mute`: Mutes the video.
+ /// - `unmute`: Unmutes the video.
+ /// - `volume(level)`: Adjusts the volume to the specified level.
+ /// - `audioTrack(languageCode)`: Selects an audio track based on the given language code.
+ ///
+ /// ### Subtitles & Playback Speed
+ /// - `subtitles(language)`: Sets subtitles to a specified language or disables them.
+ /// - `playbackSpeed(speed)`: Adjusts the video playback speed.
+ ///
+ /// ### Looping
+ /// - `loop`: Enables video looping.
+ /// - `unloop`: Disables video looping.
+ ///
+ /// ### Video Adjustments
+ /// - `brightness(level)`: Adjusts the brightness of the video playback.
+ /// - `contrast(level)`: Adjusts the contrast of the video playback.
+ ///
+ /// ### Filters
+ /// - `filter(value, clear)`: Applies a specific Core Image filter to the video, optionally clearing previous filters.
+ /// - `removeAllFilters`: Removes all applied filters from the video playback.
+ ///
+ /// ### Vector Graphics
+ /// - `addVector(builder, clear)`: Adds a vector graphic overlay to the video player, with an option to clear previous vectors.
+ /// - `removeAllVectors`: Removes all vector graphics from the video player.
+ ///
+ /// ### Platform-Specific Features
+ /// - `startPiP` (iOS only): Starts Picture-in-Picture mode.
+ /// - `stopPiP` (iOS only): Stops Picture-in-Picture mode.
func setCommand(_ value: PlaybackCommand) {
switch value {
case .play:
diff --git a/Sources/swiftui-loop-videoplayer/settings/Events.swift b/Sources/swiftui-loop-videoplayer/settings/Events.swift
new file mode 100644
index 0000000..369a198
--- /dev/null
+++ b/Sources/swiftui-loop-videoplayer/settings/Events.swift
@@ -0,0 +1,33 @@
+//
+// Events.swift
+//
+//
+// Created by Igor Shelopaev on 14.01.25.
+//
+
+import Foundation
+
+/// Represents a collection of event filters that can be converted into settings.
+/// This struct is used to encapsulate `PlayerEventFilter` instances and provide a method
+/// to transform them into an array of `Setting` objects.
+@available(iOS 14.0, macOS 11.0, tvOS 14.0, *)
+public struct Events: SettingsConvertible {
+
+ // An optional array of PlayerEventFilter objects representing event filters
+ private let value: [PlayerEventFilter]?
+
+ // MARK: - Life cycle
+
+ /// Initializes a new instance of `Events`
+ /// - Parameter value: An optional array of `PlayerEventFilter` objects, defaulting to `nil`
+ public init(_ value: [PlayerEventFilter]? = nil) {
+ self.value = value
+ }
+
+ /// Converts the event filters into an array of `Setting` objects
+ /// Used for fetching settings in the application
+ @_spi(Private)
+ public func asSettings() -> [Setting] {
+ [.events(value)]
+ }
+}
diff --git a/Sources/swiftui-loop-videoplayer/utils/VideoSettings.swift b/Sources/swiftui-loop-videoplayer/utils/VideoSettings.swift
index 61293cd..d2c3f57 100644
--- a/Sources/swiftui-loop-videoplayer/utils/VideoSettings.swift
+++ b/Sources/swiftui-loop-videoplayer/utils/VideoSettings.swift
@@ -35,6 +35,9 @@ public struct VideoSettings: Equatable{
/// Enable vector layer to add overlay vector graphics
public let vector: Bool
+ /// Disable events
+ public let events: [PlayerEventFilter]?
+
/// Don't auto play video after initialization
public let notAutoPlay: Bool
@@ -58,15 +61,21 @@ public struct VideoSettings: Equatable{
// MARK: - Life circle
/// Initializes a new instance of `VideoSettings` with specified values for various video properties.
+ ///
/// - Parameters:
- /// - name: The name of the video.
- /// - ext: The video file extension.
- /// - loop: A Boolean indicating whether the video should loop.
- /// - mute: A Boolean indicating whether the video should be muted.
- /// - notAutoPlay: A Boolean indicating whether the video should not auto-play.
- /// - timePublishing: A `CMTime` value representing the interval for time publishing updates, or `nil`.
- /// - gravity: The `AVLayerVideoGravity` value defining how the video should be displayed in its layer.
- public init(name: String, ext: String, subtitles: String, loop: Bool, pictureInPicture: Bool, mute: Bool, notAutoPlay: Bool, timePublishing: CMTime?, gravity: AVLayerVideoGravity, enableVector : Bool = false) {
+ /// - name: The name of the video file (excluding the extension).
+ /// - ext: The file extension of the video (e.g., `"mp4"`, `"mov"`).
+ /// - subtitles: The subtitle file name or identifier to be used for the video.
+ /// - loop: A Boolean indicating whether the video should continuously loop after playback ends.
+ /// - pictureInPicture: A Boolean indicating whether Picture-in-Picture (PiP) mode is enabled.
+ /// - mute: A Boolean indicating whether the video should start muted.
+ /// - notAutoPlay: A Boolean indicating whether the video should not start playing automatically.
+ /// - timePublishing: A `CMTime` value representing the interval for time update callbacks, or `nil` if disabled.
+ /// - gravity: The `AVLayerVideoGravity` value defining how the video should be displayed within its layer.
+ /// - enableVector: A Boolean indicating whether vector graphics rendering should be enabled for overlays.
+ ///
+ /// All parameters must be provided, except `timePublishing`, which can be `nil`, and `enableVector`, which defaults to `false`.
+ public init(name: String, ext: String, subtitles: String, loop: Bool, pictureInPicture: Bool, mute: Bool, notAutoPlay: Bool, timePublishing: CMTime?, gravity: AVLayerVideoGravity, enableVector : Bool = false, events : [PlayerEventFilter] = []) {
self.name = name
self.ext = ext
self.subtitles = subtitles
@@ -77,6 +86,7 @@ public struct VideoSettings: Equatable{
self.timePublishing = timePublishing
self.gravity = gravity
self.vector = enableVector
+ self.events = events
self.unique = true
}
@@ -106,21 +116,25 @@ public struct VideoSettings: Equatable{
notAutoPlay = settings.contains(.notAutoPlay)
vector = settings.contains(.vector)
+
+ let hasEvents = settings.contains {
+ if case .events = $0 {
+ return true
+ }
+ return false
+ }
+
+ if hasEvents{
+ events = settings.fetch(by : "events", defaulted: []) ?? []
+ }else{
+ events = nil
+ }
}
}
@available(iOS 14.0, macOS 11.0, tvOS 14.0, *)
public extension VideoSettings {
-
- /// Returns a new instance of VideoSettings with loop set to false and notAutoPlay set to true, keeping other settings unchanged.
- var settingsWithAutoPlay : VideoSettings {
- VideoSettings(name: self.name, ext: self.ext, subtitles: self.subtitles, loop: self.loop, pictureInPicture: self.pictureInPicture, mute: self.mute, notAutoPlay: false, timePublishing: self.timePublishing, gravity: self.gravity, enableVector: self.vector)
- }
-
- func getAssets()-> AVURLAsset?{
- assetFor(self)
- }
-
+
/// Checks if the asset has changed based on the provided settings and current asset.
/// - Parameters:
/// - asset: The current asset being played.
@@ -148,4 +162,3 @@ fileprivate func check(_ settings : [Setting]) -> Bool{
let set = Set(cases)
return cases.count == set.count
}
-
diff --git a/Sources/swiftui-loop-videoplayer/view/helpers/PlayerCoordinator.swift b/Sources/swiftui-loop-videoplayer/view/helpers/PlayerCoordinator.swift
index f567527..781d72a 100644
--- a/Sources/swiftui-loop-videoplayer/view/helpers/PlayerCoordinator.swift
+++ b/Sources/swiftui-loop-videoplayer/view/helpers/PlayerCoordinator.swift
@@ -15,8 +15,10 @@ import AVKit
@MainActor
internal class PlayerCoordinator: NSObject, PlayerDelegateProtocol {
+ /// Publisher that emits player events, allowing observers to react to changes in playback state
let eventPublisher: PassthroughSubject
+ /// Publisher that emits the current playback time as a Double, allowing real-time tracking of progress
let timePublisher: PassthroughSubject
/// Stores the last command applied to the player.
@@ -144,12 +146,27 @@ internal class PlayerCoordinator: NSObject, PlayerDelegateProtocol {
#if os(iOS)
extension PlayerCoordinator: AVPictureInPictureControllerDelegate{
+ /// Called when Picture-in-Picture (PiP) mode starts.
+ ///
+ /// - Parameter pictureInPictureController: The `AVPictureInPictureController` instance managing the PiP session.
+ ///
+ /// This method is marked as `nonisolated` to avoid being tied to the actor's execution context,
+ /// allowing it to be called from any thread. It publishes a `.startedPiP` event on the `eventPublisher`
+ /// within a `Task` running on the `MainActor`, ensuring UI updates are handled on the main thread.
nonisolated func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
Task{ @MainActor in
eventPublisher.send(.startedPiP)
}
}
+
+ /// Called when Picture-in-Picture (PiP) mode stops.
+ ///
+ /// - Parameter pictureInPictureController: The `AVPictureInPictureController` instance managing the PiP session.
+ ///
+ /// Like its counterpart for starting PiP, this method is `nonisolated`, allowing it to be executed from any thread.
+ /// It sends a `.stoppedPiP` event via `eventPublisher` on the `MainActor`, ensuring any UI-related handling
+ /// occurs safely on the main thread.
nonisolated func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
Task{ @MainActor in
eventPublisher.send(.stoppedPiP)
diff --git a/Sources/swiftui-loop-videoplayer/view/player/ios/ExtPlayerUIView.swift b/Sources/swiftui-loop-videoplayer/view/player/ios/ExtPlayerUIView.swift
index 2373e13..5f61632 100644
--- a/Sources/swiftui-loop-videoplayer/view/player/ios/ExtPlayerUIView.swift
+++ b/Sources/swiftui-loop-videoplayer/view/player/ios/ExtPlayerUIView.swift
@@ -22,10 +22,10 @@ internal class ExtPlayerUIView: UIView, ExtPlayerProtocol{
/// `filters` is an array that stores CIFilter objects used to apply different image processing effects
internal var filters: [CIFilter] = []
-
+
/// `brightness` represents the adjustment level for the brightness of the video content.
internal var brightness: Float = 0
-
+
/// `contrast` indicates the level of contrast adjustment for the video content.
internal var contrast: Float = 1
@@ -64,7 +64,7 @@ internal class ExtPlayerUIView: UIView, ExtPlayerProtocol{
/// The delegate to be notified about errors encountered by the player.
weak var delegate: PlayerDelegateProtocol?
-
+
/// The Picture-in-Picture (PiP) controller for managing PiP functionality.
internal var pipController: AVPictureInPictureController?
@@ -81,17 +81,17 @@ internal class ExtPlayerUIView: UIView, ExtPlayerProtocol{
addPlayerLayer()
addCompositeLayer(settings)
-
+
setupPlayerComponents(
settings: settings
)
-
+
}
-
+
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
-
+
/// Lays out subviews and adjusts the frame of the player layer to match the view's bounds.
override func layoutSubviews() {
super.layoutSubviews()
@@ -100,8 +100,6 @@ internal class ExtPlayerUIView: UIView, ExtPlayerProtocol{
layoutCompositeLayer()
}
-
-
/// Updates the composite layer and all its sublayers' frames.
public func layoutCompositeLayer() {
guard let compositeLayer = compositeLayer else { return }
diff --git a/Sources/swiftui-loop-videoplayer/view/player/main/ExtPlayerMultiPlatform.swift b/Sources/swiftui-loop-videoplayer/view/player/main/ExtPlayerMultiPlatform.swift
index 6f5de91..0d79ac1 100644
--- a/Sources/swiftui-loop-videoplayer/view/player/main/ExtPlayerMultiPlatform.swift
+++ b/Sources/swiftui-loop-videoplayer/view/player/main/ExtPlayerMultiPlatform.swift
@@ -8,10 +8,6 @@
import SwiftUI
import Combine
-#if canImport(AVKit)
-import AVKit
-#endif
-
#if canImport(UIKit)
import UIKit
#endif
diff --git a/Tests/swiftui-loop-videoplayerTests/ext+/testURL.swift b/Tests/swiftui-loop-videoplayerTests/ext+/testURL.swift
new file mode 100644
index 0000000..dd462b5
--- /dev/null
+++ b/Tests/swiftui-loop-videoplayerTests/ext+/testURL.swift
@@ -0,0 +1,75 @@
+//
+// testURL+.swift
+// swiftui-loop-videoplayer
+//
+// Created by Igor Shelopaev on 20.08.25.
+//
+
+import XCTest
+@testable import swiftui_loop_videoplayer
+
+final class testURL: XCTestCase {
+
+ // MARK: - Positive cases (should pass)
+
+ func testSampleVideoURLsPass() {
+ // Given: four sample URLs from the sandbox dictionary
+ let urls = [
+ "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8",
+ "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
+ "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4",
+ "https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8"
+ ]
+
+ // When/Then
+ for raw in urls {
+ let url = URL.validURLFromString(from: raw)
+ XCTAssertNotNil(url, "Expected to parse: \(raw)")
+ XCTAssertEqual(url?.scheme?.lowercased(), "https")
+ }
+ }
+
+ func testTrimsWhitespace() {
+ let raw = " https://example.com/video.m3u8 "
+ let url = URL.validURLFromString(from: raw)
+ XCTAssertNotNil(url)
+ XCTAssertEqual(url?.host, "example.com")
+ XCTAssertEqual(url?.path, "/video.m3u8")
+ }
+
+ func testIPv6AndLocalHosts() {
+ // IPv6 loopback
+ XCTAssertNotNil(URL.validURLFromString(from: "https://[::1]"))
+ // localhost
+ XCTAssertNotNil(URL.validURLFromString(from: "http://localhost"))
+ // IPv4 with port and query/fragment
+ XCTAssertNotNil(URL.validURLFromString(from: "http://127.0.0.1:8080/path?a=1#x"))
+ }
+
+ func testIDNUnicodeHost() {
+ // Unicode host (IDN). URLComponents should handle this.
+ let url = URL.validURLFromString(from: "https://bücher.de")
+ XCTAssertNotNil(url)
+ XCTAssertEqual(url?.scheme, "https")
+ XCTAssertNotNil(url?.host)
+ }
+
+ // MARK: - Negative cases (should fail)
+
+ func testRejectsNonHTTP() {
+ XCTAssertNil(URL.validURLFromString(from: "ftp://example.com/file.mp4"))
+ XCTAssertNil(URL.validURLFromString(from: "mailto:user@example.com"))
+ XCTAssertNil(URL.validURLFromString(from: "file:///Users/me/movie.mp4"))
+ }
+
+ func testRejectsInvalidPort() {
+ XCTAssertNil(URL.validURLFromString(from: "https://example.com:0"))
+ XCTAssertNil(URL.validURLFromString(from: "https://example.com:65536"))
+ XCTAssertNotNil(URL.validURLFromString(from: "https://example.com:65535"))
+ }
+
+ func testRejectsMissingHost() {
+ XCTAssertNil(URL.validURLFromString(from: "https://"))
+ XCTAssertNil(URL.validURLFromString(from: "https:///path-only"))
+ }
+}
diff --git a/Tests/swiftui-loop-videoplayerTests/ext+/textArray.swift b/Tests/swiftui-loop-videoplayerTests/ext+/textArray.swift
new file mode 100644
index 0000000..61d8bad
--- /dev/null
+++ b/Tests/swiftui-loop-videoplayerTests/ext+/textArray.swift
@@ -0,0 +1,111 @@
+//
+// textArray.swift
+// swiftui-loop-videoplayer
+//
+// Created by Igor Shelopaev on 21.08.25.
+//
+
+import XCTest
+import AVFoundation
+@testable import swiftui_loop_videoplayer
+
+final class SettingsTests: XCTestCase {
+
+ // MARK: - events([PlayerEventFilter]?)
+
+ func testFetch_Events_WithArray() {
+ let settings: [Setting] = [.events([.playing, .paused])]
+ let events: [PlayerEventFilter] = settings.fetch(by: "events", defaulted: [])
+ XCTAssertEqual(events, [.playing, .paused])
+ }
+
+ func testFetch_Events_NilAssociatedValue() {
+ let settings: [Setting] = [.events(nil)]
+ // Optional(nil) won't cast to [PlayerEventFilter] → returns default
+ let events: [PlayerEventFilter] = settings.fetch(by: "events", defaulted: [])
+ XCTAssertTrue(events.isEmpty)
+ }
+
+ // MARK: - name / ext / subtitles
+
+ func testFetch_Name_ReturnsStoredString() {
+ let settings: [Setting] = [.name("teaser")]
+ let value: String = settings.fetch(by: "name", defaulted: "")
+ XCTAssertEqual(value, "teaser")
+ }
+
+ func testFetch_Ext_ReturnsStoredString() {
+ let settings: [Setting] = [.ext("mp4")]
+ let value: String = settings.fetch(by: "ext", defaulted: "mov")
+ XCTAssertEqual(value, "mp4")
+ }
+
+ func testFetch_Subtitles_ReturnsStoredString() {
+ let settings: [Setting] = [.subtitles("de")]
+ let value: String = settings.fetch(by: "subtitles", defaulted: "en")
+ XCTAssertEqual(value, "de")
+ }
+
+ // MARK: - Missing / mismatch
+
+ func testFetch_ReturnsDefault_WhenNameMissing() {
+ let settings: [Setting] = [.name("teaser")]
+ let value: Int = settings.fetch(by: "fontSize", defaulted: 12)
+ XCTAssertEqual(value, 12)
+ }
+
+ func testFetch_ReturnsDefault_WhenTypeMismatch() {
+ let settings: [Setting] = [.name("teaser")]
+ let value: Int = settings.fetch(by: "name", defaulted: 0)
+ XCTAssertEqual(value, 0)
+ }
+
+ // MARK: - First match precedence
+
+ func testFetch_PrefersFirstMatch_WhenMultipleWithSameName() {
+ let settings: [Setting] = [.name("first"), .name("second")]
+ let value: String = settings.fetch(by: "name", defaulted: "")
+ XCTAssertEqual(value, "first")
+ }
+
+ // MARK: - Value-less cases → default
+
+ func testFetch_Vector_ReturnsDefault() {
+ let settings: [Setting] = [.vector]
+ let value: Bool = settings.fetch(by: "vector", defaulted: false)
+ XCTAssertFalse(value)
+ }
+
+ func testFetch_Loop_ReturnsDefault() {
+ let settings: [Setting] = [.loop]
+ let value: String = settings.fetch(by: "loop", defaulted: "no")
+ XCTAssertEqual(value, "no")
+ }
+
+ func testFetch_PictureInPicture_ReturnsDefault() {
+ let settings: [Setting] = [.pictureInPicture]
+ let pip: Bool = settings.fetch(by: "pictureInPicture", defaulted: false)
+ XCTAssertFalse(pip)
+ }
+
+ func testFetch_Mute_ReturnsDefault() {
+ let settings: [Setting] = [.mute]
+ let muted: Bool = settings.fetch(by: "mute", defaulted: false)
+ XCTAssertFalse(muted)
+ }
+
+ // MARK: - timePublishing / gravity
+
+ func testFetch_TimePublishing_CMTime() {
+ let t = CMTime(seconds: 0.5, preferredTimescale: 600)
+ let settings: [Setting] = [.timePublishing(t)]
+ let fetched: CMTime = settings.fetch(by: "timePublishing", defaulted: .zero)
+ XCTAssertEqual(CMTimeCompare(fetched, t), 0)
+ }
+
+ func testFetch_Gravity_CustomAssociatedValue() {
+ let settings: [Setting] = [.gravity(.resizeAspectFill)]
+ let gravity: AVLayerVideoGravity = settings.fetch(by: "gravity", defaulted: .resize)
+ XCTAssertEqual(gravity, .resizeAspectFill)
+ }
+}