diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..1600437 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [swiftui-loop-videoplayer] diff --git a/LICENSE b/LICENSE index 89562ad..a13e43d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Software Engineer +Copyright (c) 2023 Igor Shelopaev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Package.swift b/Package.swift index 4ba7c7f..2741e49 100644 --- a/Package.swift +++ b/Package.swift @@ -24,6 +24,9 @@ let package = Package( dependencies: []), .testTarget( name: "swiftui-loop-videoplayerTests", - dependencies: ["swiftui-loop-videoplayer"]), + dependencies: ["swiftui-loop-videoplayer"], + resources: [ + .process("Resources/swipe.mp4") + ]) ] ) diff --git a/README.md b/README.md index 5c02eff..04ce52f 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,433 @@ -# SwiftUI video player -### Please star the repository if you believe continuing the development of this package is worthwhile. This will help me understand which package deserves more effort. +# Coming soon: Metal shaders for video +iOS 14+, macOS 11+, tvOS 14+ -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FThe-Igor%2Fswiftui-loop-videoplayer%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/The-Igor/swiftui-loop-videoplayer) -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FThe-Igor%2Fswiftui-loop-videoplayer%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/The-Igor/swiftui-loop-videoplayer) +## ⭐ Star it, please — so I know it’s worth improving further. -`LoopPlayerView` is a SwiftUI component designed to loop video playback seamlessly on iOS, macOS, and tvOS platforms. This package uses a declarative approach to declare parameters for the video component based on building blocks. This implementation might give you some insights into how SwiftUI works under the hood. You can also pass parameters in the common way. +Support for applying **Metal shaders** directly to **video frames**. +Real-time effects on the timeline, no extra copies or heavy post-processing. + +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fswiftuiux%2Fswiftui-loop-videoplayer%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/swiftuiux/swiftui-loop-videoplayer) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fswiftuiux%2Fswiftui-loop-videoPlayer%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/swiftuiux/swiftui-loop-videoPlayer) + + +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**. + +### 🟩 Demo project showing video player usage and features: [follow the link](https://github.com/swiftuiux/swiftui-video-player-example) + + + +## Why if we have Apple’s VideoPlayer ?! + +Apple’s VideoPlayer offers a quick setup for video playback in SwiftUI. However, it does not allow you to hide or customize the default video controls UI, limiting its use in custom scenarios. Alternatively, you would need to use AVPlayerViewController, which includes a lot of functionality just to disable the controls. In contrast, this solution acts as a modular framework, allowing you to integrate only the functionalities you need while keeping the core component lightweight. It provides full control over playback, including the ability to add custom UI elements, making it ideal for background videos, tooltips, video hints, and other custom scenarios. Additionally, it supports advanced features such as subtitles, seamless looping, and real-time filter application. It also allows adding vector graphics to the video stream, accessing frame data for custom filtering, and **applying ML or AI algorithms to video processing**, enabling a wide range of enhancements and modifications etc. + +*This package uses a declarative approach to declare parameters for the video component based on building blocks. This implementation might give some insights into how SwiftUI works under the hood. You can also pass parameters in the common way. If you profile the package, do it on a real device.* + + ```swift + ExtVideoPlayer{ + VideoSettings{ + SourceName("swipe") + } + } +``` + +or + + ```swift + ExtVideoPlayer(fileName: 'swipe') +``` + +## Philosophy of Player Dynamics + +The player's functionality is designed around a dual ⇆ interaction model: + +- **Commands and Settings**: Through these, you instruct the player on what to do and how to do it. Settings define the environment and initial state, while commands offer real-time control. As for now, you can conveniently pass command by command; perhaps later I’ll add support for batch commands + +- **Event Feedback**: Through event handling, the player communicates back to the application, informing it of internal changes that may need attention. Due to the nature of media players, especially in environments with dynamic content or user interactions, the flow of events can become flooded. To manage this effectively and prevent the application from being overwhelmed by the volume of incoming events, the **system collects these events every second and returns them as a batch**. Pass `Events()` in the `settings` to enable event mechanism. + +## [Documentation(API)](https://swiftpackageindex.com/swiftuiux/swiftui-loop-videoplayer/main/documentation/swiftui_loop_videoplayer) + +![The concept](https://github.com/swiftuiux/swiftui-video-player-example/blob/main/swiftui-loop-videoplayer-example/img/swiftui_video_player.gif) + +## Implemented Specs +*The [sample app](https://github.com/swiftuiux/swiftui-video-player-example) demonstrates the majority of the specs implemented in the component* + +| **Feature Category** | **Feature Name** | **Description** | +|----------------------------|---------------------------------------------|----------------------------------------------------------------------------------------------------------| +| **General** | SwiftUI Declarative Syntax | Easily integrate using declarative syntax. | +| | Platform Compatibility | Supports iOS 14+, macOS 11+, tvOS 14+. | +| | Swift Compatibility | Alined with Swift 5 and ready for Swift 6 | +| | Loop Playback | Automatically restart videos when they end. | +| | Local and Remote Video URLs | Supports playback from local files or remote URLs. | +| | Adaptive HLS Streaming | Handles HLS streaming with dynamic quality adjustment. | +| | Error Handling | Customizable error messages and visual displays. | +| | Subtitle Support | Add external `.vtt` files or use embedded subtitle tracks. | +| | Custom Overlays | Add vector graphics and custom overlays over the video. | +| | Picture In Picture (PiP) | Picture-in-Picture (PiP) is supported on iOS and iPadOS | +| **Playback Commands** | Idle Command | Initialize without specific playback actions. | +| | Play/Pause | Control playback state. | +| | Seek Command | Move to specific video timestamps. | +| | Mute/Unmute | Toggle audio playback. | +| | Volume Control | Adjust audio levels. | +| | Playback Speed | Dynamically modify playback speed. | +| | Loop/Unloop | Toggle looping behavior. | +| | Apply Filters | Add Core Image filters to the video stream. | +| | Remove Filters | Clear all applied filters. | +| | Add Vector Graphics | Overlay custom vector graphics onto the video. | +| **Settings** | SourceName | Define video source (local or remote). | +| | File Extension | Default extension for video files (e.g., `.mp4`). | +| | Gravity | Set content resizing behavior (e.g., `.resizeAspect`). | +| | Time Publishing | Control playback time reporting intervals. | +| | AutoPlay | Toggle automatic playback on load. | +| | Mute by Default | Initialize playback without sound. | +| | Subtitle Integration | Configure subtitles from embedded tracks or external files. | +| **Visual Features** | Rounded Corners | Apply rounded corners using SwiftUI's `.mask` modifier. | +| | Overlay Graphics | Add vector graphics over video for custom effects. | +| | Brightness Adjustment | Control brightness levels dynamically. | +| | Contrast Adjustment | Modify video contrast in real time. | +| **Playback Features** | Adaptive HLS Streaming | Dynamic quality adjustment based on network speed. | +| | Seamless Item Transitions | Smooth transitions between video items. | +| | Multichannel Audio | Play Dolby Atmos, 5.1 surround, and spatial audio tracks. | +| | Subtitles and Captions | Support for multiple subtitle and caption formats. | +| **Event Handling** | Batch Event Processing | Collects and processes events in batches to avoid flooding. | +| | Playback State Events | `playing`, `paused`, `seek`, `duration(CMTime)`, etc. | +| | Current Item State | Detect when the current item changes or is removed. | +| | Volume Change Events | Listen for changes in volume levels. | +| **Testing & Development** | Unit Testing | Includes unit tests for core functionality. | +| | UI Testing | Integration of UI tests in the example app. | +| | Example Scripts | Automated testing scripts for easier test execution. | +| **Media Support** | File Types | `.mp4`, `.mov`, `.m4v`, `.3gp`, `.mkv` (limited support). | +| | Codecs | H.264, H.265 (HEVC), MPEG-4, AAC, MP3. | +| | Streaming Protocols | HLS (`.m3u8`) support for adaptive streaming. | + +### CornerRadius +You can reach out the effect simply via mask modifier + ```swift + ExtVideoPlayer( + settings : $settings, + command: $playbackCommand, + VideoSettings{ + SourceName("swipe") + } + ) + .mask{ + RoundedRectangle(cornerRadius: 25) + } + ``` + + ![CornerRadius effect video player swift](https://github.com/swiftuiux/swiftui-video-player-example/blob/main/swiftui-loop-videoplayer-example/img/cornerRadius.png) + +### By the way +[Perhaps that might be enough for your needs](https://github.com/swiftuiux/swiftui-loop-videoPlayer/issues/7#issuecomment-2341268743) + + + + +## Testing + +The package includes unit tests that cover key functionality. While not exhaustive, these tests help ensure the core components work as expected. UI tests are in progress and are being developed [in the example application](https://github.com/swiftuiux/swiftui-video-player-example). The run_tests.sh is an example script that automates testing by encapsulating test commands into a single executable file, simplifying the execution process. You can configure the script to run specific testing environment relevant to your projects. ## Disclaimer on Video Sources like YouTube -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. Always ensure the video URL is compliant with copyright laws and platform policies. +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. + +## API +| Property/Method | Type | Description | +|-------------------------------------------------------------|-------------------------------|------------------------------------------------------------------------------------------------------| +| `settings` | `Binding` | A binding to the video player settings, which configure various aspects of the player's behavior. | +| `command` | `Binding` | A binding to control playback actions, such as play, pause, or seek. | +| `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. | +| `init(settings: () -> VideoSettings, command:)` | Constructor | Initializes the player in a declarative way with a settings block and a playback command binding. | +| `init(settings: Binding, command:)` | Constructor | Initializes the player with bindings to the video settings and a playback command. | -## [SwiftUI video player example](https://github.com/The-Igor/swiftui-loop-videoplayer-example) -![The concept](https://github.com/The-Igor/swiftui-loop-videoplayer-example/blob/main/swiftui-loop-videoplayer-example/img/play_commands.gif) +## Settings -![The concept](https://github.com/The-Igor/swiftui-loop-videoplayer-example/blob/main/swiftui-loop-videoplayer-example/img/swiftui.gif) +| Name | Description | Default | +|---------------|-----------------------------------------------------------------------------------------------------|---------| +| **SourceName** | **Direct URL String** If the name represents a valid URL ( HTTP etc).
**Local File URL** If the name is a valid local file path (file:// scheme).
**Bundle Resource** It tries to locate the file in the main bundle using Bundle.main.url(forResource:withExtension:) | - | +| **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" | +| **Gravity** | How the video content should be resized to fit the player's bounds. | .resizeAspect | +| **Loop** | Whether the video should automatically restart when it reaches the end. If not explicitly passed, the video will not loop. | false | +| **Mute** | Indicates if the video should play without sound. | false | +| **NotAutoPlay** | Indicates if the video should not play after initialization. Notice that if you use `command` as a control flow for the player the start command should be `.idle` | false | +| **TimePublishing** | Specifies the interval at which the player publishes the current playback time. | Not Enabled | +| **Subtitles** | The URL or local filename of the WebVTT (.vtt) subtitles file to be merged with the video. With a AVMutableComposition approach that is used currently in the package, you cannot directly change the position or size of subtitles. AVFoundation’s built-in handling of “text” tracks simply renders them in a default style, without allowing additional layout options. Take a look on the implementation in the example app *Video8.swift* | Not Enabled | +| **EnableVector** | Use this struct to activate settings that allow the addition of vector-based overlays via commands. If it is not passed via settings, any commands to `addVector` or `removeAllVectors` will have no effect. | Not Enabled | +|**PictureInPicture**| Enable Picture-in-Picture (PiP) support. If not passed than any command like `startPiP` or `stopPiP` have no effect. Take a look the example app *Video11.swift*. It does not work on simulator. You can observe the feature only on real devices.| Not Enabled | +| **Events([.durationAny, .itemStatusChangedAny])** | If `Events()` is not passed in the `settings: VideoSettings`, the event mechanism is disabled. You can specify exactly which events you want to receive (e.g., .itemStatusChangedAny) or simply pass `Events()` to receive all available events. This setting was added to improve performance because events are emitted via @State changes, which trigger view updates. If you don’t need to observe any events, disabling them can significantly boost performance. Take a look on the implementation in the example app *Video8.swift* | Not Enabled | + +### Additional Notes on Settings + +- **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. + +- **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. -## API Specifications +- **Loop:** Whether the video should automatically restart when it reaches the end. If not explicitly passed, the video will not loop. + + +## Commands + +### Handling Commands + ```swift + @State public var playbackCommand: PlaybackCommand = .idle + ``` +`@State` updates are asynchronous and batched in SwiftUI. When you assign: + ```swift + playbackCommand = .play + playbackCommand = .pause + ``` +SwiftUI only registers the last assignment (`.pause`) in the same run loop cycle, ignoring `.play`. +To ensure .play is applied before .pause, you can use `Task` to schedule the second update on the next run loop iteration: + +**.play → .pause** + ```swift + playbackCommand = .play + + Task { + playbackCommand = .pause + } +``` +**.play → .pause → .play** + +```swift + playbackCommand = .play + + Task { + playbackCommand = .pause + Task { playbackCommand = .play } // This runs AFTER `.pause` + } +``` + +### Handling Sequential Similar Commands -| Property/Method | Type | Description | -|---------------------------------------|-------------------------------|------------------------------------------------------------------------| -| `settings` | `VideoSettings` | A struct containing configuration settings for the video player. | -| `command` | `Binding` | A binding to control playback actions (play, pause, or seek). | -| `init(fileName:ext:gravity:`
`eColor:eFontSize:command:)` | Constructor | Initializes the player with specific video parameters and playback command binding. | -| `init(settings: () -> VideoSettings, command:)` | Constructor | Initializes the player with a declarative settings block and playback command binding. | +When using the video player controls in your SwiftUI application, it's important to understand how command processing works. Specifically, issuing two identical commands consecutively will result in the second command being ignored. This is due to the underlying implementation that prevents redundant command execution to optimize performance and user experience in terms of UI updates. +### Common Scenario + +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. + +### Handling Similar Commands + +In cases where you need to re-issue a command that might appear redundant but is necessary under specific conditions, you must insert an `idle` command between the two similar commands. The `idle` command resets the command state of the player, allowing subsequent commands to be processed as new actions. + +**.play → .idle → .play** + +```swift + playbackCommand = .play + + Task { + playbackCommand = .idle + Task { playbackCommand = .play } // This runs AFTER `.idle` + } +``` ### Playback Commands | Command | Description | |-----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| +| `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` | | `play` | Command to play the video. | | `pause` | Command to pause the video. | -| `seek(to: Double)` | Command to seek to a specific time in the video. The parameter is the target position in seconds. If the time is negative, the playback will move to the start of the video. If the time exceeds the video's duration, the playback will move to the end of the video. If the time is within the video’s duration, the playback will move to the specified time. | +| `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. | | `begin` | Command to position the video at the beginning. | | `end` | Command to position the video at the end. | | `mute` | Command to mute the video. By default, the player is muted. | | `unmute` | Command to unmute the video. | | `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). | -| `subtitles(String?)` | Command to set subtitles to a specified language or turn them off. Pass a language code (e.g., "en" for English) to set subtitles, or `nil` to turn them off. | | `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. | | `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. | | `unloop` | Command to disable looping of the video playback. This command will only take effect if the video is currently being looped. | +| `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. | +| `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. | + +### Visual Adjustment Commands +| Command | Description | +|-----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| +| `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. | +| `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. | +| `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. | +| `removeAllFilters` | Command to remove all applied filters from the video playback. | -### Initializer Parameters Settings +### Vector Graphics Commands -| Name | Description | Default | -| --- | --- | --- | -| **SourceName** | The URL or local filename of the video. 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. | - | -| **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" | -| **Gravity** | How the video content should be resized to fit the player's bounds. | .resizeAspect | -| **EColor** | Error message text color. | .red | -| **EFontSize** | Size of the error text. | 17.0 | +| Command | Description | +|----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| +| `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. | +| `removeAllVectors` | Command to remove all vector graphic layers from the video stream. | +### Additional Notes on Vector Graphics Commands +- To use these commands, don’t forget to enable the Vector layer in settings via the EnableVector() setting. +- 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. +### Audio & Language Commands -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: +| Command | Description | +|-----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| +| `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). | +| `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. | + +### Additional Notes on the subtitles Command +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). + +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. +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. + +**Configuring HLS Playlist with English Subtitles** + +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. + +```plaintext +#EXTM3U +#EXT-X-MEDIA:TYPE=SUBTITLES, + GROUP-ID="subs", + NAME="English Subtitles", + LANGUAGE="en", + AUTOSELECT=YES, + DEFAULT=YES, + URI="subtitles_en.m3u8" + +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3000000, + RESOLUTION=1280x720, + SUBTITLES="subs" +video_main.m3u8 +``` + +## Player Events + + *If `Events()` is not passed in the `settings: VideoSettings`, the event mechanism is disabled. You can specify exactly which events you want to receive (e.g., .itemStatusChangedAny) or simply pass .all to receive all available events. This setting was added to improve performance because events are emitted via @State changes, which trigger view updates. If you don’t need to observe any events, disabling them can significantly boost performance.* + +| Event | Description | +|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| `seek(Bool, currentTime: Double)` | Represents an end seek action within the player. The first parameter (`Bool`) indicates whether the seek was successful, and the second parameter (`currentTime`) provides the time (in seconds) to which the player is seeking. | +| `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. | +| `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. | +| `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. | +| `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. | +| `currentItemRemoved` | Occurs when the player's `currentItem` is set to `nil`, indicating that the current media item has been removed from the player. | +| `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. | +| `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). | +| `boundsChanged(CGRect)` | Triggered when the bounds of the main layer change, allowing the developer to recalculate and update all vector layers within the CompositeLayer. | +| `startedPiP` | Event triggered when Picture-in-Picture (PiP) mode starts. | +| `stoppedPiP` | Event triggered when Picture-in-Picture (PiP) mode stops. | +| `itemStatusChanged(AVPlayerItem.Status)` | Indicates that the AVPlayerItem's status has changed. Possible statuses: `.unknown`, `.readyToPlay`, `.failed`. | +| `duration(CMTime)` | Provides the duration of the AVPlayerItem when it is ready to play. The duration is given in `CMTime`. | + +## Player event filter +`PlayerEventFilter` - this enum provides a structured way to filter `PlayerEvent` cases. I decided to introduce an additional structure that corresponds to `PlayerEvent`. All cases with parameters are replicated as *eventName***Any** to match any variation of that event, regardless of its associated values. If you need specific events that match certain event parameters, let me know, and I will add them. + +```swift + ExtVideoPlayer{ + VideoSettings{ + SourceName("swipe") + Events([.durationAny, .itemStatusChangedAny]) // *Events([PlayerEventFilter])* + } + } + .onPlayerEventChange { events in + // Here come only events [.durationAny, .itemStatusChangedAny]: any duration and any item status change events. + } +``` + +### Event filter table + +| **Filter** | **Description** | +|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| `seekAny` | Matches any `.seek(...)` case, regardless of whether the seek was successful (`Bool`) or the target seek time (`currentTime: Double`). | +| `paused` | Matches exactly the `.paused` event, which indicates that playback has been paused by the user or programmatically. | +| `waitingToPlayAtSpecifiedRate` | Matches exactly the `.waitingToPlayAtSpecifiedRate` event, which occurs when the player is buffering or waiting for sufficient data. | +| `playing` | Matches exactly the `.playing` event, indicating that the player is actively playing media. | +| `currentItemChangedAny` | Matches any `.currentItemChanged(...)` case, triggered when the player's `currentItem` is updated to a new media item. | +| `currentItemRemoved` | Matches exactly the `.currentItemRemoved` event, occurring when the player's `currentItem` is set to `nil`. | +| `errorAny` | Matches any `.error(...)` case, representing an error within the player, with a `VPErrors` enum indicating the specific issue. | +| `volumeChangedAny` | Matches any `.volumeChanged(...)` case, triggered when the player's volume level is adjusted. | +| `boundsChangedAny` | Matches any `.boundsChanged(...)` case, triggered when the bounds of the main layer change. | +| `startedPiP` | Matches exactly the `.startedPiP` event, triggered when Picture-in-Picture (PiP) mode starts. | +| `stoppedPiP` | Matches exactly the `.stoppedPiP` event, triggered when Picture-in-Picture (PiP) mode stops. | +| `itemStatusChangedAny` | Matches any `.itemStatusChanged(...)` case, indicating that the AVPlayerItem's status has changed (e.g., `.unknown`, `.readyToPlay`, `.failed`). | +| `durationAny` | Matches any `.duration(...)` case, which provides the duration of the media item when ready to play. | + + +### Additional Notes on Errors +When the URL is syntactically valid but the resource does not actually exist (e.g., a 404 response or an unreachable server), AVPlayerItem.status can remain .unknown indefinitely. It may never transition to .failed, and the .AVPlayerItemFailedToPlayToEndTime notification won’t fire if playback never starts. + +**Workarounds and Best Practices** +*Pre-Check the URL With HEAD* + +If you want to ensure that a URL is valid before passing it to the component (AVPlayerItem), use for example a simple HEAD request via URLSession to check for a valid 2xx response. + +```swift +func checkURLExists(_ url: URL) async throws -> Bool { + var request = URLRequest(url: url) + request.httpMethod = "HEAD" + + let (_, response) = try await URLSession.shared.data(for: request) + if let httpResponse = response as? HTTPURLResponse { + return (200...299).contains(httpResponse.statusCode) + } + return false +} +``` + +### Additional Notes on Adding and Removing Vector Graphics + +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. + +**Adding a Vector Layer**: + - The `addVector` command takes a `ShapeLayerBuilderProtocol` instance. This protocol defines the necessary method to build a `CAShapeLayer` based on the given geometry (frame, bounds). + - 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. + - The vector layer will be laid out directly over the video stream, allowing it to appear as part of the video playback experience. + +**Important Lifecycle Consideration**: +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. + +### Additional Notes on Brightness and Contrast + +- **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. +- **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. +- **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. -| Feature | Description | -|------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------| -| Hardware accelerator | `AVQueuePlayer` uses hardware acceleration by default where available. | -| 4k/HDR/HDR10/HDR10+/Dolby Vision | These high-definition and high-dynamic-range formats are natively supported by `AVQueuePlayer`. | -| Multichannel Audio/Dolby Atmos/Spatial Audio | `AVQueuePlayer` supports advanced audio formats natively. | -| Text subtitle/Image subtitle/Closed Captions | Subtitle and caption tracks included in the video file are automatically detected and rendered. | -| 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. | ## How to use the package ### 1. Create LoopPlayerView ```swift -LoopPlayerView(fileName: 'swipe') + ExtVideoPlayer(fileName: 'swipe') ``` or in a declarative way ```swift - LoopPlayerView{ + ExtVideoPlayer{ VideoSettings{ SourceName("swipe") + Subtitles("subtitles_eng") Ext("mp8") // Set default extension here If not provided then mp4 is default Gravity(.resizeAspectFill) - ErrorGroup{ - EColor(.accentColor) - EFontSize(27) - } - } - } -``` - - ```swift - LoopPlayerView{ - VideoSettings{ - SourceName("swipe") - Gravity(.resizeAspectFill) - EFontSize(27) + TimePublishing() + Events([.durationAny, .itemStatusChangedAny]) } } -``` + .onPlayerTimeChange { newTime in + // Current video playback time + } + .onPlayerEventChange { events in + // Player events + } +``` ```swift -LoopPlayerView{ +ExtVideoPlayer{ VideoSettings{ SourceName('https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8') - ErrorGroup{ - EFontSize(27) - } } } ``` -You can group error settings in group **ErrorGroup** or just pass all settings as a linear list of settings. You don't need to follow some specific order for settings, just pass in an arbitrary order you are interested in. The only required setting is now **SourceName**. +The only required setting is now **SourceName**. ### Supported Video Types and Formats -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. Hope they are all work well. +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. | Supported File Types | Supported Codecs | Supported Streaming Protocols | |--------------------------|------------------|-------------------------------------| @@ -133,14 +446,10 @@ The AVFoundation framework used in the package supports a wide range of video fo 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: ```swift -LoopPlayerView{ +ExtVideoPlayer{ VideoSettings{ SourceName('https://example.com/video') Gravity(.resizeAspectFill) // Video content fit - ErrorGroup{ - EColor(.red) // Error text color - EFontSize(18) // Error text font size - } } } ``` @@ -153,39 +462,65 @@ LoopPlayerView{ | Direct MP4 URLs | Yes | Directly accessible MP4 URLs can be used if they are hosted on servers that permit CORS. | | HLS Streams | Yes | HLS streams are supported and can be used for live streaming purposes. | -![The concept](https://github.com/The-Igor/swiftui-loop-videoplayer-example/blob/main/swiftui-loop-videoplayer-example/img/remote_video_player_swiftui.gif) -## New Functionality: Playback Commands +## Practical ideas for the package +You can introduce video hints about some functionality into the app, for example how to add positions to favorites. Put loop video hint into background or open as popup. -The package now supports playback commands, allowing you to control video playback actions such as play, pause, and seek to specific times. +![The concept](https://github.com/swiftuiux/swiftui-video-player-example/blob/main/swiftui-loop-videoplayer-example/img/swiftui_video_hint.gif) -```swift -struct VideoView: View { - @State private var playbackCommand: PlaybackCommand = .play - - var body: some View { - LoopPlayerView( - { - VideoSettings { - SourceName("swipe") - } - }, - command: $playbackCommand - ) - } -} -``` +![The concept](https://github.com/swiftuiux/swiftui-video-player-example/blob/main/swiftui-loop-videoplayer-example/img/tip_video_swiftui.gif) -![The concept](https://github.com/The-Igor/swiftui-loop-videoplayer-example/blob/main/swiftui-loop-videoplayer-example/img/play_commands.gif) +## HLS with Adaptive Quality -## Practical idea for the package -You can introduce video hints about some functionality into the app, for example how to add positions to favorites. Put loop video hint into background or open as popup. +### How Adaptive Quality Switching Works -![The concept](https://github.com/The-Igor/swiftui-loop-videoplayer-example/blob/main/swiftui-loop-videoplayer-example/img/swiftui_video_hint.gif) +1. **Multiple Bitrates** + - The video is encoded in multiple quality levels (e.g., 240p, 360p, 720p, 1080p), each with different bitrates. -![The concept](https://github.com/The-Igor/swiftui-loop-videoplayer-example/blob/main/swiftui-loop-videoplayer-example/img/tip_video_swiftui.gif) - -## Documentation(API) -- You need to have Xcode 13 installed in order to have access to Documentation Compiler (DocC) +2. **Manifest File** + - The server provides a manifest file: + - **In HLS**: A `.m3u8` file that contains links to video segments for each quality level. + +3. **Segments** + - The video is divided into short segments, typically 2–10 seconds long. + +4. **Dynamic Switching** + - The client (e.g., `AVQueuePlayer`) dynamically adjusts playback quality based on the current internet speed: + - Starts playback with the most suitable quality. + - Switches to higher or lower quality during playback as the connection speed changes. + +### Why This is the Best Option + +- **On-the-fly quality adjustment**: Ensures smooth transitions between quality levels without interrupting playback. +- **Minimal pauses and interruptions**: Reduces buffering and improves user experience. +- **Bandwidth efficiency**: The server sends only the appropriate stream, saving network traffic. + +## AVQueuePlayer features out of the box + +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: + +| Feature | Description | +|------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| +| **Hardware accelerator** | `AVQueuePlayer` uses hardware acceleration by default where available. | +| **4k/HDR/HDR10/HDR10+/Dolby Vision** | These high-definition and high-dynamic-range formats are natively supported by `AVQueuePlayer`. | +| **Multichannel Audio/Dolby Atmos/Spatial Audio** | `AVQueuePlayer` supports advanced audio formats natively. | +| **Text subtitle/Image subtitle/Closed Captions** | Subtitle and caption tracks included in the video file are automatically detected and rendered. | +| **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. | +| **External playback control support** | Supports playback control through external accessories like headphones and Bluetooth devices. | +| **AirPlay support** | Natively supports streaming audio and video via AirPlay to compatible devices without additional setup. | +| **Background Audio Playback** | Continues audio playback when the app is in the background, provided the appropriate audio session category is set. | +| **Picture-in-Picture (PiP) Support** | Enables Picture-in-Picture mode on compatible devices without additional setup. | +| **HLS (HTTP Live Streaming) Support** | Natively supports streaming of HLS content for live and on-demand playback. | +| **FairPlay DRM Support** | Can play FairPlay DRM-protected content. | +| **Now Playing Info Center Integration** | Automatically updates the Now Playing Info Center with current playback information for lock screen and control center displays. | +| **Remote Control Event Handling** | Supports handling remote control events from external accessories and system controls. | +| **Custom Playback Rate** | Allows setting custom playback rates for slow-motion or fast-forward playback without additional configuration. | +| **Seamless Transition Between Items** | Provides smooth transitions between queued media items, ensuring continuous playback without gaps. | +| **Automatic Audio Session Management** | Manages audio sessions to handle interruptions (like phone calls) and route changes appropriately. | +| **Subtitles and Closed Caption Styling** | Supports user preferences for styling subtitles and closed captions, including font size, color, and background. | +| **Audio Focus and Ducking** | Handles audio focus by pausing or lowering volume when necessary, such as when a navigation prompt plays. | +| **Metadata Handling** | Reads and displays metadata embedded in media files, such as song titles, artists, and artwork. | +| **Buffering and Caching** | Efficiently manages buffering of streaming content to reduce playback interruptions. | +| **Error Handling and Recovery** | Provides built-in mechanisms to handle playback errors and attempt recovery without crashing the application. | +| **Accessibility Features** | Supports VoiceOver and other accessibility features to make media content accessible to all users. | -- Go to Product > Build Documentation or **⌃⇧⌘ D** diff --git a/Sources/swiftui-loop-videoplayer/ExtVideoPlayer.swift b/Sources/swiftui-loop-videoplayer/ExtVideoPlayer.swift new file mode 100644 index 0000000..a0421f5 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/ExtVideoPlayer.swift @@ -0,0 +1,141 @@ +// +// PlayerView.swift +// +// +// Created by Igor Shelopaev on 10.02.2023. +// + +import SwiftUI +import Combine +#if canImport(AVKit) +import AVKit +#endif + +/// Player view for running a video in loop +@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) +public struct ExtVideoPlayer: View{ + + /// Set of settings for video the player + @Binding public var settings: VideoSettings + + /// Binding to a playback command that controls playback actions + @Binding public var command: PlaybackCommand + + /// The current playback time, represented as a Double. + @State private var currentTime: Double = 0.0 + + /// The current state of the player event, + @State private var playerEvent: [PlayerEvent] = [] + + /// A publisher that emits the current playback time as a `Double`. It is initialized privately within the view. + @State private var timePublisher = PassthroughSubject() + + /// A publisher that emits player events as `PlayerEvent` values. It is initialized privately within the view. + @State private var eventPublisher = PassthroughSubject() + + // MARK: - Life cycle + + /// Player initializer + /// - Parameters: + /// - fileName: The name of the video file. + /// - ext: The file extension, with a default value of "mp4". + /// - gravity: The video gravity setting, with a default value of `.resizeAspect`. + /// - timePublishing: An optional `CMTime` value for time publishing, with a default value of 1 second. + /// - command: A binding to the playback command, with a default value of `.play`. + public init( + fileName: String, + ext: String = "mp4", + gravity: AVLayerVideoGravity = .resizeAspect, + timePublishing : CMTime? = CMTime(seconds: 1, preferredTimescale: 600), + command : Binding = .constant(.play) + ) { + self._command = command + + func description(@SettingsBuilder content: () -> [Setting]) -> [Setting] { + return content() + } + + let settings: VideoSettings = VideoSettings { + SourceName(fileName) + Ext(ext) + Gravity(gravity) + if let timePublishing{ + timePublishing + } + } + + _settings = .constant(settings) + } + + /// Player initializer in a declarative way + /// - Parameters: + /// - settings: Set of settings + /// - command: A binding to control playback actions + public init( + _ settings: () -> VideoSettings, + command: Binding = .constant(.play) + ) { + + self._command = command + _settings = .constant(settings()) + } + + /// Player initializer in a declarative way + /// - Parameters: + /// - settings: A binding to the set of settings for the video player + /// - command: A binding to control playback actions + public init( + settings: Binding, + command: Binding = .constant(.play) + ) { + self._settings = settings + self._command = command + } + + // MARK: - API + + /// The body property defines the view hierarchy for the user interface. + public var body: some View { + ExtPlayerMultiPlatform( + settings: $settings, + command: $command, + timePublisher: timePublisher, + eventPublisher: eventPublisher + ) + .onReceive(timePublisher.receive(on: DispatchQueue.main), perform: { time in + currentTime = time + }) + .onReceive(eventPublisher + .collect(.byTime(DispatchQueue.main, .seconds(1))), perform: { event in + playerEvent = filterEvents(with: settings, for: event) + }) + .preference(key: CurrentTimePreferenceKey.self, value: currentTime) + .preference(key: PlayerEventPreferenceKey.self, value: playerEvent) + } +} + +// MARK: - Fileprivate + +/// Filters a list of `PlayerEvent` instances based on the provided `VideoSettings`. +/// +/// - Parameters: +/// - settings: The video settings containing event filters. +/// - events: The list of events to be filtered. +/// - Returns: A filtered list of `PlayerEvent` that match at least one filter in `settings`. +fileprivate func filterEvents(with settings: VideoSettings, for events: [PlayerEvent]) -> [PlayerEvent] { + let filters = settings.events // `[PlayerEventFilter]` + + // If no filters are provided, return an empty array. + guard let filters else { + return [] + } + + guard !filters.isEmpty else{ + return events + } + + // Keep each `PlayerEvent` only if it matches *at least* one filter in `filters`. + return events.filter { event in + filters.contains(event) + } +} diff --git a/Sources/swiftui-loop-videoplayer/LoopPlayerView.swift b/Sources/swiftui-loop-videoplayer/LoopPlayerView.swift deleted file mode 100644 index bf3afba..0000000 --- a/Sources/swiftui-loop-videoplayer/LoopPlayerView.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// PlayerView.swift -// -// -// Created by Igor on 10.02.2023. -// - -import SwiftUI -#if canImport(AVKit) -import AVKit -#endif - -/// Player view for running a video in loop -@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) -public struct LoopPlayerView: View { - - /// Set of settings for video the player - @Binding public var settings: VideoSettings - - /// Binding to a playback command that controls playback actions - @Binding public var command: PlaybackCommand - - private var videoId : String{ - [settings.name, settings.ext].joined(separator: ".") - } - - // MARK: - Life cycle - - /// Player initializer - /// - Parameters: - /// - fileName: Name of the video to play - /// - ext: Video extension - /// - gravity: A structure that defines how a layer displays a player’s visual content within the layer’s bounds - /// - eColor: Color of the error message text if the file is not found - /// - eFontSize: Size of the error text - /// - command: A binding to control playback actions - public init( - fileName: String, - ext: String = "mp4", - gravity: AVLayerVideoGravity = .resizeAspect, - eColor: Color = .accentColor, - eFontSize: CGFloat = 17.0, - command : Binding = .constant(.play) - ) { - self._command = command - - _settings = .constant( - VideoSettings { - SourceName(fileName) - Ext(ext) - Gravity(gravity) - ErrorGroup { - EColor(eColor) - EFontSize(eFontSize) - } - } - ) - } - - /// Player initializer in a declarative way - /// - Parameters: - /// - settings: Set of settings - /// - command: A binding to control playback actions - public init( - _ settings: () -> VideoSettings, - command: Binding = .constant(.play) - ) { - - self._command = command - _settings = .constant(settings()) - } - - /// Player initializer in a declarative way - /// - Parameters: - /// - settings: A binding to the set of settings for the video player - /// - command: A binding to control playback actions - public init( - settings: Binding, - command: Binding = .constant(.play) - ) { - self._settings = settings - self._command = command - } - - // MARK: - API - - public var body: some View { - LoopPlayerMultiPlatform(settings: $settings, command: $command) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} diff --git a/Sources/swiftui-loop-videoplayer/enum/PlaybackCommand.swift b/Sources/swiftui-loop-videoplayer/enum/PlaybackCommand.swift index 3f00d57..1e8dc80 100644 --- a/Sources/swiftui-loop-videoplayer/enum/PlaybackCommand.swift +++ b/Sources/swiftui-loop-videoplayer/enum/PlaybackCommand.swift @@ -1,8 +1,22 @@ +// +// PlaybackCommand.swift +// +// +// Created by Igor Shelopaev on 05.08.24. +// + import AVFoundation +#if canImport(CoreImage) +import CoreImage +#endif /// An enumeration of possible playback commands. @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) public enum PlaybackCommand: Equatable { + + /// The idle command to do nothing + case idle + /// Command to play the video. case play @@ -10,8 +24,14 @@ public enum PlaybackCommand: Equatable { case pause /// Command to seek to a specific time in the video. - /// - Parameter time: The CMTime representing the target position to seek to in the video. - case seek(to: Double) + /// + /// This case allows seeking to a specified time position in the video and optionally starts playback immediately. + /// + /// - Parameters: + /// - time: The target time position in the video, specified in seconds. + /// - play: A Boolean value indicating whether playback should automatically resume after seeking. + /// Defaults to `true`. + case seek(to: Double, play: Bool = true) /// Command to position the video at the beginning. case begin @@ -25,44 +45,101 @@ public enum PlaybackCommand: Equatable { /// Command to unmute the video. case unmute - /// Command to set the volume of the video playback. - /// - Parameter volume: A `Float` value between 0.0 (mute) and 1.0 (full volume). + /// Command to adjust the volume of the video playback. + /// - Parameter volume: A `Float` value between 0.0 (mute) and 1.0 (full volume). Values outside this range will be clamped. case volume(Float) /// Command to set subtitles for the video playback to a specified language or to turn them off. - /// - Parameter language: The language code (e.g., "en" for English) for the desired subtitles. - /// Pass `nil` to turn off subtitles. + /// - Parameter language: The language code for the desired subtitles, pass `nil` to turn subtitles off. case subtitles(String?) - /// Command to set the playback speed of the video playback. - /// - 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). + /// Command to adjust the playback speed of the video. + /// - 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. case playbackSpeed(Float) /// Command to enable looping of the video playback. + /// Looping is assumed to be enabled by default. case loop /// Command to disable looping of the video playback. + /// Only affects playback if looping is currently active. case unloop + /// Command to adjust the brightness of the video playback. + /// - Parameter brightness: A `Float` value typically ranging from -1.0 to 1.0. + case brightness(Float) + + /// Command to adjust the contrast of the video playback. + /// - Parameter contrast: A `Float` value typically ranging from 0.0 to 4.0. + case contrast(Float) + + /// Command to apply a specific Core Image filter to the video. + /// - Parameters: + /// - filter: A `CIFilter` object representing the filter to be applied. + /// - clear: A Boolean value indicating whether to clear the existing filter stack before applying this filter. + /// This filter is added to the current stack of filters, allowing for multiple filters to be combined and applied sequentially, unless `clear` is true. + case filter(CIFilter, clear: Bool = false) + + /// Command to remove all applied filters from the video playback. + case removeAllFilters + + /// Represents a command to create and possibly clear existing vectors using a shape layer builder. + /// - Parameters: + /// - builder: An instance conforming to `ShapeLayerBuilderProtocol` which will provide the shape layer. + /// - clear: A Boolean value that determines whether existing vector graphics should be cleared before applying the new vector. Defaults to `false`. + case addVector(any ShapeLayerBuilderProtocol, clear: Bool = false) + + /// Represents a command to remove all vector graphics from the current view or context. + case removeAllVectors + + /// Command to select a specific audio track based on language code. + /// - Parameter languageCode: The language code (e.g., "en" for English) of the desired audio track. + case audioTrack(languageCode: String) + + #if os(iOS) + /// 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. + case startPiP + + /// 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. + case stopPiP + + #endif + public static func == (lhs: PlaybackCommand, rhs: PlaybackCommand) -> Bool { switch (lhs, rhs) { - case (.play, .play), - (.pause, .pause), - (.begin, .begin), - (.end, .end), - (.mute, .mute), - (.unmute, .unmute), - (.loop, .loop), - (.unloop, .unloop): + case (.idle, .idle), (.play, .play), (.pause, .pause), (.begin, .begin), (.end, .end), + (.mute, .mute), (.unmute, .unmute), (.loop, .loop), (.unloop, .unloop), + (.removeAllFilters, .removeAllFilters), (.removeAllVectors, .removeAllVectors): + return true + #if os(iOS) + case (.startPiP, .startPiP), (.stopPiP, .stopPiP): return true - case (.seek(let lhsTime), .seek(let rhsTime)): - return lhsTime == rhsTime + #endif + case (.seek(let lhsTime, let lhsPlay), .seek(let rhsTime, let rhsPlay)): + return lhsTime == rhsTime && lhsPlay == rhsPlay + case (.volume(let lhsVolume), .volume(let rhsVolume)): return lhsVolume == rhsVolume + case (.subtitles(let lhsLanguage), .subtitles(let rhsLanguage)): return lhsLanguage == rhsLanguage + case (.playbackSpeed(let lhsSpeed), .playbackSpeed(let rhsSpeed)): return lhsSpeed == rhsSpeed + + case (.brightness(let lhsBrightness), .brightness(let rhsBrightness)): + return lhsBrightness == rhsBrightness + + case (.contrast(let lhsContrast), .contrast(let rhsContrast)): + return lhsContrast == rhsContrast + + case (.audioTrack(let lhsCode), .audioTrack(let rhsCode)): + return lhsCode == rhsCode + + case (.filter(let lhsFilter, let lhsClear), .filter(let rhsFilter, let rhsClear)): + return lhsFilter == rhsFilter && lhsClear == rhsClear + case let (.addVector(lhsBuilder, lhsClear), .addVector(rhsBuilder, rhsClear)): + return lhsBuilder.id == rhsBuilder.id && lhsClear == rhsClear default: return false } diff --git a/Sources/swiftui-loop-videoplayer/enum/PlayerEvent.swift b/Sources/swiftui-loop-videoplayer/enum/PlayerEvent.swift new file mode 100644 index 0000000..0398f7b --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/enum/PlayerEvent.swift @@ -0,0 +1,123 @@ +// +// PlayerEvents.swift +// +// +// Created by Igor Shelopaev on 15.08.24. +// + +import Foundation +import AVFoundation + +/// An enumeration representing various events that can occur within a media player. +@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) +public enum PlayerEvent: Equatable { + + /// Represents an end seek action within the player. + /// - Parameters: + /// - Bool: Indicates whether the seek was successful. + /// - currentTime: The time (in seconds) to which the player is seeking. + case seek(Bool, currentTime: Double) + + /// 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. + case paused + + /// 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. + case waitingToPlayAtSpecifiedRate + + /// 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. + case playing + + /// Indicates that the player has switched to a new item. + /// + /// This event is triggered when the player's `currentItem` changes to a new `AVPlayerItem`. + /// - Parameter newItem: The new `AVPlayerItem` that the player has switched to. + case currentItemChanged(newItem: AVPlayerItem?) + + /// Indicates that the player has removed the current item. + /// + /// This event is triggered when the player's `currentItem` is set to `nil`, meaning that there + /// is no media item currently loaded in the player. + case currentItemRemoved + + /// Indicates that the player's volume has changed. + /// + /// This event is triggered when the player's `volume` property is adjusted. + /// - Parameter newVolume: The new volume level, ranging from 0.0 (muted) to 1.0 (full volume). + case volumeChanged(newVolume: Float) + + /// Represents a case where a specific VPErrors type error is encountered. + /// + /// - Parameter VPErrors: The error from the VPErrors enum associated with this case. + case error(VPErrors) + + /// Event triggered when the bounds of the video player change. + /// - Parameter CGRect: The new bounds of the video player. + case boundsChanged(CGRect) + + /// Event triggered when Picture-in-Picture (PiP) mode starts. + case startedPiP + + /// Event triggered when Picture-in-Picture (PiP) mode stops. + case stoppedPiP + + /// Indicates that the AVPlayerItem's status has changed. + /// - Parameter status: The new status of the AVPlayerItem. + case itemStatusChanged(AVPlayerItem.Status) + + /// Provides the duration of the AVPlayerItem when it is ready to play. + /// - Parameter duration: The total duration of the media item in `CMTime`. + case duration(CMTime) +} + +extension PlayerEvent: CustomStringConvertible { + public var description: String { + switch self { + case .seek(let success, _): + return success ? "SeekSuccess" : "SeekFail" + case .paused: + return "Paused" + case .waitingToPlayAtSpecifiedRate: + return "Waiting" + case .playing: + return "Playing" + case .currentItemChanged(_): + return "ItemChanged" + case .currentItemRemoved: + return "ItemRemoved" + case .volumeChanged(_): + return "VolumeChanged" + case .error(let e): + return "\(e.description)" + case .boundsChanged(let bounds): + return "Bounds changed \(bounds)" + case .startedPiP: + return "Started PiP" + case .stoppedPiP: + return "Stopped PiP" + case .itemStatusChanged(let status): + switch status { + case .unknown: + return "Status: Unknown" + case .readyToPlay: + return "Status: ReadyToPlay" + case .failed: + return "Status: FailedToLoad" + @unknown default: + return "Unknown status" + } + case .duration(let value): + let roundedString = String(format: "%.0f", value.seconds) + return "Duration \(roundedString) sec" + } + } +} diff --git a/Sources/swiftui-loop-videoplayer/enum/PlayerEventFilter.swift b/Sources/swiftui-loop-videoplayer/enum/PlayerEventFilter.swift new file mode 100644 index 0000000..bb0b711 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/enum/PlayerEventFilter.swift @@ -0,0 +1,108 @@ +// +// PlayerEventFilter.swift +// swiftui-loop-videoplayer +// +// Created by Igor Shelopaev on 12.02.25. +// + +import Foundation + +/// A "parallel" structure for filtering PlayerEvent. +/// Each case here: +/// 1) Either ignores associated values (xxxAny) +/// 2) Or matches cases that have no associated values in PlayerEvent. +@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) +public enum PlayerEventFilter { + /// Matches any `.seek(...)` case, regardless of Bool or currentTime + case seekAny + + /// Matches `.paused` exactly (no associated values) + case paused + + /// Matches `.waitingToPlayAtSpecifiedRate` (no associated values) + case waitingToPlayAtSpecifiedRate + + /// Matches `.playing` (no associated values) + case playing + + /// Matches any `.currentItemChanged(...)` case + case currentItemChangedAny + + /// Matches `.currentItemRemoved` exactly (no associated values) + case currentItemRemoved + + /// Matches any `.volumeChanged(...)` case + case volumeChangedAny + + /// Matches any `.error(...)` case + case errorAny + + /// Matches any `.boundsChanged(...)` case + case boundsChangedAny + + /// Matches `.startedPiP` (no associated values) + case startedPiP + + /// Matches `.stoppedPiP` (no associated values) + case stoppedPiP + + /// Matches any `.itemStatusChanged(...)` case + case itemStatusChangedAny + + /// Matches any `.duration(...)` case + case durationAny +} + +extension PlayerEventFilter { + /// Checks whether a given `PlayerEvent` matches this filter. + /// + /// - Parameter event: The `PlayerEvent` to inspect. + /// - Returns: `true` if the event belongs to this case (ignoring parameters), `false` otherwise. + func matches(_ event: PlayerEvent) -> Bool { + switch (self, event) { + // Compare by case name only, ignoring associated values + case (.seekAny, .seek): + return true + case (.paused, .paused): + return true + case (.waitingToPlayAtSpecifiedRate, .waitingToPlayAtSpecifiedRate): + return true + case (.playing, .playing): + return true + case (.currentItemChangedAny, .currentItemChanged): + return true + case (.currentItemRemoved, .currentItemRemoved): + return true + case (.volumeChangedAny, .volumeChanged): + return true + case (.errorAny, .error): + return true + case (.boundsChangedAny, .boundsChanged): + return true + case (.startedPiP, .startedPiP): + return true + case (.stoppedPiP, .stoppedPiP): + return true + case (.itemStatusChangedAny, .itemStatusChanged): + return true + case (.durationAny, .duration): + return true + + // Default fallback: no match + default: + return false + } + } +} + +extension Collection where Element == PlayerEventFilter { + /// Checks whether any filter in this collection matches the given `PlayerEvent`. + /// + /// - Parameter event: The `PlayerEvent` to test. + /// - Returns: `true` if at least one `PlayerEventFilter` in this collection matches the `event`; otherwise, `false`. + func contains(_ event: PlayerEvent) -> Bool { + return self.contains { filter in + filter.matches(event) + } + } +} diff --git a/Sources/swiftui-loop-videoplayer/enum/Setting.swift b/Sources/swiftui-loop-videoplayer/enum/Setting.swift index c861ca4..4c7c341 100644 --- a/Sources/swiftui-loop-videoplayer/enum/Setting.swift +++ b/Sources/swiftui-loop-videoplayer/enum/Setting.swift @@ -11,41 +11,60 @@ import SwiftUI import AVKit #endif - -/// Settings for loop video player +/// Configuration settings for a loop video player. +/// These settings control various playback and UI behaviors. @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) -public enum Setting: Equatable{ - - /// File name +public enum Setting: Equatable, SettingsConvertible { + + /// Converts the current setting into an array containing itself. + /// - Returns: An array with a single instance of `Setting`. + public func asSettings() -> [Setting] { + [self] + } + + /// Event filters to monitor specific player events. + case events([PlayerEventFilter]?) + + /// Enables a vector layer for overlaying vector graphics. + case vector + + /// Enables looping of the video playback. + case loop + + /// Mutes the video. + case mute + + /// Prevents automatic playback after initialization. + case notAutoPlay + + /// Specifies the file name of the video. case name(String) - /// File extension + /// Specifies the file extension of the video. case ext(String) - /// Video gravity - case gravity(AVLayerVideoGravity = .resizeAspect) + /// Sets subtitles for the video. + case subtitles(String) + + /// Enables Picture-in-Picture (PiP) mode support. + case pictureInPicture - /// Error text is resource is not found - case errorText(String) + /// Defines the interval at which the player's current time should be published. + case timePublishing(CMTime) - /// Size of the error text - case errorFontSize(CGFloat) - - /// Color of the error text - case errorColor(Color) - - /// Case name + /// Sets the video gravity (e.g., aspect fit, aspect fill). + case gravity(AVLayerVideoGravity = .resizeAspect) + + /// Retrieves the name of the current case. var caseName: String { Mirror(reflecting: self).children.first?.label ?? "\(self)" } - - /// Associated value + + /// Retrieves the associated value of the case, if any. var associatedValue: Any? { - guard let firstChild = Mirror(reflecting: self).children.first else { return nil } - return firstChild.value } } diff --git a/Sources/swiftui-loop-videoplayer/enum/VPErrors.swift b/Sources/swiftui-loop-videoplayer/enum/VPErrors.swift index 541316b..0adb444 100644 --- a/Sources/swiftui-loop-videoplayer/enum/VPErrors.swift +++ b/Sources/swiftui-loop-videoplayer/enum/VPErrors.swift @@ -22,23 +22,57 @@ public enum VPErrors: Error, CustomStringConvertible, Sendable { /// Error case for when settings are not unique. case settingsNotUnique + /// Picture-in-Picture (PiP) is not supported. + case notSupportedPiP + + /// Failed to load. + /// - Parameter error: The error encountered during loading. + case failedToLoad(Error?) + /// A description of the error, suitable for display. public var description: String { switch self { - /// Returns a description indicating that the specified file was not found. - /// - Parameter name: The name of the file that was not found. - case .sourceNotFound(let name): - return "Source not found: \(name)" - - /// Returns a description indicating that the settings are not unique. - case .settingsNotUnique: - return "Settings are not unique" - - /// Returns a description indicating a playback error with the remote video. - /// - Parameter error: The error that occurred during remote video playback. - case .remoteVideoError(let error): - return "Playback error: \(String(describing: error?.localizedDescription))" + case .sourceNotFound(let name): + return "Source not found: \(name)" + + case .notSupportedPiP: + return "Picture-in-Picture (PiP) is not supported on this device." + + case .settingsNotUnique: + return "Settings are not unique." + + case .remoteVideoError(let error): + return "Playback error: \(error?.localizedDescription ?? "Unknown error.")" + + case .failedToLoad(let error): + return "Failed to load the video: \(error?.localizedDescription ?? "Unknown error.")" + } + } +} + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) +extension VPErrors: Equatable { + + /// Compares two `VPErrors` instances for equality based on specific error conditions. + public static func ==(lhs: VPErrors, rhs: VPErrors) -> Bool { + switch (lhs, rhs) { + case (.remoteVideoError(let a), .remoteVideoError(let b)): + return a?.localizedDescription == b?.localizedDescription + + case (.sourceNotFound(let a), .sourceNotFound(let b)): + return a == b + + case (.settingsNotUnique, .settingsNotUnique): + return true + + case (.notSupportedPiP, .notSupportedPiP): + return true + + case (.failedToLoad(let a), .failedToLoad(let b)): + return a?.localizedDescription == b?.localizedDescription + + default: + return false } } } - diff --git a/Sources/swiftui-loop-videoplayer/ext+/Array+.swift b/Sources/swiftui-loop-videoplayer/ext+/Array+.swift index 0b172a2..606cd14 100644 --- a/Sources/swiftui-loop-videoplayer/ext+/Array+.swift +++ b/Sources/swiftui-loop-videoplayer/ext+/Array+.swift @@ -16,7 +16,6 @@ extension Array where Element == Setting{ self.first(where: { $0.caseName == name }) } - /// Fetch associated value /// - Parameters: /// - name: Case name diff --git a/Sources/swiftui-loop-videoplayer/ext+/CMTime+.swift b/Sources/swiftui-loop-videoplayer/ext+/CMTime+.swift new file mode 100644 index 0000000..b25a068 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/ext+/CMTime+.swift @@ -0,0 +1,20 @@ +// +// CMTime+.swift +// +// +// Created by Igor Shelopaev on 15.08.24. +// + +#if canImport(AVKit) +import AVKit +#endif + +/// Extends `CMTime` to conform to the `SettingsConvertible` protocol. +extension CMTime : SettingsConvertible { + + /// Converts the `CMTime` instance into a settings array containing a time publishing setting. + /// - Returns: An array of `Setting` with the `timePublishing` case initialized with this `CMTime` instance. + public func asSettings() -> [Setting] { + [.timePublishing(self)] + } +} diff --git a/Sources/swiftui-loop-videoplayer/ext+/URL+.swift b/Sources/swiftui-loop-videoplayer/ext+/URL+.swift index efabad0..0186998 100644 --- a/Sources/swiftui-loop-videoplayer/ext+/URL+.swift +++ b/Sources/swiftui-loop-videoplayer/ext+/URL+.swift @@ -7,24 +7,29 @@ import Foundation -/// Validates a string as a well-formed HTTP or HTTPS URL and returns a URL object if valid. -/// -/// - Parameter urlString: The string to validate as a URL. -/// - Returns: An optional URL object if the string is a valid URL. -/// - Throws: An error if the URL is not valid or cannot be created. -extension URL { - static func validURLFromString(_ string: String) -> URL? { - let pattern = "^(https?:\\/\\/)(([a-zA-Z0-9-]+\\.)*[a-zA-Z0-9-]+\\.[a-zA-Z]{2,})(:\\d{1,5})?(\\/[\\S]*)?$" - let regex = try? NSRegularExpression(pattern: pattern, options: []) - - let matches = regex?.matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) - guard let _ = matches, !matches!.isEmpty else { - // If no matches are found, the URL is not valid - return nil +extension URL { + + /// Validates and returns an HTTP/HTTPS URL or nil. + /// Strategy: + /// 1) Parse once to detect an existing scheme (mailto, ftp, etc.). + /// 2) If a scheme exists and it's not http/https -> reject. + static func validURLFromString(from raw: String) -> URL? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + // First parse to detect an existing scheme. + if let pre = URLComponents(string: trimmed), let scheme = pre.scheme?.lowercased() { + // Reject anything that is not http/https. + guard scheme == "http" || scheme == "https" else { return nil } + + let comps = pre + // Require a host + guard let host = comps.host, !host.isEmpty else { return nil } + // Validate port range + if let port = comps.port, !(1...65535).contains(port) { return nil } + return comps.url } - // If a match is found, attempt to create a URL object - return URL(string: string) + return nil } } diff --git a/Sources/swiftui-loop-videoplayer/fn/fn+.swift b/Sources/swiftui-loop-videoplayer/fn/fn+.swift index 8304b54..a9204a3 100644 --- a/Sources/swiftui-loop-videoplayer/fn/fn+.swift +++ b/Sources/swiftui-loop-videoplayer/fn/fn+.swift @@ -7,25 +7,82 @@ import Foundation import AVFoundation +#if canImport(CoreImage) +import CoreImage +#endif /// Retrieves an `AVURLAsset` based on specified video settings. -/// - Parameter settings: The `VideoSettings` object containing details like name and extension of the video. -/// - Returns: An optional `AVURLAsset`. Returns `nil` if the video cannot be located either by URL or in the app bundle. +/// - Parameter settings: A `VideoSettings` object containing the video name and extension. +/// - Returns: An optional `AVURLAsset`. Returns `nil` if a valid URL cannot be created or the file cannot be found in the bundle. func assetFor(_ settings: VideoSettings) -> AVURLAsset? { let name = settings.name - let ext = settings.ext + // If the name already includes an extension, use that; otherwise, use `settings.ext`. + let ext = extractExtension(from: name) ?? settings.ext - // Attempt to create a URL directly from the provided video name string - if let url = URL.validURLFromString(name) { + // Leverage the common helper to construct the `AVURLAsset`. + return assetFrom(name: name, fileExtension: ext) +} + +/// Retrieves an `AVURLAsset` for the subtitles specified in `VideoSettings`. +/// - Parameter settings: A `VideoSettings` object containing the subtitle file name. +/// - Returns: An optional `AVURLAsset` for the subtitle file, or `nil` if `subtitles` is empty or cannot be found. +func subtitlesAssetFor(_ settings: VideoSettings) -> AVURLAsset? { + let subtitleName = settings.subtitles + // If no subtitle name is provided, early return `nil`. + guard !subtitleName.isEmpty else { + return nil + } + + // Use a default `.vtt` extension for subtitles. + return assetFrom(name: subtitleName, fileExtension: "vtt") +} + +/// A common helper that attempts to build an `AVURLAsset` from a given name and optional file extension. +/// - Parameters: +/// - name: The base file name or a URL string. +/// - fileExtension: An optional file extension to be appended if `name` isn't a valid URL. +/// - Returns: An optional `AVURLAsset`, or `nil` if neither a valid URL nor a local resource file is found. +fileprivate func assetFrom(name: String, fileExtension: String?) -> AVURLAsset? { + // Attempt to create a valid URL from the provided string. + if let url = URL.validURLFromString(from: name) { return AVURLAsset(url: url) - // If direct URL creation fails, attempt to locate the video in the main bundle using the name and extension - } else if let fileUrl = Bundle.main.url(forResource: name, withExtension: extractExtension(from: name) ?? ext) { + } + + if let url = fileURL(from: name){ + return AVURLAsset(url: url) + } + + // If not a valid URL, try to locate the file in the main bundle with the specified extension. + if let fileExtension = fileExtension, + let fileUrl = Bundle.main.url(forResource: name, withExtension: fileExtension) { return AVURLAsset(url: fileUrl) } + // If all attempts fail, return `nil`. return nil } + +/// Attempts to create a valid `URL` from a string that starts with `"file://"`. +/// - Parameter rawString: A file URL string, e.g. `"file:///Users/igor/My Folder/File.mp4"`. +/// - Returns: A `URL` if successfully parsed; otherwise `nil`. +public func fileURL(from rawString: String) -> URL? { + guard rawString.hasPrefix("file://") else { + // Not a file URL scheme + return nil + } + // Strip off "file://" + let pathIndex = rawString.index(rawString.startIndex, offsetBy: 7) + let pathPortion = rawString[pathIndex...] + + guard let encodedPath = pathPortion + .addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) + else { return nil } + + let finalString = "file://\(encodedPath)" + return URL(string: finalString) +} + /// Checks whether a given filename contains an extension and returns the extension if it exists. /// /// - Parameter name: The filename to check. @@ -43,44 +100,184 @@ fileprivate func extractExtension(from name: String) -> String? { return nil } -/// Cleans up the resources associated with a video player. -/// This function nullifies references to the player, player looper, and observers to facilitate resource deallocation and prevent memory leaks. +/// Combines an array of CIFilters with additional brightness and contrast adjustments. +/// +/// This function appends brightness and contrast adjustments as CIFilters to the existing array of filters. +/// +/// - Parameters: +/// - filters: An array of CIFilter objects to which the brightness and contrast filters will be added. +/// - brightness: A Float value representing the brightness adjustment to apply. +/// - contrast: A Float value representing the contrast adjustment to apply. +/// +/// - Returns: An array of CIFilter objects, including the original filters and the added brightness and contrast adjustments. +func combineFilters(_ filters: [CIFilter],_ brightness: Float,_ contrast: Float) -> [CIFilter] { + var allFilters = filters + if let filter = CIFilter(name: "CIColorControls", parameters: [kCIInputBrightnessKey: brightness]) { + allFilters.append(filter) + } + if let filter = CIFilter(name: "CIColorControls", parameters: [kCIInputContrastKey: contrast]) { + allFilters.append(filter) + } + return allFilters +} + +/// Processes an asynchronous video composition request by applying a series of CIFilters. +/// This function ensures each frame processed conforms to specified filter effects. +/// /// - Parameters: -/// - player: A reference to the AVQueuePlayer instance. This parameter is passed by reference to allow the function to nullify the external reference. -/// - playerLooper: A reference to the AVPlayerLooper associated with the player. This is also passed by reference to nullify and help in cleaning up. -/// - statusObserver: A reference to an observer watching the player's status changes. Passing by reference allows the function to dispose of it properly. -/// - errorObserver: A reference to an observer monitoring errors from the player. It is managed in the same way as statusObserver to ensure proper cleanup. -func cleanUp(player: inout AVQueuePlayer?, playerLooper: inout AVPlayerLooper?, statusObserver: inout NSKeyValueObservation?, errorObserver: inout NSKeyValueObservation?) { - // Invalidate and remove references to observers - statusObserver?.invalidate() - errorObserver?.invalidate() - statusObserver = nil - errorObserver = nil - - // Pause the player and release player-related resources - player?.pause() - player = nil - playerLooper?.disableLooping() - playerLooper = nil - - // Debugging statement to confirm cleanup in debug builds - #if DEBUG - print("Cleaned up AVPlayer and observers.") +/// - request: An AVAsynchronousCIImageFilteringRequest object representing the current video frame to be processed. +/// - filters: An array of CIFilters to be applied sequentially to the video frame. +/// +/// The function starts by clamping the source image to ensure coordinates remain within the image bounds, +/// applies each filter in the provided array, and completes by returning the modified image to the composition request. +func handleVideoComposition(request: AVAsynchronousCIImageFilteringRequest, filters: [CIFilter]) { + // Start with the source image, ensuring it's clamped to avoid any coordinate issues + var currentImage = request.sourceImage.clampedToExtent() + + // Apply each filter in the array to the image + for filter in filters { + filter.setValue(currentImage, forKey: kCIInputImageKey) + if let outputImage = filter.outputImage { + currentImage = outputImage.clampedToExtent() + } + } + // Finish the composition request by outputting the final image + request.finish(with: currentImage, context: nil) +} + +/// Merges a video asset with an external WebVTT subtitle file into an AVMutableComposition. +/// Returns a new AVAsset that has both the video/audio and subtitle tracks. +/// +/// - Note: +/// - This method supports embedding external subtitles (e.g., WebVTT) into video files +/// that can handle text tracks, such as MP4 or QuickTime (.mov). +/// - Subtitles are added as a separate track within the composition and will not be rendered +/// (burned-in) directly onto the video frames. Styling, position, and size cannot be customized. +/// +/// - Parameters: +/// - videoAsset: The video asset (e.g., an MP4 file) to which the subtitles will be added. +/// - subtitleAsset: The WebVTT subtitle asset to be merged with the video. +/// +/// - Returns: A new AVAsset with the video, audio, and subtitle tracks combined. +/// Returns `nil` if an error occurs during the merging process or if subtitles are unavailable. +func mergeAssetWithSubtitles(videoAsset: AVURLAsset, subtitleAsset: AVURLAsset) -> AVAsset? { + + #if !os(visionOS) + + // 1) Find the TEXT track in the subtitle asset + guard let textTrack = subtitleAsset.tracks(withMediaType: .text).first else { + #if DEBUG + print("No text track found in subtitle file.") + #endif + return videoAsset // Return just videoAsset if no text track + } + + // Create a new composition + let composition = AVMutableComposition() + + // 2) Copy the VIDEO track (and AUDIO track if available) from the original video + do { + // VIDEO + if let videoTrack = videoAsset.tracks(withMediaType: .video).first { + let compVideoTrack = composition.addMutableTrack( + withMediaType: .video, + preferredTrackID: kCMPersistentTrackID_Invalid + ) + try compVideoTrack?.insertTimeRange( + CMTimeRange(start: .zero, duration: videoAsset.duration), + of: videoTrack, + at: .zero + ) + } + // AUDIO (if your video has an audio track) + if let audioTrack = videoAsset.tracks(withMediaType: .audio).first { + let compAudioTrack = composition.addMutableTrack( + withMediaType: .audio, + preferredTrackID: kCMPersistentTrackID_Invalid + ) + try compAudioTrack?.insertTimeRange( + CMTimeRange(start: .zero, duration: videoAsset.duration), + of: audioTrack, + at: .zero + ) + } + } catch { + #if DEBUG + print("Error adding video/audio tracks: \(error)") + #endif + return videoAsset + } + + // 3) Insert the subtitle track into the composition + do { + let compTextTrack = composition.addMutableTrack( + withMediaType: .text, + preferredTrackID: kCMPersistentTrackID_Invalid + ) + try compTextTrack?.insertTimeRange( + CMTimeRange(start: .zero, duration: videoAsset.duration), + of: textTrack, + at: .zero + ) + } catch { + #if DEBUG + print("Error adding text track: \(error)") + #endif + return videoAsset + } + + return composition + + #else + return videoAsset #endif } +/// Determines the seek time as a `CMTime` based on a specified time and the total duration of the media. +/// The function ensures that the seek time is within valid bounds (start to end of the media). +/// +/// - Parameters: +/// - time: A `Double` value representing the desired time to seek to, in seconds. +/// If the value is negative, the function will seek to the start of the media. +/// If the value exceeds the total duration, the function will seek to the end. +/// - duration: A `CMTime` value representing the total duration of the media. +/// This value must be valid for the calculation to work correctly. +/// - Returns: A `CMTime` value representing the resolved seek position within the media. +func getSeekTime(for time: Double, duration: CMTime) -> CMTime? { + guard duration.isValid, + duration.isNumeric, + duration.timescale != 0 + else { return nil } + + let endSeconds = CMTimeGetSeconds(duration) + guard endSeconds.isFinite, endSeconds >= 0 else { return nil } + + if time <= 0 { return .zero } + if time >= endSeconds { return duration } + + let clamped = max(0, min(time, endSeconds)) -/// Detects and returns the appropriate error based on settings and asset. + let scale = max(Int32(600), duration.timescale) + return CMTime(seconds: clamped, preferredTimescale: scale) +} + +/// Creates an `AVPlayerItem` with optional subtitle merging. /// - Parameters: -/// - settings: The settings for the video player. -/// - asset: The asset for the video player. -/// - Returns: The detected error or nil if no error. -func detectError(settings: VideoSettings, asset: AVURLAsset?) -> VPErrors? { - if !settings.areUnique { - return .settingsNotUnique - } else if asset == nil { - return .sourceNotFound(settings.name) - } else { +/// - asset: The main video asset. +/// - settings: A `VideoSettings` object containing subtitle configuration. +/// - Returns: A new `AVPlayerItem` configured with the merged or original asset. +func createPlayerItem(with settings: VideoSettings) -> AVPlayerItem? { + + guard let asset = assetFor(settings) else{ return nil } + + if let subtitleAsset = subtitlesAssetFor(settings), + let mergedAsset = mergeAssetWithSubtitles(videoAsset: asset, subtitleAsset: subtitleAsset) { + // Create and return a new `AVPlayerItem` using the merged asset + return AVPlayerItem(asset: mergedAsset) + } else { + // Create and return a new `AVPlayerItem` using the original asset + return AVPlayerItem(asset: asset) + } } diff --git a/Sources/swiftui-loop-videoplayer/protocol/helpers/PlayerDelegateProtocol.swift b/Sources/swiftui-loop-videoplayer/protocol/helpers/PlayerDelegateProtocol.swift new file mode 100644 index 0000000..816451f --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/protocol/helpers/PlayerDelegateProtocol.swift @@ -0,0 +1,94 @@ +// +// PlayerDelegateProtocol.swift +// +// +// Created by Igor Shelopaev on 05.08.24. +// + +import Foundation +import AVFoundation +#if os(iOS) +import AVKit +#endif + +/// Protocol to handle player-related errors. +/// +/// Conforming to this protocol allows a class to respond to error events that occur within a media player context. +@available(iOS 14, macOS 11, tvOS 14, *) +@MainActor +public protocol PlayerDelegateProtocol: AnyObject{ + /// Called when an error is encountered within the media player. + /// + /// This method provides a way for delegate objects to respond to error conditions, allowing them to handle or + /// display errors accordingly. + /// + /// - Parameter error: The specific `VPErrors` instance describing what went wrong. + func didReceiveError(_ error: VPErrors) + + /// A method that handles the passage of time in the player. + /// - Parameter seconds: The amount of time, in seconds, that has passed. + func didPassedTime(seconds: Double) + + /// A method that handles seeking in the player. + /// - Parameters: + /// - value: A Boolean indicating whether the seek was successful. + /// - currentTime: The current time of the player after seeking, in seconds. + func didSeek(value: Bool, currentTime: Double) + + /// Called when the player has paused playback. + /// + /// This method is triggered when the player's `timeControlStatus` changes to `.paused`. + func didPausePlayback() + + /// Called when the player is waiting to play at the specified rate. + /// + /// This method is triggered when the player's `timeControlStatus` changes to `.waitingToPlayAtSpecifiedRate`. + func isWaitingToPlay() + + /// Called when the player starts or resumes playing. + /// + /// This method is triggered when the player's `timeControlStatus` changes to `.playing`. + func didStartPlaying() + + /// Called when the current media item in the player changes. + /// + /// This method is triggered when the player's `currentItem` is updated to a new `AVPlayerItem`. + /// - Parameter newItem: The new `AVPlayerItem` that the player has switched to, if any. + func currentItemDidChange(to newItem: AVPlayerItem?) + + /// Called when the current media item is removed from the player. + /// + /// This method is triggered when the player's `currentItem` is set to `nil`, indicating that there is no longer an active media item. + func currentItemWasRemoved() + + /// Called when the volume level of the player changes. + /// + /// This method is triggered when the player's `volume` property changes. + /// - Parameter newVolume: The new volume level, expressed as a float between 0.0 (muted) and 1.0 (maximum volume). + func volumeDidChange(to newVolume: Float) + + /// Notifies that the bounds have changed. + /// + /// - 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. + + func boundsDidChange(to bounds: CGRect) + + /// Called when the AVPlayerItem's status changes. + /// - Parameter status: The new status of the AVPlayerItem. + /// - `.unknown`: The item is still loading or its status is not yet determined. + /// - `.readyToPlay`: The item is fully loaded and ready to play. + /// - `.failed`: The item failed to load due to an error. + func itemStatusChanged(_ status: AVPlayerItem.Status) + + /// Called when the duration of the AVPlayerItem is available. + /// - Parameter time: The total duration of the media item in `CMTime`. + /// - This method is only called when the item reaches `.readyToPlay`, + /// ensuring that the duration value is valid. + func duration(_ time: CMTime) + +#if os(iOS) + func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) + + func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) +#endif +} diff --git a/Sources/swiftui-loop-videoplayer/protocol/helpers/PlayerErrorDelegate.swift b/Sources/swiftui-loop-videoplayer/protocol/helpers/PlayerErrorDelegate.swift deleted file mode 100644 index 47fcb98..0000000 --- a/Sources/swiftui-loop-videoplayer/protocol/helpers/PlayerErrorDelegate.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// PlayerErrorDelegate.swift -// -// -// Created by Igor Shelopaev on 05.08.24. -// - -import Foundation - -/// Protocol to handle player-related errors. -/// -/// Conforming to this protocol allows a class to respond to error events that occur within a media player context. -@available(iOS 14, macOS 11, tvOS 14, *) -public protocol PlayerErrorDelegate: AnyObject { - /// Called when an error is encountered within the media player. - /// - /// This method provides a way for delegate objects to respond to error conditions, allowing them to handle or - /// display errors accordingly. - /// - /// - Parameter error: The specific `VPErrors` instance describing what went wrong. - @MainActor - func didReceiveError(_ error: VPErrors) -} diff --git a/Sources/swiftui-loop-videoplayer/protocol/helpers/SettingsConvertible.swift b/Sources/swiftui-loop-videoplayer/protocol/helpers/SettingsConvertible.swift index b05fca7..5f55057 100644 --- a/Sources/swiftui-loop-videoplayer/protocol/helpers/SettingsConvertible.swift +++ b/Sources/swiftui-loop-videoplayer/protocol/helpers/SettingsConvertible.swift @@ -7,11 +7,13 @@ import Foundation -@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) + /// Protocol for building blocks +@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) public protocol SettingsConvertible { /// Fetch settings /// - Returns: Array of settings func asSettings() -> [Setting] } + diff --git a/Sources/swiftui-loop-videoplayer/protocol/player/AbstractPlayer.swift b/Sources/swiftui-loop-videoplayer/protocol/player/AbstractPlayer.swift new file mode 100644 index 0000000..677d931 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/protocol/player/AbstractPlayer.swift @@ -0,0 +1,474 @@ +// +// AbstractPlayer.swift +// +// +// Created by Igor Shelopaev on 07.08.24. +// + +import AVFoundation +#if canImport(CoreImage) +@preconcurrency import CoreImage +import AVKit +#endif + +/// Defines an abstract player protocol to be implemented by player objects, ensuring main-thread safety and compatibility with specific OS versions. +/// This protocol is designed for use with classes (reference types) only. +@available(iOS 14, macOS 11, tvOS 14, *) +@MainActor +public protocol AbstractPlayer: AnyObject { + + // MARK: - Properties + + #if os(iOS) + var pipController: AVPictureInPictureController? { get set } + #endif + + /// An optional property that stores the current video settings. + /// + /// This property holds an instance of `VideoSettings` or nil if no settings have been configured yet. + /// It is a computed property with both getter and setter to retrieve and update the video settings respectively. + var currentSettings: VideoSettings? { get set } + + /// The delegate to be notified about errors encountered by the player. + var delegate: PlayerDelegateProtocol? { get set } + + /// Adjusts the brightness of the video. Default is 0 (no change), with positive values increasing and negative values decreasing brightness. + var brightness: Float { get set } + + /// Controls the contrast of the video. Default is 1 (no change), with values above 1 enhancing and below 1 reducing contrast. + var contrast: Float { get set } + + /// Holds an array of CIFilters to be applied to the video. Filters are applied in the order they are added to the array. + var filters: [CIFilter] { get set } + + /// The looper responsible for continuous video playback. + var playerLooper: AVPlayerLooper? { get set } + + /// The queue player that plays the video items. + var player: AVQueuePlayer? { get set } + + // MARK: - Calculated properties + + /// Retrieves the current item being played. + var currentItem : AVPlayerItem? { get } + + /// The current asset being played, if available. + var currentAsset : AVURLAsset? { get } + + /// Check if looping is applied + var isLooping : Bool { get } + + // MARK: - Playback control methods + + /// Initiates or resumes playback of the video. + /// This method should be implemented to start playing the video from its current position. + func play() + + /// Pauses the current video playback. + /// This method should be implemented to pause the video, allowing it to be resumed later from the same position. + func pause() + + /// Stop and clean player + func stop() + + /// Inserts a new player item into the media queue of the player. + func insert(_ item : AVPlayerItem) + + /// Seeks the video to a specific time. + /// This method moves the playback position to the specified time with precise accuracy. + /// - Parameter time: The target time to seek to in the video timeline. + func seek(to time: Double, play: Bool) + + /// Seeks to the start of the video. + /// This method positions the playback at the beginning of the video. + func seekToStart() + + /// Seeks to the end of the video. + /// This method positions the playback at the end of the video. + func seekToEnd() + + /// Mutes the video playback. + /// This method silences the audio of the video. + func mute() + + /// Unmutes the video playback. + /// This method restores the audio of the video. + func unmute() + + /// Adjusts the volume for the video playback. + /// - Parameter volume: A `Float` value between 0.0 (mute) and 1.0 (full volume). + /// If the value is out of range, it will be clamped to the nearest valid value. + func setVolume(_ volume: Float) + + /// Sets the playback speed for the video playback. + func setPlaybackSpeed(_ speed: Float) + + /// Sets the subtitles for the video playback to a specified language or turns them off. + func setSubtitles(to language: String?) + + /// Enables looping for the current video item. + func loop() + + /// Disables looping for the current video item. + func unloop() + + /// Adjusts the brightness of the video playback. + /// - Parameter brightness: A `Float` value representing the brightness level. Typically ranges from -1.0 to 1.0. + func adjustBrightness(to brightness: Float) + + /// Adjusts the contrast of the video playback. + /// - Parameter contrast: A `Float` value representing the contrast level. Typically ranges from 0.0 to 4.0. + func adjustContrast(to contrast: Float) + + /// Applies a Core Image filter to the video player's content. + func applyFilter(_ value: CIFilter, _ clear : Bool) + + /// Removes all filters from the video playback. + func removeAllFilters(apply : Bool) + + /// Selects an audio track for the video playback. + /// - Parameter languageCode: The language code (e.g., "en" for English) of the desired audio track. + func selectAudioTrack(languageCode: String) + + /// Sets the playback command for the video player. + func setCommand(_ value: PlaybackCommand) + + /// Updates the current playback asset, settings, and initializes playback or a specific action when the asset is ready. + func update(settings: VideoSettings) +} + +extension AbstractPlayer{ + + /// Retrieves the current item being played. + /// + /// This computed property checks if there is a current item available in the player. + /// If available, it returns the `currentItem`; otherwise, it returns `nil`. + var currentItem : AVPlayerItem?{ + if let currentItem = player?.currentItem { + return currentItem + } + return nil + } + + /// The current asset being played, if available. + /// + /// This computed property checks the current item of the player. + /// If the current item exists and its asset can be cast to AVURLAsset, + var currentAsset : AVURLAsset?{ + if let currentItem = currentItem { + return currentItem.asset as? AVURLAsset + } + return nil + } + + // Implementations of playback control methods + + /// Initiates playback of the video. + /// This method starts or resumes playing the video from the current position. + func play() { + player?.play() + } + + /// Pauses the video playback. + /// This method pauses the video if it is currently playing, allowing it to be resumed later from the same position. + func pause() { + player?.pause() + } + + /// Clears all items from the player's queue. + func clearPlayerQueue() { + player?.removeAllItems() + } + + /// Determines whether the media queue of the player is empty. + func isEmptyQueue() -> Bool{ + player?.items().isEmpty ?? true + } + + /// Stop and clean player + func stop(){ + + pause() + + if !isEmptyQueue() { // Cleaning + if isLooping{ + unloop() + } + + removeAllFilters() + clearPlayerQueue() + } + } + /// Inserts a new player item into the media queue of the player. + /// - Parameter item: The AVPlayerItem to be inserted into the queue. + func insert(_ item : AVPlayerItem){ + player?.insert(item, after: nil) + } + + /// Seeks the video to a specific time in the timeline. + /// This method adjusts the playback position to the specified time with precise accuracy. + /// 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). + /// + /// - Parameters: + /// - time: A `Double` value representing the target time (in seconds) to seek to in the video timeline. + /// If the value is less than 0, the playback position will be set to the start of the video. + /// If the value exceeds the video's duration, it will be set to the end. + /// - play: A `Bool` value indicating whether to start playback immediately after seeking. + /// Defaults to `false`, meaning playback will remain paused after the seek operation. + func seek(to time: Double, play: Bool = false) { + guard let player = player, let duration = player.currentItem?.duration else { + delegate?.didSeek(value: false, currentTime: time) + return + } + + guard let seekTime = getSeekTime(for: time, duration: duration) else { + delegate?.didSeek(value: false, currentTime: time) + return + } + + player.seek(to: seekTime) { [weak self] success in + Task { @MainActor in + self?.seekCompletion(success: success, autoPlay: play) + } + } + } + + private func seekCompletion(success: Bool, autoPlay: Bool) { + guard let player = player else { return } + let currentTime = CMTimeGetSeconds(player.currentTime()) + delegate?.didSeek(value: success, currentTime: currentTime) + autoPlay ? play() : pause() + } + + /// Seeks to the start of the video. + /// This method positions the playback at the beginning of the video. + func seekToStart() { + seek(to: 0) + } + + /// Seeks to the end of the video. + /// This method positions the playback at the end of the video. + func seekToEnd() { + if let duration = currentItem?.duration { + let endTime = CMTimeGetSeconds(duration) + seek(to: endTime) + } + } + + /// Mutes the video playback. + /// This method silences the audio of the video. + func mute() { + player?.isMuted = true + } + + /// Unmutes the video playback. + /// This method restores the audio of the video. + func unmute() { + player?.isMuted = false + } + + /// Sets the volume for the video playback. + /// - Parameter volume: A `Float` value between 0.0 (mute) and 1.0 (full volume). + /// If the value is out of range, it will be clamped to the nearest valid value. + func setVolume(_ volume: Float) { + let clampedVolume = max(0.0, min(volume, 1.0)) // Clamp the value between 0.0 and 1.0 + player?.volume = clampedVolume + } + + /// Sets the playback speed for the video playback. + /// - 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). + /// If the value is out of range (negative), it will be clamped to the nearest valid value. + func setPlaybackSpeed(_ speed: Float) { + let clampedSpeed = max(0.0, speed) // Clamp to non-negative values, or adjust the upper bound as needed + player?.rate = clampedSpeed + } + + /// Sets the subtitles for the video playback to a specified language or turns them off. + /// 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). + /// - Parameters: + /// - language: The language code (e.g., "en" for English) for the desired subtitles. + /// Pass `nil` to turn off subtitles. + func setSubtitles(to language: String?) { + #if !os(visionOS) + guard let currentItem = currentItem, + let group = currentAsset?.mediaSelectionGroup(forMediaCharacteristic: .legible) else { + return + } + + if let language = language { + // Filter the subtitle options based on the language code + let options = group.options.filter { option in + guard let locale = option.locale else { return false } + return locale.languageCode == language + } + // Select the first matching subtitle option + if let option = options.first { + currentItem.select(option, in: group) + } + } else { + // Turn off subtitles by deselecting any option in the legible media selection group + currentItem.select(nil, in: group) + } + #endif + } + + /// Check if looping is applied + var isLooping : Bool{ + playerLooper != nil + } + + /// Enables looping for the current video item. + /// This method sets up the `playerLooper` to loop the currently playing item indefinitely. + func loop() { + guard let player = player, let currentItem = player.currentItem else { + return + } + + // Check if the video is already being looped + if isLooping { + return + } + + playerLooper = AVPlayerLooper(player: player, templateItem: currentItem) + } + + /// Disables looping for the current video item. + /// This method removes the `playerLooper`, stopping the loop. + func unloop() { + // Check if the video is not looped (i.e., playerLooper is nil) + guard isLooping else { + return // Not looped, no need to unloop + } + + playerLooper?.disableLooping() + playerLooper = nil + } + + /// Adjusts the brightness of the video playback. + /// - Parameter brightness: A `Float` value representing the brightness level. Typically ranges from -1.0 to 1.0. + func adjustBrightness(to brightness: Float) { + let clampedBrightness = max(-1.0, min(brightness, 1.0)) // Clamp brightness to the range [-1.0, 1.0] + self.brightness = clampedBrightness + applyVideoComposition() + } + + /// Adjusts the contrast of the video playback. + /// - Parameter contrast: A `Float` value representing the contrast level. Typically ranges from 0.0 to 4.0. + func adjustContrast(to contrast: Float) { + let clampedContrast = max(0.0, min(contrast, 4.0)) // Clamp contrast to the range [0.0, 4.0] + self.contrast = clampedContrast + applyVideoComposition() + } + + /// Applies a Core Image filter to the video playback. + /// This function adds the provided filter to the stack of existing filters and updates the video composition accordingly. + /// - Parameter value: A `CIFilter` object representing the filter to be applied to the video playback. + func applyFilter(_ value: CIFilter, _ clear : Bool) { + if clear{ + removeAllFilters(apply: false) + } + appendFilter(value) // Appends the provided filter to the current stack. + applyVideoComposition() // Updates the video composition to include the new filter. + } + + /// Appends a Core Image filter to the current list of filters. + /// - Parameters: + /// - value: Core Image filter to be applied. + private func appendFilter(_ value: CIFilter) { + filters.append(value) + } + + /// Removes all applied CIFilters from the video playback. + /// + /// This function clears the array of filters and optionally re-applies the video composition + /// to ensure the changes take effect immediately. + /// + /// - Parameters: + /// - apply: A Boolean value indicating whether to immediately apply the video composition after removing the filters. + /// Defaults to `true`. + func removeAllFilters(apply : Bool = true) { + + guard !filters.isEmpty else { return } + + filters = [] + + if apply{ + applyVideoComposition() + } + } + + /// Applies the current set of filters to the video using an AVVideoComposition. + /// This method combines the existing filters and brightness/contrast adjustments, creates a new video composition, + /// and assigns it to the current AVPlayerItem. The video is paused during this process to ensure smooth application. + /// This method is not supported on Vision OS. + private func applyVideoComposition() { + guard let player = player else { return } + let allFilters = combineFilters(filters, brightness, contrast) + + #if !os(visionOS) + // Optionally, check if the player is currently playing + let wasPlaying = player.rate != 0 + + // Pause the player if it was playing + if wasPlaying { + player.pause() + } + + player.items().forEach{ item in + + let videoComposition = AVVideoComposition(asset: item.asset, applyingCIFiltersWithHandler: { request in + handleVideoComposition(request: request, filters: allFilters) + }) + + item.videoComposition = videoComposition + } + + if wasPlaying{ + player.play() + } + + #endif + } + + /// Selects an audio track for the video playback. + /// - Parameter languageCode: The language code (e.g., "en" for English) of the desired audio track. + func selectAudioTrack(languageCode: String) { + guard let currentItem = currentItem else { return } + #if !os(visionOS) + // Retrieve the media selection group for audible tracks + if let group = currentAsset?.mediaSelectionGroup(forMediaCharacteristic: .audible) { + + // Filter options by language code using Locale + let options = group.options.filter { option in + return option.locale?.languageCode == languageCode + } + + // Select the first matching option, if available + if let option = options.first { + currentItem.select(option, in: group) + } + } + #endif + } + + #if os(iOS) + func startPiP() { + guard let pipController = pipController else { return } + + if !pipController.isPictureInPictureActive { + pipController.startPictureInPicture() + + } + } + + func stopPiP() { + guard let pipController = pipController else { return } + + if pipController.isPictureInPictureActive { + // Stop PiP + pipController.stopPictureInPicture() + } + } + + #endif +} diff --git a/Sources/swiftui-loop-videoplayer/protocol/player/ExtPlayerProtocol.swift b/Sources/swiftui-loop-videoplayer/protocol/player/ExtPlayerProtocol.swift new file mode 100644 index 0000000..f7c04e7 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/protocol/player/ExtPlayerProtocol.swift @@ -0,0 +1,434 @@ +// +// ExtPlayerProtocol.swift +// +// +// Created by Igor Shelopaev on 05.08.24. +// + +import AVFoundation +import Foundation +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// A protocol defining the requirements for a looping video player. +/// +/// Conforming types are expected to manage a video player that can loop content continuously, +/// handle errors, and notify a delegate of important events. +@available(iOS 14, macOS 11, tvOS 14, *) +@MainActor +public protocol ExtPlayerProtocol: AbstractPlayer, LayerMakerProtocol{ + + #if canImport(UIKit) + /// Provides a non-optional `CALayer` for use within UIKit environments. + var layer: CALayer { get } + #elseif canImport(AppKit) + /// Provides an optional `CALayer` which can be set, and a property to indicate if the layer is wanted, for use within AppKit environments. + var layer: CALayer? { get set } + /// WantsLayer is necessary. Otherwise, your NSView will not render the layer-based content at all. + var wantsLayer: Bool { get set } + #endif + + /// Provides a `AVPlayerLayer` specific to the player implementation, applicable across all platforms. + var playerLayer: AVPlayerLayer? { get set } + + /// An optional NSKeyValueObservation to monitor errors encountered by the video player. + /// This observer should be configured to detect and handle errors from the AVQueuePlayer, + /// ensuring that all playback errors are managed and reported appropriately. + var errorObserver: NSKeyValueObservation? { get set } + + /// An optional observer for monitoring changes to the player's `timeControlStatus` property. + var timeControlObserver: NSKeyValueObservation? { get set } + + /// An optional observer for monitoring changes to the player's `currentItem` property. + var currentItemObserver: NSKeyValueObservation? { get set } + + /// Item status observer + var itemStatusObserver: NSKeyValueObservation? { get set } + + /// An optional observer for monitoring changes to the player's `volume` property. + var volumeObserver: NSKeyValueObservation? { get set } + + /// Declare a variable to hold the time observer token outside the if statement + var timeObserver: Any? { get set } + + /// Initializes a new player view with a video asset and custom settings. + /// + /// - Parameters: + /// - settings: The `VideoSettings` struct that includes all necessary configurations like gravity, loop, and mute. + init(settings: VideoSettings) + + /// Sets up the necessary observers on the AVPlayerItem and AVQueuePlayer to monitor changes and errors. + /// + /// - Parameters: + /// - item: The AVPlayerItem to observe for status changes. + /// - player: The AVQueuePlayer to observe for errors. + func setupObservers(for player: AVQueuePlayer) + + /// Handles errors + func onError(_ error : VPErrors) +} + +internal extension ExtPlayerProtocol { + + /// Initializes a new player view with a video asset and custom settings. + /// + /// - Parameters: + /// - settings: The `VideoSettings` struct that includes all necessary configurations like gravity, loop, and mute. + /// - timePublishing: Optional `CMTime` that specifies a particular time for publishing or triggering an event. + func setupPlayerComponents(settings: VideoSettings) { + + guard let player else { return } + + configurePlayer(player, settings: settings) + update(settings: settings) + setupObservers(for: player) + } + + /// Configures the provided AVQueuePlayer with specific properties for video playback. + /// + /// - Parameters: + /// - player: The AVQueuePlayer to be configured. + /// - settings: The `VideoSettings` struct that includes all necessary configurations like gravity, loop, and mute. + func configurePlayer(_ player: AVQueuePlayer, settings: VideoSettings) { + + player.isMuted = settings.mute + if !settings.loop{ + player.actionAtItemEnd = .pause + } + + configurePlayerLayer(player, settings) + configureCompositeLayer(settings) + configureTimePublishing(player, settings) + } + + /// Configures the player layer for the specified video player using the provided settings. + /// - Parameters: + /// - player: The `AVQueuePlayer` instance for which the player layer will be configured. + /// - settings: A `VideoSettings` object containing configuration details for the player layer. + func configurePlayerLayer(_ player: AVQueuePlayer, _ settings: VideoSettings) { + playerLayer?.player = player + playerLayer?.videoGravity = settings.gravity + + #if canImport(UIKit) + playerLayer?.backgroundColor = UIColor.clear.cgColor + if let playerLayer{ + layer.addSublayer(playerLayer) + } + #elseif canImport(AppKit) + playerLayer?.backgroundColor = NSColor.clear.cgColor + let layer = CALayer() + if let playerLayer{ + layer.addSublayer(playerLayer) + } + self.layer = layer + self.wantsLayer = true + #endif + } + + /// Configures the time publishing observer for the specified video player. + /// - Parameters: + /// - player: The `AVQueuePlayer` instance to which the time observer will be added. + /// - settings: A `VideoSettings` object containing the time publishing interval and related configuration. + func configureTimePublishing(_ player: AVQueuePlayer, _ settings: VideoSettings) { + if let timePublishing = settings.timePublishing{ + timeObserver = player.addPeriodicTimeObserver(forInterval: timePublishing, queue: .global()) { [weak self] time in + guard let self = self else{ return } + Task { @MainActor in + self.delegate?.didPassedTime(seconds: time.seconds) + } + } + } + } + + /// Configures the composite layer for the view based on the provided video settings. + /// - Parameter settings: A `VideoSettings` object containing configuration details for the composite layer. + func configureCompositeLayer(_ settings: VideoSettings) { + + guard settings.vector else { return } + + compositeLayer?.frame = CGRect(x: 0, y: 0, width: bounds.width, height: bounds.height) + + guard let compositeLayer else { return } + + #if canImport(UIKit) + layer.addSublayer(compositeLayer) + #elseif canImport(AppKit) + self.layer?.addSublayer(compositeLayer) + #endif + } + + /// Updates the player with a new asset and applies the specified video settings. + /// + /// This method sets a new `AVURLAsset` for playback and configures it according to the provided settings. + /// It can adjust options such as playback gravity, looping, and muting. If `doUpdate` is `true`, the player is + /// updated immediately with the new asset. The method also provides an optional callback that is executed when + /// the asset transitions to the `.readyToPlay` status, enabling additional actions to be performed once the + /// player item is ready for playback. + /// + /// - Parameters: + /// - settings: A `VideoSettings` struct containing configurations such as playback gravity, looping behavior, whether the audio should be muted. + func update(settings: VideoSettings) { + + if settings.isEqual(currentSettings){ + return + } + + stop() + + currentSettings = settings + + guard let newItem = createPlayerItem(with: settings) else{ + itemNotFound(with: settings.name) + return + } + + observeItemStatus(newItem) + + insert(newItem) + + if settings.loop{ + loop() + } + + if !settings.notAutoPlay{ + play() + } + } + + /// Handles errors + /// - Parameter error: An instance of `VPErrors` representing the error to be handled. + func onError(_ error : VPErrors){ + delegate?.didReceiveError(error) + } + + /// Emit the error "Item not found" with delay + /// - Parameter name: resource name + func itemNotFound(with name: String){ + Task{ @MainActor [weak self] in + self?.onError(.sourceNotFound(name)) + } + } + + /// Observes the status of an AVPlayerItem and notifies the delegate when the status changes. + /// - Parameter item: The AVPlayerItem whose status should be observed. + private func observeItemStatus(_ item: AVPlayerItem) { + + removeItemObserver() + + itemStatusObserver = item.observe(\.status, options: [.new, .initial]) { [weak self] item, _ in + Task { @MainActor in + self?.delegate?.itemStatusChanged(item.status) + } + + switch item.status { + case .unknown: break + case .readyToPlay: + Task { @MainActor in + self?.delegate?.duration(item.duration) + } + case .failed: + Task { @MainActor in + let error = self?.currentItem?.error + self?.onError(.failedToLoad(error)) + } + @unknown default: + Task { @MainActor in + let error = self?.currentItem?.error + self?.onError(.failedToLoad(error)) + } + } + } + } + + /// Removes the current AVPlayerItem observer, if any, to prevent memory leaks. + private func removeItemObserver() { + itemStatusObserver?.invalidate() + itemStatusObserver = nil + } + + /// Sets up observers on the player item and the player to track their status and error states. + /// + /// - Parameters: + /// - item: The player item to observe. + /// - player: The player to observe. + func setupObservers(for player: AVQueuePlayer) { + errorObserver = player.observe(\.error, options: [.new]) { [weak self] player, _ in + guard let error = player.error else { return } + Task { @MainActor in + self?.onError(.remoteVideoError(error)) + } + } + + timeControlObserver = player.observe(\.timeControlStatus, options: [.new, .old]) { [weak self] player, change in + switch player.timeControlStatus { + case .paused: + // This could mean playback has stopped, but it's not specific to end of playback + Task { @MainActor in + self?.delegate?.didPausePlayback() + } + case .waitingToPlayAtSpecifiedRate: + // Player is waiting to play (e.g., buffering) + Task { @MainActor in + self?.delegate?.isWaitingToPlay() + } + case .playing: + // Player is currently playing + Task { @MainActor in + self?.delegate?.didStartPlaying() + } + @unknown default: + break + } + } + + currentItemObserver = player.observe(\.currentItem, options: [.new]) { [weak self] player, change in + // Detecting when the current item is changed + if let newItem = change.newValue as? AVPlayerItem { + Task { @MainActor in + self?.delegate?.currentItemDidChange(to: newItem) + } + } else if change.newValue == nil { + Task { @MainActor in + self?.delegate?.currentItemWasRemoved() + } + } + } + + volumeObserver = player.observe(\.volume, options: [.new, .old]) { [weak self] player, change in + if let newVolume = change.newValue{ + Task { @MainActor in + self?.delegate?.volumeDidChange(to: newVolume) + } + } + } + } + + /// Clear observers + func clearObservers(){ + + removeItemObserver() + + errorObserver?.invalidate() + errorObserver = nil + + timeControlObserver?.invalidate() + timeControlObserver = nil + + currentItemObserver?.invalidate() + currentItemObserver = nil + + volumeObserver?.invalidate() + volumeObserver = nil + + if let observerToken = timeObserver { + player?.removeTimeObserver(observerToken) + timeObserver = nil + } + } + + /// Add player layer + func addPlayerLayer(){ + playerLayer = AVPlayerLayer() + } + + /// Removes the player layer from its super layer. + /// + /// This method checks if the player layer is associated with a super layer and removes it to clean up resources + /// and prevent potential retain cycles or unwanted video display when the player is no longer needed. + func removePlayerLayer() { + playerLayer?.player = nil + playerLayer?.removeFromSuperlayer() + playerLayer = nil + } + + /// Sets the playback command for the video player. + /// + /// - Parameter value: The `PlaybackCommand` to set. Available commands include: + /// + /// ### Playback Controls + /// - `play`: Starts video playback. + /// - `pause`: Pauses video playback. + /// - `seek(to:play:)`: Moves to a specified time in the video, with an option to start playing. + /// - `begin`: Moves the video to the beginning. + /// - `end`: Moves the video to the end. + /// + /// ### Audio & Volume + /// - `mute`: Mutes the video. + /// - `unmute`: Unmutes the video. + /// - `volume(level)`: Adjusts the volume to the specified level. + /// - `audioTrack(languageCode)`: Selects an audio track based on the given language code. + /// + /// ### Subtitles & Playback Speed + /// - `subtitles(language)`: Sets subtitles to a specified language or disables them. + /// - `playbackSpeed(speed)`: Adjusts the video playback speed. + /// + /// ### Looping + /// - `loop`: Enables video looping. + /// - `unloop`: Disables video looping. + /// + /// ### Video Adjustments + /// - `brightness(level)`: Adjusts the brightness of the video playback. + /// - `contrast(level)`: Adjusts the contrast of the video playback. + /// + /// ### Filters + /// - `filter(value, clear)`: Applies a specific Core Image filter to the video, optionally clearing previous filters. + /// - `removeAllFilters`: Removes all applied filters from the video playback. + /// + /// ### Vector Graphics + /// - `addVector(builder, clear)`: Adds a vector graphic overlay to the video player, with an option to clear previous vectors. + /// - `removeAllVectors`: Removes all vector graphics from the video player. + /// + /// ### Platform-Specific Features + /// - `startPiP` (iOS only): Starts Picture-in-Picture mode. + /// - `stopPiP` (iOS only): Stops Picture-in-Picture mode. + func setCommand(_ value: PlaybackCommand) { + switch value { + case .play: + play() + case .pause: + pause() + case .seek(to: let time, play: let play): + seek(to: time, play: play) + case .begin: + seekToStart() + case .end: + seekToEnd() + case .mute: + mute() + case .unmute: + unmute() + case .volume(let volume): + setVolume(volume) + case .subtitles(let language): + setSubtitles(to: language) + case .playbackSpeed(let speed): + setPlaybackSpeed(speed) + case .loop: + loop() + case .unloop: + unloop() + case .brightness(let brightness): + adjustBrightness(to: brightness) + case .contrast(let contrast): + adjustContrast(to: contrast) + case .filter(let value, let clear): + applyFilter(value, clear) + case .removeAllFilters: + removeAllFilters() + case .audioTrack(let languageCode): + selectAudioTrack(languageCode: languageCode) + case .addVector(let builder, let clear): + addVectorLayer(builder: builder, clear: clear) + case .removeAllVectors: + removeAllVectors() + #if os(iOS) + case .startPiP: startPiP() + case .stopPiP: stopPiP() + #endif + default : return + } + } +} diff --git a/Sources/swiftui-loop-videoplayer/protocol/vector/ShapeLayerBuilderProtocol.swift b/Sources/swiftui-loop-videoplayer/protocol/vector/ShapeLayerBuilderProtocol.swift new file mode 100644 index 0000000..d3d8eb6 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/protocol/vector/ShapeLayerBuilderProtocol.swift @@ -0,0 +1,31 @@ +// +// ShapeLayerProtocol.swift +// +// +// Created by Igor Shelopaev on 13.08.24. +// + +import CoreGraphics + +#if canImport(QuartzCore) +import QuartzCore +#endif + + +/// A protocol defining a builder for creating shape layers with a unique identifier. +/// +/// Conforming types will be able to construct a CAShapeLayer based on provided frame, bounds, and center. +@available(iOS 14, macOS 11, tvOS 14, *) +public protocol ShapeLayerBuilderProtocol: Identifiable { + + /// Unique identifier + var id : UUID { get } + + /// Builds a CAShapeLayer using specified geometry. + /// + /// - Parameters: + /// - geometry: A tuple containing frame, bounds, and center as `CGRect` and `CGPoint`. + /// - Returns: A configured `CAShapeLayer`. + @MainActor + func build(with geometry: (frame: CGRect, bounds: CGRect)) -> CAShapeLayer +} diff --git a/Sources/swiftui-loop-videoplayer/protocol/vector/VectorLayerProtocol.swift b/Sources/swiftui-loop-videoplayer/protocol/vector/VectorLayerProtocol.swift new file mode 100644 index 0000000..57d144b --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/protocol/vector/VectorLayerProtocol.swift @@ -0,0 +1,87 @@ +// +// VectorLayerProtocol.swift +// +// +// Created by Igor Shelopaev on 13.08.24. +// + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif +#if canImport(QuartzCore) +import QuartzCore +#endif + +/// A protocol that defines methods and properties for managing vector layers within a composite layer. +/// +/// This protocol is intended to be used for managing the addition and removal of vector layers, +/// which are overlaid on top of other content, such as video streams. +/// +@available(iOS 14, macOS 11, tvOS 14, *) +@MainActor +public protocol LayerMakerProtocol: AnyObject { + + /// The composite layer that contains all the sublayers, including vector layers. + /// + /// This layer acts as a container for all vector layers added through the protocol methods. + var compositeLayer: CALayer? { get set } + + /// The frame of the composite layer. + /// + /// This property defines the size and position of the composite layer within its parent view. + var frame: CGRect { get set } + + /// The bounds of the composite layer. + /// + /// This property defines the drawable area of the composite layer, relative to its own coordinate system. + var bounds: CGRect { get set } + + /// Adds a vector layer to the composite layer using a specified builder. + /// + /// - Parameters: + /// - builder: An instance conforming to `ShapeLayerBuilderProtocol` that constructs the vector layer. + /// - clear: A Boolean value that indicates whether to clear existing vector layers before adding the new one. + func addVectorLayer(builder: any ShapeLayerBuilderProtocol, clear: Bool) + + /// Removes all vector layers from the composite layer. + func removeAllVectors() +} + +extension LayerMakerProtocol{ + + /// Adds a composite layer if vector mode is enabled in the provided `VideoSettings`. + @MainActor + func addCompositeLayer(_ settings: VideoSettings) { + if settings.vector { + compositeLayer = CALayer() + } + } + + /// Removes the composite layer from its superlayer and sets `compositeLayer` to `nil`. + @MainActor + func removeCompositeLayer() { + compositeLayer?.removeFromSuperlayer() + compositeLayer = nil + } + + /// Adds a vector layer to the composite layer using a specified builder. + /// + /// - Parameters: + /// - builder: An instance conforming to `ShapeLayerBuilderProtocol` that constructs the vector layer. + /// - clear: A Boolean value that indicates whether to clear existing vector layers before adding the new one. + @MainActor + func addVectorLayer(builder : any ShapeLayerBuilderProtocol, clear: Bool){ + if clear{ removeAllVectors() } + let layer = builder.build(with: (frame, bounds)) + compositeLayer?.addSublayer(layer) + } + + + /// Removes all vector layers from the composite layer. + @MainActor + func removeAllVectors(){ + compositeLayer?.sublayers?.forEach { $0.removeFromSuperlayer() } + } +} diff --git a/Sources/swiftui-loop-videoplayer/protocol/view/AbstractPlayer.swift b/Sources/swiftui-loop-videoplayer/protocol/view/AbstractPlayer.swift deleted file mode 100644 index 2d6e212..0000000 --- a/Sources/swiftui-loop-videoplayer/protocol/view/AbstractPlayer.swift +++ /dev/null @@ -1,232 +0,0 @@ -// -// AbstractPlayer.swift -// -// -// Created by Igor Shelopaev on 07.08.24. -// - -import AVFoundation -import Foundation - -@available(iOS 14, macOS 11, tvOS 14, *) -public protocol AbstractPlayer: AnyObject{ - - /// The looper responsible for continuous video playback. - var playerLooper: AVPlayerLooper? { get set } - - /// The queue player that plays the video items. - var player: AVQueuePlayer? { get set } - - // Playback control methods - - /// Initiates or resumes playback of the video. - /// This method should be implemented to start playing the video from its current position. - func play() - - /// Pauses the current video playback. - /// This method should be implemented to pause the video, allowing it to be resumed later from the same position. - func pause() - - /// Seeks the video to a specific time. - /// This method moves the playback position to the specified time with precise accuracy. - /// - Parameter time: The target time to seek to in the video timeline. - func seek(to time: Double) - - /// Seeks to the start of the video. - /// This method positions the playback at the beginning of the video. - func seekToStart() - - /// Seeks to the end of the video. - /// This method positions the playback at the end of the video. - func seekToEnd() - - /// Mutes the video playback. - /// This method silences the audio of the video. - func mute() - - /// Unmutes the video playback. - /// This method restores the audio of the video. - func unmute() -} - -extension AbstractPlayer{ - - // Implementations of playback control methods - - /// Initiates playback of the video. - /// This method starts or resumes playing the video from the current position. - func play() { - player?.play() - } - - /// Pauses the video playback. - /// This method pauses the video if it is currently playing, allowing it to be resumed later from the same position. - func pause() { - player?.pause() - } - - /// Seeks the video to a specific time. - /// This method moves the playback position to the specified time with precise accuracy. - /// If the specified time is out of bounds, it will be clamped to the nearest valid time. - /// - Parameter time: The target time to seek to in the video timeline. - func seek(to time: Double) { - guard let player = player, let duration = player.currentItem?.duration else { - return - } - - let endTime = CMTimeGetSeconds(duration) - - if time < 0 { - // If the time is negative, seek to the start of the video - player.seek(to: .zero) - } else if time > endTime { - // If the time exceeds the video duration, seek to the end of the video - let endCMTime = CMTime(seconds: endTime, preferredTimescale: duration.timescale) - player.seek(to: endCMTime) - } else { - // Otherwise, seek to the specified time - let seekCMTime = CMTime(seconds: time, preferredTimescale: duration.timescale) - player.seek(to: seekCMTime) - } - } - - /// Seeks to the start of the video. - /// This method positions the playback at the beginning of the video. - func seekToStart() { - seek(to: 0) - } - - /// Seeks to the end of the video. - /// This method positions the playback at the end of the video. - func seekToEnd() { - if let duration = player?.currentItem?.duration { - let endTime = CMTimeGetSeconds(duration) - seek(to: endTime) - } - } - - /// Mutes the video playback. - /// This method silences the audio of the video. - func mute() { - player?.isMuted = true - } - - /// Unmutes the video playback. - /// This method restores the audio of the video. - func unmute() { - player?.isMuted = false - } - - /// Sets the volume for the video playback. - /// - Parameter volume: A `Float` value between 0.0 (mute) and 1.0 (full volume). - /// If the value is out of range, it will be clamped to the nearest valid value. - func setVolume(_ volume: Float) { - let clampedVolume = max(0.0, min(volume, 1.0)) // Clamp the value between 0.0 and 1.0 - player?.volume = clampedVolume - } - - /// Sets the playback speed for the video playback. - /// - 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). - /// If the value is out of range (negative), it will be clamped to the nearest valid value. - func setPlaybackSpeed(_ speed: Float) { - let clampedSpeed = max(0.0, speed) // Clamp to non-negative values, or adjust the upper bound as needed - player?.rate = clampedSpeed - } - - /// Sets the subtitles for the video playback to a specified language or turns them off. - /// - Parameters: - /// - language: The language code (e.g., "en" for English) for the desired subtitles. - /// Pass `nil` to turn off subtitles. - func setSubtitles(to language: String?) { - guard let currentItem = player?.currentItem, - let group = currentItem.asset.mediaSelectionGroup(forMediaCharacteristic: .legible) else { - return - } - - if let language = language { - // Filter the subtitle options based on the language code - let options = group.options.filter { option in - guard let locale = option.locale else { return false } - return locale.languageCode == language - } - // Select the first matching subtitle option - if let option = options.first { - currentItem.select(option, in: group) - } - } else { - // Turn off subtitles by deselecting any option in the legible media selection group - currentItem.select(nil, in: group) - } - } - - /// Enables looping for the current video item. - /// This method sets up the `playerLooper` to loop the currently playing item indefinitely. - func loop() { - guard let player = player, let currentItem = player.currentItem else { - return - } - - // Check if the video is already being looped - if playerLooper != nil { - return // Already looped, no need to loop again - } - - // Initialize the player looper with the current item - playerLooper = AVPlayerLooper(player: player, templateItem: currentItem) - } - - /// Disables looping for the current video item. - /// This method removes the `playerLooper`, stopping the loop. - func unloop() { - // Check if the video is not looped (i.e., playerLooper is nil) - guard playerLooper != nil else { - return // Not looped, no need to unloop - } - - playerLooper?.disableLooping() - playerLooper = nil - } - - /// Sets the playback command for the video player. - /// - Parameter value: The `PlaybackCommand` to set. This can be one of the following: - /// - `play`: Command to play the video. - /// - `pause`: Command to pause the video. - /// - `seek(to:)`: Command to seek to a specific time in the video. - /// - `begin`: Command to position the video at the beginning. - /// - `end`: Command to position the video at the end. - /// - `mute`: Command to mute the video. - /// - `unmute`: Command to unmute the video. - /// - `volume`: Command to adjust the volume of the video playback. - /// - `subtitles`: Command to set subtitles to a specified language or turn them off. - /// - `playbackSpeed`: Command to adjust the playback speed of the video. - /// - `loop`: Command to enable looping of the video playback. - /// - `unloop`: Command to disable looping of the video playback. - func setCommand(_ value: PlaybackCommand) { - switch value { - case .play: - play() - case .pause: - pause() - case .seek(to: let time): - seek(to: time) - case .begin: - seekToStart() - case .end: - seekToEnd() - case .mute: - mute() - case .unmute: - unmute() - case .volume(let volume): - setVolume(volume) - case .subtitles(let language): - setSubtitles(to: language) - case .playbackSpeed(let speed): - setPlaybackSpeed(speed) - case .loop: - loop() - case .unloop: - unloop() - } - } -} diff --git a/Sources/swiftui-loop-videoplayer/protocol/view/ExtPlayerViewProtocol.swift b/Sources/swiftui-loop-videoplayer/protocol/view/ExtPlayerViewProtocol.swift new file mode 100644 index 0000000..ab4acd9 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/protocol/view/ExtPlayerViewProtocol.swift @@ -0,0 +1,72 @@ +// +// ExtPlayerViewProtocol.swift +// +// +// Created by Igor Shelopaev on 06.08.24. +// + +import AVFoundation +import SwiftUI +import Combine + +/// Protocol that defines the common functionalities and properties +/// for looping video players on different platforms. +@available(iOS 14, macOS 11, tvOS 14, *) +@MainActor @preconcurrency +protocol ExtPlayerViewProtocol { + + #if canImport(UIKit) + /// Typealias for the main view on iOS, using `UIView`. + associatedtype View: UIView + #elseif os(macOS) + /// Typealias for the main view on macOS, using `NSView`. + associatedtype View: NSView + #else + /// Typealias for a custom view type on platforms other than iOS and macOS. + associatedtype View: CustomView + #endif + + #if canImport(UIKit) + /// Typealias for the player view on iOS, conforming to `LoopingPlayerProtocol` and using `UIView`. + associatedtype PlayerView: ExtPlayerProtocol, UIView + #elseif os(macOS) + /// Typealias for the player view on macOS, conforming to `LoopingPlayerProtocol` and using `NSView`. + associatedtype PlayerView: ExtPlayerProtocol, NSView + #else + /// Typealias for a custom player view on other platforms, conforming to `LoopingPlayerProtocol`. + associatedtype PlayerView: ExtPlayerProtocol, CustomView + #endif + + /// Settings for configuring the video player. + var settings: VideoSettings { get set } + + /// Initializes a new instance of `LoopPlayerView`. + /// - Parameters: + /// - settings: A binding to the video settings used by the player. + /// - command: A binding to the playback command that controls playback actions. + /// - timePublisher: A publisher that emits the current playback time as a `Double`. + /// - eventPublisher: A publisher that emits player events as `PlayerEvent` values. + init( + settings: Binding, + command: Binding, + timePublisher: PassthroughSubject, + eventPublisher: PassthroughSubject + ) +} + +@available(iOS 14, macOS 11, tvOS 14, *) +extension ExtPlayerViewProtocol{ + + /// Creates a player view for looping video content. + /// - Parameters: + /// - context: The UIViewRepresentable context providing environment data and coordinator. + /// - Returns: A PlayerView instance conforming to LoopingPlayerProtocol. + @MainActor + func makePlayerView(_ container: View) -> PlayerView? { + + let player = PlayerView(settings: settings) + container.addSubview(player) + activateFullScreenConstraints(for: player, in: container) + return player + } +} diff --git a/Sources/swiftui-loop-videoplayer/protocol/view/LoopPlayerViewProtocol.swift b/Sources/swiftui-loop-videoplayer/protocol/view/LoopPlayerViewProtocol.swift deleted file mode 100644 index 50757ac..0000000 --- a/Sources/swiftui-loop-videoplayer/protocol/view/LoopPlayerViewProtocol.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// LoopPlayerViewProtocol.swift -// -// -// Created by Igor Shelopaev on 06.08.24. -// - -import AVFoundation -import SwiftUI - -/// Protocol that defines the common functionalities and properties -/// for looping video players on different platforms. -@available(iOS 14, macOS 11, tvOS 14, *) -@MainActor -public protocol LoopPlayerViewProtocol { - -#if canImport(UIKit) - associatedtype View : UIView - #elseif os(macOS) - associatedtype View : NSView - #else - associatedtype View : CustomView - #endif - - associatedtype ErrorView - - #if canImport(UIKit) - associatedtype PlayerView: LoopingPlayerProtocol, UIView - #elseif os(macOS) - associatedtype PlayerView: LoopingPlayerProtocol, NSView - #else - associatedtype PlayerView: LoopingPlayerProtocol, CustomView - #endif - - /// Settings for configuring the video player. - var settings: VideoSettings { get set } - - /// Initializes a new instance with the provided settings and playback command. - /// - /// - Parameters: - /// - settings: A binding to a `VideoSettings` containing configuration details. - /// - command: A binding to a `PlaybackCommand` that controls playback actions. - /// - /// This initializer sets up the necessary configuration and command bindings for playback functionality. - init(settings: Binding, command: Binding) - -} - -@available(iOS 14, macOS 11, tvOS 14, *) -public extension LoopPlayerViewProtocol{ - - /// Updates the view by removing existing error messages and displaying a new one if an error is present. - /// - Parameters: - /// - view: The view that needs to be updated with potential error messages. - /// - error: The optional error that might need to be displayed. - @MainActor - func updateView(_ view: View, error: VPErrors?) { - - makeErrorView(view, error: error) - } - - /// Constructs an error view and adds it to the specified view if an error is present. - /// - Parameters: - /// - view: The view to which the error message view will be added. - /// - error: The optional error which, if present, triggers the creation and addition of an error-specific view. - @MainActor - func makeErrorView(_ view: View, error: VPErrors?) { - if let error = error { - let errorView = errorTpl(error, settings.errorColor, settings.errorFontSize) - view.addSubview(errorView) - activateFullScreenConstraints(for: errorView, in: view) - } - } - - /// Creates a player view for looping video content. - /// - Parameters: - /// - context: The UIViewRepresentable context providing environment data and coordinator. - /// - asset: The AVURLAsset to be used for video playback. - /// - Returns: A PlayerView instance conforming to LoopingPlayerProtocol. - @MainActor - func makePlayerView( - _ container: View, - asset: AVURLAsset?) -> PlayerView? { - - if let asset{ - let player = PlayerView(asset: asset, gravity: settings.gravity) - container.addSubview(player) - activateFullScreenConstraints(for: player, in: container) - return player - } - - return nil - } -} diff --git a/Sources/swiftui-loop-videoplayer/protocol/view/LoopingPlayerProtocol.swift b/Sources/swiftui-loop-videoplayer/protocol/view/LoopingPlayerProtocol.swift deleted file mode 100644 index fbce6da..0000000 --- a/Sources/swiftui-loop-videoplayer/protocol/view/LoopingPlayerProtocol.swift +++ /dev/null @@ -1,196 +0,0 @@ -// -// LoopingPlayerProtocol.swift -// -// -// Created by Igor Shelopaev on 05.08.24. -// - -import AVFoundation -import Foundation -#if canImport(UIKit) -import UIKit -#elseif canImport(AppKit) -import AppKit -#endif -/// A protocol defining the requirements for a looping video player. -/// -/// Conforming types are expected to manage a video player that can loop content continuously, -/// handle errors, and notify a delegate of important events. -@available(iOS 14, macOS 11, tvOS 14, *) -@MainActor -public protocol LoopingPlayerProtocol: AbstractPlayer{ - - var playerLayer : AVPlayerLayer { get } - - #if canImport(UIKit) - var layer : CALayer { get } - #elseif canImport(AppKit) - var layer : CALayer? { get set } - var wantsLayer : Bool { get set } - #endif - - /// The delegate to be notified about errors encountered by the player. - var delegate: PlayerErrorDelegate? { get set } - - /// An optional NSKeyValueObservation to monitor changes in the playback status of the video. - /// This observer should be set up to watch for changes such as play, pause, or errors in the AVPlayerItem, - /// allowing the conforming object to respond to different states of the video playback. - var statusObserver: NSKeyValueObservation? { get set } - - /// An optional NSKeyValueObservation to monitor errors encountered by the video player. - /// This observer should be configured to detect and handle errors from the AVQueuePlayer, - /// ensuring that all playback errors are managed and reported appropriately. - var errorObserver: NSKeyValueObservation? { get set } - - /// Initializes a video player with a specified media asset and layer gravity. - /// - Parameters: - /// - asset: The `AVURLAsset` representing the media content to be played. This asset encapsulates the properties of the media file. - /// - gravity: The `AVLayerVideoGravity` that determines how the video content is displayed within the bounds of the player layer. Common values are `.resizeAspect`, `.resizeAspectFill`, and `.resize` to control the scaling and filling behavior of the video content. - init(asset: AVURLAsset, gravity: AVLayerVideoGravity) - - /// Sets up the necessary observers on the AVPlayerItem and AVQueuePlayer to monitor changes and errors. - /// - /// - Parameters: - /// - item: The AVPlayerItem to observe for status changes. - /// - player: The AVQueuePlayer to observe for errors. - func setupObservers(for item: AVPlayerItem, player: AVQueuePlayer) - - /// Responds to changes in the playback status of an AVPlayerItem. - /// - /// - Parameter item: The AVPlayerItem whose status changed. - func handlePlayerItemStatusChange(_ item: AVPlayerItem) - - /// Responds to errors reported by the AVQueuePlayer. - /// - /// - Parameter player: The AVQueuePlayer that encountered an error. - func handlePlayerError(_ player: AVPlayer) -} - -extension LoopingPlayerProtocol { - - /// The current asset being played, if available. - /// - /// This computed property checks the current item of the player. - /// If the current item exists and its asset can be cast to AVURLAsset, - var currentAsset : AVURLAsset?{ - if let currentItem = player?.currentItem { - return currentItem.asset as? AVURLAsset - } - return nil - } - - /// Updates the player to play a new asset and handles the playback state. - /// - /// This method pauses the player if it was previously playing, - /// replaces the current player item with a new item created from the provided asset, - /// and seeks to the start of the new item. It resumes playing if the player was playing before the update. - /// - /// - Parameters: - /// - asset: The AVURLAsset to load into the player. - func update(asset: AVURLAsset){ - // Optionally, check if the player is currently playing - let wasPlaying = player?.rate != 0 - - // Pause the player if it was playing - if wasPlaying { - player?.pause() - } - - // Replace the current item with a new item created from the asset - let newItem = AVPlayerItem(asset: asset) - unloop() - player?.replaceCurrentItem(with: newItem) - - // Seek to the beginning of the item if you want to start from the start - player?.seek(to: .zero, completionHandler: { _ in - // Resume playing if the player was playing before - if wasPlaying { - self.player?.play() - } - }) - } - - /// Sets up the player components using the provided asset and video gravity. - /// - /// This method initializes an AVPlayerItem with the provided asset, - /// configures an AVQueuePlayer for playback, sets up the player for the view, - /// and adds necessary observers to monitor playback status and errors. - /// - /// - Parameters: - /// - asset: The AVURLAsset to be played. - /// - gravity: The AVLayerVideoGravity to be applied to the video layer. - func setupPlayerComponents(asset: AVURLAsset, gravity: AVLayerVideoGravity) { - // Create an AVPlayerItem with the provided asset - let item = AVPlayerItem(asset: asset) - - // Initialize an AVQueuePlayer with the player item - let player = AVQueuePlayer(items: [item]) - self.player = player - - // Configure the player with the specified gravity - configurePlayer(player, gravity: gravity) - - // Set up observers to monitor status and errors - setupObservers(for: item, player: player) - } - - /// Configures the provided AVQueuePlayer with specific properties for video playback. - /// - /// This method sets the video gravity and muted state of the player, and assigns it to a player layer. - /// It is intended to set up the player with the necessary configuration for video presentation based on the given gravity. - /// - Parameters: - /// - player: The AVQueuePlayer to be configured. - /// - gravity: The AVLayerVideoGravity determining how the video content should be scaled or fit within the player layer. - internal func configurePlayer(_ player: AVQueuePlayer, gravity: AVLayerVideoGravity) { - player.isMuted = true - playerLayer.player = player - playerLayer.videoGravity = gravity - #if canImport(UIKit) - playerLayer.backgroundColor = UIColor.clear.cgColor - layer.addSublayer(playerLayer) - #elseif canImport(AppKit) - playerLayer.backgroundColor = NSColor.clear.cgColor - layer = CALayer() - layer?.addSublayer(playerLayer) - wantsLayer = true - #endif - - loop() - player.play() - } - - /// Sets up observers on the player item and the player to track their status and error states. - /// - /// - Parameters: - /// - item: The player item to observe. - /// - player: The player to observe. - func setupObservers(for item: AVPlayerItem, player: AVQueuePlayer) { - statusObserver = item.observe(\.status, options: [.new]) { [weak self] item, _ in - self?.handlePlayerItemStatusChange(item) - } - - errorObserver = player.observe(\.error, options: [.new]) { [weak self] player, _ in - self?.handlePlayerError(player) - } - } - - /// Responds to changes in the status of an AVPlayerItem. - /// - /// This method checks if the status of the AVPlayerItem indicates a failure. - /// If a failure occurs, it notifies the delegate about the error. - /// - Parameter item: The AVPlayerItem whose status has changed to be evaluated. - func handlePlayerItemStatusChange(_ item: AVPlayerItem) { - guard item.status == .failed, let error = item.error else { return } - delegate?.didReceiveError(.remoteVideoError(error)) - } - - /// Responds to errors reported by the AVPlayer. - /// - /// If an error is present, this method notifies the delegate of the encountered error, - /// encapsulated within a `remoteVideoError`. - /// - Parameter player: The AVPlayer that encountered an error to be evaluated. - func handlePlayerError(_ player: AVPlayer) { - guard let error = player.error else { return } - delegate?.didReceiveError(.remoteVideoError(error)) - } -} diff --git a/Sources/swiftui-loop-videoplayer/settings/EnableVector.swift b/Sources/swiftui-loop-videoplayer/settings/EnableVector.swift new file mode 100644 index 0000000..f386581 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/settings/EnableVector.swift @@ -0,0 +1,29 @@ +// +// EnableVector.swift +// +// +// Created by Igor Shelopaev on 14.01.25. +// + +import Foundation + +/// A structure to enable a vector layer for overlaying vector graphics. +/// +/// Use this struct to activate settings that allow the addition of vector-based +/// overlays via commands, such as shapes, paths, or other scalable graphics, on top of existing content. +/// This structure is designed with optimization in mind, ensuring that extra layers +/// are not added if they are unnecessary, reducing overhead and improving performance. +@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) +public struct EnableVector: SettingsConvertible{ + + // MARK: - Life circle + + /// Initializes a new instance + public init() {} + + /// Fetch settings + @_spi(Private) + public func asSettings() -> [Setting] { + [.vector] + } +} diff --git a/Sources/swiftui-loop-videoplayer/settings/Events.swift b/Sources/swiftui-loop-videoplayer/settings/Events.swift new file mode 100644 index 0000000..369a198 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/settings/Events.swift @@ -0,0 +1,33 @@ +// +// Events.swift +// +// +// Created by Igor Shelopaev on 14.01.25. +// + +import Foundation + +/// Represents a collection of event filters that can be converted into settings. +/// This struct is used to encapsulate `PlayerEventFilter` instances and provide a method +/// to transform them into an array of `Setting` objects. +@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) +public struct Events: SettingsConvertible { + + // An optional array of PlayerEventFilter objects representing event filters + private let value: [PlayerEventFilter]? + + // MARK: - Life cycle + + /// Initializes a new instance of `Events` + /// - Parameter value: An optional array of `PlayerEventFilter` objects, defaulting to `nil` + public init(_ value: [PlayerEventFilter]? = nil) { + self.value = value + } + + /// Converts the event filters into an array of `Setting` objects + /// Used for fetching settings in the application + @_spi(Private) + public func asSettings() -> [Setting] { + [.events(value)] + } +} diff --git a/Sources/swiftui-loop-videoplayer/settings/Ext.swift b/Sources/swiftui-loop-videoplayer/settings/Ext.swift index 52aa8ca..1bbc710 100644 --- a/Sources/swiftui-loop-videoplayer/settings/Ext.swift +++ b/Sources/swiftui-loop-videoplayer/settings/Ext.swift @@ -2,19 +2,23 @@ // Ext.swift // // -// Created by Igor on 07.07.2023. +// Created by Igor Shelopaev on 07.07.2023. // import Foundation + +/// Represents a structure that holds the file extension for a video, conforming to the `SettingsConvertible` protocol. @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) public struct Ext: SettingsConvertible{ - /// Video file extension - let value : String + /// The video file extension value. + let value: String // MARK: - Life circle + /// Initializes a new instance of `Ext` with a specific file extension. + /// - Parameter value: A string representing the file extension of a video. public init(_ value: String) { self.value = value } /// Fetch settings diff --git a/Sources/swiftui-loop-videoplayer/settings/Gravity.swift b/Sources/swiftui-loop-videoplayer/settings/Gravity.swift index 8925277..dd85e23 100644 --- a/Sources/swiftui-loop-videoplayer/settings/Gravity.swift +++ b/Sources/swiftui-loop-videoplayer/settings/Gravity.swift @@ -2,20 +2,23 @@ // Gravity.swift // // -// Created by Igor on 07.07.2023. +// Created by Igor Shelopaev on 07.07.2023. // import Foundation import AVKit +/// Represents video layout options as gravity settings, conforming to `SettingsConvertible`. @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) public struct Gravity : SettingsConvertible{ - /// Video gravity spec + /// Holds the specific AVLayerVideoGravity setting defining how video content should align within its layer. private let value : AVLayerVideoGravity // MARK: - Life circle + /// Initializes a new Gravity instance with a specified video gravity. + /// - Parameter value: The `AVLayerVideoGravity` value to set. public init(_ value: AVLayerVideoGravity) { self.value = value } /// Fetch settings diff --git a/Sources/swiftui-loop-videoplayer/settings/Loop.swift b/Sources/swiftui-loop-videoplayer/settings/Loop.swift new file mode 100644 index 0000000..bf59640 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/settings/Loop.swift @@ -0,0 +1,25 @@ +// +// Loop.swift +// +// +// Created by Igor Shelopaev on 16.08.24. +// + +import Foundation + + +/// Represents a settings structure that enables looping functionality, conforming to `SettingsConvertible`. +@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) +public struct Loop: SettingsConvertible{ + + // MARK: - Life circle + + /// Initializes a new instance + public init() {} + + /// Fetch settings + @_spi(Private) + public func asSettings() -> [Setting] { + [.loop] + } +} diff --git a/Sources/swiftui-loop-videoplayer/settings/Mute.swift b/Sources/swiftui-loop-videoplayer/settings/Mute.swift new file mode 100644 index 0000000..0b89525 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/settings/Mute.swift @@ -0,0 +1,25 @@ +// +// Mute.swift +// +// +// Created by Igor Shelopaev on 10.09.24. +// + +import Foundation + + +/// Represents a structure that enables muting functionality, conforming to `SettingsConvertible`. +@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) +public struct Mute: SettingsConvertible{ + + // MARK: - Life circle + + /// Initializes a new instance + public init() {} + + /// Fetch settings + @_spi(Private) + public func asSettings() -> [Setting] { + [.mute] + } +} diff --git a/Sources/swiftui-loop-videoplayer/settings/NotAutoPlay.swift b/Sources/swiftui-loop-videoplayer/settings/NotAutoPlay.swift new file mode 100644 index 0000000..dce9e8e --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/settings/NotAutoPlay.swift @@ -0,0 +1,25 @@ +// +// NotAutoPlay.swift +// +// +// Created by Igor on 10.09.24. +// + +import Foundation + + +/// Represents a setting to disable automatic playback, conforming to `SettingsConvertible`. +@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) +public struct NotAutoPlay: SettingsConvertible{ + + // MARK: - Life circle + + /// Initializes a new instance + public init() {} + + /// Fetch settings + @_spi(Private) + public func asSettings() -> [Setting] { + [.notAutoPlay] + } +} diff --git a/Sources/swiftui-loop-videoplayer/settings/PictureInPicture .swift b/Sources/swiftui-loop-videoplayer/settings/PictureInPicture .swift new file mode 100644 index 0000000..c98b49a --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/settings/PictureInPicture .swift @@ -0,0 +1,25 @@ +// +// PictureInPicture.swift +// +// +// Created by Igor Shelopaev on 21.01.25. +// + +import Foundation + + +/// Represents a PictureInPicture functionality, conforming to `SettingsConvertible`. +@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) +public struct PictureInPicture : SettingsConvertible{ + + // MARK: - Life circle + + /// Initializes a new instance + public init() {} + + /// Fetch settings + @_spi(Private) + public func asSettings() -> [Setting] { + [.pictureInPicture] + } +} diff --git a/Sources/swiftui-loop-videoplayer/settings/SourceName.swift b/Sources/swiftui-loop-videoplayer/settings/SourceName.swift index 786b272..c909b70 100644 --- a/Sources/swiftui-loop-videoplayer/settings/SourceName.swift +++ b/Sources/swiftui-loop-videoplayer/settings/SourceName.swift @@ -2,11 +2,13 @@ // FileName.swift // // -// Created by Igor on 07.07.2023. +// Created by Igor Shelopaev on 07.07.2023. // import Foundation + +/// Represents a structure that holds the name of a video source, conforming to `SettingsConvertible`. @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) public struct SourceName : SettingsConvertible{ @@ -15,6 +17,8 @@ public struct SourceName : SettingsConvertible{ // MARK: - Life circle + /// Initializes a new instance with a specific video file name. + /// - Parameter value: The string representing the video file name. public init(_ value: String) { self.value = value } /// Fetch settings @@ -22,5 +26,4 @@ public struct SourceName : SettingsConvertible{ public func asSettings() -> [Setting] { [.name(value)] } - } diff --git a/Sources/swiftui-loop-videoplayer/settings/Subtitles.swift b/Sources/swiftui-loop-videoplayer/settings/Subtitles.swift new file mode 100644 index 0000000..b418ee2 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/settings/Subtitles.swift @@ -0,0 +1,34 @@ +// +// Subtitles.swift +// swiftui-loop-videoplayer +// +// Created by Igor Shelopaev on 07.01.25. +// + +/// Represents a structure that holds the name of subtitles, conforming to `SettingsConvertible`. +/// +/// Important: +/// - When using `.vtt` subtitles, a file-based container format such as MP4 or QuickTime (`.mov`) +/// generally supports embedding those subtitles as a `.text` track. +/// - Formats like HLS (`.m3u8`) typically reference `.vtt` files externally rather than merging them +/// into a single file. +/// - Attempting to merge `.vtt` subtitles into an HLS playlist via `AVMutableComposition` won't work; +/// instead, you’d attach the `.vtt` as a separate media playlist in the HLS master manifest. +@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) +public struct Subtitles : SettingsConvertible{ + + /// Video file name + let value : String + + // MARK: - Life circle + + /// Initializes a new instance with a specific video file name. + /// - Parameter value: The string representing the video file name. + public init(_ value: String) { self.value = value } + + /// Fetch settings + @_spi(Private) + public func asSettings() -> [Setting] { + [.subtitles(value)] + } +} diff --git a/Sources/swiftui-loop-videoplayer/settings/TimePublishing.swift b/Sources/swiftui-loop-videoplayer/settings/TimePublishing.swift new file mode 100644 index 0000000..bd45ea6 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/settings/TimePublishing.swift @@ -0,0 +1,29 @@ +// +// TimePublishing.swift +// +// +// Created by Igor Shelopaev on 15.08.24. +// + +import Foundation +import AVFoundation + +/// Represents a structure for setting the publishing time of a video, conforming to `SettingsConvertible`. +@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) +public struct TimePublishing: SettingsConvertible{ + + /// Holds the time value associated with the video publishing time, using `CMTime`. + let value : CMTime + + // MARK: - Life circle + + /// Initializes a new instance of `TimePublishing` with an optional `CMTime` value, defaulting to 1 second at a timescale of 600 if not provided. + /// - Parameter value: Optional `CMTime` value to set as the default time. + public init(_ value: CMTime? = nil) { self.value = value ?? CMTime(seconds: 1, preferredTimescale: 600) } + + /// Fetch settings + @_spi(Private) + public func asSettings() -> [Setting] { + [.timePublishing(value)] + } +} diff --git a/Sources/swiftui-loop-videoplayer/settings/errors/EColor.swift b/Sources/swiftui-loop-videoplayer/settings/errors/EColor.swift deleted file mode 100644 index 6ea4bc8..0000000 --- a/Sources/swiftui-loop-videoplayer/settings/errors/EColor.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// EText.swift -// -// -// Created by Igor on 07.07.2023. -// - -import SwiftUI - -@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) -public struct EColor: SettingsConvertible{ - - /// Error color - private let value : Color - - // MARK: - Life circle - - /// - Parameter value: Error color - public init(_ value: Color) { self.value = value } - - /// Fetch settings - @_spi(Private) - public func asSettings() -> [Setting] { - [.errorColor(value)] - } -} diff --git a/Sources/swiftui-loop-videoplayer/settings/errors/EFontSize.swift b/Sources/swiftui-loop-videoplayer/settings/errors/EFontSize.swift deleted file mode 100644 index 895114f..0000000 --- a/Sources/swiftui-loop-videoplayer/settings/errors/EFontSize.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// EFontSize.swift -// -// -// Created by Igor on 07.07.2023. -// - -import Foundation -import CoreGraphics - -@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) -public struct EFontSize: SettingsConvertible{ - - /// Font size value - private let value : CGFloat - - // MARK: - Life circle - - public init(_ value: CGFloat) { self.value = value } - - /// Fetch settings - @_spi(Private) - public func asSettings() -> [Setting] { - [.errorFontSize(value)] - } -} diff --git a/Sources/swiftui-loop-videoplayer/settings/errors/ErrorGroup.swift b/Sources/swiftui-loop-videoplayer/settings/errors/ErrorGroup.swift deleted file mode 100644 index 56b8150..0000000 --- a/Sources/swiftui-loop-videoplayer/settings/errors/ErrorGroup.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// ErrorGroup.swift -// -// -// Created by Igor on 07.07.2023. -// - -import Foundation - -@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) -public struct ErrorGroup: SettingsConvertible{ - - /// Errors settings - private let settings : [Setting] - - // MARK: - Life circle - - public init(@SettingsBuilder builder: () -> [Setting] ) { - settings = builder() - } - - /// Fetch settings - @_spi(Private) - public func asSettings() -> [Setting] { - settings - } -} diff --git a/Sources/swiftui-loop-videoplayer/utils/SettingsBuilder.swift b/Sources/swiftui-loop-videoplayer/utils/SettingsBuilder.swift index 746b2b1..5c4f4a4 100644 --- a/Sources/swiftui-loop-videoplayer/utils/SettingsBuilder.swift +++ b/Sources/swiftui-loop-videoplayer/utils/SettingsBuilder.swift @@ -2,23 +2,35 @@ // SettingsBuilder.swift // // -// Created by Igor on 07.07.2023. +// Created by Igor Shelopaev on 07.07.2023. // import SwiftUI +import AVKit +/// Result builder to construct an array of 'Setting' objects. @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) @resultBuilder -public struct SettingsBuilder{ +public struct SettingsBuilder { - /// Build block - /// - Returns: Empty array - public static func buildBlock() -> [Setting] { [] } + /// Combines a single expression into an array of settings. + /// - Parameter expression: A type conforming to `SettingsConvertible`. + /// - Returns: An array of settings derived from the expression. + public static func buildExpression(_ expression: SettingsConvertible) -> [Setting] { + return expression.asSettings() + } + + /// Combines an optional expression into an array of settings. + /// - Parameter component: An optional type conforming to `SettingsConvertible`. + /// - Returns: An array of settings derived from the expression if it's non-nil, otherwise an empty array. + public static func buildOptional(_ component: [Setting]?) -> [Setting] { + return component ?? [] + } - /// Build block - /// - Parameter values: Input values - /// - Returns: Array of settings - public static func buildBlock(_ values: any SettingsConvertible...) -> [Setting]{ - values.flatMap{ $0.asSettings() } + /// Combines multiple expressions into a single array of settings. + /// - Parameter components: An array of arrays of settings. + /// - Returns: A flattened array of settings. + public static func buildBlock(_ components: [Setting]...) -> [Setting] { + return components.flatMap { $0 } } } diff --git a/Sources/swiftui-loop-videoplayer/utils/VideoSettings.swift b/Sources/swiftui-loop-videoplayer/utils/VideoSettings.swift index ede5fcd..d2c3f57 100644 --- a/Sources/swiftui-loop-videoplayer/utils/VideoSettings.swift +++ b/Sources/swiftui-loop-videoplayer/utils/VideoSettings.swift @@ -2,12 +2,13 @@ // Settings.swift // // -// Created by Igor on 07.07.2023. +// Created by Igor Shelopaev on 07.07.2023. // import SwiftUI import AVKit +/// Represents a structure for video settings. @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) public struct VideoSettings: Equatable{ @@ -18,16 +19,35 @@ public struct VideoSettings: Equatable{ /// Video extension public let ext: String + + /// Subtitles + public let subtitles: String + + /// Loop video + public let loop: Bool + + /// Loop video + public let pictureInPicture: Bool + + /// Mute video + public let mute: Bool + + /// Enable vector layer to add overlay vector graphics + public let vector: Bool + + /// Disable events + public let events: [PlayerEventFilter]? + + /// Don't auto play video after initialization + public let notAutoPlay: Bool + + /// A CMTime value representing the interval at which the player's current time should be published. + /// If set, the player will publish periodic time updates based on this interval. + public let timePublishing: CMTime? /// A structure that defines how a layer displays a player’s visual content within the layer’s bounds public let gravity: AVLayerVideoGravity - - /// Error message text color - public let errorColor : Color - - /// Size of the error text Default : 17.0 - public let errorFontSize : CGFloat - + /// Are the params unique public var areUnique : Bool { unique @@ -39,8 +59,39 @@ public struct VideoSettings: Equatable{ private let unique : Bool // MARK: - Life circle + + /// Initializes a new instance of `VideoSettings` with specified values for various video properties. + /// + /// - Parameters: + /// - name: The name of the video file (excluding the extension). + /// - ext: The file extension of the video (e.g., `"mp4"`, `"mov"`). + /// - subtitles: The subtitle file name or identifier to be used for the video. + /// - loop: A Boolean indicating whether the video should continuously loop after playback ends. + /// - pictureInPicture: A Boolean indicating whether Picture-in-Picture (PiP) mode is enabled. + /// - mute: A Boolean indicating whether the video should start muted. + /// - notAutoPlay: A Boolean indicating whether the video should not start playing automatically. + /// - timePublishing: A `CMTime` value representing the interval for time update callbacks, or `nil` if disabled. + /// - gravity: The `AVLayerVideoGravity` value defining how the video should be displayed within its layer. + /// - enableVector: A Boolean indicating whether vector graphics rendering should be enabled for overlays. + /// + /// All parameters must be provided, except `timePublishing`, which can be `nil`, and `enableVector`, which defaults to `false`. + public init(name: String, ext: String, subtitles: String, loop: Bool, pictureInPicture: Bool, mute: Bool, notAutoPlay: Bool, timePublishing: CMTime?, gravity: AVLayerVideoGravity, enableVector : Bool = false, events : [PlayerEventFilter] = []) { + self.name = name + self.ext = ext + self.subtitles = subtitles + self.loop = loop + self.pictureInPicture = pictureInPicture + self.mute = mute + self.notAutoPlay = notAutoPlay + self.timePublishing = timePublishing + self.gravity = gravity + self.vector = enableVector + self.events = events + self.unique = true + } - /// - Parameter builder: Block builder + /// Initializes `VideoSettings` using a settings builder closure. + /// - Parameter builder: A block builder that generates an array of settings. public init(@SettingsBuilder builder: () -> [Setting]){ let settings = builder() @@ -50,11 +101,56 @@ public struct VideoSettings: Equatable{ ext = settings.fetch(by : "ext", defaulted: "mp4") + subtitles = settings.fetch(by : "subtitles", defaulted: "") + gravity = settings.fetch(by : "gravity", defaulted: .resizeAspect) + + timePublishing = settings.fetch(by : "timePublishing", defaulted: nil) - errorColor = settings.fetch(by : "errorColor", defaulted: .red) + loop = settings.contains(.loop) - errorFontSize = settings.fetch(by : "errorFontSize", defaulted: 17) + pictureInPicture = settings.contains(.pictureInPicture) + + mute = settings.contains(.mute) + + notAutoPlay = settings.contains(.notAutoPlay) + + vector = settings.contains(.vector) + + let hasEvents = settings.contains { + if case .events = $0 { + return true + } + return false + } + + if hasEvents{ + events = settings.fetch(by : "events", defaulted: []) ?? [] + }else{ + events = nil + } + } +} + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) +public extension VideoSettings { + + /// Checks if the asset has changed based on the provided settings and current asset. + /// - Parameters: + /// - asset: The current asset being played. + /// - Returns: A new `AVURLAsset` if the asset has changed, or `nil` if the asset remains the same. + func isEqual(_ settings : VideoSettings?) -> Bool{ + let newAsset = assetFor(self) + + guard let settings = settings else{ return false } + + let oldAsset = assetFor(settings) + + if let newUrl = newAsset?.url, let oldUrl = oldAsset?.url, newUrl != oldUrl{ + return false + } + + return true } } @@ -66,4 +162,3 @@ fileprivate func check(_ settings : [Setting]) -> Bool{ let set = Set(cases) return cases.count == set.count } - diff --git a/Sources/swiftui-loop-videoplayer/view/helpers/PlayerCoordinator.swift b/Sources/swiftui-loop-videoplayer/view/helpers/PlayerCoordinator.swift new file mode 100644 index 0000000..781d72a --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/view/helpers/PlayerCoordinator.swift @@ -0,0 +1,176 @@ +// +// PlayerCoordinator.swift +// +// +// Created by Igor Shelopaev on 06.08.24. +// + +import SwiftUI +import Combine +import AVFoundation +#if os(iOS) +import AVKit +#endif + +@MainActor +internal class PlayerCoordinator: NSObject, PlayerDelegateProtocol { + + /// Publisher that emits player events, allowing observers to react to changes in playback state + let eventPublisher: PassthroughSubject + + /// Publisher that emits the current playback time as a Double, allowing real-time tracking of progress + let timePublisher: PassthroughSubject + + /// Stores the last command applied to the player. + private var lastCommand: PlaybackCommand? + + init( + timePublisher: PassthroughSubject, + eventPublisher: PassthroughSubject + ) { + self.timePublisher = timePublisher + self.eventPublisher = eventPublisher + } + + /// Deinitializes the coordinator and prints a debug message if in DEBUG mode. + deinit { + #if DEBUG + print("Deinit Coordinator") + #endif + } + + /// Handles receiving an error and updates the error state in the parent view. + /// This method is called when an error is encountered during playback or other operations. + /// - Parameter error: The error received. + func didReceiveError(_ error: VPErrors) { + eventPublisher.send(.error(error)) + } + + /// Sets the last command applied to the player. + /// This method updates the stored `lastCommand` to the provided value. + /// - Parameter command: The `PlaybackCommand` that was last applied to the player. + func setLastCommand(_ command: PlaybackCommand) { + self.lastCommand = command + } + + /// Retrieves the last command applied to the player. + /// - Returns: The `PlaybackCommand` that was last applied to the player. + var getLastCommand : PlaybackCommand? { + return lastCommand + } + + /// A method that handles the passage of time in the player. + /// - Parameter seconds: The amount of time, in seconds, that has passed. + func didPassedTime(seconds : Double) { + timePublisher.send(seconds) + } + + /// A method that handles seeking in the player. + /// - Parameters: + /// - value: A Boolean indicating whether the seek was successful. + /// - currentTime: The current time of the player after seeking, in seconds. + func didSeek(value: Bool, currentTime : Double) { + eventPublisher.send(.seek(value, currentTime: currentTime)) + } + + /// Called when the player has paused playback. + /// + /// This method is triggered when the player's `timeControlStatus` changes to `.paused`. + func didPausePlayback(){ + eventPublisher.send(.paused) + } + + /// Called when the player is waiting to play at the specified rate. + /// + /// This method is triggered when the player's `timeControlStatus` changes to `.waitingToPlayAtSpecifiedRate`. + func isWaitingToPlay(){ + eventPublisher.send(.waitingToPlayAtSpecifiedRate) + } + + /// Called when the player starts or resumes playing. + /// + /// This method is triggered when the player's `timeControlStatus` changes to `.playing`. + func didStartPlaying(){ + eventPublisher.send(.playing) + } + + /// Called when the current media item in the player changes. + /// + /// This method is triggered when the player's `currentItem` is updated to a new `AVPlayerItem`. + /// - Parameter newItem: The new `AVPlayerItem` that the player has switched to, if any. + func currentItemDidChange(to newItem: AVPlayerItem?){ + eventPublisher.send(.currentItemChanged(newItem: newItem)) + } + + /// Called when the current media item is removed from the player. + /// + /// This method is triggered when the player's `currentItem` is set to `nil`, indicating that there is no longer an active media item. + func currentItemWasRemoved(){ + eventPublisher.send(.currentItemRemoved) + } + + /// Called when the volume level of the player changes. + /// + /// This method is triggered when the player's `volume` property changes. + /// - Parameter newVolume: The new volume level, expressed as a float between 0.0 (muted) and 1.0 (maximum volume). + func volumeDidChange(to newVolume: Float){ + eventPublisher.send(.volumeChanged(newVolume: newVolume)) + } + + /// Notifies that the bounds have changed. + /// + /// - 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. + func boundsDidChange(to bounds: CGRect) { + eventPublisher.send(.boundsChanged(bounds)) + } + + /// Called when the AVPlayerItem's status changes. + /// - Parameter status: The new status of the AVPlayerItem. + /// - `.unknown`: The item is still loading or its status is not yet determined. + /// - `.readyToPlay`: The item is fully loaded and ready to play. + /// - `.failed`: The item failed to load due to an error. + func itemStatusChanged(_ status: AVPlayerItem.Status) { + eventPublisher.send(.itemStatusChanged(status)) + } + + /// Called when the duration of the AVPlayerItem is available. + /// - Parameter time: The total duration of the media item in `CMTime`. + /// - This method is only called when the item reaches `.readyToPlay`, + /// ensuring that the duration value is valid. + func duration(_ time: CMTime) { + eventPublisher.send(.duration(time)) + } + +} + +#if os(iOS) +extension PlayerCoordinator: AVPictureInPictureControllerDelegate{ + + /// Called when Picture-in-Picture (PiP) mode starts. + /// + /// - Parameter pictureInPictureController: The `AVPictureInPictureController` instance managing the PiP session. + /// + /// This method is marked as `nonisolated` to avoid being tied to the actor's execution context, + /// allowing it to be called from any thread. It publishes a `.startedPiP` event on the `eventPublisher` + /// within a `Task` running on the `MainActor`, ensuring UI updates are handled on the main thread. + nonisolated func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + Task{ @MainActor in + eventPublisher.send(.startedPiP) + } + } + + + /// Called when Picture-in-Picture (PiP) mode stops. + /// + /// - Parameter pictureInPictureController: The `AVPictureInPictureController` instance managing the PiP session. + /// + /// Like its counterpart for starting PiP, this method is `nonisolated`, allowing it to be executed from any thread. + /// It sends a `.stoppedPiP` event via `eventPublisher` on the `MainActor`, ensuring any UI-related handling + /// occurs safely on the main thread. + nonisolated func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + Task{ @MainActor in + eventPublisher.send(.stoppedPiP) + } + } +} +#endif diff --git a/Sources/swiftui-loop-videoplayer/view/loop/helpers/PlayerErrorCoordinator.swift b/Sources/swiftui-loop-videoplayer/view/loop/helpers/PlayerErrorCoordinator.swift deleted file mode 100644 index 3b93a39..0000000 --- a/Sources/swiftui-loop-videoplayer/view/loop/helpers/PlayerErrorCoordinator.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// PlayerErrorCoordinator.swift -// -// -// Created by Igor on 06.08.24. -// - -import SwiftUI - -@MainActor -internal class PlayerCoordinator: NSObject, PlayerErrorDelegate { - - /// Stores the last command applied to the player. - private var lastCommand: PlaybackCommand? - - /// A binding to an optional `VPErrors` instance, used to report errors back to the parent view. - @Binding private var error: VPErrors? - - /// Initializes a new instance of `PlayerCoordinator`. - /// - Parameter error: A binding to an optional `VPErrors` instance to manage error reporting. - init(_ error: Binding) { - self._error = error - } - - /// Deinitializes the coordinator and prints a debug message if in DEBUG mode. - deinit { - #if DEBUG - print("deinit Coordinator") - #endif - } - - /// Handles receiving an error and updates the error state in the parent view. - /// This method is called when an error is encountered during playback or other operations. - /// - Parameter error: The error received. - func didReceiveError(_ error: VPErrors) { - self.error = error - } - - /// Sets the last command applied to the player. - /// This method updates the stored `lastCommand` to the provided value. - /// - Parameter command: The `PlaybackCommand` that was last applied to the player. - func setLastCommand(_ command: PlaybackCommand) { - self.lastCommand = command - } - - /// Retrieves the last command applied to the player. - /// - Returns: The `PlaybackCommand` that was last applied to the player. - var getLastCommand : PlaybackCommand? { - return lastCommand - } -} diff --git a/Sources/swiftui-loop-videoplayer/view/loop/main/LoopPlayerMultiPlatform.swift b/Sources/swiftui-loop-videoplayer/view/loop/main/LoopPlayerMultiPlatform.swift deleted file mode 100644 index f8f0a8b..0000000 --- a/Sources/swiftui-loop-videoplayer/view/loop/main/LoopPlayerMultiPlatform.swift +++ /dev/null @@ -1,177 +0,0 @@ -// -// LoopPlayerMultiPlatform.swift -// -// -// Created by Igor Shelopaev on 05.08.24. -// - -import SwiftUI - -#if canImport(AVKit) -import AVKit -#endif - -#if canImport(UIKit) -import UIKit -#endif - -#if canImport(AppKit) -import AppKit -#endif - -@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) -@MainActor -struct LoopPlayerMultiPlatform: LoopPlayerViewProtocol { - - #if canImport(UIKit) - typealias View = UIView - - typealias ErrorView = ErrorMsgViewIOS - - typealias PlayerView = LoopingPlayerUIView - #elseif canImport(AppKit) - typealias View = NSView - - typealias ErrorView = ErrorMsgViewMacOS - - typealias PlayerView = LoopingPlayerNSView - #endif - - /// Command for the player view - @Binding public var command : PlaybackCommand - - /// Settings for the player view - @Binding public var settings: VideoSettings - - /// State to store any error that occurs - @State private var error: VPErrors? - - var asset : AVURLAsset?{ - assetFor(settings) - } - - /// Initializes a new instance with the provided settings and playback command. - /// - /// This initializer sets up the necessary configuration and command bindings for playback functionality. - /// - /// - Parameters: - /// - settings: A binding to an instance of `VideoSettings` containing configuration details. - /// - command: A binding to a `PlaybackCommand` that controls playback actions. - init(settings: Binding, command: Binding) { - self._settings = settings - self._command = command - let settings = settings.wrappedValue - let asset = assetFor(settings) - self._error = State(initialValue: detectError(settings: settings, asset: asset)) - } - - - /// Creates a coordinator that handles error-related updates and interactions between the SwiftUI view and its underlying model. - /// - Returns: An instance of PlayerErrorCoordinator that can be used to manage error states and communicate between the view and model. - func makeCoordinator() -> PlayerCoordinator { - PlayerCoordinator($error) - } -} - -#if canImport(UIKit) -extension LoopPlayerMultiPlatform: UIViewRepresentable{ - /// Creates the container view with the player view and error view if needed - /// - Parameter context: The context for the view - /// - Returns: A configured UIView - @MainActor func makeUIView(context: Context) -> UIView { - let container = UIView() - - if let player: PlayerView = makePlayerView( - container, - asset: asset){ - player.delegate = context.coordinator - } - - makeErrorView(container, error: error) - - return container - } - - /// Updates the container view, removing any existing error views and adding a new one if needed - /// - Parameters: - /// - uiView: The UIView to update - /// - context: The context for the view - @MainActor func updateUIView(_ uiView: UIView, context: Context) { - let player = uiView.findFirstSubview(ofType: PlayerView.self) - if let player { - if let asset = getAssetIfChanged(for: settings, and: player.currentAsset) { - player.update(asset: asset) - } - - // Check if command changed before applying it - if context.coordinator.getLastCommand != command { - player.setCommand(command) - context.coordinator.setLastCommand(command) // Update the last command in the coordinator - } - } - - updateView(uiView, error: error) - } -} -#endif - -#if canImport(AppKit) -extension LoopPlayerMultiPlatform: NSViewRepresentable{ - /// 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. - /// - Parameter context: The context containing environment and state information used during view creation. - /// - Returns: A fully configured NSView containing both the media player and potentially an error message display. - @MainActor func makeNSView(context: Context) -> NSView { - let container = NSView() - - if let player: PlayerView = makePlayerView( - container, - asset: asset){ - player.delegate = context.coordinator - } - - makeErrorView(container, error: error) - - return container - } - - /// Updates the specified NSView during the view's lifecycle in response to state changes. - /// - Parameters: - /// - nsView: The NSView that needs updating. - /// - context: The context containing environment and state information used during the view update. - @MainActor func updateNSView(_ nsView: NSView, context: Context) { - let player = nsView.findFirstSubview(ofType: PlayerView.self) - if let player { - if let asset = getAssetIfChanged(for: settings, and: player.currentAsset){ - player.update(asset: asset) - } - // Check if command changed before applying it - if context.coordinator.getLastCommand != command { - player.setCommand(command) - context.coordinator.setLastCommand(command) // Update the last command in the coordinator - } - - } - - updateView(nsView, error: error) - } -} -#endif - -/// Checks if the asset has changed based on the provided settings and current asset. -/// - Parameters: -/// - settings: The current video settings, containing the asset's name and extension. -/// - asset: The current asset being played. -/// - Returns: A new `AVURLAsset` if the asset has changed, or `nil` if the asset remains the same. -fileprivate func getAssetIfChanged(for settings: VideoSettings, and asset: AVURLAsset?) -> AVURLAsset?{ - let newAsset = assetFor(settings) - - guard asset != nil else{ - return newAsset - } - - if let newUrl = newAsset?.url, let oldUrl = asset?.url, newUrl != oldUrl{ - return newAsset - } - - return nil -} diff --git a/Sources/swiftui-loop-videoplayer/view/loop/player/ios/LoopingPlayerUIView.swift b/Sources/swiftui-loop-videoplayer/view/loop/player/ios/LoopingPlayerUIView.swift deleted file mode 100644 index bffe5c4..0000000 --- a/Sources/swiftui-loop-videoplayer/view/loop/player/ios/LoopingPlayerUIView.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// LoopingPlayerProtocol.swift -// -// -// Created by Igor on 05.08.24. -// - -import SwiftUI - -#if canImport(AVKit) -import AVKit -#endif - -#if canImport(UIKit) -import UIKit - -@available(iOS 14.0, tvOS 14.0, *) -@MainActor -class LoopingPlayerUIView: UIView, LoopingPlayerProtocol { - - /// The AVPlayerLayer that displays the video content. - internal let playerLayer = AVPlayerLayer() - - /// The looper responsible for continuous video playback. - internal var playerLooper: AVPlayerLooper? - - /// The queue player that plays the video items. - internal var player: AVQueuePlayer? - - /// Observer for the status of the AVPlayerItem. - internal var statusObserver: NSKeyValueObservation? - - /// Observer for errors from the AVQueuePlayer. - internal var errorObserver: NSKeyValueObservation? - - /// The delegate to be notified about errors encountered by the player. - weak var delegate: PlayerErrorDelegate? - - /// Initializes a new looping video player view with the specified asset and gravity. - /// - /// - Parameters: - /// - asset: The AVURLAsset to be played. - /// - gravity: The AVLayerVideoGravity to be applied to the video layer. - required init(asset: AVURLAsset, gravity: AVLayerVideoGravity) { - super.init(frame: .zero) - setupPlayerComponents(asset: asset, gravity: gravity) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - /// Lays out subviews and adjusts the frame of the player layer to match the view's bounds. - override func layoutSubviews() { - super.layoutSubviews() - playerLayer.frame = bounds - } - - /// Cleans up resources and observers associated with the player. - /// - /// This method invalidates the status and error observers to prevent memory leaks, - /// pauses the player, and clears out player-related references to assist in clean deinitialization. - /// It also conditionally logs the cleanup process during debug mode. - deinit { - cleanUp(player: &player, playerLooper: &playerLooper, statusObserver: &statusObserver, errorObserver: &errorObserver) - } -} -#endif diff --git a/Sources/swiftui-loop-videoplayer/view/loop/player/ios/error/ErrorMsgViewIOS.swift b/Sources/swiftui-loop-videoplayer/view/loop/player/ios/error/ErrorMsgViewIOS.swift deleted file mode 100644 index a3b18fd..0000000 --- a/Sources/swiftui-loop-videoplayer/view/loop/player/ios/error/ErrorMsgViewIOS.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// ErrorMsgViewIOS.swift -// -// -// Created by Igor on 06.08.24. -// - -import SwiftUI -import Foundation - -#if canImport(UIKit) -import UIKit - -internal class ErrorMsgViewIOS: UITextView { - - /// Adjusts the top content inset to vertically center the text. - override var contentSize: CGSize { - didSet { - var top = (bounds.size.height - contentSize.height * zoomScale) / 2.0 - top = max(0, top) - contentInset = UIEdgeInsets(top: top, left: 0, bottom: 0, right: 0) - } - } -} - -/// Creates an error message view for iOS with the specified error, color, and font size. -/// -/// - Parameters: -/// - error: The error to display. -/// - color: The color of the error text. -/// - fontSize: The font size of the error text. -/// - Returns: A configured UIView displaying the error message. -@MainActor -internal func errorTpl(_ error: VPErrors, _ color: Color, _ fontSize: CGFloat) -> UIView { - let textView = ErrorMsgViewIOS() - textView.backgroundColor = .clear - textView.text = error.description - textView.textAlignment = .center - textView.font = UIFont.systemFont(ofSize: fontSize) - textView.textColor = UIColor(color) - return textView -} - -#endif diff --git a/Sources/swiftui-loop-videoplayer/view/loop/player/mac/LoopingPlayerNSView.swift b/Sources/swiftui-loop-videoplayer/view/loop/player/mac/LoopingPlayerNSView.swift deleted file mode 100644 index f0f9e83..0000000 --- a/Sources/swiftui-loop-videoplayer/view/loop/player/mac/LoopingPlayerNSView.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// LoopingPlayerNSView.swift -// -// -// Created by Igor on 05.08.24. -// - -import SwiftUI - -#if canImport(AVKit) -import AVKit -#endif - -#if canImport(AppKit) -import AppKit - -/// A NSView subclass that loops video using AVFoundation on macOS. -/// This class handles the initialization and management of a looping video player with customizable video gravity. -@available(macOS 11.0, *) -@MainActor -class LoopingPlayerNSView: NSView, LoopingPlayerProtocol { - - /// The AVPlayerLayer that displays the video content. - internal let playerLayer = AVPlayerLayer() - - /// The looper responsible for continuous video playback. - internal var playerLooper: AVPlayerLooper? - - /// The queue player that plays the video items. - internal var player: AVQueuePlayer? - - /// Observer for the status of the AVPlayerItem. - internal var statusObserver: NSKeyValueObservation? - - /// Observer for errors from the AVQueuePlayer. - internal var errorObserver: NSKeyValueObservation? - - /// The delegate to be notified about errors encountered by the player. - weak var delegate: PlayerErrorDelegate? - - /// Initializes a new looping video player view with the specified asset and gravity. - /// - /// - Parameters: - /// - asset: The AVURLAsset to be played. - /// - gravity: The AVLayerVideoGravity to be applied to the video layer. - required init(asset: AVURLAsset, gravity: AVLayerVideoGravity) { - super.init(frame: .zero) - setupPlayerComponents(asset: asset, gravity: gravity) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - /// Lays out subviews and adjusts the frame of the player layer to match the view's bounds. - override func layout() { - super.layout() - playerLayer.frame = bounds - } - - /// Cleans up resources and observers associated with the player. - /// - /// This method invalidates the status and error observers to prevent memory leaks, - /// pauses the player, and clears out player-related references to assist in clean deinitialization. - /// It also conditionally logs the cleanup process during debug mode. - deinit { - cleanUp(player: &player, playerLooper: &playerLooper, statusObserver: &statusObserver, errorObserver: &errorObserver) - } -} -#endif diff --git a/Sources/swiftui-loop-videoplayer/view/loop/player/mac/error/ErrorMsgViewMacOS.swift b/Sources/swiftui-loop-videoplayer/view/loop/player/mac/error/ErrorMsgViewMacOS.swift deleted file mode 100644 index 8f4b1cd..0000000 --- a/Sources/swiftui-loop-videoplayer/view/loop/player/mac/error/ErrorMsgViewMacOS.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// ErrorMsgViewMacOS.swift -// -// -// Created by Igor on 06.08.24. -// - -import Foundation -import SwiftUI - -#if canImport(AppKit) -import AppKit - -/// A custom NSTextView for displaying error messages on macOS. -internal class ErrorMsgViewMacOS: NSTextView { - - /// Overrides the intrinsic content size to allow flexible width and height. - override var intrinsicContentSize: NSSize { - return NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric) - } - - /// Called when the view is added to a superview. Sets up the constraints for the view. - override func viewDidMoveToSuperview() { - super.viewDidMoveToSuperview() - guard let superview = superview else { return } - - translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - leadingAnchor.constraint(equalTo: superview.leadingAnchor, constant: 10), - trailingAnchor.constraint(equalTo: superview.trailingAnchor, constant: -10), - topAnchor.constraint(equalTo: superview.topAnchor), - bottomAnchor.constraint(equalTo: superview.bottomAnchor) - ]) - } - - /// Adjusts the layout to center the text vertically within the view. - override func layout() { - super.layout() - - guard let layoutManager = layoutManager, let textContainer = textContainer else { - return - } - - let textHeight = layoutManager.usedRect(for: textContainer).size.height - let containerHeight = bounds.size.height - let verticalInset = max(0, (containerHeight - textHeight) / 2) - - textContainerInset = NSSize(width: 0, height: verticalInset) - } -} - -/// Creates a custom error view for macOS displaying an error message. -/// - Parameters: -/// - error: The error object containing the error description. -/// - color: The color to be used for the error text. -/// - fontSize: The font size to be used for the error text. -/// - Returns: An `NSView` containing the error message text view centered with padding. -internal func errorTpl(_ error: VPErrors, _ color: Color, _ fontSize: CGFloat) -> NSView { - let textView = ErrorMsgViewMacOS() - textView.isEditable = false - textView.isSelectable = false - textView.drawsBackground = false - textView.string = error.description - textView.alignment = .center - textView.font = NSFont.systemFont(ofSize: fontSize) - textView.textColor = NSColor(color) - - let containerView = NSView() - containerView.addSubview(textView) - - textView.translatesAutoresizingMaskIntoConstraints = false - - // Center textView in containerView with padding - NSLayoutConstraint.activate([ - textView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), - textView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), - textView.leadingAnchor.constraint(greaterThanOrEqualTo: containerView.leadingAnchor, constant: 10), - textView.trailingAnchor.constraint(lessThanOrEqualTo: containerView.trailingAnchor, constant: -10) - ]) - - return containerView -} - -#endif diff --git a/Sources/swiftui-loop-videoplayer/view/modifier/OnPlayerEventChangeModifier.swift b/Sources/swiftui-loop-videoplayer/view/modifier/OnPlayerEventChangeModifier.swift new file mode 100644 index 0000000..2e94b73 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/view/modifier/OnPlayerEventChangeModifier.swift @@ -0,0 +1,39 @@ +// +// OnPlayerEventChangeModifier.swift +// +// +// Created by Igor Shelopaev on 15.08.24. +// + +import SwiftUI + +/// Defines a custom `PreferenceKey` for handling player events within SwiftUI views. +internal struct PlayerEventPreferenceKey: PreferenceKey { + /// The default value of player events, initialized as an empty array. + public static var defaultValue: [PlayerEvent] = [] + + /// Aggregates values from the view hierarchy when child views provide values. + public static func reduce(value: inout [PlayerEvent], nextValue: () -> [PlayerEvent]) { + value = nextValue() + } +} + +/// A view modifier that monitors changes to player events and triggers a closure. +internal struct OnPlayerEventChangeModifier: ViewModifier { + /// The closure to execute when player events change. + var onPlayerEventChange: ([PlayerEvent]) -> Void + + /// Attaches a preference change listener to the content and executes a closure when player events change. + func body(content: Content) -> some View { + content + .onPreferenceChange(PlayerEventPreferenceKey.self, perform: onPlayerEventChange) + } +} + +/// Extends `View` to include a custom modifier for handling player event changes. +public extension View { + /// Applies the `OnPlayerEventChangeModifier` to the view to handle player event changes. + func onPlayerEventChange(perform action: @escaping ([PlayerEvent]) -> Void) -> some View { + self.modifier(OnPlayerEventChangeModifier(onPlayerEventChange: action)) + } +} diff --git a/Sources/swiftui-loop-videoplayer/view/modifier/OnTimeChangeModifier.swift b/Sources/swiftui-loop-videoplayer/view/modifier/OnTimeChangeModifier.swift new file mode 100644 index 0000000..bf3b694 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/view/modifier/OnTimeChangeModifier.swift @@ -0,0 +1,39 @@ +// +// OnTimeChangeModifier.swift +// +// +// Created by Igor Shelopaev on 15.08.24. +// + +import SwiftUI + +/// Defines a custom `PreferenceKey` for storing and updating the current playback time. +internal struct CurrentTimePreferenceKey: PreferenceKey { + /// Sets the default playback time to 0.0 seconds. + public static var defaultValue: Double = 0.0 + + /// Aggregates the most recent playback time from child views. + public static func reduce(value: inout Double, nextValue: () -> Double) { + value = nextValue() + } +} + +/// A view modifier that listens for changes in playback time and triggers a response. +internal struct OnTimeChangeModifier: ViewModifier { + /// The closure to execute when there is a change in playback time. + var onTimeChange: (Double) -> Void + + /// Attaches a preference change listener to the content that triggers `onTimeChange` when the playback time updates. + func body(content: Content) -> some View { + content + .onPreferenceChange(CurrentTimePreferenceKey.self, perform: onTimeChange) + } +} + +/// Extends `View` to include functionality for responding to changes in playback time. +public extension View { + /// Applies the `OnTimeChangeModifier` to the view to manage updates in playback time. + func onPlayerTimeChange(perform action: @escaping (Double) -> Void) -> some View { + self.modifier(OnTimeChangeModifier(onTimeChange: action)) + } +} diff --git a/Sources/swiftui-loop-videoplayer/view/player/ios/ExtPlayerUIView.swift b/Sources/swiftui-loop-videoplayer/view/player/ios/ExtPlayerUIView.swift new file mode 100644 index 0000000..5f61632 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/view/player/ios/ExtPlayerUIView.swift @@ -0,0 +1,159 @@ +// +// ExtPlayerUIView.swift +// +// +// Created by Igor Shelopaev on 05.08.24. +// + +import SwiftUI + +#if canImport(AVKit) +import AVKit +#endif + +#if canImport(UIKit) +import UIKit + +@MainActor +internal class ExtPlayerUIView: UIView, ExtPlayerProtocol{ + + /// This property holds an instance of `VideoSettings` + internal var currentSettings : VideoSettings? + + /// `filters` is an array that stores CIFilter objects used to apply different image processing effects + internal var filters: [CIFilter] = [] + + /// `brightness` represents the adjustment level for the brightness of the video content. + internal var brightness: Float = 0 + + /// `contrast` indicates the level of contrast adjustment for the video content. + internal var contrast: Float = 1 + + /// A CALayer instance used for composing content, accessible only within the module. + internal var compositeLayer : CALayer? = nil + + /// The AVPlayerLayer that displays the video content. + internal var playerLayer : AVPlayerLayer? = nil + + /// The looper responsible for continuous video playback. + internal var playerLooper: AVPlayerLooper? + + /// The queue player that plays the video items. + internal var player: AVQueuePlayer? + + /// Declare a variable to hold the time observer token outside the if statement + internal var timeObserver: Any? + + /// Observer for errors from the AVQueuePlayer. + internal var errorObserver: NSKeyValueObservation? + + /// An optional observer for monitoring changes to the player's `timeControlStatus` property. + internal var timeControlObserver: NSKeyValueObservation? + + /// An optional observer for monitoring changes to the player's `currentItem` property. + internal var currentItemObserver: NSKeyValueObservation? + + /// Item status observer + internal var itemStatusObserver: NSKeyValueObservation? + + /// An optional observer for monitoring changes to the player's `volume` property. + /// + /// This property holds an instance of `NSKeyValueObservation`, which observes the `volume` + /// of an `AVPlayer`. + internal var volumeObserver: NSKeyValueObservation? + + /// The delegate to be notified about errors encountered by the player. + weak var delegate: PlayerDelegateProtocol? + + /// The Picture-in-Picture (PiP) controller for managing PiP functionality. + internal var pipController: AVPictureInPictureController? + + /// Initializes a new player view with a video asset and custom settings. + /// + /// - Parameters: + /// - asset: The `AVURLAsset` used for video playback. + /// - settings: The `VideoSettings` struct that includes all necessary configurations like gravity, loop, and mute. + required init(settings: VideoSettings){ + + player = AVQueuePlayer(items: []) + + super.init(frame: .zero) + + addPlayerLayer() + addCompositeLayer(settings) + + setupPlayerComponents( + settings: settings + ) + + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Lays out subviews and adjusts the frame of the player layer to match the view's bounds. + override func layoutSubviews() { + super.layoutSubviews() + playerLayer?.frame = bounds + // Update the composite layer (and sublayers) + layoutCompositeLayer() + } + + /// Updates the composite layer and all its sublayers' frames. + public func layoutCompositeLayer() { + guard let compositeLayer = compositeLayer else { return } + + // Update the composite layer's frame to match the parent + compositeLayer.frame = bounds + + // Adjust each sublayer's frame (if they should fill the entire composite layer) + compositeLayer.sublayers?.forEach { sublayer in + sublayer.frame = compositeLayer.bounds + } + + delegate?.boundsDidChange(to: bounds) + } + + func onDisappear(){ + // First, clear all observers to prevent memory leaks + clearObservers() + + // Stop the player to ensure it's not playing any media + stop() + + // Remove visual layers to clean up the UI components + removePlayerLayer() + removeCompositeLayer() + + // Finally, release player and delegate references to free up memory + player = nil + delegate = nil + + // Log the cleanup process for debugging purposes + #if DEBUG + print("Player deinitialized and resources cleaned up.") + #endif + } + + #if os(iOS) + /// Called by the Coordinator to set up PiP + func setupPiP(delegate: AVPictureInPictureControllerDelegate) { + // Check if PiP is supported + guard AVPictureInPictureController.isPictureInPictureSupported() else { + DispatchQueue.main.asyncAfter(deadline: .now() + 1){ [weak self] in + self?.onError(.notSupportedPiP) + } + return + } + + guard let playerLayer else{ + return + } + + pipController = AVPictureInPictureController(playerLayer: playerLayer) + pipController?.delegate = delegate + } + #endif +} +#endif diff --git a/Sources/swiftui-loop-videoplayer/view/player/mac/ExtPlayerNSView.swift b/Sources/swiftui-loop-videoplayer/view/player/mac/ExtPlayerNSView.swift new file mode 100644 index 0000000..f2667aa --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/view/player/mac/ExtPlayerNSView.swift @@ -0,0 +1,136 @@ +// +// ExtPlayerNSView.swift +// +// +// Created by Igor Shelopaev on 05.08.24. +// + +import SwiftUI + +#if canImport(AVKit) +import AVKit +#endif + +#if canImport(AppKit) +import AppKit + +/// A NSView subclass that loops video using AVFoundation on macOS. +/// This class handles the initialization and management of a looping video player with customizable video gravity. +@MainActor +internal class ExtPlayerNSView: NSView, ExtPlayerProtocol { + + /// This property holds an instance of `VideoSettings` + internal var currentSettings : VideoSettings? + + /// `filters` is an array that stores CIFilter objects used to apply different image processing effects + internal var filters: [CIFilter] = [] + + /// `brightness` represents the adjustment level for the brightness of the video content. + internal var brightness: Float = 0 + + /// `contrast` indicates the level of contrast adjustment for the video content. + internal var contrast: Float = 1 + + /// A CALayer instance used for composing content, accessible only within the module. + internal var compositeLayer : CALayer? + + /// The AVPlayerLayer that displays the video content. + internal var playerLayer : AVPlayerLayer? + + /// The looper responsible for continuous video playback. + internal var playerLooper: AVPlayerLooper? + + /// The queue player that plays the video items. + internal var player: AVQueuePlayer? = AVQueuePlayer(items: []) + + /// Declare a variable to hold the time observer token outside the if statement + internal var timeObserver: Any? + + /// Observer for errors from the AVQueuePlayer. + internal var errorObserver: NSKeyValueObservation? + + /// An optional observer for monitoring changes to the player's `timeControlStatus` property. + internal var timeControlObserver: NSKeyValueObservation? + + /// An optional observer for monitoring changes to the player's `currentItem` property. + internal var currentItemObserver: NSKeyValueObservation? + + /// Item status observer + internal var itemStatusObserver: NSKeyValueObservation? + + /// An optional observer for monitoring changes to the player's `volume` property. + /// + /// This property holds an instance of `NSKeyValueObservation`, which observes the `volume` + /// of an `AVPlayer`. + internal var volumeObserver: NSKeyValueObservation? + + /// The delegate to be notified about errors encountered by the player. + weak var delegate: PlayerDelegateProtocol? + + /// Initializes a new player view with a video asset and specified configurations. + /// + /// - Parameters: + /// - asset: The `AVURLAsset` for video playback. + /// - settings: The `VideoSettings` struct that includes all necessary configurations like gravity, loop, and mute. + required init(settings: VideoSettings) { + + player = AVQueuePlayer(items: []) + + super.init(frame: .zero) + + addPlayerLayer() + addCompositeLayer(settings) + + setupPlayerComponents(settings: settings) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Lays out subviews and adjusts the frame of the player layer to match the view's bounds. + override func layout() { + super.layout() + playerLayer?.frame = bounds + // Update the composite layer (and sublayers) + layoutCompositeLayer() + } + + /// Updates the composite layer and all its sublayers' frames. + public func layoutCompositeLayer() { + guard let compositeLayer = compositeLayer else { return } + + // Update the composite layer's frame to match the parent + compositeLayer.frame = bounds + + // Adjust each sublayer's frame (if they should fill the entire composite layer) + compositeLayer.sublayers?.forEach { sublayer in + sublayer.frame = compositeLayer.bounds + } + + delegate?.boundsDidChange(to: bounds) + } + + + func onDisappear(){ + // First, clear all observers to prevent memory leaks + clearObservers() + + // Stop the player to ensure it's not playing any media + stop() + + // Remove visual layers to clean up the UI components + removePlayerLayer() + removeCompositeLayer() + + // Finally, release player and delegate references to free up memory + player = nil + delegate = nil + + // Log the cleanup process for debugging purposes + #if DEBUG + print("Player deinitialized and resources cleaned up.") + #endif + } +} +#endif diff --git a/Sources/swiftui-loop-videoplayer/view/player/main/ExtPlayerMultiPlatform.swift b/Sources/swiftui-loop-videoplayer/view/player/main/ExtPlayerMultiPlatform.swift new file mode 100644 index 0000000..0d79ac1 --- /dev/null +++ b/Sources/swiftui-loop-videoplayer/view/player/main/ExtPlayerMultiPlatform.swift @@ -0,0 +1,167 @@ +// +// ExtPlayerMultiPlatform.swift +// +// +// Created by Igor Shelopaev on 05.08.24. +// + +import SwiftUI +import Combine + +#if canImport(UIKit) +import UIKit +#endif + +#if canImport(AppKit) +import AppKit +#endif + +@MainActor +internal struct ExtPlayerMultiPlatform: ExtPlayerViewProtocol { + + #if canImport(UIKit) + typealias View = UIView + + typealias PlayerView = ExtPlayerUIView + #elseif canImport(AppKit) + typealias View = NSView + + typealias PlayerView = ExtPlayerNSView + #endif + + /// A publisher that emits the current playback time as a `Double`. + private let timePublisher: PassthroughSubject + + /// A publisher that emits player events as `PlayerEvent` values. + private let eventPublisher: PassthroughSubject + + /// Command for the player view + @Binding public var command : PlaybackCommand + + /// Settings for the player view + @Binding public var settings: VideoSettings + + /// Initializes a new instance of `ExtPlayerView`. + /// - Parameters: + /// - settings: A binding to the video settings used by the player. + /// - command: A binding to the playback command that controls playback actions. + /// - timePublisher: A publisher that emits the current playback time as a `Double`. + /// - eventPublisher: A publisher that emits player events as `PlayerEvent` values. + init( + settings: Binding, + command: Binding, + timePublisher : PassthroughSubject, + eventPublisher : PassthroughSubject + ) { + self.timePublisher = timePublisher + self.eventPublisher = eventPublisher + self._settings = settings + self._command = command + } + /// Creates a coordinator that handles error-related updates and interactions between the SwiftUI view and its underlying model. + /// - Returns: An instance of PlayerErrorCoordinator that can be used to manage error states and communicate between the view and model. + func makeCoordinator() -> PlayerCoordinator { + PlayerCoordinator(timePublisher: timePublisher, eventPublisher: eventPublisher) + } +} + +#if canImport(UIKit) +extension ExtPlayerMultiPlatform: UIViewRepresentable{ + /// Creates the container view with the player view and error view if needed + /// - Parameter context: The context for the view + /// - Returns: A configured UIView + func makeUIView(context: Context) -> UIView { + let container = UIView() + + if let player: PlayerView = makePlayerView(container){ + player.delegate = context.coordinator + #if os(iOS) + if settings.pictureInPicture{ + player.setupPiP(delegate: context.coordinator) + } + #endif + } + + return container + } + + /// Updates the container view, removing any existing error views and adding a new one if needed + /// - Parameters: + /// - uiView: The UIView to update + /// - context: The context for the view + func updateUIView(_ uiView: UIView, context: Context) { + let player = uiView.findFirstSubview(ofType: PlayerView.self) + + if let player{ + player.update(settings: settings) + + // Check if command changed before applying it + if context.coordinator.getLastCommand != command { + player.setCommand(command) + context.coordinator.setLastCommand(command) // Update the last command in the coordinator + } + } + } + + /// Called by SwiftUI to dismantle the UIView when the associated SwiftUI view is removed from the view hierarchy. + /// https://developer.apple.com/documentation/swiftui/uiviewrepresentable/dismantleuiview(_:coordinator:) + /// - Parameters: + /// - uiView: The UIView instance being dismantled. + /// - coordinator: The coordinator instance that manages interactions between SwiftUI and the UIView. + static func dismantleUIView(_ uiView: UIView, coordinator: PlayerCoordinator) { + // Called by SwiftUI when this view is removed from the hierarchy + let player = uiView.findFirstSubview(ofType: PlayerView.self) + if let player{ + player.onDisappear() + } + } +} +#endif + +#if canImport(AppKit) +extension ExtPlayerMultiPlatform: NSViewRepresentable{ + /// 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. + /// - Parameter context: The context containing environment and state information used during view creation. + /// - Returns: A fully configured NSView containing both the media player and potentially an error message display. + func makeNSView(context: Context) -> NSView { + let container = NSView() + + if let player: PlayerView = makePlayerView(container){ + player.delegate = context.coordinator + } + + return container + } + + /// Updates the specified NSView during the view's lifecycle in response to state changes. + /// - Parameters: + /// - nsView: The NSView that needs updating. + /// - context: The context containing environment and state information used during the view update. + func updateNSView(_ nsView: NSView, context: Context) { + let player = nsView.findFirstSubview(ofType: PlayerView.self) + if let player { + + player.update(settings: settings) + + // Check if command changed before applying it + if context.coordinator.getLastCommand != command { + player.setCommand(command) + context.coordinator.setLastCommand(command) // Update the last command in the coordinator + } + } + } + + /// Called by SwiftUI to dismantle the NSView when the associated SwiftUI view is removed from the view hierarchy. + /// + /// - Parameters: + /// - uiView: The NSView instance being dismantled. + /// - coordinator: The coordinator instance that manages interactions between SwiftUI and the NSView. + static func dismantleNSView(_ uiView: NSView, coordinator: PlayerCoordinator) { + // Called by SwiftUI when this view is removed from the hierarchy + let player = uiView.findFirstSubview(ofType: PlayerView.self) + if let player{ + player.onDisappear() + } + } +} +#endif diff --git a/Tests/swiftui-loop-videoplayerTests/Resources/swipe.mp4 b/Tests/swiftui-loop-videoplayerTests/Resources/swipe.mp4 new file mode 100644 index 0000000..f12ba69 Binary files /dev/null and b/Tests/swiftui-loop-videoplayerTests/Resources/swipe.mp4 differ diff --git a/Tests/swiftui-loop-videoplayerTests/ext+/testURL.swift b/Tests/swiftui-loop-videoplayerTests/ext+/testURL.swift new file mode 100644 index 0000000..dd462b5 --- /dev/null +++ b/Tests/swiftui-loop-videoplayerTests/ext+/testURL.swift @@ -0,0 +1,75 @@ +// +// testURL+.swift +// swiftui-loop-videoplayer +// +// Created by Igor Shelopaev on 20.08.25. +// + +import XCTest +@testable import swiftui_loop_videoplayer + +final class testURL: XCTestCase { + + // MARK: - Positive cases (should pass) + + func testSampleVideoURLsPass() { + // Given: four sample URLs from the sandbox dictionary + let urls = [ + "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8" + ] + + // When/Then + for raw in urls { + let url = URL.validURLFromString(from: raw) + XCTAssertNotNil(url, "Expected to parse: \(raw)") + XCTAssertEqual(url?.scheme?.lowercased(), "https") + } + } + + func testTrimsWhitespace() { + let raw = " https://example.com/video.m3u8 " + let url = URL.validURLFromString(from: raw) + XCTAssertNotNil(url) + XCTAssertEqual(url?.host, "example.com") + XCTAssertEqual(url?.path, "/video.m3u8") + } + + func testIPv6AndLocalHosts() { + // IPv6 loopback + XCTAssertNotNil(URL.validURLFromString(from: "https://[::1]")) + // localhost + XCTAssertNotNil(URL.validURLFromString(from: "http://localhost")) + // IPv4 with port and query/fragment + XCTAssertNotNil(URL.validURLFromString(from: "http://127.0.0.1:8080/path?a=1#x")) + } + + func testIDNUnicodeHost() { + // Unicode host (IDN). URLComponents should handle this. + let url = URL.validURLFromString(from: "https://bücher.de") + XCTAssertNotNil(url) + XCTAssertEqual(url?.scheme, "https") + XCTAssertNotNil(url?.host) + } + + // MARK: - Negative cases (should fail) + + func testRejectsNonHTTP() { + XCTAssertNil(URL.validURLFromString(from: "ftp://example.com/file.mp4")) + XCTAssertNil(URL.validURLFromString(from: "mailto:user@example.com")) + XCTAssertNil(URL.validURLFromString(from: "file:///Users/me/movie.mp4")) + } + + func testRejectsInvalidPort() { + XCTAssertNil(URL.validURLFromString(from: "https://example.com:0")) + XCTAssertNil(URL.validURLFromString(from: "https://example.com:65536")) + XCTAssertNotNil(URL.validURLFromString(from: "https://example.com:65535")) + } + + func testRejectsMissingHost() { + XCTAssertNil(URL.validURLFromString(from: "https://")) + XCTAssertNil(URL.validURLFromString(from: "https:///path-only")) + } +} diff --git a/Tests/swiftui-loop-videoplayerTests/ext+/textArray.swift b/Tests/swiftui-loop-videoplayerTests/ext+/textArray.swift new file mode 100644 index 0000000..61d8bad --- /dev/null +++ b/Tests/swiftui-loop-videoplayerTests/ext+/textArray.swift @@ -0,0 +1,111 @@ +// +// textArray.swift +// swiftui-loop-videoplayer +// +// Created by Igor Shelopaev on 21.08.25. +// + +import XCTest +import AVFoundation +@testable import swiftui_loop_videoplayer + +final class SettingsTests: XCTestCase { + + // MARK: - events([PlayerEventFilter]?) + + func testFetch_Events_WithArray() { + let settings: [Setting] = [.events([.playing, .paused])] + let events: [PlayerEventFilter] = settings.fetch(by: "events", defaulted: []) + XCTAssertEqual(events, [.playing, .paused]) + } + + func testFetch_Events_NilAssociatedValue() { + let settings: [Setting] = [.events(nil)] + // Optional(nil) won't cast to [PlayerEventFilter] → returns default + let events: [PlayerEventFilter] = settings.fetch(by: "events", defaulted: []) + XCTAssertTrue(events.isEmpty) + } + + // MARK: - name / ext / subtitles + + func testFetch_Name_ReturnsStoredString() { + let settings: [Setting] = [.name("teaser")] + let value: String = settings.fetch(by: "name", defaulted: "") + XCTAssertEqual(value, "teaser") + } + + func testFetch_Ext_ReturnsStoredString() { + let settings: [Setting] = [.ext("mp4")] + let value: String = settings.fetch(by: "ext", defaulted: "mov") + XCTAssertEqual(value, "mp4") + } + + func testFetch_Subtitles_ReturnsStoredString() { + let settings: [Setting] = [.subtitles("de")] + let value: String = settings.fetch(by: "subtitles", defaulted: "en") + XCTAssertEqual(value, "de") + } + + // MARK: - Missing / mismatch + + func testFetch_ReturnsDefault_WhenNameMissing() { + let settings: [Setting] = [.name("teaser")] + let value: Int = settings.fetch(by: "fontSize", defaulted: 12) + XCTAssertEqual(value, 12) + } + + func testFetch_ReturnsDefault_WhenTypeMismatch() { + let settings: [Setting] = [.name("teaser")] + let value: Int = settings.fetch(by: "name", defaulted: 0) + XCTAssertEqual(value, 0) + } + + // MARK: - First match precedence + + func testFetch_PrefersFirstMatch_WhenMultipleWithSameName() { + let settings: [Setting] = [.name("first"), .name("second")] + let value: String = settings.fetch(by: "name", defaulted: "") + XCTAssertEqual(value, "first") + } + + // MARK: - Value-less cases → default + + func testFetch_Vector_ReturnsDefault() { + let settings: [Setting] = [.vector] + let value: Bool = settings.fetch(by: "vector", defaulted: false) + XCTAssertFalse(value) + } + + func testFetch_Loop_ReturnsDefault() { + let settings: [Setting] = [.loop] + let value: String = settings.fetch(by: "loop", defaulted: "no") + XCTAssertEqual(value, "no") + } + + func testFetch_PictureInPicture_ReturnsDefault() { + let settings: [Setting] = [.pictureInPicture] + let pip: Bool = settings.fetch(by: "pictureInPicture", defaulted: false) + XCTAssertFalse(pip) + } + + func testFetch_Mute_ReturnsDefault() { + let settings: [Setting] = [.mute] + let muted: Bool = settings.fetch(by: "mute", defaulted: false) + XCTAssertFalse(muted) + } + + // MARK: - timePublishing / gravity + + func testFetch_TimePublishing_CMTime() { + let t = CMTime(seconds: 0.5, preferredTimescale: 600) + let settings: [Setting] = [.timePublishing(t)] + let fetched: CMTime = settings.fetch(by: "timePublishing", defaulted: .zero) + XCTAssertEqual(CMTimeCompare(fetched, t), 0) + } + + func testFetch_Gravity_CustomAssociatedValue() { + let settings: [Setting] = [.gravity(.resizeAspectFill)] + let gravity: AVLayerVideoGravity = settings.fetch(by: "gravity", defaulted: .resize) + XCTAssertEqual(gravity, .resizeAspectFill) + } +} diff --git a/Tests/swiftui-loop-videoplayerTests/swiftui_loop_videoplayerTests.swift b/Tests/swiftui-loop-videoplayerTests/swiftui_loop_videoplayerTests.swift deleted file mode 100644 index 6378d62..0000000 --- a/Tests/swiftui-loop-videoplayerTests/swiftui_loop_videoplayerTests.swift +++ /dev/null @@ -1,11 +0,0 @@ -import XCTest -@testable import swiftui_loop_videoplayer - -final class swiftui_loop_videoplayerTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - // XCTAssertEqual(swiftui_loop_videoplayer().text, "Hello, World!") - } -} diff --git a/Tests/swiftui-loop-videoplayerTests/testPlaybackCommandChangesOverTime.swift b/Tests/swiftui-loop-videoplayerTests/testPlaybackCommandChangesOverTime.swift new file mode 100644 index 0000000..352803c --- /dev/null +++ b/Tests/swiftui-loop-videoplayerTests/testPlaybackCommandChangesOverTime.swift @@ -0,0 +1,56 @@ +// +// testPlaybackCommandChangesOverTime.swift +// +// +// Created by Igor Shelopaev on 16.08.24. +// + +import XCTest +import SwiftUI +@testable import swiftui_loop_videoplayer +import AVKit + +final class testPlaybackCommandChangesOverTime: XCTestCase { + + func testPlaybackCommandChangesOverTime() { + // Setup initial command and a binding + let initialCommand = PlaybackCommand.play + var command = initialCommand + let commandBinding = Binding( + get: { command }, + set: { command = $0 } + ) + + // Create an instance of the view with the initial command + let playerView = ExtVideoPlayer(fileName: "swipe", command: commandBinding) + + // Setup expectation for asynchronous test + let expectation = self.expectation(description: "Command should change to .pause") + + // Change the command after 5 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + commandBinding.wrappedValue = .pause + } + + // Periodically check if the command has changed + let checkInterval = 0.1 // Check every 0.1 seconds + var timeElapsed = 0.0 + Timer.scheduledTimer(withTimeInterval: checkInterval, repeats: true) { timer in + if command == .pause { + timer.invalidate() + expectation.fulfill() + } else if timeElapsed >= 5 { // Failsafe timeout + timer.invalidate() + XCTFail("Command did not change within the expected time") + } + timeElapsed += checkInterval + } + + // Wait for the expectation to be fulfilled, or time out after 10 seconds + waitForExpectations(timeout: 5, handler: nil) + + // Verify the command has indeed changed + XCTAssertEqual(playerView.command, .pause, "Playback command should have updated to .pause") + } + +} diff --git a/Tests/swiftui-loop-videoplayerTests/testPlayerInitialization.swift b/Tests/swiftui-loop-videoplayerTests/testPlayerInitialization.swift new file mode 100644 index 0000000..ae19f9c --- /dev/null +++ b/Tests/swiftui-loop-videoplayerTests/testPlayerInitialization.swift @@ -0,0 +1,99 @@ +// +// testPlaybackCommandChangesOverTime.swift +// +// +// Created by Igor Shelopaev on 16.08.24. +// + +import XCTest +import SwiftUI +@testable import swiftui_loop_videoplayer +import AVKit + +final class testPlayerInitialization: XCTestCase { + + // Test initialization with custom parameters + func testInitializationWithCustomParameters() { + let playbackCommand = PlaybackCommand.pause // Example of a non-default command + let commandBinding = Binding.constant(playbackCommand) + let playerView = ExtVideoPlayer( + fileName: "swipe", + ext: "mov", + gravity: .resizeAspectFill, + timePublishing: CMTime(seconds: 1.5, preferredTimescale: 600), + command: commandBinding + ) + + XCTAssertEqual(playerView.settings.name, "swipe") + XCTAssertEqual(playerView.settings.ext, "mov") + XCTAssertEqual(playerView.settings.gravity, .resizeAspectFill) + XCTAssertEqual(playerView.settings.timePublishing?.seconds, 1.5) + XCTAssertEqual(playerView.settings.timePublishing?.timescale, 600) + XCTAssertEqual(playerView.command, playbackCommand) + } + + // Test initialization with default parameters + func testInitializationWithDefaultParameters() { + let playerView = ExtVideoPlayer(fileName: "swipe") + + XCTAssertEqual(playerView.settings.name, "swipe") + XCTAssertEqual(playerView.settings.ext, "mp4") // Default extension + XCTAssertEqual(playerView.settings.gravity, .resizeAspect) // Default gravity + XCTAssertNotNil(playerView.settings.timePublishing) // Default should not be nil + XCTAssertEqual(playerView.settings.timePublishing?.seconds, 1) + XCTAssertEqual(playerView.command, .play) // Default command + } + + // Test the initializer that takes a closure returning VideoSettings + func testExtPlayerView_InitializesWithValues() { + let playerView = ExtVideoPlayer{ + VideoSettings{ + SourceName("swipe") + Ext("mp8") // Set default extension here If not provided then mp4 is default + Gravity(.resizeAspectFill) + TimePublishing() + } + } + XCTAssertEqual(playerView.settings.name, "swipe") + XCTAssertEqual(playerView.settings.ext, "mp8") + XCTAssertEqual(playerView.settings.gravity, .resizeAspectFill) + XCTAssertNotEqual(playerView.settings.timePublishing, nil) + XCTAssertEqual(playerView.command, .play) + } + + // Test the initializer that takes a closure returning VideoSettings + func testExtPlayerView_InitializesWithClosureProvidedSettings() { + let playerView = ExtVideoPlayer { + VideoSettings { + SourceName("swipe") + Ext("mp8") + Gravity(.resizeAspectFill) + TimePublishing(CMTime(seconds: 2, preferredTimescale: 600)) + } + } + XCTAssertEqual(playerView.settings.name, "swipe") + XCTAssertEqual(playerView.settings.ext, "mp8") + XCTAssertEqual(playerView.settings.gravity, .resizeAspectFill) + XCTAssertEqual(playerView.settings.timePublishing?.seconds, 2) + XCTAssertEqual(playerView.command, .play) + } + + // Test the initializer that takes a binding to VideoSettings + func testExtPlayerView_InitializesWithBindingProvidedSettings() { + let initialSettings = VideoSettings { + SourceName("swipe") + Ext("mkv") + Gravity(.resizeAspect) + TimePublishing(CMTime(seconds: 1, preferredTimescale: 600)) + } + let settings = Binding.constant(initialSettings) + let playerView = ExtVideoPlayer(settings: settings, command: .constant(.pause)) + + XCTAssertEqual(settings.wrappedValue.name, "swipe") + XCTAssertEqual(settings.wrappedValue.ext, "mkv") + XCTAssertEqual(settings.wrappedValue.gravity, .resizeAspect) + XCTAssertEqual(settings.wrappedValue.timePublishing?.seconds, 1) + XCTAssertEqual(playerView.command, .pause) + } + +}