├── .gitignore ├── .spi.yml ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── swiftui-loop-videoplayer │ ├── ExtVideoPlayer.swift │ ├── enum │ ├── PlaybackCommand.swift │ ├── PlayerEvent.swift │ ├── PlayerEventFilter.swift │ ├── Setting.swift │ └── VPErrors.swift │ ├── ext+ │ ├── Array+.swift │ ├── CMTime+.swift │ ├── URL+.swift │ └── View+.swift │ ├── fn │ ├── constraintsFn.swift │ └── fn+.swift │ ├── protocol │ ├── helpers │ │ ├── CustomView.swift │ │ ├── PlayerDelegateProtocol.swift │ │ └── SettingsConvertible.swift │ ├── player │ │ ├── AbstractPlayer.swift │ │ └── ExtPlayerProtocol.swift │ ├── vector │ │ ├── ShapeLayerBuilderProtocol.swift │ │ └── VectorLayerProtocol.swift │ └── view │ │ └── ExtPlayerViewProtocol.swift │ ├── settings │ ├── EnableVector.swift │ ├── Events.swift │ ├── Ext.swift │ ├── Gravity.swift │ ├── Loop.swift │ ├── Mute.swift │ ├── NotAutoPlay.swift │ ├── PictureInPicture .swift │ ├── SourceName.swift │ ├── Subtitles.swift │ └── TimePublishing.swift │ ├── utils │ ├── SettingsBuilder.swift │ └── VideoSettings.swift │ └── view │ ├── helpers │ └── PlayerCoordinator.swift │ ├── modifier │ ├── OnPlayerEventChangeModifier.swift │ └── OnTimeChangeModifier.swift │ └── player │ ├── ios │ └── ExtPlayerUIView.swift │ ├── mac │ └── ExtPlayerNSView.swift │ └── main │ └── ExtPlayerMultiPlatform.swift └── Tests └── swiftui-loop-videoplayerTests ├── Resources └── swipe.mp4 ├── testPlaybackCommandChangesOverTime.swift └── testPlayerInitialization.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [swiftui-loop-videoplayer] 5 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Igor Shelopaev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "swiftui-loop-videoplayer", 8 | platforms: [.iOS(.v14), .macOS(.v11), .tvOS(.v14)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "swiftui-loop-videoplayer", 13 | targets: ["swiftui-loop-videoplayer"]), 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | // .package(url: /* package url */, from: "1.0.0"), 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 21 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 22 | .target( 23 | name: "swiftui-loop-videoplayer", 24 | dependencies: []), 25 | .testTarget( 26 | name: "swiftui-loop-videoplayerTests", 27 | dependencies: ["swiftui-loop-videoplayer"], 28 | resources: [ 29 | .process("Resources/swipe.mp4") 30 | ]) 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUI video player iOS 14+, macOS 11+, tvOS 14+ 2 | ### *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.* 3 | 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fswiftuiux%2Fswiftui-loop-videoplayer%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/swiftuiux/swiftui-loop-videoplayer) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fswiftuiux%2Fswiftui-loop-videoPlayer%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/swiftuiux/swiftui-loop-videoPlayer) 6 | 7 | 8 | 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**. 9 | 10 | ### SwiftUI app example [follow the link](https://github.com/swiftuiux/swiftui-video-player-example) 11 | 12 | ## Why if we have Apple’s VideoPlayer ?! 13 | 14 | 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. 15 | 16 | *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.* 17 | 18 | ```swift 19 | ExtVideoPlayer{ 20 | VideoSettings{ 21 | SourceName("swipe") 22 | } 23 | } 24 | ``` 25 | 26 | or 27 | 28 | ```swift 29 | ExtVideoPlayer(fileName: 'swipe') 30 | ``` 31 | 32 | ## Philosophy of Player Dynamics 33 | 34 | The player's functionality is designed around a dual ⇆ interaction model: 35 | 36 | - **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 37 | 38 | - **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. 39 | 40 | ## [Documentation(API)](https://swiftpackageindex.com/swiftuiux/swiftui-loop-videoplayer/main/documentation/swiftui_loop_videoplayer) 41 | 42 | ![The concept](https://github.com/swiftuiux/swiftui-video-player-example/blob/main/swiftui-loop-videoplayer-example/img/swiftui_video_player.gif) 43 | 44 | ## Specs 45 | 46 | | **Feature Category** | **Feature Name** | **Description** | 47 | |----------------------------|---------------------------------------------|----------------------------------------------------------------------------------------------------------| 48 | | **General** | SwiftUI Declarative Syntax | Easily integrate using declarative syntax. | 49 | | | Platform Compatibility | Supports iOS 14+, macOS 11+, tvOS 14+. | 50 | | | Swift Compatibility | Alined with Swift 5 and ready for Swift 6 | 51 | | | Loop Playback | Automatically restart videos when they end. | 52 | | | Local and Remote Video URLs | Supports playback from local files or remote URLs. | 53 | | | Adaptive HLS Streaming | Handles HLS streaming with dynamic quality adjustment. | 54 | | | Error Handling | Customizable error messages and visual displays. | 55 | | | Subtitle Support | Add external `.vtt` files or use embedded subtitle tracks. | 56 | | | Custom Overlays | Add vector graphics and custom overlays over the video. | 57 | | | Picture In Picture (PiP) | Picture-in-Picture (PiP) is supported on iOS and iPadOS | 58 | | **Playback Commands** | Idle Command | Initialize without specific playback actions. | 59 | | | Play/Pause | Control playback state. | 60 | | | Seek Command | Move to specific video timestamps. | 61 | | | Mute/Unmute | Toggle audio playback. | 62 | | | Volume Control | Adjust audio levels. | 63 | | | Playback Speed | Dynamically modify playback speed. | 64 | | | Loop/Unloop | Toggle looping behavior. | 65 | | | Apply Filters | Add Core Image filters to the video stream. | 66 | | | Remove Filters | Clear all applied filters. | 67 | | | Add Vector Graphics | Overlay custom vector graphics onto the video. | 68 | | **Settings** | SourceName | Define video source (local or remote). | 69 | | | File Extension | Default extension for video files (e.g., `.mp4`). | 70 | | | Gravity | Set content resizing behavior (e.g., `.resizeAspect`). | 71 | | | Time Publishing | Control playback time reporting intervals. | 72 | | | AutoPlay | Toggle automatic playback on load. | 73 | | | Mute by Default | Initialize playback without sound. | 74 | | | Subtitle Integration | Configure subtitles from embedded tracks or external files. | 75 | | **Visual Features** | Rounded Corners | Apply rounded corners using SwiftUI's `.mask` modifier. | 76 | | | Overlay Graphics | Add vector graphics over video for custom effects. | 77 | | | Brightness Adjustment | Control brightness levels dynamically. | 78 | | | Contrast Adjustment | Modify video contrast in real time. | 79 | | **Playback Features** | Adaptive HLS Streaming | Dynamic quality adjustment based on network speed. | 80 | | | Seamless Item Transitions | Smooth transitions between video items. | 81 | | | Multichannel Audio | Play Dolby Atmos, 5.1 surround, and spatial audio tracks. | 82 | | | Subtitles and Captions | Support for multiple subtitle and caption formats. | 83 | | **Event Handling** | Batch Event Processing | Collects and processes events in batches to avoid flooding. | 84 | | | Playback State Events | `playing`, `paused`, `seek`, `duration(CMTime)`, etc. | 85 | | | Current Item State | Detect when the current item changes or is removed. | 86 | | | Volume Change Events | Listen for changes in volume levels. | 87 | | **Testing & Development** | Unit Testing | Includes unit tests for core functionality. | 88 | | | UI Testing | Integration of UI tests in the example app. | 89 | | | Example Scripts | Automated testing scripts for easier test execution. | 90 | | **Media Support** | File Types | `.mp4`, `.mov`, `.m4v`, `.3gp`, `.mkv` (limited support). | 91 | | | Codecs | H.264, H.265 (HEVC), MPEG-4, AAC, MP3. | 92 | | | Streaming Protocols | HLS (`.m3u8`) support for adaptive streaming. | 93 | 94 | ### CornerRadius 95 | You can reach out the effect simply via mask modifier 96 | ```swift 97 | ExtVideoPlayer( 98 | settings : $settings, 99 | command: $playbackCommand, 100 | VideoSettings{ 101 | SourceName("swipe") 102 | } 103 | ) 104 | .mask{ 105 | RoundedRectangle(cornerRadius: 25) 106 | } 107 | ``` 108 | 109 | ![CornerRadius effect video player swift](https://github.com/swiftuiux/swiftui-video-player-example/blob/main/swiftui-loop-videoplayer-example/img/cornerRadius.png) 110 | 111 | ### By the way 112 | [Perhaps that might be enough for your needs](https://github.com/swiftuiux/swiftui-loop-videoPlayer/issues/7#issuecomment-2341268743) 113 | 114 | 115 | ## Testing 116 | 117 | 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. 118 | 119 | ## Disclaimer on Video Sources like YouTube 120 | Please note that using videos from URLs requires ensuring that you have the right to use and stream these videos. Videos hosted on platforms like YouTube cannot be used directly due to restrictions in their terms of service. 121 | 122 | ## API 123 | 124 | | Property/Method | Type | Description | 125 | |-------------------------------------------------------------|-------------------------------|------------------------------------------------------------------------------------------------------| 126 | | `settings` | `Binding` | A binding to the video player settings, which configure various aspects of the player's behavior. | 127 | | `command` | `Binding` | A binding to control playback actions, such as play, pause, or seek. | 128 | | `init(fileName:ext:gravity:timePublishing:`
`command:)` | Constructor | Initializes the player with specific video parameters, such as file name, extension, gravity, time publishing and a playback command binding. | 129 | | `init(settings: () -> VideoSettings, command:)` | Constructor | Initializes the player in a declarative way with a settings block and a playback command binding. | 130 | | `init(settings: Binding, command:)` | Constructor | Initializes the player with bindings to the video settings and a playback command. | 131 | 132 | 133 | ## Settings 134 | 135 | | Name | Description | Default | 136 | |---------------|-----------------------------------------------------------------------------------------------------|---------| 137 | | **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:) | - | 138 | | **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" | 139 | | **Gravity** | How the video content should be resized to fit the player's bounds. | .resizeAspect | 140 | | **Loop** | Whether the video should automatically restart when it reaches the end. If not explicitly passed, the video will not loop. | false | 141 | | **Mute** | Indicates if the video should play without sound. | false | 142 | | **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 | 143 | | **TimePublishing** | Specifies the interval at which the player publishes the current playback time. | Not Enabled | 144 | | **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 | 145 | | **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 | 146 | |**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 | 147 | | **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 | 148 | 149 | ### Additional Notes on Settings 150 | 151 | - **Time Publishing:** If the parameter is passed during initialization, the player will publish the time according to the input settings. You can pass just `TimePublishing` without any value to use the default interval of 1 second, or you can pass a specific `CMTime` value to set a custom interval. | 1 second (CMTime with 1 second and preferred timescale of 600) If no `TimePublishing` is provided, the player will not emit time events, which can improve performance when timing information is not needed. 152 | 153 | - **SourceName:** If a valid URL (http or https) is provided, the video will be streamed from the URL. If not a URL, the system will check if a video with the given name exists in the local bundle. The local name provided can either include an extension or be without one. The system first checks if the local name contains an extension. If the local name includes an extension, it extracts this extension and uses it as the default. If the local name does not contain an extension, the system assigns a default extension of .mp4 The default file extension can be set up via Ext param. 154 | 155 | - **Loop:** Whether the video should automatically restart when it reaches the end. If not explicitly passed, the video will not loop. 156 | 157 | 158 | ## Commands 159 | 160 | ### Handling Commands 161 | ```swift 162 | @State public var playbackCommand: PlaybackCommand = .idle 163 | ``` 164 | `@State` updates are asynchronous and batched in SwiftUI. When you assign: 165 | ```swift 166 | playbackCommand = .play 167 | playbackCommand = .pause 168 | ``` 169 | SwiftUI only registers the last assignment (`.pause`) in the same run loop cycle, ignoring `.play`. 170 | To ensure .play is applied before .pause, you can use `Task` to schedule the second update on the next run loop iteration: 171 | 172 | **.play → .pause** 173 | ```swift 174 | playbackCommand = .play 175 | 176 | Task { 177 | playbackCommand = .pause 178 | } 179 | ``` 180 | **.play → .pause → .play** 181 | 182 | ```swift 183 | playbackCommand = .play 184 | 185 | Task { 186 | playbackCommand = .pause 187 | Task { playbackCommand = .play } // This runs AFTER `.pause` 188 | } 189 | ``` 190 | 191 | ### Handling Sequential Similar Commands 192 | 193 | 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. 194 | 195 | ### Common Scenario 196 | 197 | For example, if you attempt to pause the video player twice in a row, the second pause command will have no effect because the player is already in a paused state. Similarly, sending two consecutive play commands will not re-trigger playback if the video is already playing. 198 | 199 | ### Handling Similar Commands 200 | 201 | 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. 202 | 203 | **.play → .idle → .play** 204 | 205 | ```swift 206 | playbackCommand = .play 207 | 208 | Task { 209 | playbackCommand = .idle 210 | Task { playbackCommand = .play } // This runs AFTER `.idle` 211 | } 212 | ``` 213 | 214 | ### Playback Commands 215 | 216 | | Command | Description | 217 | |-----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| 218 | | `idle` | Start without any actions. Any command passed during initialization will be executed. If you'd like to start without any actions based on settings values just setup command to `.idle` | 219 | | `play` | Command to play the video. | 220 | | `pause` | Command to pause the video. | 221 | | `seek(to: Double, play: Bool)` | Command to seek to a specific time in the video. The time parameter specifies the target position in seconds. If time is negative, playback will jump to the start of the video. If time exceeds the video’s duration, playback will move to the end of the video. For valid values within the video’s duration, playback will move precisely to the specified time. The play parameter determines whether playback should resume automatically after seeking, with a default value of true. | 222 | | `begin` | Command to position the video at the beginning. | 223 | | `end` | Command to position the video at the end. | 224 | | `mute` | Command to mute the video. By default, the player is muted. | 225 | | `unmute` | Command to unmute the video. | 226 | | `volume(Float)` | Command to adjust the volume of the video playback. The `volume` parameter is a `Float` value between 0.0 (mute) and 1.0 (full volume). If a value outside this range is passed, it will be clamped to the nearest valid value (0.0 or 1.0). | 227 | | `playbackSpeed(Float)` | Command to adjust the playback speed of the video. The `speed` parameter is a `Float` value representing the playback speed (e.g., 1.0 for normal speed, 0.5 for half speed, 2.0 for double speed). If a negative value is passed, it will be clamped to 0.0. | 228 | | `loop` | Command to enable looping of the video playback. By default, looping is enabled, so this command will have no effect if looping is already active. | 229 | | `unloop` | Command to disable looping of the video playback. This command will only take effect if the video is currently being looped. | 230 | | `startPiP` | Command to initiate **Picture-in-Picture (PiP)** mode for video playback. If the PiP feature is already active, this command will have no additional effect. Don't forget to add PictureInPicture() in settings to enable the PiP feature. | 231 | | `stopPiP` | Command to terminate **Picture-in-Picture (PiP)** mode, returning the video playback to its inline view. If PiP is not active, this command will have no effect. Don't forget to add PictureInPicture() in settings to enable the PiP feature. | 232 | 233 | ### Visual Adjustment Commands 234 | 235 | | Command | Description | 236 | |-----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| 237 | | `brightness(Float)` | Command to adjust the brightness of the video playback. The `brightness` parameter is a `Float` value typically ranging from -1.0 (darkest) to 1.0 (brightest). Values outside this range will be clamped to the nearest valid value. | 238 | | `contrast(Float)` | Command to adjust the contrast of the video playback. The `contrast` parameter is a `Float` value typically ranging from 0.0 (no contrast) to 4.0 (high contrast). Values outside this range will be clamped to the nearest valid value. | 239 | | `filter(CIFilter, clear: Bool)` | Applies a specific Core Image filter to the video. If `clear` is true, any existing filters on the stack are removed before applying the new filter; otherwise, the new filter is added to the existing stack. | 240 | | `removeAllFilters` | Command to remove all applied filters from the video playback. | 241 | 242 | ### Vector Graphics Commands 243 | 244 | | Command | Description | 245 | |----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| 246 | | `addVector(ShapeLayerBuilderProtocol, clear: Bool)` | Command to add a vector graphic layer over the video stream. The `builder` parameter is an instance conforming to `ShapeLayerBuilderProtocol`. The `clear` parameter specifies whether to clear existing vector layers before adding the new one. | 247 | | `removeAllVectors` | Command to remove all vector graphic layers from the video stream. | 248 | ### Additional Notes on Vector Graphics Commands 249 | - To use these commands, don’t forget to enable the Vector layer in settings via the EnableVector() setting. 250 | - The boundsChanged event(`boundsChanged(CGRect)`) is triggered when the main layer’s bounds are updated. This approach is particularly useful when overlays or custom vector layers need to adapt dynamically to changes in video player dimensions or other layout adjustments. To handle the frequent boundsChanged events effectively and improve performance, you can use a **throttle** function to limit how often the updates occur. 251 | 252 | 253 | ### Audio & Language Commands 254 | 255 | | Command | Description | 256 | |-----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| 257 | | `audioTrack(String)` | Command to select a specific audio track based on language code. The `languageCode` parameter specifies the desired audio track's language (e.g., "en" for English). | 258 | | `subtitles(String?)` | This command sets subtitles to a specified language or turns them off. Provide a language code (for example, `"en"` for English) to display that language's subtitles, or pass `nil` to disable subtitles altogether. **Note**: This only applies when the video file has embedded subtitles tracks. | 259 | 260 | ### Additional Notes on the subtitles Command 261 | This functionality is designed for use cases where the video file already contains multiple subtitle tracks (i.e., legible media tracks) embedded in its metadata. In other words, the container format (such as MP4, MOV, or QuickTime) holds one or more subtitle or closed-caption tracks that can be selected at runtime. By calling this function and providing a language code (e.g., “en”, “fr”, “de”), you instruct the component to look for the corresponding subtitle track in the asset’s media selection group. If it finds a match, it will activate that subtitle track; otherwise, no subtitles will appear. Passing nil disables subtitles altogether. This approach is convenient when you want to switch between multiple embedded subtitle languages or turn them off without relying on external subtitle files (like SRT or WebVTT). 262 | 263 | Another option to add subtitles is by using **Settings** (take a look above), where you can provide subtitles as a separate source file (e.g., SRT or WebVTT). In this case, subtitles are dynamically loaded and managed alongside the video without requiring them to be embedded in the video file itself. 264 | Both of these methods — using embedded subtitle tracks or adding subtitles via Settings as external files — do not merge and save the resulting video with subtitles locally. Instead, the subtitles are rendered dynamically during playback. 265 | 266 | **Configuring HLS Playlist with English Subtitles** 267 | 268 | Here’s an example of an HLS playlist configured with English subtitles. The subtitles are defined as a separate track using WebVTT or a similar format, referenced within the master playlist. This setup allows seamless subtitle rendering during video playback, synchronized with the video stream. 269 | 270 | ```plaintext 271 | #EXTM3U 272 | #EXT-X-MEDIA:TYPE=SUBTITLES, 273 | GROUP-ID="subs", 274 | NAME="English Subtitles", 275 | LANGUAGE="en", 276 | AUTOSELECT=YES, 277 | DEFAULT=YES, 278 | URI="subtitles_en.m3u8" 279 | 280 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3000000, 281 | RESOLUTION=1280x720, 282 | SUBTITLES="subs" 283 | video_main.m3u8 284 | ``` 285 | 286 | ## Player Events 287 | 288 | *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.* 289 | 290 | | Event | Description | 291 | |------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------| 292 | | `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. | 293 | | `paused` | Indicates that the player's playback is currently paused. This state occurs when the player has been manually paused by the user or programmatically through a method like `pause()`. The player is not playing any content while in this state. | 294 | | `waitingToPlayAtSpecifiedRate` | Indicates that the player is currently waiting to play at the specified rate. This state generally occurs when the player is buffering or waiting for sufficient data to continue playback. It can also occur if the playback rate is temporarily reduced to zero due to external factors, such as network conditions or system resource limitations. | 295 | | `playing` | Indicates that the player is actively playing content. This state occurs when the player is currently playing video or audio content at the specified playback rate. This is the active state where media is being rendered to the user. | 296 | | `currentItemChanged` | Triggered when the player's `currentItem` is updated to a new `AVPlayerItem`. This event indicates a change in the media item currently being played. | 297 | | `currentItemRemoved` | Occurs when the player's `currentItem` is set to `nil`, indicating that the current media item has been removed from the player. | 298 | | `error(VPErrors)` | Represents an occurrence of an error within the player. The event provides a `VPErrors` enum value indicating the specific type of error encountered. | 299 | | `volumeChanged` | Happens when the player's volume level is adjusted. This event provides the new volume level, which ranges from 0.0 (muted) to 1.0 (maximum volume). | 300 | | `boundsChanged(CGRect)` | Triggered when the bounds of the main layer change, allowing the developer to recalculate and update all vector layers within the CompositeLayer. | 301 | | `startedPiP` | Event triggered when Picture-in-Picture (PiP) mode starts. | 302 | | `stoppedPiP` | Event triggered when Picture-in-Picture (PiP) mode stops. | 303 | | `itemStatusChanged(AVPlayerItem.Status)` | Indicates that the AVPlayerItem's status has changed. Possible statuses: `.unknown`, `.readyToPlay`, `.failed`. | 304 | | `duration(CMTime)` | Provides the duration of the AVPlayerItem when it is ready to play. The duration is given in `CMTime`. | 305 | 306 | ## Player event filter 307 | `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. 308 | 309 | ```swift 310 | ExtVideoPlayer{ 311 | VideoSettings{ 312 | SourceName("swipe") 313 | Events([.durationAny, .itemStatusChangedAny]) // *Events([PlayerEventFilter])* 314 | } 315 | } 316 | .onPlayerEventChange { events in 317 | // Here come only events [.durationAny, .itemStatusChangedAny]: any duration and any item status change events. 318 | } 319 | ``` 320 | 321 | ### Event filter table 322 | 323 | | **Filter** | **Description** | 324 | |------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------| 325 | | `seekAny` | Matches any `.seek(...)` case, regardless of whether the seek was successful (`Bool`) or the target seek time (`currentTime: Double`). | 326 | | `paused` | Matches exactly the `.paused` event, which indicates that playback has been paused by the user or programmatically. | 327 | | `waitingToPlayAtSpecifiedRate` | Matches exactly the `.waitingToPlayAtSpecifiedRate` event, which occurs when the player is buffering or waiting for sufficient data. | 328 | | `playing` | Matches exactly the `.playing` event, indicating that the player is actively playing media. | 329 | | `currentItemChangedAny` | Matches any `.currentItemChanged(...)` case, triggered when the player's `currentItem` is updated to a new media item. | 330 | | `currentItemRemoved` | Matches exactly the `.currentItemRemoved` event, occurring when the player's `currentItem` is set to `nil`. | 331 | | `errorAny` | Matches any `.error(...)` case, representing an error within the player, with a `VPErrors` enum indicating the specific issue. | 332 | | `volumeChangedAny` | Matches any `.volumeChanged(...)` case, triggered when the player's volume level is adjusted. | 333 | | `boundsChangedAny` | Matches any `.boundsChanged(...)` case, triggered when the bounds of the main layer change. | 334 | | `startedPiP` | Matches exactly the `.startedPiP` event, triggered when Picture-in-Picture (PiP) mode starts. | 335 | | `stoppedPiP` | Matches exactly the `.stoppedPiP` event, triggered when Picture-in-Picture (PiP) mode stops. | 336 | | `itemStatusChangedAny` | Matches any `.itemStatusChanged(...)` case, indicating that the AVPlayerItem's status has changed (e.g., `.unknown`, `.readyToPlay`, `.failed`). | 337 | | `durationAny` | Matches any `.duration(...)` case, which provides the duration of the media item when ready to play. | 338 | 339 | 340 | ### Additional Notes on Errors 341 | 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. 342 | 343 | **Workarounds and Best Practices** 344 | *Pre-Check the URL With HEAD* 345 | 346 | 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. 347 | 348 | ```swift 349 | func checkURLExists(_ url: URL) async throws -> Bool { 350 | var request = URLRequest(url: url) 351 | request.httpMethod = "HEAD" 352 | 353 | let (_, response) = try await URLSession.shared.data(for: request) 354 | if let httpResponse = response as? HTTPURLResponse { 355 | return (200...299).contains(httpResponse.statusCode) 356 | } 357 | return false 358 | } 359 | ``` 360 | 361 | ### Additional Notes on Adding and Removing Vector Graphics 362 | 363 | When you use the `addVector` command, you can dynamically add a new vector graphic layer (such as a logo or animated vector) over the video stream. This is particularly useful for enhancing the user experience with overlays, such as branding elements, animated graphics. 364 | 365 | **Adding a Vector Layer**: 366 | - The `addVector` command takes a `ShapeLayerBuilderProtocol` instance. This protocol defines the necessary method to build a `CAShapeLayer` based on the given geometry (frame, bounds). 367 | - The `clear` parameter determines whether existing vector layers should be removed before adding the new one. If set to `true`, all existing vector layers are cleared, and only the new layer will be displayed. 368 | - The vector layer will be laid out directly over the video stream, allowing it to appear as part of the video playback experience. 369 | 370 | **Important Lifecycle Consideration**: 371 | Integrating vector graphics into SwiftUI views, particularly during lifecycle events such as onAppear, requires careful consideration of underlying system behaviors. When vectors are added as the view appears, developers might encounter issues where the builder receives frame and bounds values of zero. This discrepancy stems from the inherent mismatch between the lifecycle of SwiftUI views and the lifecycle of UIView or NSView, depending on the platform. SwiftUI defers much of its view layout and rendering to a later stage in the view lifecycle. To mitigate these issues, a small delay can be introduced during onAppear. I'll try to add this command in the initial config later to cover the case when you need a vector layer at the very early stage of the video streaming. 372 | 373 | ### Additional Notes on Brightness and Contrast 374 | 375 | - **Brightness and Contrast**: These settings function also filters but are managed separately from the filter stack. Adjustments to brightness and contrast are applied additionally and independently of the image filters. 376 | - **Persistent Settings**: Changes to brightness and contrast do not reset when the filter stack is cleared. They remain at their last set values and must be adjusted or reset separately by the developer as needed. 377 | - **Independent Management**: Developers should manage brightness and contrast adjustments through their dedicated methods or properties to ensure these settings are accurately reflected in the video output. 378 | 379 | 380 | ## How to use the package 381 | ### 1. Create LoopPlayerView 382 | 383 | ```swift 384 | ExtVideoPlayer(fileName: 'swipe') 385 | ``` 386 | 387 | or in a declarative way 388 | 389 | ```swift 390 | ExtVideoPlayer{ 391 | VideoSettings{ 392 | SourceName("swipe") 393 | Subtitles("subtitles_eng") 394 | Ext("mp8") // Set default extension here If not provided then mp4 is default 395 | Gravity(.resizeAspectFill) 396 | TimePublishing() 397 | Events([.durationAny, .itemStatusChangedAny]) 398 | } 399 | } 400 | .onPlayerTimeChange { newTime in 401 | // Current video playback time 402 | } 403 | .onPlayerEventChange { events in 404 | // Player events 405 | } 406 | ``` 407 | 408 | ```swift 409 | ExtVideoPlayer{ 410 | VideoSettings{ 411 | SourceName('https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8') 412 | } 413 | } 414 | ``` 415 | 416 | The only required setting is now **SourceName**. 417 | 418 | 419 | ### Supported Video Types and Formats 420 | The AVFoundation framework used in the package supports a wide range of video formats and codecs, including both file-based media and streaming protocols. Below is a list of supported video types, codecs, and streaming protocols organized into a grid according to Apple’s documentation. Sorry, didn’t check all codecs and files. 421 | 422 | | Supported File Types | Supported Codecs | Supported Streaming Protocols | 423 | |--------------------------|------------------|-------------------------------------| 424 | | **3GP** | **H.264** | **HTTP Live Streaming (HLS)** | 425 | | `.3gp`, `.3g2` | **H.265 (HEVC)** | `.m3u8` | 426 | | **MKV** (Limited support)| **MPEG-4 Part 2**| | 427 | | `.mkv` | **AAC** (audio) | | 428 | | **MP4** | **MP3** (audio) | | 429 | | `.mp4` | | | 430 | | **MOV** | | | 431 | | `.mov` | | | 432 | | **M4V** | | | 433 | | `.m4v` | | | 434 | 435 | ## Remote Video URLs 436 | The package now supports using remote video URLs, allowing you to stream videos directly from web resources. This is an extension to the existing functionality that primarily focused on local video files. Here's how to specify a remote URL within the settings: 437 | 438 | ```swift 439 | ExtVideoPlayer{ 440 | VideoSettings{ 441 | SourceName('https://example.com/video') 442 | Gravity(.resizeAspectFill) // Video content fit 443 | } 444 | } 445 | ``` 446 | 447 | ### Video Source Compatibility 448 | 449 | | Video Source | Possible to Use | Comments | 450 | | --- | --- | --- | 451 | | YouTube | No | Violates YouTube's policy as it doesn't allow direct video streaming outside its platform. | 452 | | Direct MP4 URLs | Yes | Directly accessible MP4 URLs can be used if they are hosted on servers that permit CORS. | 453 | | HLS Streams | Yes | HLS streams are supported and can be used for live streaming purposes. | 454 | 455 | 456 | ## New Functionality: Playback Commands 457 | 458 | The package now supports playback commands, allowing you to control video playback actions such as play, pause, and seek to specific times. 459 | 460 | ```swift 461 | struct VideoView: View { 462 | @State private var playbackCommand: PlaybackCommand = .play 463 | 464 | var body: some View { 465 | ExtVideoPlayer( 466 | { 467 | VideoSettings { 468 | SourceName("swipe") 469 | } 470 | }, 471 | command: $playbackCommand 472 | ) 473 | } 474 | } 475 | ``` 476 | 477 | ## Practical ideas for the package 478 | 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. 479 | 480 | ![The concept](https://github.com/swiftuiux/swiftui-video-player-example/blob/main/swiftui-loop-videoplayer-example/img/swiftui_video_hint.gif) 481 | 482 | ![The concept](https://github.com/swiftuiux/swiftui-video-player-example/blob/main/swiftui-loop-videoplayer-example/img/tip_video_swiftui.gif) 483 | 484 | ## HLS with Adaptive Quality 485 | 486 | ### How Adaptive Quality Switching Works 487 | 488 | 1. **Multiple Bitrates** 489 | - The video is encoded in multiple quality levels (e.g., 240p, 360p, 720p, 1080p), each with different bitrates. 490 | 491 | 2. **Manifest File** 492 | - The server provides a manifest file: 493 | - **In HLS**: A `.m3u8` file that contains links to video segments for each quality level. 494 | 495 | 3. **Segments** 496 | - The video is divided into short segments, typically 2–10 seconds long. 497 | 498 | 4. **Dynamic Switching** 499 | - The client (e.g., `AVQueuePlayer`) dynamically adjusts playback quality based on the current internet speed: 500 | - Starts playback with the most suitable quality. 501 | - Switches to higher or lower quality during playback as the connection speed changes. 502 | 503 | ### Why This is the Best Option 504 | 505 | - **On-the-fly quality adjustment**: Ensures smooth transitions between quality levels without interrupting playback. 506 | - **Minimal pauses and interruptions**: Reduces buffering and improves user experience. 507 | - **Bandwidth efficiency**: The server sends only the appropriate stream, saving network traffic. 508 | 509 | ## AVQueuePlayer features out of the box 510 | 511 | In the core of this package, I use `AVQueuePlayer`. Here are the supported features that are automatically enabled by `AVQueuePlayer` without passing any extra parameters: 512 | 513 | | Feature | Description | 514 | |------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| 515 | | **Hardware accelerator** | `AVQueuePlayer` uses hardware acceleration by default where available. | 516 | | **4k/HDR/HDR10/HDR10+/Dolby Vision** | These high-definition and high-dynamic-range formats are natively supported by `AVQueuePlayer`. | 517 | | **Multichannel Audio/Dolby Atmos/Spatial Audio** | `AVQueuePlayer` supports advanced audio formats natively. | 518 | | **Text subtitle/Image subtitle/Closed Captions** | Subtitle and caption tracks included in the video file are automatically detected and rendered. | 519 | | **Automatically switch to multi-bitrate streams based on network** | Adaptive bitrate streaming is handled automatically by `AVQueuePlayer` when streaming from a source that supports it. | 520 | | **External playback control support** | Supports playback control through external accessories like headphones and Bluetooth devices. | 521 | | **AirPlay support** | Natively supports streaming audio and video via AirPlay to compatible devices without additional setup. | 522 | | **Background Audio Playback** | Continues audio playback when the app is in the background, provided the appropriate audio session category is set. | 523 | | **Picture-in-Picture (PiP) Support** | Enables Picture-in-Picture mode on compatible devices without additional setup. | 524 | | **HLS (HTTP Live Streaming) Support** | Natively supports streaming of HLS content for live and on-demand playback. | 525 | | **FairPlay DRM Support** | Can play FairPlay DRM-protected content. | 526 | | **Now Playing Info Center Integration** | Automatically updates the Now Playing Info Center with current playback information for lock screen and control center displays. | 527 | | **Remote Control Event Handling** | Supports handling remote control events from external accessories and system controls. | 528 | | **Custom Playback Rate** | Allows setting custom playback rates for slow-motion or fast-forward playback without additional configuration. | 529 | | **Seamless Transition Between Items** | Provides smooth transitions between queued media items, ensuring continuous playback without gaps. | 530 | | **Automatic Audio Session Management** | Manages audio sessions to handle interruptions (like phone calls) and route changes appropriately. | 531 | | **Subtitles and Closed Caption Styling** | Supports user preferences for styling subtitles and closed captions, including font size, color, and background. | 532 | | **Audio Focus and Ducking** | Handles audio focus by pausing or lowering volume when necessary, such as when a navigation prompt plays. | 533 | | **Metadata Handling** | Reads and displays metadata embedded in media files, such as song titles, artists, and artwork. | 534 | | **Buffering and Caching** | Efficiently manages buffering of streaming content to reduce playback interruptions. | 535 | | **Error Handling and Recovery** | Provides built-in mechanisms to handle playback errors and attempt recovery without crashing the application. | 536 | | **Accessibility Features** | Supports VoiceOver and other accessibility features to make media content accessible to all users. | 537 | 538 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/ExtVideoPlayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayerView.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 10.02.2023. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | #if canImport(AVKit) 11 | import AVKit 12 | #endif 13 | 14 | /// Player view for running a video in loop 15 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 16 | public struct ExtVideoPlayer: View{ 17 | 18 | /// Set of settings for video the player 19 | @Binding public var settings: VideoSettings 20 | 21 | /// Binding to a playback command that controls playback actions 22 | @Binding public var command: PlaybackCommand 23 | 24 | /// The current playback time, represented as a Double. 25 | @State private var currentTime: Double = 0.0 26 | 27 | /// The current state of the player event, 28 | @State private var playerEvent: [PlayerEvent] = [] 29 | 30 | /// A publisher that emits the current playback time as a `Double`. It is initialized privately within the view. 31 | @State private var timePublisher = PassthroughSubject() 32 | 33 | /// A publisher that emits player events as `PlayerEvent` values. It is initialized privately within the view. 34 | @State private var eventPublisher = PassthroughSubject() 35 | 36 | // MARK: - Life cycle 37 | 38 | /// Player initializer 39 | /// - Parameters: 40 | /// - fileName: The name of the video file. 41 | /// - ext: The file extension, with a default value of "mp4". 42 | /// - gravity: The video gravity setting, with a default value of `.resizeAspect`. 43 | /// - timePublishing: An optional `CMTime` value for time publishing, with a default value of 1 second. 44 | /// - command: A binding to the playback command, with a default value of `.play`. 45 | public init( 46 | fileName: String, 47 | ext: String = "mp4", 48 | gravity: AVLayerVideoGravity = .resizeAspect, 49 | timePublishing : CMTime? = CMTime(seconds: 1, preferredTimescale: 600), 50 | command : Binding = .constant(.play) 51 | ) { 52 | self._command = command 53 | 54 | func description(@SettingsBuilder content: () -> [Setting]) -> [Setting] { 55 | return content() 56 | } 57 | 58 | let settings: VideoSettings = VideoSettings { 59 | SourceName(fileName) 60 | Ext(ext) 61 | Gravity(gravity) 62 | if let timePublishing{ 63 | timePublishing 64 | } 65 | } 66 | 67 | _settings = .constant(settings) 68 | } 69 | 70 | /// Player initializer in a declarative way 71 | /// - Parameters: 72 | /// - settings: Set of settings 73 | /// - command: A binding to control playback actions 74 | public init( 75 | _ settings: () -> VideoSettings, 76 | command: Binding = .constant(.play) 77 | ) { 78 | 79 | self._command = command 80 | _settings = .constant(settings()) 81 | } 82 | 83 | /// Player initializer in a declarative way 84 | /// - Parameters: 85 | /// - settings: A binding to the set of settings for the video player 86 | /// - command: A binding to control playback actions 87 | public init( 88 | settings: Binding, 89 | command: Binding = .constant(.play) 90 | ) { 91 | self._settings = settings 92 | self._command = command 93 | } 94 | 95 | // MARK: - API 96 | 97 | /// The body property defines the view hierarchy for the user interface. 98 | public var body: some View { 99 | ExtPlayerMultiPlatform( 100 | settings: $settings, 101 | command: $command, 102 | timePublisher: timePublisher, 103 | eventPublisher: eventPublisher 104 | ) 105 | .onReceive(timePublisher.receive(on: DispatchQueue.main), perform: { time in 106 | currentTime = time 107 | }) 108 | .onReceive(eventPublisher.collect(.byTime(DispatchQueue.main, .seconds(1))), perform: { event in 109 | playerEvent = filterEvents(with: settings, for: event) 110 | }) 111 | .preference(key: CurrentTimePreferenceKey.self, value: currentTime) 112 | .preference(key: PlayerEventPreferenceKey.self, value: playerEvent) 113 | } 114 | } 115 | 116 | // MARK: - Fileprivate 117 | 118 | /// Filters a list of `PlayerEvent` instances based on the provided `VideoSettings`. 119 | /// 120 | /// - Parameters: 121 | /// - settings: The video settings containing event filters. 122 | /// - events: The list of events to be filtered. 123 | /// - Returns: A filtered list of `PlayerEvent` that match at least one filter in `settings`. 124 | fileprivate func filterEvents(with settings: VideoSettings, for events: [PlayerEvent]) -> [PlayerEvent] { 125 | let filters = settings.events // `[PlayerEventFilter]` 126 | 127 | // If no filters are provided, return an empty array. 128 | guard let filters else { 129 | return [] 130 | } 131 | 132 | guard !filters.isEmpty else{ 133 | return events 134 | } 135 | 136 | // Keep each `PlayerEvent` only if it matches *at least* one filter in `filters`. 137 | return events.filter { event in 138 | filters.contains(event) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/enum/PlaybackCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaybackCommand.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 05.08.24. 6 | // 7 | 8 | import AVFoundation 9 | #if canImport(CoreImage) 10 | import CoreImage 11 | #endif 12 | 13 | /// An enumeration of possible playback commands. 14 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 15 | public enum PlaybackCommand: Equatable { 16 | 17 | /// The idle command to do nothing 18 | case idle 19 | 20 | /// Command to play the video. 21 | case play 22 | 23 | /// Command to pause the video. 24 | case pause 25 | 26 | /// Command to seek to a specific time in the video. 27 | /// 28 | /// This case allows seeking to a specified time position in the video and optionally starts playback immediately. 29 | /// 30 | /// - Parameters: 31 | /// - time: The target time position in the video, specified in seconds. 32 | /// - play: A Boolean value indicating whether playback should automatically resume after seeking. 33 | /// Defaults to `true`. 34 | case seek(to: Double, play: Bool = true) 35 | 36 | /// Command to position the video at the beginning. 37 | case begin 38 | 39 | /// Command to position the video at the end. 40 | case end 41 | 42 | /// Command to mute the video. 43 | case mute 44 | 45 | /// Command to unmute the video. 46 | case unmute 47 | 48 | /// Command to adjust the volume of the video playback. 49 | /// - Parameter volume: A `Float` value between 0.0 (mute) and 1.0 (full volume). Values outside this range will be clamped. 50 | case volume(Float) 51 | 52 | /// Command to set subtitles for the video playback to a specified language or to turn them off. 53 | /// - Parameter language: The language code for the desired subtitles, pass `nil` to turn subtitles off. 54 | case subtitles(String?) 55 | 56 | /// Command to adjust the playback speed of the video. 57 | /// - Parameter speed: A `Float` value representing the playback speed. Valid range is typically from 0.5 to 2.0. Negative values will be clamped to 0.0. 58 | case playbackSpeed(Float) 59 | 60 | /// Command to enable looping of the video playback. 61 | /// Looping is assumed to be enabled by default. 62 | case loop 63 | 64 | /// Command to disable looping of the video playback. 65 | /// Only affects playback if looping is currently active. 66 | case unloop 67 | 68 | /// Command to adjust the brightness of the video playback. 69 | /// - Parameter brightness: A `Float` value typically ranging from -1.0 to 1.0. 70 | case brightness(Float) 71 | 72 | /// Command to adjust the contrast of the video playback. 73 | /// - Parameter contrast: A `Float` value typically ranging from 0.0 to 4.0. 74 | case contrast(Float) 75 | 76 | /// Command to apply a specific Core Image filter to the video. 77 | /// - Parameters: 78 | /// - filter: A `CIFilter` object representing the filter to be applied. 79 | /// - clear: A Boolean value indicating whether to clear the existing filter stack before applying this filter. 80 | /// This filter is added to the current stack of filters, allowing for multiple filters to be combined and applied sequentially, unless `clear` is true. 81 | case filter(CIFilter, clear: Bool = false) 82 | 83 | /// Command to remove all applied filters from the video playback. 84 | case removeAllFilters 85 | 86 | /// Represents a command to create and possibly clear existing vectors using a shape layer builder. 87 | /// - Parameters: 88 | /// - builder: An instance conforming to `ShapeLayerBuilderProtocol` which will provide the shape layer. 89 | /// - clear: A Boolean value that determines whether existing vector graphics should be cleared before applying the new vector. Defaults to `false`. 90 | case addVector(any ShapeLayerBuilderProtocol, clear: Bool = false) 91 | 92 | /// Represents a command to remove all vector graphics from the current view or context. 93 | case removeAllVectors 94 | 95 | /// Command to select a specific audio track based on language code. 96 | /// - Parameter languageCode: The language code (e.g., "en" for English) of the desired audio track. 97 | case audioTrack(languageCode: String) 98 | 99 | #if os(iOS) 100 | /// Command to initiate Picture-in-Picture (PiP) mode for video playback. If the PiP feature is already active, this command will have no additional effect. 101 | case startPiP 102 | 103 | /// Command to terminate Picture-in-Picture (PiP) mode, returning the video playback to its inline view. If PiP is not active, this command will have no effect. 104 | case stopPiP 105 | 106 | #endif 107 | 108 | public static func == (lhs: PlaybackCommand, rhs: PlaybackCommand) -> Bool { 109 | switch (lhs, rhs) { 110 | case (.idle, .idle), (.play, .play), (.pause, .pause), (.begin, .begin), (.end, .end), 111 | (.mute, .mute), (.unmute, .unmute), (.loop, .loop), (.unloop, .unloop), 112 | (.removeAllFilters, .removeAllFilters), (.removeAllVectors, .removeAllVectors): 113 | return true 114 | #if os(iOS) 115 | case (.startPiP, .startPiP), (.stopPiP, .stopPiP): 116 | return true 117 | #endif 118 | case (.seek(let lhsTime, let lhsPlay), .seek(let rhsTime, let rhsPlay)): 119 | return lhsTime == rhsTime && lhsPlay == rhsPlay 120 | 121 | case (.volume(let lhsVolume), .volume(let rhsVolume)): 122 | return lhsVolume == rhsVolume 123 | 124 | case (.subtitles(let lhsLanguage), .subtitles(let rhsLanguage)): 125 | return lhsLanguage == rhsLanguage 126 | 127 | case (.playbackSpeed(let lhsSpeed), .playbackSpeed(let rhsSpeed)): 128 | return lhsSpeed == rhsSpeed 129 | 130 | case (.brightness(let lhsBrightness), .brightness(let rhsBrightness)): 131 | return lhsBrightness == rhsBrightness 132 | 133 | case (.contrast(let lhsContrast), .contrast(let rhsContrast)): 134 | return lhsContrast == rhsContrast 135 | 136 | case (.audioTrack(let lhsCode), .audioTrack(let rhsCode)): 137 | return lhsCode == rhsCode 138 | 139 | case (.filter(let lhsFilter, let lhsClear), .filter(let rhsFilter, let rhsClear)): 140 | return lhsFilter == rhsFilter && lhsClear == rhsClear 141 | case let (.addVector(lhsBuilder, lhsClear), .addVector(rhsBuilder, rhsClear)): 142 | return lhsBuilder.id == rhsBuilder.id && lhsClear == rhsClear 143 | default: 144 | return false 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/enum/PlayerEvent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayerEvents.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 15.08.24. 6 | // 7 | 8 | import Foundation 9 | import AVFoundation 10 | 11 | /// An enumeration representing various events that can occur within a media player. 12 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 13 | public enum PlayerEvent: Equatable { 14 | 15 | /// Represents an end seek action within the player. 16 | /// - Parameters: 17 | /// - Bool: Indicates whether the seek was successful. 18 | /// - currentTime: The time (in seconds) to which the player is seeking. 19 | case seek(Bool, currentTime: Double) 20 | 21 | /// Indicates that the player's playback is currently paused. 22 | /// 23 | /// This state occurs when the player has been manually paused by the user or programmatically 24 | /// through a method like `pause()`. The player is not playing any content while in this state. 25 | case paused 26 | 27 | /// Indicates that the player is currently waiting to play at the specified rate. 28 | /// 29 | /// This state generally occurs when the player is buffering or waiting for sufficient data 30 | /// to continue playback. It can also occur if the playback rate is temporarily reduced to zero 31 | /// due to external factors, such as network conditions or system resource limitations. 32 | case waitingToPlayAtSpecifiedRate 33 | 34 | /// Indicates that the player is actively playing content. 35 | /// 36 | /// This state occurs when the player is currently playing video or audio content at the 37 | /// specified playback rate. This is the active state where media is being rendered to the user. 38 | case playing 39 | 40 | /// Indicates that the player has switched to a new item. 41 | /// 42 | /// This event is triggered when the player's `currentItem` changes to a new `AVPlayerItem`. 43 | /// - Parameter newItem: The new `AVPlayerItem` that the player has switched to. 44 | case currentItemChanged(newItem: AVPlayerItem?) 45 | 46 | /// Indicates that the player has removed the current item. 47 | /// 48 | /// This event is triggered when the player's `currentItem` is set to `nil`, meaning that there 49 | /// is no media item currently loaded in the player. 50 | case currentItemRemoved 51 | 52 | /// Indicates that the player's volume has changed. 53 | /// 54 | /// This event is triggered when the player's `volume` property is adjusted. 55 | /// - Parameter newVolume: The new volume level, ranging from 0.0 (muted) to 1.0 (full volume). 56 | case volumeChanged(newVolume: Float) 57 | 58 | /// Represents a case where a specific VPErrors type error is encountered. 59 | /// 60 | /// - Parameter VPErrors: The error from the VPErrors enum associated with this case. 61 | case error(VPErrors) 62 | 63 | /// Event triggered when the bounds of the video player change. 64 | /// - Parameter CGRect: The new bounds of the video player. 65 | case boundsChanged(CGRect) 66 | 67 | /// Event triggered when Picture-in-Picture (PiP) mode starts. 68 | case startedPiP 69 | 70 | /// Event triggered when Picture-in-Picture (PiP) mode stops. 71 | case stoppedPiP 72 | 73 | /// Indicates that the AVPlayerItem's status has changed. 74 | /// - Parameter status: The new status of the AVPlayerItem. 75 | case itemStatusChanged(AVPlayerItem.Status) 76 | 77 | /// Provides the duration of the AVPlayerItem when it is ready to play. 78 | /// - Parameter duration: The total duration of the media item in `CMTime`. 79 | case duration(CMTime) 80 | } 81 | 82 | extension PlayerEvent: CustomStringConvertible { 83 | public var description: String { 84 | switch self { 85 | case .seek(let success, _): 86 | return success ? "SeekSuccess" : "SeekFail" 87 | case .paused: 88 | return "Paused" 89 | case .waitingToPlayAtSpecifiedRate: 90 | return "Waiting" 91 | case .playing: 92 | return "Playing" 93 | case .currentItemChanged(_): 94 | return "ItemChanged" 95 | case .currentItemRemoved: 96 | return "ItemRemoved" 97 | case .volumeChanged(_): 98 | return "VolumeChanged" 99 | case .error(let e): 100 | return "\(e.description)" 101 | case .boundsChanged(let bounds): 102 | return "Bounds changed \(bounds)" 103 | case .startedPiP: 104 | return "Started PiP" 105 | case .stoppedPiP: 106 | return "Stopped PiP" 107 | case .itemStatusChanged(let status): 108 | switch status { 109 | case .unknown: 110 | return "Status: Unknown" 111 | case .readyToPlay: 112 | return "Status: ReadyToPlay" 113 | case .failed: 114 | return "Status: FailedToLoad" 115 | @unknown default: 116 | return "Unknown status" 117 | } 118 | case .duration(let value): 119 | let roundedString = String(format: "%.0f", value.seconds) 120 | return "Duration \(roundedString) sec" 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/enum/PlayerEventFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayerEventFilter.swift 3 | // swiftui-loop-videoplayer 4 | // 5 | // Created by Igor on 12.02.25. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A "parallel" structure for filtering PlayerEvent. 11 | /// Each case here: 12 | /// 1) Either ignores associated values (xxxAny) 13 | /// 2) Or matches cases that have no associated values in PlayerEvent. 14 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 15 | public enum PlayerEventFilter { 16 | /// Matches any `.seek(...)` case, regardless of Bool or currentTime 17 | case seekAny 18 | 19 | /// Matches `.paused` exactly (no associated values) 20 | case paused 21 | 22 | /// Matches `.waitingToPlayAtSpecifiedRate` (no associated values) 23 | case waitingToPlayAtSpecifiedRate 24 | 25 | /// Matches `.playing` (no associated values) 26 | case playing 27 | 28 | /// Matches any `.currentItemChanged(...)` case 29 | case currentItemChangedAny 30 | 31 | /// Matches `.currentItemRemoved` exactly (no associated values) 32 | case currentItemRemoved 33 | 34 | /// Matches any `.volumeChanged(...)` case 35 | case volumeChangedAny 36 | 37 | /// Matches any `.error(...)` case 38 | case errorAny 39 | 40 | /// Matches any `.boundsChanged(...)` case 41 | case boundsChangedAny 42 | 43 | /// Matches `.startedPiP` (no associated values) 44 | case startedPiP 45 | 46 | /// Matches `.stoppedPiP` (no associated values) 47 | case stoppedPiP 48 | 49 | /// Matches any `.itemStatusChanged(...)` case 50 | case itemStatusChangedAny 51 | 52 | /// Matches any `.duration(...)` case 53 | case durationAny 54 | } 55 | 56 | extension PlayerEventFilter { 57 | /// Checks whether a given `PlayerEvent` matches this filter. 58 | /// 59 | /// - Parameter event: The `PlayerEvent` to inspect. 60 | /// - Returns: `true` if the event belongs to this case (ignoring parameters), `false` otherwise. 61 | func matches(_ event: PlayerEvent) -> Bool { 62 | switch (self, event) { 63 | // Compare by case name only, ignoring associated values 64 | case (.seekAny, .seek): 65 | return true 66 | case (.paused, .paused): 67 | return true 68 | case (.waitingToPlayAtSpecifiedRate, .waitingToPlayAtSpecifiedRate): 69 | return true 70 | case (.playing, .playing): 71 | return true 72 | case (.currentItemChangedAny, .currentItemChanged): 73 | return true 74 | case (.currentItemRemoved, .currentItemRemoved): 75 | return true 76 | case (.volumeChangedAny, .volumeChanged): 77 | return true 78 | case (.errorAny, .error): 79 | return true 80 | case (.boundsChangedAny, .boundsChanged): 81 | return true 82 | case (.startedPiP, .startedPiP): 83 | return true 84 | case (.stoppedPiP, .stoppedPiP): 85 | return true 86 | case (.itemStatusChangedAny, .itemStatusChanged): 87 | return true 88 | case (.durationAny, .duration): 89 | return true 90 | 91 | // Default fallback: no match 92 | default: 93 | return false 94 | } 95 | } 96 | } 97 | 98 | extension Collection where Element == PlayerEventFilter { 99 | /// Checks whether any filter in this collection matches the given `PlayerEvent`. 100 | /// 101 | /// - Parameter event: The `PlayerEvent` to test. 102 | /// - Returns: `true` if at least one `PlayerEventFilter` in this collection matches the `event`; otherwise, `false`. 103 | func contains(_ event: PlayerEvent) -> Bool { 104 | return self.contains { filter in 105 | filter.matches(event) 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/enum/Setting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Setting.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 07.07.2023. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | #if canImport(AVKit) 11 | import AVKit 12 | #endif 13 | 14 | /// Configuration settings for a loop video player. 15 | /// These settings control various playback and UI behaviors. 16 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 17 | public enum Setting: Equatable, SettingsConvertible { 18 | 19 | /// Converts the current setting into an array containing itself. 20 | /// - Returns: An array with a single instance of `Setting`. 21 | public func asSettings() -> [Setting] { 22 | [self] 23 | } 24 | 25 | /// Event filters to monitor specific player events. 26 | case events([PlayerEventFilter]?) 27 | 28 | /// Enables a vector layer for overlaying vector graphics. 29 | case vector 30 | 31 | /// Enables looping of the video playback. 32 | case loop 33 | 34 | /// Mutes the video. 35 | case mute 36 | 37 | /// Prevents automatic playback after initialization. 38 | case notAutoPlay 39 | 40 | /// Specifies the file name of the video. 41 | case name(String) 42 | 43 | /// Specifies the file extension of the video. 44 | case ext(String) 45 | 46 | /// Sets subtitles for the video. 47 | case subtitles(String) 48 | 49 | /// Enables Picture-in-Picture (PiP) mode support. 50 | case pictureInPicture 51 | 52 | /// Defines the interval at which the player's current time should be published. 53 | case timePublishing(CMTime) 54 | 55 | /// Sets the video gravity (e.g., aspect fit, aspect fill). 56 | case gravity(AVLayerVideoGravity = .resizeAspect) 57 | 58 | /// Retrieves the name of the current case. 59 | var caseName: String { 60 | Mirror(reflecting: self).children.first?.label ?? "\(self)" 61 | } 62 | 63 | /// Retrieves the associated value of the case, if any. 64 | var associatedValue: Any? { 65 | guard let firstChild = Mirror(reflecting: self).children.first else { 66 | return nil 67 | } 68 | return firstChild.value 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/enum/VPErrors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VPErrors.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 09.07.2023. 6 | // 7 | 8 | import Foundation 9 | 10 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 11 | /// An enumeration of possible errors that can occur in the video player. 12 | public enum VPErrors: Error, CustomStringConvertible, Sendable { 13 | 14 | /// Error case for when there is an error with remote video playback. 15 | /// - Parameter error: The error that occurred during remote video playback. 16 | case remoteVideoError(Error?) 17 | 18 | /// Error case for when a file is not found. 19 | /// - Parameter name: The name of the file that was not found. 20 | case sourceNotFound(String) 21 | 22 | /// Error case for when settings are not unique. 23 | case settingsNotUnique 24 | 25 | /// Picture-in-Picture (PiP) is not supported. 26 | case notSupportedPiP 27 | 28 | /// Failed to load. 29 | /// - Parameter error: The error encountered during loading. 30 | case failedToLoad(Error?) 31 | 32 | /// A description of the error, suitable for display. 33 | public var description: String { 34 | switch self { 35 | case .sourceNotFound(let name): 36 | return "Source not found: \(name)" 37 | 38 | case .notSupportedPiP: 39 | return "Picture-in-Picture (PiP) is not supported on this device." 40 | 41 | case .settingsNotUnique: 42 | return "Settings are not unique." 43 | 44 | case .remoteVideoError(let error): 45 | return "Playback error: \(error?.localizedDescription ?? "Unknown error.")" 46 | 47 | case .failedToLoad(let error): 48 | return "Failed to load the video: \(error?.localizedDescription ?? "Unknown error.")" 49 | } 50 | } 51 | } 52 | 53 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 54 | extension VPErrors: Equatable { 55 | 56 | /// Compares two `VPErrors` instances for equality based on specific error conditions. 57 | public static func ==(lhs: VPErrors, rhs: VPErrors) -> Bool { 58 | switch (lhs, rhs) { 59 | case (.remoteVideoError(let a), .remoteVideoError(let b)): 60 | return a?.localizedDescription == b?.localizedDescription 61 | 62 | case (.sourceNotFound(let a), .sourceNotFound(let b)): 63 | return a == b 64 | 65 | case (.settingsNotUnique, .settingsNotUnique): 66 | return true 67 | 68 | case (.notSupportedPiP, .notSupportedPiP): 69 | return true 70 | 71 | case (.failedToLoad(let a), .failedToLoad(let b)): 72 | return a?.localizedDescription == b?.localizedDescription 73 | 74 | default: 75 | return false 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/ext+/Array+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 09.07.2023. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array where Element == Setting{ 11 | 12 | /// Find first setting by case name 13 | /// - Parameter name: Case name 14 | /// - Returns: Setting 15 | private func first(_ name : String) -> Setting?{ 16 | self.first(where: { $0.caseName == name }) 17 | } 18 | 19 | /// Fetch associated value 20 | /// - Parameters: 21 | /// - name: Case name 22 | /// - defaulted: Default value 23 | /// - Returns: Associated value 24 | func fetch(by name : String, defaulted : T) -> T{ 25 | guard let value = first(name)?.associatedValue as? T else { 26 | return defaulted 27 | } 28 | 29 | return value 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/ext+/CMTime+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CMTime+.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 15.08.24. 6 | // 7 | 8 | #if canImport(AVKit) 9 | import AVKit 10 | #endif 11 | 12 | /// Extends `CMTime` to conform to the `SettingsConvertible` protocol. 13 | extension CMTime : SettingsConvertible { 14 | 15 | /// Converts the `CMTime` instance into a settings array containing a time publishing setting. 16 | /// - Returns: An array of `Setting` with the `timePublishing` case initialized with this `CMTime` instance. 17 | public func asSettings() -> [Setting] { 18 | [.timePublishing(self)] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/ext+/URL+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 05.08.24. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | extension URL { 12 | 13 | /// Validates a string as a well-formed HTTP or HTTPS URL and returns a URL object if valid. 14 | /// 15 | /// - Parameter urlString: The string to validate as a URL. 16 | /// - Returns: An optional URL object if the string is a valid URL. 17 | /// - Throws: An error if the URL is not valid or cannot be created. 18 | static func validURLFromString(_ string: String) -> URL? { 19 | let pattern = "^(https?:\\/\\/)(([a-zA-Z0-9-]+\\.)*[a-zA-Z0-9-]+\\.[a-zA-Z]{2,})(:\\d{1,5})?(\\/[\\S]*)?$" 20 | let regex = try? NSRegularExpression(pattern: pattern, options: []) 21 | 22 | let matches = regex?.matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) 23 | 24 | guard let _ = matches, !matches!.isEmpty else { 25 | // If no matches are found, the URL is not valid 26 | return nil 27 | } 28 | 29 | // If a match is found, attempt to create a URL object 30 | return URL(string: string) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/ext+/View+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 09.08.24. 6 | // 7 | 8 | #if canImport(UIKit) 9 | import UIKit 10 | 11 | internal extension UIView { 12 | 13 | /// Finds the first subview of the specified type within the view's direct children. 14 | func findFirstSubview(ofType type: T.Type) -> T? { 15 | return subviews.compactMap { $0 as? T }.first 16 | } 17 | } 18 | #endif 19 | 20 | #if canImport(AppKit) 21 | import AppKit 22 | 23 | internal extension NSView { 24 | 25 | /// Finds the first subview of the specified type within the view's direct children. 26 | func findFirstSubview(ofType type: T.Type) -> T? { 27 | return subviews.compactMap { $0 as? T }.first 28 | } 29 | } 30 | 31 | #endif 32 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/fn/constraintsFn.swift: -------------------------------------------------------------------------------- 1 | // 2 | // constraintsFn.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 06.08.24. 6 | // 7 | 8 | import Foundation 9 | 10 | #if canImport(AppKit) 11 | import AppKit 12 | 13 | /// Activates full-screen constraints for a given view within its container view. 14 | /// This method sets up constraints to make the `view` fill the entire `containerView`. 15 | /// - Parameters: 16 | /// - view: The view for which full-screen constraints will be applied. 17 | /// - containerView: The parent view in which `view` will be constrained to match the full size. 18 | func activateFullScreenConstraints(for view: NSView, in containerView: NSView) { 19 | view.translatesAutoresizingMaskIntoConstraints = false 20 | NSLayoutConstraint.activate([ 21 | view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), 22 | view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), 23 | view.topAnchor.constraint(equalTo: containerView.topAnchor), 24 | view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) 25 | ]) 26 | } 27 | #endif 28 | 29 | #if canImport(UIKit) 30 | import UIKit 31 | 32 | /// Activates full-screen constraints for a view within a container view. 33 | /// 34 | /// - Parameters: 35 | /// - view: The view to be constrained. 36 | /// - containerView: The container view to which the constraints are applied. 37 | func activateFullScreenConstraints(for view: UIView, in containerView: UIView) { 38 | view.translatesAutoresizingMaskIntoConstraints = false 39 | NSLayoutConstraint.activate([ 40 | view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), 41 | view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), 42 | view.topAnchor.constraint(equalTo: containerView.topAnchor), 43 | view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) 44 | ]) 45 | } 46 | #endif 47 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/fn/fn+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // fn+.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 06.08.24. 6 | // 7 | 8 | import Foundation 9 | import AVFoundation 10 | #if canImport(CoreImage) 11 | import CoreImage 12 | #endif 13 | 14 | /// Retrieves an `AVURLAsset` based on specified video settings. 15 | /// - Parameter settings: A `VideoSettings` object containing the video name and extension. 16 | /// - Returns: An optional `AVURLAsset`. Returns `nil` if a valid URL cannot be created or the file cannot be found in the bundle. 17 | func assetFor(_ settings: VideoSettings) -> AVURLAsset? { 18 | let name = settings.name 19 | // If the name already includes an extension, use that; otherwise, use `settings.ext`. 20 | let ext = extractExtension(from: name) ?? settings.ext 21 | 22 | // Leverage the common helper to construct the `AVURLAsset`. 23 | return assetFrom(name: name, fileExtension: ext) 24 | } 25 | 26 | /// Retrieves an `AVURLAsset` for the subtitles specified in `VideoSettings`. 27 | /// - Parameter settings: A `VideoSettings` object containing the subtitle file name. 28 | /// - Returns: An optional `AVURLAsset` for the subtitle file, or `nil` if `subtitles` is empty or cannot be found. 29 | func subtitlesAssetFor(_ settings: VideoSettings) -> AVURLAsset? { 30 | let subtitleName = settings.subtitles 31 | // If no subtitle name is provided, early return `nil`. 32 | guard !subtitleName.isEmpty else { 33 | return nil 34 | } 35 | 36 | // Use a default `.vtt` extension for subtitles. 37 | return assetFrom(name: subtitleName, fileExtension: "vtt") 38 | } 39 | 40 | /// A common helper that attempts to build an `AVURLAsset` from a given name and optional file extension. 41 | /// - Parameters: 42 | /// - name: The base file name or a URL string. 43 | /// - fileExtension: An optional file extension to be appended if `name` isn't a valid URL. 44 | /// - Returns: An optional `AVURLAsset`, or `nil` if neither a valid URL nor a local resource file is found. 45 | fileprivate func assetFrom(name: String, fileExtension: String?) -> AVURLAsset? { 46 | // Attempt to create a valid URL from the provided string. 47 | if let url = URL.validURLFromString(name) { 48 | return AVURLAsset(url: url) 49 | } 50 | 51 | if let url = fileURL(from: name){ 52 | return AVURLAsset(url: url) 53 | } 54 | 55 | // If not a valid URL, try to locate the file in the main bundle with the specified extension. 56 | if let fileExtension = fileExtension, 57 | let fileUrl = Bundle.main.url(forResource: name, withExtension: fileExtension) { 58 | return AVURLAsset(url: fileUrl) 59 | } 60 | 61 | // If all attempts fail, return `nil`. 62 | return nil 63 | } 64 | 65 | 66 | /// Attempts to create a valid `URL` from a string that starts with `"file://"`. 67 | /// - Parameter rawString: A file URL string, e.g. `"file:///Users/igor/My Folder/File.mp4"`. 68 | /// - Returns: A `URL` if successfully parsed; otherwise `nil`. 69 | public func fileURL(from rawString: String) -> URL? { 70 | guard rawString.hasPrefix("file://") else { 71 | // Not a file URL scheme 72 | return nil 73 | } 74 | // Strip off "file://" 75 | let pathIndex = rawString.index(rawString.startIndex, offsetBy: 7) 76 | let pathPortion = rawString[pathIndex...] 77 | 78 | guard let encodedPath = pathPortion 79 | .addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) 80 | else { return nil } 81 | 82 | let finalString = "file://\(encodedPath)" 83 | return URL(string: finalString) 84 | } 85 | 86 | /// Checks whether a given filename contains an extension and returns the extension if it exists. 87 | /// 88 | /// - Parameter name: The filename to check. 89 | /// - Returns: An optional string containing the extension if it exists, otherwise nil. 90 | fileprivate func extractExtension(from name: String) -> String? { 91 | let pattern = "^.*\\.([^\\s]+)$" 92 | let regex = try? NSRegularExpression(pattern: pattern, options: []) 93 | let range = NSRange(location: 0, length: name.utf16.count) 94 | 95 | if let match = regex?.firstMatch(in: name, options: [], range: range) { 96 | if let extensionRange = Range(match.range(at: 1), in: name) { 97 | return String(name[extensionRange]) 98 | } 99 | } 100 | return nil 101 | } 102 | 103 | /// Combines an array of CIFilters with additional brightness and contrast adjustments. 104 | /// 105 | /// This function appends brightness and contrast adjustments as CIFilters to the existing array of filters. 106 | /// 107 | /// - Parameters: 108 | /// - filters: An array of CIFilter objects to which the brightness and contrast filters will be added. 109 | /// - brightness: A Float value representing the brightness adjustment to apply. 110 | /// - contrast: A Float value representing the contrast adjustment to apply. 111 | /// 112 | /// - Returns: An array of CIFilter objects, including the original filters and the added brightness and contrast adjustments. 113 | func combineFilters(_ filters: [CIFilter],_ brightness: Float,_ contrast: Float) -> [CIFilter] { 114 | var allFilters = filters 115 | if let filter = CIFilter(name: "CIColorControls", parameters: [kCIInputBrightnessKey: brightness]) { 116 | allFilters.append(filter) 117 | } 118 | if let filter = CIFilter(name: "CIColorControls", parameters: [kCIInputContrastKey: contrast]) { 119 | allFilters.append(filter) 120 | } 121 | return allFilters 122 | } 123 | 124 | /// Processes an asynchronous video composition request by applying a series of CIFilters. 125 | /// This function ensures each frame processed conforms to specified filter effects. 126 | /// 127 | /// - Parameters: 128 | /// - request: An AVAsynchronousCIImageFilteringRequest object representing the current video frame to be processed. 129 | /// - filters: An array of CIFilters to be applied sequentially to the video frame. 130 | /// 131 | /// The function starts by clamping the source image to ensure coordinates remain within the image bounds, 132 | /// applies each filter in the provided array, and completes by returning the modified image to the composition request. 133 | func handleVideoComposition(request: AVAsynchronousCIImageFilteringRequest, filters: [CIFilter]) { 134 | // Start with the source image, ensuring it's clamped to avoid any coordinate issues 135 | var currentImage = request.sourceImage.clampedToExtent() 136 | 137 | // Apply each filter in the array to the image 138 | for filter in filters { 139 | filter.setValue(currentImage, forKey: kCIInputImageKey) 140 | if let outputImage = filter.outputImage { 141 | currentImage = outputImage.clampedToExtent() 142 | } 143 | } 144 | // Finish the composition request by outputting the final image 145 | request.finish(with: currentImage, context: nil) 146 | } 147 | 148 | /// Merges a video asset with an external WebVTT subtitle file into an AVMutableComposition. 149 | /// Returns a new AVAsset that has both the video/audio and subtitle tracks. 150 | /// 151 | /// - Note: 152 | /// - This method supports embedding external subtitles (e.g., WebVTT) into video files 153 | /// that can handle text tracks, such as MP4 or QuickTime (.mov). 154 | /// - Subtitles are added as a separate track within the composition and will not be rendered 155 | /// (burned-in) directly onto the video frames. Styling, position, and size cannot be customized. 156 | /// 157 | /// - Parameters: 158 | /// - videoAsset: The video asset (e.g., an MP4 file) to which the subtitles will be added. 159 | /// - subtitleAsset: The WebVTT subtitle asset to be merged with the video. 160 | /// 161 | /// - Returns: A new AVAsset with the video, audio, and subtitle tracks combined. 162 | /// Returns `nil` if an error occurs during the merging process or if subtitles are unavailable. 163 | func mergeAssetWithSubtitles(videoAsset: AVURLAsset, subtitleAsset: AVURLAsset) -> AVAsset? { 164 | 165 | #if !os(visionOS) 166 | 167 | // 1) Find the TEXT track in the subtitle asset 168 | guard let textTrack = subtitleAsset.tracks(withMediaType: .text).first else { 169 | #if DEBUG 170 | print("No text track found in subtitle file.") 171 | #endif 172 | return videoAsset // Return just videoAsset if no text track 173 | } 174 | 175 | // Create a new composition 176 | let composition = AVMutableComposition() 177 | 178 | // 2) Copy the VIDEO track (and AUDIO track if available) from the original video 179 | do { 180 | // VIDEO 181 | if let videoTrack = videoAsset.tracks(withMediaType: .video).first { 182 | let compVideoTrack = composition.addMutableTrack( 183 | withMediaType: .video, 184 | preferredTrackID: kCMPersistentTrackID_Invalid 185 | ) 186 | try compVideoTrack?.insertTimeRange( 187 | CMTimeRange(start: .zero, duration: videoAsset.duration), 188 | of: videoTrack, 189 | at: .zero 190 | ) 191 | } 192 | // AUDIO (if your video has an audio track) 193 | if let audioTrack = videoAsset.tracks(withMediaType: .audio).first { 194 | let compAudioTrack = composition.addMutableTrack( 195 | withMediaType: .audio, 196 | preferredTrackID: kCMPersistentTrackID_Invalid 197 | ) 198 | try compAudioTrack?.insertTimeRange( 199 | CMTimeRange(start: .zero, duration: videoAsset.duration), 200 | of: audioTrack, 201 | at: .zero 202 | ) 203 | } 204 | } catch { 205 | #if DEBUG 206 | print("Error adding video/audio tracks: \(error)") 207 | #endif 208 | return videoAsset 209 | } 210 | 211 | // 3) Insert the subtitle track into the composition 212 | do { 213 | let compTextTrack = composition.addMutableTrack( 214 | withMediaType: .text, 215 | preferredTrackID: kCMPersistentTrackID_Invalid 216 | ) 217 | try compTextTrack?.insertTimeRange( 218 | CMTimeRange(start: .zero, duration: videoAsset.duration), 219 | of: textTrack, 220 | at: .zero 221 | ) 222 | } catch { 223 | #if DEBUG 224 | print("Error adding text track: \(error)") 225 | #endif 226 | return videoAsset 227 | } 228 | 229 | return composition 230 | 231 | #else 232 | return videoAsset 233 | #endif 234 | } 235 | 236 | /// Determines the seek time as a `CMTime` based on a specified time and the total duration of the media. 237 | /// The function ensures that the seek time is within valid bounds (start to end of the media). 238 | /// 239 | /// - Parameters: 240 | /// - time: A `Double` value representing the desired time to seek to, in seconds. 241 | /// If the value is negative, the function will seek to the start of the media. 242 | /// If the value exceeds the total duration, the function will seek to the end. 243 | /// - duration: A `CMTime` value representing the total duration of the media. 244 | /// This value must be valid for the calculation to work correctly. 245 | /// - Returns: A `CMTime` value representing the resolved seek position within the media. 246 | func getSeekTime(for time: Double, duration : CMTime) -> CMTime?{ 247 | 248 | guard duration.value != 0 else{ return nil } 249 | 250 | let endTime = CMTimeGetSeconds(duration) 251 | let seekTime : CMTime 252 | 253 | if time < 0 { 254 | // If the time is negative, seek to the start of the video 255 | seekTime = .zero 256 | } else if time >= endTime { 257 | // If the time exceeds the video duration, seek to the end of the video 258 | let endCMTime = CMTime(seconds: endTime, preferredTimescale: duration.timescale) 259 | seekTime = endCMTime 260 | } else { 261 | // Otherwise, seek to the specified time 262 | let seekCMTime = CMTime(seconds: time, preferredTimescale: duration.timescale) 263 | seekTime = seekCMTime 264 | } 265 | 266 | return seekTime 267 | } 268 | 269 | /// Creates an `AVPlayerItem` with optional subtitle merging. 270 | /// - Parameters: 271 | /// - asset: The main video asset. 272 | /// - settings: A `VideoSettings` object containing subtitle configuration. 273 | /// - Returns: A new `AVPlayerItem` configured with the merged or original asset. 274 | func createPlayerItem(with settings: VideoSettings) -> AVPlayerItem? { 275 | 276 | guard let asset = assetFor(settings) else{ 277 | return nil 278 | } 279 | 280 | if let subtitleAsset = subtitlesAssetFor(settings), 281 | let mergedAsset = mergeAssetWithSubtitles(videoAsset: asset, subtitleAsset: subtitleAsset) { 282 | // Create and return a new `AVPlayerItem` using the merged asset 283 | return AVPlayerItem(asset: mergedAsset) 284 | } else { 285 | // Create and return a new `AVPlayerItem` using the original asset 286 | return AVPlayerItem(asset: asset) 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/protocol/helpers/CustomView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomView.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 06.08.24. 6 | // 7 | 8 | import Foundation 9 | 10 | internal protocol CustomView { 11 | /// A collection of subviews contained within the view. 12 | var subviews: [Self] { get } 13 | 14 | /// Removes a subview from the view. 15 | func removeFromSuperview() 16 | 17 | /// Adds a subview to the view. 18 | func addSubview(_ subview: Self) 19 | } 20 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/protocol/helpers/PlayerDelegateProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayerDelegateProtocol.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 05.08.24. 6 | // 7 | 8 | import Foundation 9 | import AVFoundation 10 | #if os(iOS) 11 | import AVKit 12 | #endif 13 | 14 | /// Protocol to handle player-related errors. 15 | /// 16 | /// Conforming to this protocol allows a class to respond to error events that occur within a media player context. 17 | @available(iOS 14, macOS 11, tvOS 14, *) 18 | @MainActor 19 | public protocol PlayerDelegateProtocol: AnyObject{ 20 | /// Called when an error is encountered within the media player. 21 | /// 22 | /// This method provides a way for delegate objects to respond to error conditions, allowing them to handle or 23 | /// display errors accordingly. 24 | /// 25 | /// - Parameter error: The specific `VPErrors` instance describing what went wrong. 26 | func didReceiveError(_ error: VPErrors) 27 | 28 | /// A method that handles the passage of time in the player. 29 | /// - Parameter seconds: The amount of time, in seconds, that has passed. 30 | func didPassedTime(seconds: Double) 31 | 32 | /// A method that handles seeking in the player. 33 | /// - Parameters: 34 | /// - value: A Boolean indicating whether the seek was successful. 35 | /// - currentTime: The current time of the player after seeking, in seconds. 36 | func didSeek(value: Bool, currentTime: Double) 37 | 38 | /// Called when the player has paused playback. 39 | /// 40 | /// This method is triggered when the player's `timeControlStatus` changes to `.paused`. 41 | func didPausePlayback() 42 | 43 | /// Called when the player is waiting to play at the specified rate. 44 | /// 45 | /// This method is triggered when the player's `timeControlStatus` changes to `.waitingToPlayAtSpecifiedRate`. 46 | func isWaitingToPlay() 47 | 48 | /// Called when the player starts or resumes playing. 49 | /// 50 | /// This method is triggered when the player's `timeControlStatus` changes to `.playing`. 51 | func didStartPlaying() 52 | 53 | /// Called when the current media item in the player changes. 54 | /// 55 | /// This method is triggered when the player's `currentItem` is updated to a new `AVPlayerItem`. 56 | /// - Parameter newItem: The new `AVPlayerItem` that the player has switched to, if any. 57 | func currentItemDidChange(to newItem: AVPlayerItem?) 58 | 59 | /// Called when the current media item is removed from the player. 60 | /// 61 | /// This method is triggered when the player's `currentItem` is set to `nil`, indicating that there is no longer an active media item. 62 | func currentItemWasRemoved() 63 | 64 | /// Called when the volume level of the player changes. 65 | /// 66 | /// This method is triggered when the player's `volume` property changes. 67 | /// - Parameter newVolume: The new volume level, expressed as a float between 0.0 (muted) and 1.0 (maximum volume). 68 | func volumeDidChange(to newVolume: Float) 69 | 70 | /// Notifies that the bounds have changed. 71 | /// 72 | /// - Parameter bounds: The new bounds of the main layer where we keep the video player and all vector layers. This allows a developer to recalculate and update all vector layers that lie in the CompositeLayer. 73 | 74 | func boundsDidChange(to bounds: CGRect) 75 | 76 | /// Called when the AVPlayerItem's status changes. 77 | /// - Parameter status: The new status of the AVPlayerItem. 78 | /// - `.unknown`: The item is still loading or its status is not yet determined. 79 | /// - `.readyToPlay`: The item is fully loaded and ready to play. 80 | /// - `.failed`: The item failed to load due to an error. 81 | func itemStatusChanged(_ status: AVPlayerItem.Status) 82 | 83 | /// Called when the duration of the AVPlayerItem is available. 84 | /// - Parameter time: The total duration of the media item in `CMTime`. 85 | /// - This method is only called when the item reaches `.readyToPlay`, 86 | /// ensuring that the duration value is valid. 87 | func duration(_ time: CMTime) 88 | 89 | #if os(iOS) 90 | func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) 91 | 92 | func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) 93 | #endif 94 | } 95 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/protocol/helpers/SettingsConvertible.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsConvertible.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 07.07.2023. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | /// Protocol for building blocks 12 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 13 | public protocol SettingsConvertible { 14 | 15 | /// Fetch settings 16 | /// - Returns: Array of settings 17 | func asSettings() -> [Setting] 18 | } 19 | 20 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/protocol/player/AbstractPlayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AbstractPlayer.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 07.08.24. 6 | // 7 | 8 | import AVFoundation 9 | #if canImport(CoreImage) 10 | @preconcurrency import CoreImage 11 | import AVKit 12 | #endif 13 | 14 | /// Defines an abstract player protocol to be implemented by player objects, ensuring main-thread safety and compatibility with specific OS versions. 15 | /// This protocol is designed for use with classes (reference types) only. 16 | @available(iOS 14, macOS 11, tvOS 14, *) 17 | @MainActor 18 | public protocol AbstractPlayer: AnyObject { 19 | 20 | // MARK: - Properties 21 | 22 | #if os(iOS) 23 | var pipController: AVPictureInPictureController? { get set } 24 | #endif 25 | 26 | /// An optional property that stores the current video settings. 27 | /// 28 | /// This property holds an instance of `VideoSettings` or nil if no settings have been configured yet. 29 | /// It is a computed property with both getter and setter to retrieve and update the video settings respectively. 30 | var currentSettings: VideoSettings? { get set } 31 | 32 | /// The delegate to be notified about errors encountered by the player. 33 | var delegate: PlayerDelegateProtocol? { get set } 34 | 35 | /// Adjusts the brightness of the video. Default is 0 (no change), with positive values increasing and negative values decreasing brightness. 36 | var brightness: Float { get set } 37 | 38 | /// Controls the contrast of the video. Default is 1 (no change), with values above 1 enhancing and below 1 reducing contrast. 39 | var contrast: Float { get set } 40 | 41 | /// Holds an array of CIFilters to be applied to the video. Filters are applied in the order they are added to the array. 42 | var filters: [CIFilter] { get set } 43 | 44 | /// The looper responsible for continuous video playback. 45 | var playerLooper: AVPlayerLooper? { get set } 46 | 47 | /// The queue player that plays the video items. 48 | var player: AVQueuePlayer? { get set } 49 | 50 | // MARK: - Calculated properties 51 | 52 | /// Retrieves the current item being played. 53 | var currentItem : AVPlayerItem? { get } 54 | 55 | /// The current asset being played, if available. 56 | var currentAsset : AVURLAsset? { get } 57 | 58 | /// Check if looping is applied 59 | var isLooping : Bool { get } 60 | 61 | // MARK: - Playback control methods 62 | 63 | /// Initiates or resumes playback of the video. 64 | /// This method should be implemented to start playing the video from its current position. 65 | func play() 66 | 67 | /// Pauses the current video playback. 68 | /// This method should be implemented to pause the video, allowing it to be resumed later from the same position. 69 | func pause() 70 | 71 | /// Stop and clean player 72 | func stop() 73 | 74 | /// Inserts a new player item into the media queue of the player. 75 | func insert(_ item : AVPlayerItem) 76 | 77 | /// Seeks the video to a specific time. 78 | /// This method moves the playback position to the specified time with precise accuracy. 79 | /// - Parameter time: The target time to seek to in the video timeline. 80 | func seek(to time: Double, play: Bool) 81 | 82 | /// Seeks to the start of the video. 83 | /// This method positions the playback at the beginning of the video. 84 | func seekToStart() 85 | 86 | /// Seeks to the end of the video. 87 | /// This method positions the playback at the end of the video. 88 | func seekToEnd() 89 | 90 | /// Mutes the video playback. 91 | /// This method silences the audio of the video. 92 | func mute() 93 | 94 | /// Unmutes the video playback. 95 | /// This method restores the audio of the video. 96 | func unmute() 97 | 98 | /// Adjusts the volume for the video playback. 99 | /// - Parameter volume: A `Float` value between 0.0 (mute) and 1.0 (full volume). 100 | /// If the value is out of range, it will be clamped to the nearest valid value. 101 | func setVolume(_ volume: Float) 102 | 103 | /// Sets the playback speed for the video playback. 104 | func setPlaybackSpeed(_ speed: Float) 105 | 106 | /// Sets the subtitles for the video playback to a specified language or turns them off. 107 | func setSubtitles(to language: String?) 108 | 109 | /// Enables looping for the current video item. 110 | func loop() 111 | 112 | /// Disables looping for the current video item. 113 | func unloop() 114 | 115 | /// Adjusts the brightness of the video playback. 116 | /// - Parameter brightness: A `Float` value representing the brightness level. Typically ranges from -1.0 to 1.0. 117 | func adjustBrightness(to brightness: Float) 118 | 119 | /// Adjusts the contrast of the video playback. 120 | /// - Parameter contrast: A `Float` value representing the contrast level. Typically ranges from 0.0 to 4.0. 121 | func adjustContrast(to contrast: Float) 122 | 123 | /// Applies a Core Image filter to the video player's content. 124 | func applyFilter(_ value: CIFilter, _ clear : Bool) 125 | 126 | /// Removes all filters from the video playback. 127 | func removeAllFilters(apply : Bool) 128 | 129 | /// Selects an audio track for the video playback. 130 | /// - Parameter languageCode: The language code (e.g., "en" for English) of the desired audio track. 131 | func selectAudioTrack(languageCode: String) 132 | 133 | /// Sets the playback command for the video player. 134 | func setCommand(_ value: PlaybackCommand) 135 | 136 | /// Updates the current playback asset, settings, and initializes playback or a specific action when the asset is ready. 137 | func update(settings: VideoSettings) 138 | } 139 | 140 | extension AbstractPlayer{ 141 | 142 | /// Retrieves the current item being played. 143 | /// 144 | /// This computed property checks if there is a current item available in the player. 145 | /// If available, it returns the `currentItem`; otherwise, it returns `nil`. 146 | var currentItem : AVPlayerItem?{ 147 | if let currentItem = player?.currentItem { 148 | return currentItem 149 | } 150 | return nil 151 | } 152 | 153 | /// The current asset being played, if available. 154 | /// 155 | /// This computed property checks the current item of the player. 156 | /// If the current item exists and its asset can be cast to AVURLAsset, 157 | var currentAsset : AVURLAsset?{ 158 | if let currentItem = currentItem { 159 | return currentItem.asset as? AVURLAsset 160 | } 161 | return nil 162 | } 163 | 164 | // Implementations of playback control methods 165 | 166 | /// Initiates playback of the video. 167 | /// This method starts or resumes playing the video from the current position. 168 | func play() { 169 | player?.play() 170 | } 171 | 172 | /// Pauses the video playback. 173 | /// This method pauses the video if it is currently playing, allowing it to be resumed later from the same position. 174 | func pause() { 175 | player?.pause() 176 | } 177 | 178 | /// Clears all items from the player's queue. 179 | func clearPlayerQueue() { 180 | player?.removeAllItems() 181 | } 182 | 183 | /// Determines whether the media queue of the player is empty. 184 | func isEmptyQueue() -> Bool{ 185 | player?.items().isEmpty ?? true 186 | } 187 | 188 | /// Stop and clean player 189 | func stop(){ 190 | 191 | pause() 192 | 193 | if !isEmptyQueue() { // Cleaning 194 | if isLooping{ 195 | unloop() 196 | } 197 | 198 | removeAllFilters() 199 | clearPlayerQueue() 200 | } 201 | } 202 | /// Inserts a new player item into the media queue of the player. 203 | /// - Parameter item: The AVPlayerItem to be inserted into the queue. 204 | func insert(_ item : AVPlayerItem){ 205 | player?.insert(item, after: nil) 206 | } 207 | 208 | /// Seeks the video to a specific time in the timeline. 209 | /// This method adjusts the playback position to the specified time with precise accuracy. 210 | /// If the target time is out of bounds (negative or beyond the duration), it will be clamped to the nearest valid time (start or end of the video). 211 | /// 212 | /// - Parameters: 213 | /// - time: A `Double` value representing the target time (in seconds) to seek to in the video timeline. 214 | /// If the value is less than 0, the playback position will be set to the start of the video. 215 | /// If the value exceeds the video's duration, it will be set to the end. 216 | /// - play: A `Bool` value indicating whether to start playback immediately after seeking. 217 | /// Defaults to `false`, meaning playback will remain paused after the seek operation. 218 | func seek(to time: Double, play: Bool = false) { 219 | guard let player = player, let duration = player.currentItem?.duration else { 220 | delegate?.didSeek(value: false, currentTime: time) 221 | return 222 | } 223 | 224 | guard let seekTime = getSeekTime(for: time, duration: duration) else { 225 | delegate?.didSeek(value: false, currentTime: time) 226 | return 227 | } 228 | 229 | player.seek(to: seekTime) { [weak self] success in 230 | Task { @MainActor in 231 | self?.seekCompletion(success: success, autoPlay: play) 232 | } 233 | } 234 | } 235 | 236 | private func seekCompletion(success: Bool, autoPlay: Bool) { 237 | guard let player = player else { return } 238 | let currentTime = CMTimeGetSeconds(player.currentTime()) 239 | delegate?.didSeek(value: success, currentTime: currentTime) 240 | autoPlay ? play() : pause() 241 | } 242 | 243 | /// Seeks to the start of the video. 244 | /// This method positions the playback at the beginning of the video. 245 | func seekToStart() { 246 | seek(to: 0) 247 | } 248 | 249 | /// Seeks to the end of the video. 250 | /// This method positions the playback at the end of the video. 251 | func seekToEnd() { 252 | if let duration = currentItem?.duration { 253 | let endTime = CMTimeGetSeconds(duration) 254 | seek(to: endTime) 255 | } 256 | } 257 | 258 | /// Mutes the video playback. 259 | /// This method silences the audio of the video. 260 | func mute() { 261 | player?.isMuted = true 262 | } 263 | 264 | /// Unmutes the video playback. 265 | /// This method restores the audio of the video. 266 | func unmute() { 267 | player?.isMuted = false 268 | } 269 | 270 | /// Sets the volume for the video playback. 271 | /// - Parameter volume: A `Float` value between 0.0 (mute) and 1.0 (full volume). 272 | /// If the value is out of range, it will be clamped to the nearest valid value. 273 | func setVolume(_ volume: Float) { 274 | let clampedVolume = max(0.0, min(volume, 1.0)) // Clamp the value between 0.0 and 1.0 275 | player?.volume = clampedVolume 276 | } 277 | 278 | /// Sets the playback speed for the video playback. 279 | /// - Parameter speed: A `Float` value representing the playback speed (e.g., 1.0 for normal speed, 0.5 for half speed, 2.0 for double speed). 280 | /// If the value is out of range (negative), it will be clamped to the nearest valid value. 281 | func setPlaybackSpeed(_ speed: Float) { 282 | let clampedSpeed = max(0.0, speed) // Clamp to non-negative values, or adjust the upper bound as needed 283 | player?.rate = clampedSpeed 284 | } 285 | 286 | /// Sets the subtitles for the video playback to a specified language or turns them off. 287 | /// This function is designed for use cases where the video file already contains multiple subtitle tracks (i.e., legible media tracks) embedded in its metadata. In other words, the container format (such as MP4, MOV, or QuickTime) holds one or more subtitle or closed-caption tracks that can be selected at runtime. By calling this function and providing a language code (e.g., “en”, “fr”, “de”), you instruct the AVPlayerItem to look for the corresponding subtitle track in the asset’s media selection group. If it finds a match, it will activate that subtitle track; otherwise, no subtitles will appear. Passing nil disables subtitles altogether. This approach is convenient when you want to switch between multiple embedded subtitle languages or turn them off without relying on external subtitle files (like SRT or WebVTT). 288 | /// - Parameters: 289 | /// - language: The language code (e.g., "en" for English) for the desired subtitles. 290 | /// Pass `nil` to turn off subtitles. 291 | func setSubtitles(to language: String?) { 292 | #if !os(visionOS) 293 | guard let currentItem = currentItem, 294 | let group = currentAsset?.mediaSelectionGroup(forMediaCharacteristic: .legible) else { 295 | return 296 | } 297 | 298 | if let language = language { 299 | // Filter the subtitle options based on the language code 300 | let options = group.options.filter { option in 301 | guard let locale = option.locale else { return false } 302 | return locale.languageCode == language 303 | } 304 | // Select the first matching subtitle option 305 | if let option = options.first { 306 | currentItem.select(option, in: group) 307 | } 308 | } else { 309 | // Turn off subtitles by deselecting any option in the legible media selection group 310 | currentItem.select(nil, in: group) 311 | } 312 | #endif 313 | } 314 | 315 | /// Check if looping is applied 316 | var isLooping : Bool{ 317 | playerLooper != nil 318 | } 319 | 320 | /// Enables looping for the current video item. 321 | /// This method sets up the `playerLooper` to loop the currently playing item indefinitely. 322 | func loop() { 323 | guard let player = player, let currentItem = player.currentItem else { 324 | return 325 | } 326 | 327 | // Check if the video is already being looped 328 | if isLooping { 329 | return 330 | } 331 | 332 | playerLooper = AVPlayerLooper(player: player, templateItem: currentItem) 333 | } 334 | 335 | /// Disables looping for the current video item. 336 | /// This method removes the `playerLooper`, stopping the loop. 337 | func unloop() { 338 | // Check if the video is not looped (i.e., playerLooper is nil) 339 | guard isLooping else { 340 | return // Not looped, no need to unloop 341 | } 342 | 343 | playerLooper?.disableLooping() 344 | playerLooper = nil 345 | } 346 | 347 | /// Adjusts the brightness of the video playback. 348 | /// - Parameter brightness: A `Float` value representing the brightness level. Typically ranges from -1.0 to 1.0. 349 | func adjustBrightness(to brightness: Float) { 350 | let clampedBrightness = max(-1.0, min(brightness, 1.0)) // Clamp brightness to the range [-1.0, 1.0] 351 | self.brightness = clampedBrightness 352 | applyVideoComposition() 353 | } 354 | 355 | /// Adjusts the contrast of the video playback. 356 | /// - Parameter contrast: A `Float` value representing the contrast level. Typically ranges from 0.0 to 4.0. 357 | func adjustContrast(to contrast: Float) { 358 | let clampedContrast = max(0.0, min(contrast, 4.0)) // Clamp contrast to the range [0.0, 4.0] 359 | self.contrast = clampedContrast 360 | applyVideoComposition() 361 | } 362 | 363 | /// Applies a Core Image filter to the video playback. 364 | /// This function adds the provided filter to the stack of existing filters and updates the video composition accordingly. 365 | /// - Parameter value: A `CIFilter` object representing the filter to be applied to the video playback. 366 | func applyFilter(_ value: CIFilter, _ clear : Bool) { 367 | if clear{ 368 | removeAllFilters(apply: false) 369 | } 370 | appendFilter(value) // Appends the provided filter to the current stack. 371 | applyVideoComposition() // Updates the video composition to include the new filter. 372 | } 373 | 374 | /// Appends a Core Image filter to the current list of filters. 375 | /// - Parameters: 376 | /// - value: Core Image filter to be applied. 377 | private func appendFilter(_ value: CIFilter) { 378 | filters.append(value) 379 | } 380 | 381 | /// Removes all applied CIFilters from the video playback. 382 | /// 383 | /// This function clears the array of filters and optionally re-applies the video composition 384 | /// to ensure the changes take effect immediately. 385 | /// 386 | /// - Parameters: 387 | /// - apply: A Boolean value indicating whether to immediately apply the video composition after removing the filters. 388 | /// Defaults to `true`. 389 | func removeAllFilters(apply : Bool = true) { 390 | 391 | guard !filters.isEmpty else { return } 392 | 393 | filters = [] 394 | 395 | if apply{ 396 | applyVideoComposition() 397 | } 398 | } 399 | 400 | /// Applies the current set of filters to the video using an AVVideoComposition. 401 | /// This method combines the existing filters and brightness/contrast adjustments, creates a new video composition, 402 | /// and assigns it to the current AVPlayerItem. The video is paused during this process to ensure smooth application. 403 | /// This method is not supported on Vision OS. 404 | private func applyVideoComposition() { 405 | guard let player = player else { return } 406 | let allFilters = combineFilters(filters, brightness, contrast) 407 | 408 | #if !os(visionOS) 409 | // Optionally, check if the player is currently playing 410 | let wasPlaying = player.rate != 0 411 | 412 | // Pause the player if it was playing 413 | if wasPlaying { 414 | player.pause() 415 | } 416 | 417 | player.items().forEach{ item in 418 | 419 | let videoComposition = AVVideoComposition(asset: item.asset, applyingCIFiltersWithHandler: { request in 420 | handleVideoComposition(request: request, filters: allFilters) 421 | }) 422 | 423 | item.videoComposition = videoComposition 424 | } 425 | 426 | if wasPlaying{ 427 | player.play() 428 | } 429 | 430 | #endif 431 | } 432 | 433 | /// Selects an audio track for the video playback. 434 | /// - Parameter languageCode: The language code (e.g., "en" for English) of the desired audio track. 435 | func selectAudioTrack(languageCode: String) { 436 | guard let currentItem = currentItem else { return } 437 | #if !os(visionOS) 438 | // Retrieve the media selection group for audible tracks 439 | if let group = currentAsset?.mediaSelectionGroup(forMediaCharacteristic: .audible) { 440 | 441 | // Filter options by language code using Locale 442 | let options = group.options.filter { option in 443 | return option.locale?.languageCode == languageCode 444 | } 445 | 446 | // Select the first matching option, if available 447 | if let option = options.first { 448 | currentItem.select(option, in: group) 449 | } 450 | } 451 | #endif 452 | } 453 | 454 | #if os(iOS) 455 | func startPiP() { 456 | guard let pipController = pipController else { return } 457 | 458 | if !pipController.isPictureInPictureActive { 459 | pipController.startPictureInPicture() 460 | 461 | } 462 | } 463 | 464 | func stopPiP() { 465 | guard let pipController = pipController else { return } 466 | 467 | if pipController.isPictureInPictureActive { 468 | // Stop PiP 469 | pipController.stopPictureInPicture() 470 | } 471 | } 472 | 473 | #endif 474 | } 475 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/protocol/player/ExtPlayerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExtPlayerProtocol.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 05.08.24. 6 | // 7 | 8 | import AVFoundation 9 | import Foundation 10 | #if canImport(UIKit) 11 | import UIKit 12 | #elseif canImport(AppKit) 13 | import AppKit 14 | #endif 15 | 16 | /// A protocol defining the requirements for a looping video player. 17 | /// 18 | /// Conforming types are expected to manage a video player that can loop content continuously, 19 | /// handle errors, and notify a delegate of important events. 20 | @available(iOS 14, macOS 11, tvOS 14, *) 21 | @MainActor 22 | public protocol ExtPlayerProtocol: AbstractPlayer, LayerMakerProtocol{ 23 | 24 | #if canImport(UIKit) 25 | /// Provides a non-optional `CALayer` for use within UIKit environments. 26 | var layer: CALayer { get } 27 | #elseif canImport(AppKit) 28 | /// Provides an optional `CALayer` which can be set, and a property to indicate if the layer is wanted, for use within AppKit environments. 29 | var layer: CALayer? { get set } 30 | /// WantsLayer is necessary. Otherwise, your NSView will not render the layer-based content at all. 31 | var wantsLayer: Bool { get set } 32 | #endif 33 | 34 | /// Provides a `AVPlayerLayer` specific to the player implementation, applicable across all platforms. 35 | var playerLayer: AVPlayerLayer? { get set } 36 | 37 | /// An optional NSKeyValueObservation to monitor errors encountered by the video player. 38 | /// This observer should be configured to detect and handle errors from the AVQueuePlayer, 39 | /// ensuring that all playback errors are managed and reported appropriately. 40 | var errorObserver: NSKeyValueObservation? { get set } 41 | 42 | /// An optional observer for monitoring changes to the player's `timeControlStatus` property. 43 | var timeControlObserver: NSKeyValueObservation? { get set } 44 | 45 | /// An optional observer for monitoring changes to the player's `currentItem` property. 46 | var currentItemObserver: NSKeyValueObservation? { get set } 47 | 48 | /// Item status observer 49 | var itemStatusObserver: NSKeyValueObservation? { get set } 50 | 51 | /// An optional observer for monitoring changes to the player's `volume` property. 52 | var volumeObserver: NSKeyValueObservation? { get set } 53 | 54 | /// Declare a variable to hold the time observer token outside the if statement 55 | var timeObserver: Any? { get set } 56 | 57 | /// Initializes a new player view with a video asset and custom settings. 58 | /// 59 | /// - Parameters: 60 | /// - settings: The `VideoSettings` struct that includes all necessary configurations like gravity, loop, and mute. 61 | init(settings: VideoSettings) 62 | 63 | /// Sets up the necessary observers on the AVPlayerItem and AVQueuePlayer to monitor changes and errors. 64 | /// 65 | /// - Parameters: 66 | /// - item: The AVPlayerItem to observe for status changes. 67 | /// - player: The AVQueuePlayer to observe for errors. 68 | func setupObservers(for player: AVQueuePlayer) 69 | 70 | /// Handles errors 71 | func onError(_ error : VPErrors) 72 | } 73 | 74 | internal extension ExtPlayerProtocol { 75 | 76 | /// Initializes a new player view with a video asset and custom settings. 77 | /// 78 | /// - Parameters: 79 | /// - settings: The `VideoSettings` struct that includes all necessary configurations like gravity, loop, and mute. 80 | /// - timePublishing: Optional `CMTime` that specifies a particular time for publishing or triggering an event. 81 | func setupPlayerComponents(settings: VideoSettings) { 82 | 83 | guard let player else { return } 84 | 85 | configurePlayer(player, settings: settings) 86 | update(settings: settings) 87 | setupObservers(for: player) 88 | } 89 | 90 | /// Configures the provided AVQueuePlayer with specific properties for video playback. 91 | /// 92 | /// - Parameters: 93 | /// - player: The AVQueuePlayer to be configured. 94 | /// - settings: The `VideoSettings` struct that includes all necessary configurations like gravity, loop, and mute. 95 | func configurePlayer(_ player: AVQueuePlayer, settings: VideoSettings) { 96 | 97 | player.isMuted = settings.mute 98 | if !settings.loop{ 99 | player.actionAtItemEnd = .pause 100 | } 101 | 102 | configurePlayerLayer(player, settings) 103 | configureCompositeLayer(settings) 104 | configureTimePublishing(player, settings) 105 | } 106 | 107 | /// Configures the player layer for the specified video player using the provided settings. 108 | /// - Parameters: 109 | /// - player: The `AVQueuePlayer` instance for which the player layer will be configured. 110 | /// - settings: A `VideoSettings` object containing configuration details for the player layer. 111 | func configurePlayerLayer(_ player: AVQueuePlayer, _ settings: VideoSettings) { 112 | playerLayer?.player = player 113 | playerLayer?.videoGravity = settings.gravity 114 | 115 | #if canImport(UIKit) 116 | playerLayer?.backgroundColor = UIColor.clear.cgColor 117 | if let playerLayer{ 118 | layer.addSublayer(playerLayer) 119 | } 120 | #elseif canImport(AppKit) 121 | playerLayer?.backgroundColor = NSColor.clear.cgColor 122 | let layer = CALayer() 123 | if let playerLayer{ 124 | layer.addSublayer(playerLayer) 125 | } 126 | self.layer = layer 127 | self.wantsLayer = true 128 | #endif 129 | } 130 | 131 | /// Configures the time publishing observer for the specified video player. 132 | /// - Parameters: 133 | /// - player: The `AVQueuePlayer` instance to which the time observer will be added. 134 | /// - settings: A `VideoSettings` object containing the time publishing interval and related configuration. 135 | func configureTimePublishing(_ player: AVQueuePlayer, _ settings: VideoSettings) { 136 | if let timePublishing = settings.timePublishing{ 137 | timeObserver = player.addPeriodicTimeObserver(forInterval: timePublishing, queue: .global()) { [weak self] time in 138 | guard let self = self else{ return } 139 | Task { @MainActor in 140 | self.delegate?.didPassedTime(seconds: time.seconds) 141 | } 142 | } 143 | } 144 | } 145 | 146 | /// Configures the composite layer for the view based on the provided video settings. 147 | /// - Parameter settings: A `VideoSettings` object containing configuration details for the composite layer. 148 | func configureCompositeLayer(_ settings: VideoSettings) { 149 | 150 | guard settings.vector else { return } 151 | 152 | compositeLayer?.frame = CGRect(x: 0, y: 0, width: bounds.width, height: bounds.height) 153 | 154 | guard let compositeLayer else { return } 155 | 156 | #if canImport(UIKit) 157 | layer.addSublayer(compositeLayer) 158 | #elseif canImport(AppKit) 159 | self.layer?.addSublayer(compositeLayer) 160 | #endif 161 | } 162 | 163 | /// Updates the player with a new asset and applies the specified video settings. 164 | /// 165 | /// This method sets a new `AVURLAsset` for playback and configures it according to the provided settings. 166 | /// It can adjust options such as playback gravity, looping, and muting. If `doUpdate` is `true`, the player is 167 | /// updated immediately with the new asset. The method also provides an optional callback that is executed when 168 | /// the asset transitions to the `.readyToPlay` status, enabling additional actions to be performed once the 169 | /// player item is ready for playback. 170 | /// 171 | /// - Parameters: 172 | /// - settings: A `VideoSettings` struct containing configurations such as playback gravity, looping behavior, whether the audio should be muted. 173 | func update(settings: VideoSettings) { 174 | 175 | if settings.isEqual(currentSettings){ 176 | return 177 | } 178 | 179 | stop() 180 | 181 | currentSettings = settings 182 | 183 | guard let newItem = createPlayerItem(with: settings) else{ 184 | itemNotFound(with: settings.name) 185 | return 186 | } 187 | 188 | observeItemStatus(newItem) 189 | 190 | insert(newItem) 191 | 192 | if settings.loop{ 193 | loop() 194 | } 195 | 196 | if !settings.notAutoPlay{ 197 | play() 198 | } 199 | } 200 | 201 | /// Handles errors 202 | /// - Parameter error: An instance of `VPErrors` representing the error to be handled. 203 | func onError(_ error : VPErrors){ 204 | delegate?.didReceiveError(error) 205 | } 206 | 207 | /// Emit the error "Item not found" with delay 208 | /// - Parameter name: resource name 209 | func itemNotFound(with name: String){ 210 | Task{ @MainActor [weak self] in 211 | self?.onError(.sourceNotFound(name)) 212 | } 213 | } 214 | 215 | /// Observes the status of an AVPlayerItem and notifies the delegate when the status changes. 216 | /// - Parameter item: The AVPlayerItem whose status should be observed. 217 | private func observeItemStatus(_ item: AVPlayerItem) { 218 | 219 | removeItemObserver() 220 | 221 | itemStatusObserver = item.observe(\.status, options: [.new, .initial]) { [weak self] item, _ in 222 | Task { @MainActor in 223 | self?.delegate?.itemStatusChanged(item.status) 224 | } 225 | 226 | switch item.status { 227 | case .unknown: break 228 | case .readyToPlay: 229 | Task { @MainActor in 230 | self?.delegate?.duration(item.duration) 231 | } 232 | case .failed: 233 | Task { @MainActor in 234 | let error = self?.currentItem?.error 235 | self?.onError(.failedToLoad(error)) 236 | } 237 | @unknown default: 238 | Task { @MainActor in 239 | let error = self?.currentItem?.error 240 | self?.onError(.failedToLoad(error)) 241 | } 242 | } 243 | } 244 | } 245 | 246 | /// Removes the current AVPlayerItem observer, if any, to prevent memory leaks. 247 | private func removeItemObserver() { 248 | itemStatusObserver?.invalidate() 249 | itemStatusObserver = nil 250 | } 251 | 252 | /// Sets up observers on the player item and the player to track their status and error states. 253 | /// 254 | /// - Parameters: 255 | /// - item: The player item to observe. 256 | /// - player: The player to observe. 257 | func setupObservers(for player: AVQueuePlayer) { 258 | errorObserver = player.observe(\.error, options: [.new]) { [weak self] player, _ in 259 | guard let error = player.error else { return } 260 | Task { @MainActor in 261 | self?.onError(.remoteVideoError(error)) 262 | } 263 | } 264 | 265 | timeControlObserver = player.observe(\.timeControlStatus, options: [.new, .old]) { [weak self] player, change in 266 | switch player.timeControlStatus { 267 | case .paused: 268 | // This could mean playback has stopped, but it's not specific to end of playback 269 | Task { @MainActor in 270 | self?.delegate?.didPausePlayback() 271 | } 272 | case .waitingToPlayAtSpecifiedRate: 273 | // Player is waiting to play (e.g., buffering) 274 | Task { @MainActor in 275 | self?.delegate?.isWaitingToPlay() 276 | } 277 | case .playing: 278 | // Player is currently playing 279 | Task { @MainActor in 280 | self?.delegate?.didStartPlaying() 281 | } 282 | @unknown default: 283 | break 284 | } 285 | } 286 | 287 | currentItemObserver = player.observe(\.currentItem, options: [.new, .old, .initial]) { [weak self] player, change in 288 | // Detecting when the current item is changed 289 | if let newItem = change.newValue as? AVPlayerItem { 290 | Task { @MainActor in 291 | self?.delegate?.currentItemDidChange(to: newItem) 292 | } 293 | } else if change.newValue == nil { 294 | Task { @MainActor in 295 | self?.delegate?.currentItemWasRemoved() 296 | } 297 | } 298 | } 299 | 300 | volumeObserver = player.observe(\.volume, options: [.new, .old]) { [weak self] player, change in 301 | if let newVolume = change.newValue{ 302 | Task { @MainActor in 303 | self?.delegate?.volumeDidChange(to: newVolume) 304 | } 305 | } 306 | } 307 | } 308 | 309 | /// Clear observers 310 | func clearObservers(){ 311 | 312 | removeItemObserver() 313 | 314 | errorObserver?.invalidate() 315 | errorObserver = nil 316 | 317 | timeControlObserver?.invalidate() 318 | timeControlObserver = nil 319 | 320 | currentItemObserver?.invalidate() 321 | currentItemObserver = nil 322 | 323 | volumeObserver?.invalidate() 324 | volumeObserver = nil 325 | 326 | if let observerToken = timeObserver { 327 | player?.removeTimeObserver(observerToken) 328 | timeObserver = nil 329 | } 330 | } 331 | 332 | /// Add player layer 333 | func addPlayerLayer(){ 334 | playerLayer = AVPlayerLayer() 335 | } 336 | 337 | /// Removes the player layer from its super layer. 338 | /// 339 | /// This method checks if the player layer is associated with a super layer and removes it to clean up resources 340 | /// and prevent potential retain cycles or unwanted video display when the player is no longer needed. 341 | func removePlayerLayer() { 342 | playerLayer?.player = nil 343 | playerLayer?.removeFromSuperlayer() 344 | playerLayer = nil 345 | } 346 | 347 | /// Sets the playback command for the video player. 348 | /// 349 | /// - Parameter value: The `PlaybackCommand` to set. Available commands include: 350 | /// 351 | /// ### Playback Controls 352 | /// - `play`: Starts video playback. 353 | /// - `pause`: Pauses video playback. 354 | /// - `seek(to:play:)`: Moves to a specified time in the video, with an option to start playing. 355 | /// - `begin`: Moves the video to the beginning. 356 | /// - `end`: Moves the video to the end. 357 | /// 358 | /// ### Audio & Volume 359 | /// - `mute`: Mutes the video. 360 | /// - `unmute`: Unmutes the video. 361 | /// - `volume(level)`: Adjusts the volume to the specified level. 362 | /// - `audioTrack(languageCode)`: Selects an audio track based on the given language code. 363 | /// 364 | /// ### Subtitles & Playback Speed 365 | /// - `subtitles(language)`: Sets subtitles to a specified language or disables them. 366 | /// - `playbackSpeed(speed)`: Adjusts the video playback speed. 367 | /// 368 | /// ### Looping 369 | /// - `loop`: Enables video looping. 370 | /// - `unloop`: Disables video looping. 371 | /// 372 | /// ### Video Adjustments 373 | /// - `brightness(level)`: Adjusts the brightness of the video playback. 374 | /// - `contrast(level)`: Adjusts the contrast of the video playback. 375 | /// 376 | /// ### Filters 377 | /// - `filter(value, clear)`: Applies a specific Core Image filter to the video, optionally clearing previous filters. 378 | /// - `removeAllFilters`: Removes all applied filters from the video playback. 379 | /// 380 | /// ### Vector Graphics 381 | /// - `addVector(builder, clear)`: Adds a vector graphic overlay to the video player, with an option to clear previous vectors. 382 | /// - `removeAllVectors`: Removes all vector graphics from the video player. 383 | /// 384 | /// ### Platform-Specific Features 385 | /// - `startPiP` (iOS only): Starts Picture-in-Picture mode. 386 | /// - `stopPiP` (iOS only): Stops Picture-in-Picture mode. 387 | func setCommand(_ value: PlaybackCommand) { 388 | switch value { 389 | case .play: 390 | play() 391 | case .pause: 392 | pause() 393 | case .seek(to: let time, play: let play): 394 | seek(to: time, play: play) 395 | case .begin: 396 | seekToStart() 397 | case .end: 398 | seekToEnd() 399 | case .mute: 400 | mute() 401 | case .unmute: 402 | unmute() 403 | case .volume(let volume): 404 | setVolume(volume) 405 | case .subtitles(let language): 406 | setSubtitles(to: language) 407 | case .playbackSpeed(let speed): 408 | setPlaybackSpeed(speed) 409 | case .loop: 410 | loop() 411 | case .unloop: 412 | unloop() 413 | case .brightness(let brightness): 414 | adjustBrightness(to: brightness) 415 | case .contrast(let contrast): 416 | adjustContrast(to: contrast) 417 | case .filter(let value, let clear): 418 | applyFilter(value, clear) 419 | case .removeAllFilters: 420 | removeAllFilters() 421 | case .audioTrack(let languageCode): 422 | selectAudioTrack(languageCode: languageCode) 423 | case .addVector(let builder, let clear): 424 | addVectorLayer(builder: builder, clear: clear) 425 | case .removeAllVectors: 426 | removeAllVectors() 427 | #if os(iOS) 428 | case .startPiP: startPiP() 429 | case .stopPiP: stopPiP() 430 | #endif 431 | default : return 432 | } 433 | } 434 | } 435 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/protocol/vector/ShapeLayerBuilderProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShapeLayerProtocol.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 13.08.24. 6 | // 7 | 8 | import CoreGraphics 9 | 10 | #if canImport(QuartzCore) 11 | import QuartzCore 12 | #endif 13 | 14 | 15 | /// A protocol defining a builder for creating shape layers with a unique identifier. 16 | /// 17 | /// Conforming types will be able to construct a CAShapeLayer based on provided frame, bounds, and center. 18 | @available(iOS 14, macOS 11, tvOS 14, *) 19 | public protocol ShapeLayerBuilderProtocol: Identifiable { 20 | 21 | /// Unique identifier 22 | var id : UUID { get } 23 | 24 | /// Builds a CAShapeLayer using specified geometry. 25 | /// 26 | /// - Parameters: 27 | /// - geometry: A tuple containing frame, bounds, and center as `CGRect` and `CGPoint`. 28 | /// - Returns: A configured `CAShapeLayer`. 29 | @MainActor 30 | func build(with geometry: (frame: CGRect, bounds: CGRect)) -> CAShapeLayer 31 | } 32 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/protocol/vector/VectorLayerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VectorLayerProtocol.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 13.08.24. 6 | // 7 | 8 | #if canImport(UIKit) 9 | import UIKit 10 | #elseif canImport(AppKit) 11 | import AppKit 12 | #endif 13 | #if canImport(QuartzCore) 14 | import QuartzCore 15 | #endif 16 | 17 | /// A protocol that defines methods and properties for managing vector layers within a composite layer. 18 | /// 19 | /// This protocol is intended to be used for managing the addition and removal of vector layers, 20 | /// which are overlaid on top of other content, such as video streams. 21 | /// 22 | @available(iOS 14, macOS 11, tvOS 14, *) 23 | @MainActor 24 | public protocol LayerMakerProtocol: AnyObject { 25 | 26 | /// The composite layer that contains all the sublayers, including vector layers. 27 | /// 28 | /// This layer acts as a container for all vector layers added through the protocol methods. 29 | var compositeLayer: CALayer? { get set } 30 | 31 | /// The frame of the composite layer. 32 | /// 33 | /// This property defines the size and position of the composite layer within its parent view. 34 | var frame: CGRect { get set } 35 | 36 | /// The bounds of the composite layer. 37 | /// 38 | /// This property defines the drawable area of the composite layer, relative to its own coordinate system. 39 | var bounds: CGRect { get set } 40 | 41 | /// Adds a vector layer to the composite layer using a specified builder. 42 | /// 43 | /// - Parameters: 44 | /// - builder: An instance conforming to `ShapeLayerBuilderProtocol` that constructs the vector layer. 45 | /// - clear: A Boolean value that indicates whether to clear existing vector layers before adding the new one. 46 | func addVectorLayer(builder: any ShapeLayerBuilderProtocol, clear: Bool) 47 | 48 | /// Removes all vector layers from the composite layer. 49 | func removeAllVectors() 50 | } 51 | 52 | extension LayerMakerProtocol{ 53 | 54 | /// Adds a composite layer if vector mode is enabled in the provided `VideoSettings`. 55 | @MainActor 56 | func addCompositeLayer(_ settings: VideoSettings) { 57 | if settings.vector { 58 | compositeLayer = CALayer() 59 | } 60 | } 61 | 62 | /// Removes the composite layer from its superlayer and sets `compositeLayer` to `nil`. 63 | @MainActor 64 | func removeCompositeLayer() { 65 | compositeLayer?.removeFromSuperlayer() 66 | compositeLayer = nil 67 | } 68 | 69 | /// Adds a vector layer to the composite layer using a specified builder. 70 | /// 71 | /// - Parameters: 72 | /// - builder: An instance conforming to `ShapeLayerBuilderProtocol` that constructs the vector layer. 73 | /// - clear: A Boolean value that indicates whether to clear existing vector layers before adding the new one. 74 | @MainActor 75 | func addVectorLayer(builder : any ShapeLayerBuilderProtocol, clear: Bool){ 76 | if clear{ removeAllVectors() } 77 | let layer = builder.build(with: (frame, bounds)) 78 | compositeLayer?.addSublayer(layer) 79 | } 80 | 81 | 82 | /// Removes all vector layers from the composite layer. 83 | @MainActor 84 | func removeAllVectors(){ 85 | compositeLayer?.sublayers?.forEach { $0.removeFromSuperlayer() } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/protocol/view/ExtPlayerViewProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExtPlayerViewProtocol.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 06.08.24. 6 | // 7 | 8 | import AVFoundation 9 | import SwiftUI 10 | import Combine 11 | 12 | /// Protocol that defines the common functionalities and properties 13 | /// for looping video players on different platforms. 14 | @available(iOS 14, macOS 11, tvOS 14, *) 15 | @MainActor @preconcurrency 16 | protocol ExtPlayerViewProtocol { 17 | 18 | #if canImport(UIKit) 19 | /// Typealias for the main view on iOS, using `UIView`. 20 | associatedtype View: UIView 21 | #elseif os(macOS) 22 | /// Typealias for the main view on macOS, using `NSView`. 23 | associatedtype View: NSView 24 | #else 25 | /// Typealias for a custom view type on platforms other than iOS and macOS. 26 | associatedtype View: CustomView 27 | #endif 28 | 29 | #if canImport(UIKit) 30 | /// Typealias for the player view on iOS, conforming to `LoopingPlayerProtocol` and using `UIView`. 31 | associatedtype PlayerView: ExtPlayerProtocol, UIView 32 | #elseif os(macOS) 33 | /// Typealias for the player view on macOS, conforming to `LoopingPlayerProtocol` and using `NSView`. 34 | associatedtype PlayerView: ExtPlayerProtocol, NSView 35 | #else 36 | /// Typealias for a custom player view on other platforms, conforming to `LoopingPlayerProtocol`. 37 | associatedtype PlayerView: ExtPlayerProtocol, CustomView 38 | #endif 39 | 40 | /// Settings for configuring the video player. 41 | var settings: VideoSettings { get set } 42 | 43 | /// Initializes a new instance of `LoopPlayerView`. 44 | /// - Parameters: 45 | /// - settings: A binding to the video settings used by the player. 46 | /// - command: A binding to the playback command that controls playback actions. 47 | /// - timePublisher: A publisher that emits the current playback time as a `Double`. 48 | /// - eventPublisher: A publisher that emits player events as `PlayerEvent` values. 49 | init( 50 | settings: Binding, 51 | command: Binding, 52 | timePublisher: PassthroughSubject, 53 | eventPublisher: PassthroughSubject 54 | ) 55 | } 56 | 57 | @available(iOS 14, macOS 11, tvOS 14, *) 58 | extension ExtPlayerViewProtocol{ 59 | 60 | /// Creates a player view for looping video content. 61 | /// - Parameters: 62 | /// - context: The UIViewRepresentable context providing environment data and coordinator. 63 | /// - Returns: A PlayerView instance conforming to LoopingPlayerProtocol. 64 | @MainActor 65 | func makePlayerView(_ container: View) -> PlayerView? { 66 | 67 | let player = PlayerView(settings: settings) 68 | container.addSubview(player) 69 | activateFullScreenConstraints(for: player, in: container) 70 | return player 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/settings/EnableVector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnableVector.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 14.01.25. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A structure to enable a vector layer for overlaying vector graphics. 11 | /// 12 | /// Use this struct to activate settings that allow the addition of vector-based 13 | /// overlays via commands, such as shapes, paths, or other scalable graphics, on top of existing content. 14 | /// This structure is designed with optimization in mind, ensuring that extra layers 15 | /// are not added if they are unnecessary, reducing overhead and improving performance. 16 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 17 | public struct EnableVector: SettingsConvertible{ 18 | 19 | // MARK: - Life circle 20 | 21 | /// Initializes a new instance 22 | public init() {} 23 | 24 | /// Fetch settings 25 | @_spi(Private) 26 | public func asSettings() -> [Setting] { 27 | [.vector] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/settings/Events.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Events.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 14.01.25. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Represents a collection of event filters that can be converted into settings. 11 | /// This struct is used to encapsulate `PlayerEventFilter` instances and provide a method 12 | /// to transform them into an array of `Setting` objects. 13 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 14 | public struct Events: SettingsConvertible { 15 | 16 | // An optional array of PlayerEventFilter objects representing event filters 17 | private let value: [PlayerEventFilter]? 18 | 19 | // MARK: - Life cycle 20 | 21 | /// Initializes a new instance of `Events` 22 | /// - Parameter value: An optional array of `PlayerEventFilter` objects, defaulting to `nil` 23 | public init(_ value: [PlayerEventFilter]? = nil) { 24 | self.value = value 25 | } 26 | 27 | /// Converts the event filters into an array of `Setting` objects 28 | /// Used for fetching settings in the application 29 | @_spi(Private) 30 | public func asSettings() -> [Setting] { 31 | [.events(value)] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/settings/Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Ext.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 07.07.2023. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | /// Represents a structure that holds the file extension for a video, conforming to the `SettingsConvertible` protocol. 12 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 13 | public struct Ext: SettingsConvertible{ 14 | 15 | /// The video file extension value. 16 | let value: String 17 | 18 | // MARK: - Life circle 19 | 20 | /// Initializes a new instance of `Ext` with a specific file extension. 21 | /// - Parameter value: A string representing the file extension of a video. 22 | public init(_ value: String) { self.value = value } 23 | 24 | /// Fetch settings 25 | @_spi(Private) 26 | public func asSettings() -> [Setting] { 27 | [.ext(value)] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/settings/Gravity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Gravity.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 07.07.2023. 6 | // 7 | 8 | import Foundation 9 | import AVKit 10 | 11 | /// Represents video layout options as gravity settings, conforming to `SettingsConvertible`. 12 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 13 | public struct Gravity : SettingsConvertible{ 14 | 15 | /// Holds the specific AVLayerVideoGravity setting defining how video content should align within its layer. 16 | private let value : AVLayerVideoGravity 17 | 18 | // MARK: - Life circle 19 | 20 | /// Initializes a new Gravity instance with a specified video gravity. 21 | /// - Parameter value: The `AVLayerVideoGravity` value to set. 22 | public init(_ value: AVLayerVideoGravity) { self.value = value } 23 | 24 | /// Fetch settings 25 | @_spi(Private) 26 | public func asSettings() -> [Setting] { 27 | [.gravity(value)] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/settings/Loop.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Loop.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 16.08.24. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | /// Represents a settings structure that enables looping functionality, conforming to `SettingsConvertible`. 12 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 13 | public struct Loop: SettingsConvertible{ 14 | 15 | // MARK: - Life circle 16 | 17 | /// Initializes a new instance 18 | public init() {} 19 | 20 | /// Fetch settings 21 | @_spi(Private) 22 | public func asSettings() -> [Setting] { 23 | [.loop] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/settings/Mute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Mute.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 10.09.24. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | /// Represents a structure that enables muting functionality, conforming to `SettingsConvertible`. 12 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 13 | public struct Mute: SettingsConvertible{ 14 | 15 | // MARK: - Life circle 16 | 17 | /// Initializes a new instance 18 | public init() {} 19 | 20 | /// Fetch settings 21 | @_spi(Private) 22 | public func asSettings() -> [Setting] { 23 | [.mute] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/settings/NotAutoPlay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotAutoPlay.swift 3 | // 4 | // 5 | // Created by Igor on 10.09.24. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | /// Represents a setting to disable automatic playback, conforming to `SettingsConvertible`. 12 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 13 | public struct NotAutoPlay: SettingsConvertible{ 14 | 15 | // MARK: - Life circle 16 | 17 | /// Initializes a new instance 18 | public init() {} 19 | 20 | /// Fetch settings 21 | @_spi(Private) 22 | public func asSettings() -> [Setting] { 23 | [.notAutoPlay] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/settings/PictureInPicture .swift: -------------------------------------------------------------------------------- 1 | // 2 | // PictureInPicture.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 21.01.25. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | /// Represents a PictureInPicture functionality, conforming to `SettingsConvertible`. 12 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 13 | public struct PictureInPicture : SettingsConvertible{ 14 | 15 | // MARK: - Life circle 16 | 17 | /// Initializes a new instance 18 | public init() {} 19 | 20 | /// Fetch settings 21 | @_spi(Private) 22 | public func asSettings() -> [Setting] { 23 | [.pictureInPicture] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/settings/SourceName.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileName.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 07.07.2023. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | /// Represents a structure that holds the name of a video source, conforming to `SettingsConvertible`. 12 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 13 | public struct SourceName : SettingsConvertible{ 14 | 15 | /// Video file name 16 | let value : String 17 | 18 | // MARK: - Life circle 19 | 20 | /// Initializes a new instance with a specific video file name. 21 | /// - Parameter value: The string representing the video file name. 22 | public init(_ value: String) { self.value = value } 23 | 24 | /// Fetch settings 25 | @_spi(Private) 26 | public func asSettings() -> [Setting] { 27 | [.name(value)] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/settings/Subtitles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Subtitles.swift 3 | // swiftui-loop-videoplayer 4 | // 5 | // Created by Igor Shelopaev on 07.01.25. 6 | // 7 | 8 | /// Represents a structure that holds the name of subtitles, conforming to `SettingsConvertible`. 9 | /// 10 | /// Important: 11 | /// - When using `.vtt` subtitles, a file-based container format such as MP4 or QuickTime (`.mov`) 12 | /// generally supports embedding those subtitles as a `.text` track. 13 | /// - Formats like HLS (`.m3u8`) typically reference `.vtt` files externally rather than merging them 14 | /// into a single file. 15 | /// - Attempting to merge `.vtt` subtitles into an HLS playlist via `AVMutableComposition` won't work; 16 | /// instead, you’d attach the `.vtt` as a separate media playlist in the HLS master manifest. 17 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 18 | public struct Subtitles : SettingsConvertible{ 19 | 20 | /// Video file name 21 | let value : String 22 | 23 | // MARK: - Life circle 24 | 25 | /// Initializes a new instance with a specific video file name. 26 | /// - Parameter value: The string representing the video file name. 27 | public init(_ value: String) { self.value = value } 28 | 29 | /// Fetch settings 30 | @_spi(Private) 31 | public func asSettings() -> [Setting] { 32 | [.subtitles(value)] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/settings/TimePublishing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimePublishing.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 15.08.24. 6 | // 7 | 8 | import Foundation 9 | import AVFoundation 10 | 11 | /// Represents a structure for setting the publishing time of a video, conforming to `SettingsConvertible`. 12 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 13 | public struct TimePublishing: SettingsConvertible{ 14 | 15 | /// Holds the time value associated with the video publishing time, using `CMTime`. 16 | let value : CMTime 17 | 18 | // MARK: - Life circle 19 | 20 | /// Initializes a new instance of `TimePublishing` with an optional `CMTime` value, defaulting to 1 second at a timescale of 600 if not provided. 21 | /// - Parameter value: Optional `CMTime` value to set as the default time. 22 | public init(_ value: CMTime? = nil) { self.value = value ?? CMTime(seconds: 1, preferredTimescale: 600) } 23 | 24 | /// Fetch settings 25 | @_spi(Private) 26 | public func asSettings() -> [Setting] { 27 | [.timePublishing(value)] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/utils/SettingsBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsBuilder.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 07.07.2023. 6 | // 7 | 8 | import SwiftUI 9 | import AVKit 10 | 11 | /// Result builder to construct an array of 'Setting' objects. 12 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 13 | @resultBuilder 14 | public struct SettingsBuilder { 15 | 16 | /// Combines a single expression into an array of settings. 17 | /// - Parameter expression: A type conforming to `SettingsConvertible`. 18 | /// - Returns: An array of settings derived from the expression. 19 | public static func buildExpression(_ expression: SettingsConvertible) -> [Setting] { 20 | return expression.asSettings() 21 | } 22 | 23 | /// Combines an optional expression into an array of settings. 24 | /// - Parameter component: An optional type conforming to `SettingsConvertible`. 25 | /// - Returns: An array of settings derived from the expression if it's non-nil, otherwise an empty array. 26 | public static func buildOptional(_ component: [Setting]?) -> [Setting] { 27 | return component ?? [] 28 | } 29 | 30 | /// Combines multiple expressions into a single array of settings. 31 | /// - Parameter components: An array of arrays of settings. 32 | /// - Returns: A flattened array of settings. 33 | public static func buildBlock(_ components: [Setting]...) -> [Setting] { 34 | return components.flatMap { $0 } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/utils/VideoSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Settings.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 07.07.2023. 6 | // 7 | 8 | import SwiftUI 9 | import AVKit 10 | 11 | /// Represents a structure for video settings. 12 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 13 | public struct VideoSettings: Equatable{ 14 | 15 | // MARK: - Public properties 16 | 17 | /// Name of the video to play 18 | public let name: String 19 | 20 | /// Video extension 21 | public let ext: String 22 | 23 | /// Subtitles 24 | public let subtitles: String 25 | 26 | /// Loop video 27 | public let loop: Bool 28 | 29 | /// Loop video 30 | public let pictureInPicture: Bool 31 | 32 | /// Mute video 33 | public let mute: Bool 34 | 35 | /// Enable vector layer to add overlay vector graphics 36 | public let vector: Bool 37 | 38 | /// Disable events 39 | public let events: [PlayerEventFilter]? 40 | 41 | /// Don't auto play video after initialization 42 | public let notAutoPlay: Bool 43 | 44 | /// A CMTime value representing the interval at which the player's current time should be published. 45 | /// If set, the player will publish periodic time updates based on this interval. 46 | public let timePublishing: CMTime? 47 | 48 | /// A structure that defines how a layer displays a player’s visual content within the layer’s bounds 49 | public let gravity: AVLayerVideoGravity 50 | 51 | /// Are the params unique 52 | public var areUnique : Bool { 53 | unique 54 | } 55 | 56 | // MARK: - Private properties 57 | 58 | /// Is settings are unique 59 | private let unique : Bool 60 | 61 | // MARK: - Life circle 62 | 63 | /// Initializes a new instance of `VideoSettings` with specified values for various video properties. 64 | /// 65 | /// - Parameters: 66 | /// - name: The name of the video file (excluding the extension). 67 | /// - ext: The file extension of the video (e.g., `"mp4"`, `"mov"`). 68 | /// - subtitles: The subtitle file name or identifier to be used for the video. 69 | /// - loop: A Boolean indicating whether the video should continuously loop after playback ends. 70 | /// - pictureInPicture: A Boolean indicating whether Picture-in-Picture (PiP) mode is enabled. 71 | /// - mute: A Boolean indicating whether the video should start muted. 72 | /// - notAutoPlay: A Boolean indicating whether the video should not start playing automatically. 73 | /// - timePublishing: A `CMTime` value representing the interval for time update callbacks, or `nil` if disabled. 74 | /// - gravity: The `AVLayerVideoGravity` value defining how the video should be displayed within its layer. 75 | /// - enableVector: A Boolean indicating whether vector graphics rendering should be enabled for overlays. 76 | /// 77 | /// All parameters must be provided, except `timePublishing`, which can be `nil`, and `enableVector`, which defaults to `false`. 78 | 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] = []) { 79 | self.name = name 80 | self.ext = ext 81 | self.subtitles = subtitles 82 | self.loop = loop 83 | self.pictureInPicture = pictureInPicture 84 | self.mute = mute 85 | self.notAutoPlay = notAutoPlay 86 | self.timePublishing = timePublishing 87 | self.gravity = gravity 88 | self.vector = enableVector 89 | self.events = events 90 | self.unique = true 91 | } 92 | 93 | /// Initializes `VideoSettings` using a settings builder closure. 94 | /// - Parameter builder: A block builder that generates an array of settings. 95 | public init(@SettingsBuilder builder: () -> [Setting]){ 96 | let settings = builder() 97 | 98 | unique = check(settings) 99 | 100 | name = settings.fetch(by : "name", defaulted: "") 101 | 102 | ext = settings.fetch(by : "ext", defaulted: "mp4") 103 | 104 | subtitles = settings.fetch(by : "subtitles", defaulted: "") 105 | 106 | gravity = settings.fetch(by : "gravity", defaulted: .resizeAspect) 107 | 108 | timePublishing = settings.fetch(by : "timePublishing", defaulted: nil) 109 | 110 | loop = settings.contains(.loop) 111 | 112 | pictureInPicture = settings.contains(.pictureInPicture) 113 | 114 | mute = settings.contains(.mute) 115 | 116 | notAutoPlay = settings.contains(.notAutoPlay) 117 | 118 | vector = settings.contains(.vector) 119 | 120 | let hasEvents = settings.contains { 121 | if case .events = $0 { 122 | return true 123 | } 124 | return false 125 | } 126 | 127 | if hasEvents{ 128 | events = settings.fetch(by : "events", defaulted: []) ?? [] 129 | }else{ 130 | events = nil 131 | } 132 | } 133 | } 134 | 135 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) 136 | public extension VideoSettings { 137 | 138 | /// Checks if the asset has changed based on the provided settings and current asset. 139 | /// - Parameters: 140 | /// - asset: The current asset being played. 141 | /// - Returns: A new `AVURLAsset` if the asset has changed, or `nil` if the asset remains the same. 142 | func isEqual(_ settings : VideoSettings?) -> Bool{ 143 | let newAsset = assetFor(self) 144 | 145 | guard let settings = settings else{ return false } 146 | 147 | let oldAsset = assetFor(settings) 148 | 149 | if let newUrl = newAsset?.url, let oldUrl = oldAsset?.url, newUrl != oldUrl{ 150 | return false 151 | } 152 | 153 | return true 154 | } 155 | } 156 | 157 | /// Check if unique 158 | /// - Parameter settings: Passed array of settings flatted by block builder 159 | /// - Returns: True - unique False - not 160 | fileprivate func check(_ settings : [Setting]) -> Bool{ 161 | let cases : [String] = settings.map{ $0.caseName } 162 | let set = Set(cases) 163 | return cases.count == set.count 164 | } 165 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/view/helpers/PlayerCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayerCoordinator.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 06.08.24. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | import AVFoundation 11 | #if os(iOS) 12 | import AVKit 13 | #endif 14 | 15 | @MainActor 16 | internal class PlayerCoordinator: NSObject, PlayerDelegateProtocol { 17 | 18 | /// Publisher that emits player events, allowing observers to react to changes in playback state 19 | let eventPublisher: PassthroughSubject 20 | 21 | /// Publisher that emits the current playback time as a Double, allowing real-time tracking of progress 22 | let timePublisher: PassthroughSubject 23 | 24 | /// Stores the last command applied to the player. 25 | private var lastCommand: PlaybackCommand? 26 | 27 | init( 28 | timePublisher: PassthroughSubject, 29 | eventPublisher: PassthroughSubject 30 | ) { 31 | self.timePublisher = timePublisher 32 | self.eventPublisher = eventPublisher 33 | } 34 | 35 | /// Deinitializes the coordinator and prints a debug message if in DEBUG mode. 36 | deinit { 37 | #if DEBUG 38 | print("Deinit Coordinator") 39 | #endif 40 | } 41 | 42 | /// Handles receiving an error and updates the error state in the parent view. 43 | /// This method is called when an error is encountered during playback or other operations. 44 | /// - Parameter error: The error received. 45 | func didReceiveError(_ error: VPErrors) { 46 | eventPublisher.send(.error(error)) 47 | } 48 | 49 | /// Sets the last command applied to the player. 50 | /// This method updates the stored `lastCommand` to the provided value. 51 | /// - Parameter command: The `PlaybackCommand` that was last applied to the player. 52 | func setLastCommand(_ command: PlaybackCommand) { 53 | self.lastCommand = command 54 | } 55 | 56 | /// Retrieves the last command applied to the player. 57 | /// - Returns: The `PlaybackCommand` that was last applied to the player. 58 | var getLastCommand : PlaybackCommand? { 59 | return lastCommand 60 | } 61 | 62 | /// A method that handles the passage of time in the player. 63 | /// - Parameter seconds: The amount of time, in seconds, that has passed. 64 | func didPassedTime(seconds : Double) { 65 | timePublisher.send(seconds) 66 | } 67 | 68 | /// A method that handles seeking in the player. 69 | /// - Parameters: 70 | /// - value: A Boolean indicating whether the seek was successful. 71 | /// - currentTime: The current time of the player after seeking, in seconds. 72 | func didSeek(value: Bool, currentTime : Double) { 73 | eventPublisher.send(.seek(value, currentTime: currentTime)) 74 | } 75 | 76 | /// Called when the player has paused playback. 77 | /// 78 | /// This method is triggered when the player's `timeControlStatus` changes to `.paused`. 79 | func didPausePlayback(){ 80 | eventPublisher.send(.paused) 81 | } 82 | 83 | /// Called when the player is waiting to play at the specified rate. 84 | /// 85 | /// This method is triggered when the player's `timeControlStatus` changes to `.waitingToPlayAtSpecifiedRate`. 86 | func isWaitingToPlay(){ 87 | eventPublisher.send(.waitingToPlayAtSpecifiedRate) 88 | } 89 | 90 | /// Called when the player starts or resumes playing. 91 | /// 92 | /// This method is triggered when the player's `timeControlStatus` changes to `.playing`. 93 | func didStartPlaying(){ 94 | eventPublisher.send(.playing) 95 | } 96 | 97 | /// Called when the current media item in the player changes. 98 | /// 99 | /// This method is triggered when the player's `currentItem` is updated to a new `AVPlayerItem`. 100 | /// - Parameter newItem: The new `AVPlayerItem` that the player has switched to, if any. 101 | func currentItemDidChange(to newItem: AVPlayerItem?){ 102 | eventPublisher.send(.currentItemChanged(newItem: newItem)) 103 | } 104 | 105 | /// Called when the current media item is removed from the player. 106 | /// 107 | /// This method is triggered when the player's `currentItem` is set to `nil`, indicating that there is no longer an active media item. 108 | func currentItemWasRemoved(){ 109 | eventPublisher.send(.currentItemRemoved) 110 | } 111 | 112 | /// Called when the volume level of the player changes. 113 | /// 114 | /// This method is triggered when the player's `volume` property changes. 115 | /// - Parameter newVolume: The new volume level, expressed as a float between 0.0 (muted) and 1.0 (maximum volume). 116 | func volumeDidChange(to newVolume: Float){ 117 | eventPublisher.send(.volumeChanged(newVolume: newVolume)) 118 | } 119 | 120 | /// Notifies that the bounds have changed. 121 | /// 122 | /// - Parameter bounds: The new bounds of the main layer where we keep the video player and all vector layers. This allows a developer to recalculate and update all vector layers that lie in the CompositeLayer. 123 | func boundsDidChange(to bounds: CGRect) { 124 | eventPublisher.send(.boundsChanged(bounds)) 125 | } 126 | 127 | /// Called when the AVPlayerItem's status changes. 128 | /// - Parameter status: The new status of the AVPlayerItem. 129 | /// - `.unknown`: The item is still loading or its status is not yet determined. 130 | /// - `.readyToPlay`: The item is fully loaded and ready to play. 131 | /// - `.failed`: The item failed to load due to an error. 132 | func itemStatusChanged(_ status: AVPlayerItem.Status) { 133 | eventPublisher.send(.itemStatusChanged(status)) 134 | } 135 | 136 | /// Called when the duration of the AVPlayerItem is available. 137 | /// - Parameter time: The total duration of the media item in `CMTime`. 138 | /// - This method is only called when the item reaches `.readyToPlay`, 139 | /// ensuring that the duration value is valid. 140 | func duration(_ time: CMTime) { 141 | eventPublisher.send(.duration(time)) 142 | } 143 | 144 | } 145 | 146 | #if os(iOS) 147 | extension PlayerCoordinator: AVPictureInPictureControllerDelegate{ 148 | 149 | /// Called when Picture-in-Picture (PiP) mode starts. 150 | /// 151 | /// - Parameter pictureInPictureController: The `AVPictureInPictureController` instance managing the PiP session. 152 | /// 153 | /// This method is marked as `nonisolated` to avoid being tied to the actor's execution context, 154 | /// allowing it to be called from any thread. It publishes a `.startedPiP` event on the `eventPublisher` 155 | /// within a `Task` running on the `MainActor`, ensuring UI updates are handled on the main thread. 156 | nonisolated func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { 157 | Task{ @MainActor in 158 | eventPublisher.send(.startedPiP) 159 | } 160 | } 161 | 162 | 163 | /// Called when Picture-in-Picture (PiP) mode stops. 164 | /// 165 | /// - Parameter pictureInPictureController: The `AVPictureInPictureController` instance managing the PiP session. 166 | /// 167 | /// Like its counterpart for starting PiP, this method is `nonisolated`, allowing it to be executed from any thread. 168 | /// It sends a `.stoppedPiP` event via `eventPublisher` on the `MainActor`, ensuring any UI-related handling 169 | /// occurs safely on the main thread. 170 | nonisolated func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { 171 | Task{ @MainActor in 172 | eventPublisher.send(.stoppedPiP) 173 | } 174 | } 175 | } 176 | #endif 177 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/view/modifier/OnPlayerEventChangeModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnPlayerEventChangeModifier.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 15.08.24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Defines a custom `PreferenceKey` for handling player events within SwiftUI views. 11 | internal struct PlayerEventPreferenceKey: PreferenceKey { 12 | /// The default value of player events, initialized as an empty array. 13 | public static var defaultValue: [PlayerEvent] = [] 14 | 15 | /// Aggregates values from the view hierarchy when child views provide values. 16 | public static func reduce(value: inout [PlayerEvent], nextValue: () -> [PlayerEvent]) { 17 | value = nextValue() 18 | } 19 | } 20 | 21 | /// A view modifier that monitors changes to player events and triggers a closure. 22 | internal struct OnPlayerEventChangeModifier: ViewModifier { 23 | /// The closure to execute when player events change. 24 | var onPlayerEventChange: ([PlayerEvent]) -> Void 25 | 26 | /// Attaches a preference change listener to the content and executes a closure when player events change. 27 | func body(content: Content) -> some View { 28 | content 29 | .onPreferenceChange(PlayerEventPreferenceKey.self, perform: onPlayerEventChange) 30 | } 31 | } 32 | 33 | /// Extends `View` to include a custom modifier for handling player event changes. 34 | public extension View { 35 | /// Applies the `OnPlayerEventChangeModifier` to the view to handle player event changes. 36 | func onPlayerEventChange(perform action: @escaping ([PlayerEvent]) -> Void) -> some View { 37 | self.modifier(OnPlayerEventChangeModifier(onPlayerEventChange: action)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/view/modifier/OnTimeChangeModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnTimeChangeModifier.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 15.08.24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Defines a custom `PreferenceKey` for storing and updating the current playback time. 11 | internal struct CurrentTimePreferenceKey: PreferenceKey { 12 | /// Sets the default playback time to 0.0 seconds. 13 | public static var defaultValue: Double = 0.0 14 | 15 | /// Aggregates the most recent playback time from child views. 16 | public static func reduce(value: inout Double, nextValue: () -> Double) { 17 | value = nextValue() 18 | } 19 | } 20 | 21 | /// A view modifier that listens for changes in playback time and triggers a response. 22 | internal struct OnTimeChangeModifier: ViewModifier { 23 | /// The closure to execute when there is a change in playback time. 24 | var onTimeChange: (Double) -> Void 25 | 26 | /// Attaches a preference change listener to the content that triggers `onTimeChange` when the playback time updates. 27 | func body(content: Content) -> some View { 28 | content 29 | .onPreferenceChange(CurrentTimePreferenceKey.self, perform: onTimeChange) 30 | } 31 | } 32 | 33 | /// Extends `View` to include functionality for responding to changes in playback time. 34 | public extension View { 35 | /// Applies the `OnTimeChangeModifier` to the view to manage updates in playback time. 36 | func onPlayerTimeChange(perform action: @escaping (Double) -> Void) -> some View { 37 | self.modifier(OnTimeChangeModifier(onTimeChange: action)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/view/player/ios/ExtPlayerUIView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExtPlayerUIView.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 05.08.24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if canImport(AVKit) 11 | import AVKit 12 | #endif 13 | 14 | #if canImport(UIKit) 15 | import UIKit 16 | 17 | @MainActor 18 | internal class ExtPlayerUIView: UIView, ExtPlayerProtocol{ 19 | 20 | /// This property holds an instance of `VideoSettings` 21 | internal var currentSettings : VideoSettings? 22 | 23 | /// `filters` is an array that stores CIFilter objects used to apply different image processing effects 24 | internal var filters: [CIFilter] = [] 25 | 26 | /// `brightness` represents the adjustment level for the brightness of the video content. 27 | internal var brightness: Float = 0 28 | 29 | /// `contrast` indicates the level of contrast adjustment for the video content. 30 | internal var contrast: Float = 1 31 | 32 | /// A CALayer instance used for composing content, accessible only within the module. 33 | internal var compositeLayer : CALayer? = nil 34 | 35 | /// The AVPlayerLayer that displays the video content. 36 | internal var playerLayer : AVPlayerLayer? = nil 37 | 38 | /// The looper responsible for continuous video playback. 39 | internal var playerLooper: AVPlayerLooper? 40 | 41 | /// The queue player that plays the video items. 42 | internal var player: AVQueuePlayer? 43 | 44 | /// Declare a variable to hold the time observer token outside the if statement 45 | internal var timeObserver: Any? 46 | 47 | /// Observer for errors from the AVQueuePlayer. 48 | internal var errorObserver: NSKeyValueObservation? 49 | 50 | /// An optional observer for monitoring changes to the player's `timeControlStatus` property. 51 | internal var timeControlObserver: NSKeyValueObservation? 52 | 53 | /// An optional observer for monitoring changes to the player's `currentItem` property. 54 | internal var currentItemObserver: NSKeyValueObservation? 55 | 56 | /// Item status observer 57 | internal var itemStatusObserver: NSKeyValueObservation? 58 | 59 | /// An optional observer for monitoring changes to the player's `volume` property. 60 | /// 61 | /// This property holds an instance of `NSKeyValueObservation`, which observes the `volume` 62 | /// of an `AVPlayer`. 63 | internal var volumeObserver: NSKeyValueObservation? 64 | 65 | /// The delegate to be notified about errors encountered by the player. 66 | weak var delegate: PlayerDelegateProtocol? 67 | 68 | /// The Picture-in-Picture (PiP) controller for managing PiP functionality. 69 | internal var pipController: AVPictureInPictureController? 70 | 71 | /// Initializes a new player view with a video asset and custom settings. 72 | /// 73 | /// - Parameters: 74 | /// - asset: The `AVURLAsset` used for video playback. 75 | /// - settings: The `VideoSettings` struct that includes all necessary configurations like gravity, loop, and mute. 76 | required init(settings: VideoSettings){ 77 | 78 | player = AVQueuePlayer(items: []) 79 | 80 | super.init(frame: .zero) 81 | 82 | addPlayerLayer() 83 | addCompositeLayer(settings) 84 | 85 | setupPlayerComponents( 86 | settings: settings 87 | ) 88 | 89 | } 90 | 91 | required init?(coder: NSCoder) { 92 | fatalError("init(coder:) has not been implemented") 93 | } 94 | 95 | /// Lays out subviews and adjusts the frame of the player layer to match the view's bounds. 96 | override func layoutSubviews() { 97 | super.layoutSubviews() 98 | playerLayer?.frame = bounds 99 | // Update the composite layer (and sublayers) 100 | layoutCompositeLayer() 101 | } 102 | 103 | /// Updates the composite layer and all its sublayers' frames. 104 | public func layoutCompositeLayer() { 105 | guard let compositeLayer = compositeLayer else { return } 106 | 107 | // Update the composite layer's frame to match the parent 108 | compositeLayer.frame = bounds 109 | 110 | // Adjust each sublayer's frame (if they should fill the entire composite layer) 111 | compositeLayer.sublayers?.forEach { sublayer in 112 | sublayer.frame = compositeLayer.bounds 113 | } 114 | 115 | delegate?.boundsDidChange(to: bounds) 116 | } 117 | 118 | func onDisappear(){ 119 | // First, clear all observers to prevent memory leaks 120 | clearObservers() 121 | 122 | // Stop the player to ensure it's not playing any media 123 | stop() 124 | 125 | // Remove visual layers to clean up the UI components 126 | removePlayerLayer() 127 | removeCompositeLayer() 128 | 129 | // Finally, release player and delegate references to free up memory 130 | player = nil 131 | delegate = nil 132 | 133 | // Log the cleanup process for debugging purposes 134 | #if DEBUG 135 | print("Player deinitialized and resources cleaned up.") 136 | #endif 137 | } 138 | 139 | #if os(iOS) 140 | /// Called by the Coordinator to set up PiP 141 | func setupPiP(delegate: AVPictureInPictureControllerDelegate) { 142 | // Check if PiP is supported 143 | guard AVPictureInPictureController.isPictureInPictureSupported() else { 144 | DispatchQueue.main.asyncAfter(deadline: .now() + 1){ [weak self] in 145 | self?.onError(.notSupportedPiP) 146 | } 147 | return 148 | } 149 | 150 | guard let playerLayer else{ 151 | return 152 | } 153 | 154 | pipController = AVPictureInPictureController(playerLayer: playerLayer) 155 | pipController?.delegate = delegate 156 | } 157 | #endif 158 | } 159 | #endif 160 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/view/player/mac/ExtPlayerNSView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExtPlayerNSView.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 05.08.24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if canImport(AVKit) 11 | import AVKit 12 | #endif 13 | 14 | #if canImport(AppKit) 15 | import AppKit 16 | 17 | /// A NSView subclass that loops video using AVFoundation on macOS. 18 | /// This class handles the initialization and management of a looping video player with customizable video gravity. 19 | @MainActor 20 | internal class ExtPlayerNSView: NSView, ExtPlayerProtocol { 21 | 22 | /// This property holds an instance of `VideoSettings` 23 | internal var currentSettings : VideoSettings? 24 | 25 | /// `filters` is an array that stores CIFilter objects used to apply different image processing effects 26 | internal var filters: [CIFilter] = [] 27 | 28 | /// `brightness` represents the adjustment level for the brightness of the video content. 29 | internal var brightness: Float = 0 30 | 31 | /// `contrast` indicates the level of contrast adjustment for the video content. 32 | internal var contrast: Float = 1 33 | 34 | /// A CALayer instance used for composing content, accessible only within the module. 35 | internal var compositeLayer : CALayer? 36 | 37 | /// The AVPlayerLayer that displays the video content. 38 | internal var playerLayer : AVPlayerLayer? 39 | 40 | /// The looper responsible for continuous video playback. 41 | internal var playerLooper: AVPlayerLooper? 42 | 43 | /// The queue player that plays the video items. 44 | internal var player: AVQueuePlayer? = AVQueuePlayer(items: []) 45 | 46 | /// Declare a variable to hold the time observer token outside the if statement 47 | internal var timeObserver: Any? 48 | 49 | /// Observer for errors from the AVQueuePlayer. 50 | internal var errorObserver: NSKeyValueObservation? 51 | 52 | /// An optional observer for monitoring changes to the player's `timeControlStatus` property. 53 | internal var timeControlObserver: NSKeyValueObservation? 54 | 55 | /// An optional observer for monitoring changes to the player's `currentItem` property. 56 | internal var currentItemObserver: NSKeyValueObservation? 57 | 58 | /// Item status observer 59 | internal var itemStatusObserver: NSKeyValueObservation? 60 | 61 | /// An optional observer for monitoring changes to the player's `volume` property. 62 | /// 63 | /// This property holds an instance of `NSKeyValueObservation`, which observes the `volume` 64 | /// of an `AVPlayer`. 65 | internal var volumeObserver: NSKeyValueObservation? 66 | 67 | /// The delegate to be notified about errors encountered by the player. 68 | weak var delegate: PlayerDelegateProtocol? 69 | 70 | /// Initializes a new player view with a video asset and specified configurations. 71 | /// 72 | /// - Parameters: 73 | /// - asset: The `AVURLAsset` for video playback. 74 | /// - settings: The `VideoSettings` struct that includes all necessary configurations like gravity, loop, and mute. 75 | required init(settings: VideoSettings) { 76 | 77 | player = AVQueuePlayer(items: []) 78 | 79 | super.init(frame: .zero) 80 | 81 | addPlayerLayer() 82 | addCompositeLayer(settings) 83 | 84 | setupPlayerComponents(settings: settings) 85 | } 86 | 87 | required init?(coder: NSCoder) { 88 | fatalError("init(coder:) has not been implemented") 89 | } 90 | 91 | /// Lays out subviews and adjusts the frame of the player layer to match the view's bounds. 92 | override func layout() { 93 | super.layout() 94 | playerLayer?.frame = bounds 95 | // Update the composite layer (and sublayers) 96 | layoutCompositeLayer() 97 | } 98 | 99 | /// Updates the composite layer and all its sublayers' frames. 100 | public func layoutCompositeLayer() { 101 | guard let compositeLayer = compositeLayer else { return } 102 | 103 | // Update the composite layer's frame to match the parent 104 | compositeLayer.frame = bounds 105 | 106 | // Adjust each sublayer's frame (if they should fill the entire composite layer) 107 | compositeLayer.sublayers?.forEach { sublayer in 108 | sublayer.frame = compositeLayer.bounds 109 | } 110 | 111 | delegate?.boundsDidChange(to: bounds) 112 | } 113 | 114 | 115 | func onDisappear(){ 116 | // First, clear all observers to prevent memory leaks 117 | clearObservers() 118 | 119 | // Stop the player to ensure it's not playing any media 120 | stop() 121 | 122 | // Remove visual layers to clean up the UI components 123 | removePlayerLayer() 124 | removeCompositeLayer() 125 | 126 | // Finally, release player and delegate references to free up memory 127 | player = nil 128 | delegate = nil 129 | 130 | // Log the cleanup process for debugging purposes 131 | #if DEBUG 132 | print("Player deinitialized and resources cleaned up.") 133 | #endif 134 | } 135 | } 136 | #endif 137 | -------------------------------------------------------------------------------- /Sources/swiftui-loop-videoplayer/view/player/main/ExtPlayerMultiPlatform.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExtPlayerMultiPlatform.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 05.08.24. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | #if canImport(UIKit) 12 | import UIKit 13 | #endif 14 | 15 | #if canImport(AppKit) 16 | import AppKit 17 | #endif 18 | 19 | @MainActor 20 | internal struct ExtPlayerMultiPlatform: ExtPlayerViewProtocol { 21 | 22 | #if canImport(UIKit) 23 | typealias View = UIView 24 | 25 | typealias PlayerView = ExtPlayerUIView 26 | #elseif canImport(AppKit) 27 | typealias View = NSView 28 | 29 | typealias PlayerView = ExtPlayerNSView 30 | #endif 31 | 32 | /// A publisher that emits the current playback time as a `Double`. 33 | private let timePublisher: PassthroughSubject 34 | 35 | /// A publisher that emits player events as `PlayerEvent` values. 36 | private let eventPublisher: PassthroughSubject 37 | 38 | /// Command for the player view 39 | @Binding public var command : PlaybackCommand 40 | 41 | /// Settings for the player view 42 | @Binding public var settings: VideoSettings 43 | 44 | /// Initializes a new instance of `ExtPlayerView`. 45 | /// - Parameters: 46 | /// - settings: A binding to the video settings used by the player. 47 | /// - command: A binding to the playback command that controls playback actions. 48 | /// - timePublisher: A publisher that emits the current playback time as a `Double`. 49 | /// - eventPublisher: A publisher that emits player events as `PlayerEvent` values. 50 | init( 51 | settings: Binding, 52 | command: Binding, 53 | timePublisher : PassthroughSubject, 54 | eventPublisher : PassthroughSubject 55 | ) { 56 | self.timePublisher = timePublisher 57 | self.eventPublisher = eventPublisher 58 | self._settings = settings 59 | self._command = command 60 | } 61 | /// Creates a coordinator that handles error-related updates and interactions between the SwiftUI view and its underlying model. 62 | /// - Returns: An instance of PlayerErrorCoordinator that can be used to manage error states and communicate between the view and model. 63 | func makeCoordinator() -> PlayerCoordinator { 64 | PlayerCoordinator(timePublisher: timePublisher, eventPublisher: eventPublisher) 65 | } 66 | } 67 | 68 | #if canImport(UIKit) 69 | extension ExtPlayerMultiPlatform: UIViewRepresentable{ 70 | /// Creates the container view with the player view and error view if needed 71 | /// - Parameter context: The context for the view 72 | /// - Returns: A configured UIView 73 | func makeUIView(context: Context) -> UIView { 74 | let container = UIView() 75 | 76 | if let player: PlayerView = makePlayerView(container){ 77 | player.delegate = context.coordinator 78 | #if os(iOS) 79 | if settings.pictureInPicture{ 80 | player.setupPiP(delegate: context.coordinator) 81 | } 82 | #endif 83 | } 84 | 85 | return container 86 | } 87 | 88 | /// Updates the container view, removing any existing error views and adding a new one if needed 89 | /// - Parameters: 90 | /// - uiView: The UIView to update 91 | /// - context: The context for the view 92 | func updateUIView(_ uiView: UIView, context: Context) { 93 | let player = uiView.findFirstSubview(ofType: PlayerView.self) 94 | 95 | if let player{ 96 | player.update(settings: settings) 97 | 98 | // Check if command changed before applying it 99 | if context.coordinator.getLastCommand != command { 100 | player.setCommand(command) 101 | context.coordinator.setLastCommand(command) // Update the last command in the coordinator 102 | } 103 | } 104 | } 105 | 106 | /// Called by SwiftUI to dismantle the UIView when the associated SwiftUI view is removed from the view hierarchy. 107 | /// https://developer.apple.com/documentation/swiftui/uiviewrepresentable/dismantleuiview(_:coordinator:) 108 | /// - Parameters: 109 | /// - uiView: The UIView instance being dismantled. 110 | /// - coordinator: The coordinator instance that manages interactions between SwiftUI and the UIView. 111 | static func dismantleUIView(_ uiView: UIView, coordinator: PlayerCoordinator) { 112 | // Called by SwiftUI when this view is removed from the hierarchy 113 | let player = uiView.findFirstSubview(ofType: PlayerView.self) 114 | if let player{ 115 | player.onDisappear() 116 | } 117 | } 118 | } 119 | #endif 120 | 121 | #if canImport(AppKit) 122 | extension ExtPlayerMultiPlatform: NSViewRepresentable{ 123 | /// Creates the NSView for the representable component. It initializes the view, configures it with a player if available, and adds an error view if necessary. 124 | /// - Parameter context: The context containing environment and state information used during view creation. 125 | /// - Returns: A fully configured NSView containing both the media player and potentially an error message display. 126 | func makeNSView(context: Context) -> NSView { 127 | let container = NSView() 128 | 129 | if let player: PlayerView = makePlayerView(container){ 130 | player.delegate = context.coordinator 131 | } 132 | 133 | return container 134 | } 135 | 136 | /// Updates the specified NSView during the view's lifecycle in response to state changes. 137 | /// - Parameters: 138 | /// - nsView: The NSView that needs updating. 139 | /// - context: The context containing environment and state information used during the view update. 140 | func updateNSView(_ nsView: NSView, context: Context) { 141 | let player = nsView.findFirstSubview(ofType: PlayerView.self) 142 | if let player { 143 | 144 | player.update(settings: settings) 145 | 146 | // Check if command changed before applying it 147 | if context.coordinator.getLastCommand != command { 148 | player.setCommand(command) 149 | context.coordinator.setLastCommand(command) // Update the last command in the coordinator 150 | } 151 | } 152 | } 153 | 154 | /// Called by SwiftUI to dismantle the NSView when the associated SwiftUI view is removed from the view hierarchy. 155 | /// 156 | /// - Parameters: 157 | /// - uiView: The NSView instance being dismantled. 158 | /// - coordinator: The coordinator instance that manages interactions between SwiftUI and the NSView. 159 | static func dismantleNSView(_ uiView: NSView, coordinator: PlayerCoordinator) { 160 | // Called by SwiftUI when this view is removed from the hierarchy 161 | let player = uiView.findFirstSubview(ofType: PlayerView.self) 162 | if let player{ 163 | player.onDisappear() 164 | } 165 | } 166 | } 167 | #endif 168 | -------------------------------------------------------------------------------- /Tests/swiftui-loop-videoplayerTests/Resources/swipe.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swiftuiux/swiftui-loop-videoPlayer/41c0b136f9263e0287543f5254912d2f9bbdfa20/Tests/swiftui-loop-videoplayerTests/Resources/swipe.mp4 -------------------------------------------------------------------------------- /Tests/swiftui-loop-videoplayerTests/testPlaybackCommandChangesOverTime.swift: -------------------------------------------------------------------------------- 1 | // 2 | // testPlaybackCommandChangesOverTime.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 16.08.24. 6 | // 7 | 8 | import XCTest 9 | import SwiftUI 10 | @testable import swiftui_loop_videoplayer 11 | import AVKit 12 | 13 | final class testPlaybackCommandChangesOverTime: XCTestCase { 14 | 15 | func testPlaybackCommandChangesOverTime() { 16 | // Setup initial command and a binding 17 | let initialCommand = PlaybackCommand.play 18 | var command = initialCommand 19 | let commandBinding = Binding( 20 | get: { command }, 21 | set: { command = $0 } 22 | ) 23 | 24 | // Create an instance of the view with the initial command 25 | let playerView = ExtVideoPlayer(fileName: "swipe", command: commandBinding) 26 | 27 | // Setup expectation for asynchronous test 28 | let expectation = self.expectation(description: "Command should change to .pause") 29 | 30 | // Change the command after 5 seconds 31 | DispatchQueue.main.asyncAfter(deadline: .now() + 3) { 32 | commandBinding.wrappedValue = .pause 33 | } 34 | 35 | // Periodically check if the command has changed 36 | let checkInterval = 0.1 // Check every 0.1 seconds 37 | var timeElapsed = 0.0 38 | Timer.scheduledTimer(withTimeInterval: checkInterval, repeats: true) { timer in 39 | if command == .pause { 40 | timer.invalidate() 41 | expectation.fulfill() 42 | } else if timeElapsed >= 5 { // Failsafe timeout 43 | timer.invalidate() 44 | XCTFail("Command did not change within the expected time") 45 | } 46 | timeElapsed += checkInterval 47 | } 48 | 49 | // Wait for the expectation to be fulfilled, or time out after 10 seconds 50 | waitForExpectations(timeout: 5, handler: nil) 51 | 52 | // Verify the command has indeed changed 53 | XCTAssertEqual(playerView.command, .pause, "Playback command should have updated to .pause") 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /Tests/swiftui-loop-videoplayerTests/testPlayerInitialization.swift: -------------------------------------------------------------------------------- 1 | // 2 | // testPlaybackCommandChangesOverTime.swift 3 | // 4 | // 5 | // Created by Igor Shelopaev on 16.08.24. 6 | // 7 | 8 | import XCTest 9 | import SwiftUI 10 | @testable import swiftui_loop_videoplayer 11 | import AVKit 12 | 13 | final class testPlayerInitialization: XCTestCase { 14 | 15 | // Test initialization with custom parameters 16 | func testInitializationWithCustomParameters() { 17 | let playbackCommand = PlaybackCommand.pause // Example of a non-default command 18 | let commandBinding = Binding.constant(playbackCommand) 19 | let playerView = ExtVideoPlayer( 20 | fileName: "swipe", 21 | ext: "mov", 22 | gravity: .resizeAspectFill, 23 | timePublishing: CMTime(seconds: 1.5, preferredTimescale: 600), 24 | command: commandBinding 25 | ) 26 | 27 | XCTAssertEqual(playerView.settings.name, "swipe") 28 | XCTAssertEqual(playerView.settings.ext, "mov") 29 | XCTAssertEqual(playerView.settings.gravity, .resizeAspectFill) 30 | XCTAssertEqual(playerView.settings.timePublishing?.seconds, 1.5) 31 | XCTAssertEqual(playerView.settings.timePublishing?.timescale, 600) 32 | XCTAssertEqual(playerView.command, playbackCommand) 33 | } 34 | 35 | // Test initialization with default parameters 36 | func testInitializationWithDefaultParameters() { 37 | let playerView = ExtVideoPlayer(fileName: "swipe") 38 | 39 | XCTAssertEqual(playerView.settings.name, "swipe") 40 | XCTAssertEqual(playerView.settings.ext, "mp4") // Default extension 41 | XCTAssertEqual(playerView.settings.gravity, .resizeAspect) // Default gravity 42 | XCTAssertNotNil(playerView.settings.timePublishing) // Default should not be nil 43 | XCTAssertEqual(playerView.settings.timePublishing?.seconds, 1) 44 | XCTAssertEqual(playerView.command, .play) // Default command 45 | } 46 | 47 | // Test the initializer that takes a closure returning VideoSettings 48 | func testExtPlayerView_InitializesWithValues() { 49 | let playerView = ExtVideoPlayer{ 50 | VideoSettings{ 51 | SourceName("swipe") 52 | Ext("mp8") // Set default extension here If not provided then mp4 is default 53 | Gravity(.resizeAspectFill) 54 | TimePublishing() 55 | } 56 | } 57 | XCTAssertEqual(playerView.settings.name, "swipe") 58 | XCTAssertEqual(playerView.settings.ext, "mp8") 59 | XCTAssertEqual(playerView.settings.gravity, .resizeAspectFill) 60 | XCTAssertNotEqual(playerView.settings.timePublishing, nil) 61 | XCTAssertEqual(playerView.command, .play) 62 | } 63 | 64 | // Test the initializer that takes a closure returning VideoSettings 65 | func testExtPlayerView_InitializesWithClosureProvidedSettings() { 66 | let playerView = ExtVideoPlayer { 67 | VideoSettings { 68 | SourceName("swipe") 69 | Ext("mp8") 70 | Gravity(.resizeAspectFill) 71 | TimePublishing(CMTime(seconds: 2, preferredTimescale: 600)) 72 | } 73 | } 74 | XCTAssertEqual(playerView.settings.name, "swipe") 75 | XCTAssertEqual(playerView.settings.ext, "mp8") 76 | XCTAssertEqual(playerView.settings.gravity, .resizeAspectFill) 77 | XCTAssertEqual(playerView.settings.timePublishing?.seconds, 2) 78 | XCTAssertEqual(playerView.command, .play) 79 | } 80 | 81 | // Test the initializer that takes a binding to VideoSettings 82 | func testExtPlayerView_InitializesWithBindingProvidedSettings() { 83 | let initialSettings = VideoSettings { 84 | SourceName("swipe") 85 | Ext("mkv") 86 | Gravity(.resizeAspect) 87 | TimePublishing(CMTime(seconds: 1, preferredTimescale: 600)) 88 | } 89 | let settings = Binding.constant(initialSettings) 90 | let playerView = ExtVideoPlayer(settings: settings, command: .constant(.pause)) 91 | 92 | XCTAssertEqual(settings.wrappedValue.name, "swipe") 93 | XCTAssertEqual(settings.wrappedValue.ext, "mkv") 94 | XCTAssertEqual(settings.wrappedValue.gravity, .resizeAspect) 95 | XCTAssertEqual(settings.wrappedValue.timePublishing?.seconds, 1) 96 | XCTAssertEqual(playerView.command, .pause) 97 | } 98 | 99 | } 100 | --------------------------------------------------------------------------------