├── .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://swiftpackageindex.com/swiftuiux/swiftui-loop-videoplayer)
5 | [](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 | 
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 | 
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 | 
481 |
482 | 
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 |
--------------------------------------------------------------------------------