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 546d553..2741e49 100644 --- a/Package.swift +++ b/Package.swift @@ -25,8 +25,8 @@ let package = Package( .testTarget( name: "swiftui-loop-videoplayerTests", dependencies: ["swiftui-loop-videoplayer"], - resources: [ - .process("Resources/swipe.mp4") // Include your video file as a resource - ]) + resources: [ + .process("Resources/swipe.mp4") + ]) ] ) diff --git a/README.md b/README.md index 23f4259..04ce52f 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,160 @@ -# SwiftUI video player iOS 14+, macOS 11+, tvOS 14+ -### I am currently developing three open-source packages. Please star the repository if you believe continuing the development of this package is worthwhile. This will help me understand which package deserves more effort. +# Coming soon: Metal shaders for video +iOS 14+, macOS 11+, tvOS 14+ + +## ⭐ Star it, please — so I know it’s worth improving further. + +Support for applying **Metal shaders** directly to **video frames**. +Real-time effects on the timeline, no extra copies or heavy post-processing. + +[![](https://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) + + -[![](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) ## Why if we have Apple’s VideoPlayer ?! -Apple’s VideoPlayer offers a quick setup for video playback in SwiftUI but for example it doesn’t allow you to hide or customize the default video controls UI, limiting its use for custom scenarios. In contrast, this solution provides full control over playback, including the ability to disable or hide UI elements, making it suitable for background videos, tooltips, and video hints. Additionally, it supports advanced features like seamless looping and real-time filter application, adding vector graphics upon the video stream etc. This package uses a declarative approach to declare parameters for the video component based on building blocks. This implementation might give you some insights into how SwiftUI works under the hood. You can also pass parameters in the common way. -## 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. +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.* -## [SwiftUI video player example](https://github.com/The-Igor/swiftui-loop-videoplayer-example) + ```swift + ExtVideoPlayer{ + VideoSettings{ + SourceName("swipe") + } + } +``` + +or -![The concept](https://github.com/The-Igor/swiftui-loop-videoplayer-example/blob/main/swiftui-loop-videoplayer-example/img/swiftui_video_player.gif) + ```swift + ExtVideoPlayer(fileName: 'swipe') +``` -## Philosophy of Interactive Player Dynamics +## Philosophy of Player Dynamics -The player's functionality is designed around a dual interaction model: +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. +- **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. +- **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 -## API Specifications +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. + +## 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:`
`eColor:eFontSize:command:)` | Constructor | Initializes the player with specific video parameters, such as file name, extension, gravity, time publishing, color, font size, and a playback command binding. | +| `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. | + ## Settings -| Name | Description | Default | -| --- | --- | --- | -| **SourceName** | The URL or local filename of the video.| - | -| **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" | +| 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 | -| **TimePublishing** | Specifies the interval at which the player publishes the current playback time. | -| **Loop** | Whether the video should automatically restart when it reaches the end. If not explicitly passed, the video will not loop. | - | -| **EColor** | Error message text color. | .red | -| **EFontSize** | Size of the error text. | 17.0 | +| **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 @@ -55,13 +167,68 @@ The player's functionality is designed around a dual interaction model: ## Commands +### Handling Commands + ```swift + @State public var playbackCommand: PlaybackCommand = .idle + ``` +`@State` updates are asynchronous and batched in SwiftUI. When you assign: + ```swift + playbackCommand = .play + playbackCommand = .pause + ``` +SwiftUI only registers the last assignment (`.pause`) in the same run loop cycle, ignoring `.play`. +To ensure .play is applied before .pause, you can use `Task` to schedule the second update on the next run loop iteration: + +**.play → .pause** + ```swift + playbackCommand = .play + + Task { + playbackCommand = .pause + } +``` +**.play → .pause → .play** + +```swift + playbackCommand = .play + + Task { + playbackCommand = .pause + Task { playbackCommand = .play } // This runs AFTER `.pause` + } +``` + +### Handling Sequential Similar Commands + +When using the video player controls in your SwiftUI application, it's important to understand how command processing works. Specifically, issuing two identical commands consecutively will result in the second command being ignored. This is due to the underlying implementation 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. | @@ -70,6 +237,8 @@ The player's functionality is designed around a dual interaction model: | `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 @@ -86,20 +255,118 @@ The player's functionality is designed around a dual interaction model: |----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| | `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 | 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?)` | 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. | +| `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 | |------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------| -| `idle` | Represents a state where the player is idle, meaning it is not currently performing any action. | | `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 @@ -120,25 +387,11 @@ Integrating vector graphics into SwiftUI views, particularly during lifecycle ev - **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. -## 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. | - ## How to use the package ### 1. Create LoopPlayerView ```swift -ExtVideoPlayer(fileName: 'swipe') + ExtVideoPlayer(fileName: 'swipe') ``` or in a declarative way @@ -147,49 +400,34 @@ or in a declarative way ExtVideoPlayer{ VideoSettings{ SourceName("swipe") + Subtitles("subtitles_eng") Ext("mp8") // Set default extension here If not provided then mp4 is default Gravity(.resizeAspectFill) TimePublishing() - ErrorGroup{ - EColor(.accentColor) - EFontSize(27) - } + Events([.durationAny, .itemStatusChangedAny]) } } .onPlayerTimeChange { newTime in // Current video playback time } - .onPlayerEventChange { event in - // Player event + .onPlayerEventChange { events in + // Player events } -``` - - ```swift - ExtVideoPlayer{ - VideoSettings{ - SourceName("swipe") - Gravity(.resizeAspectFill) - EFontSize(27) - } - } -``` +``` ```swift 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 | |--------------------------|------------------|-------------------------------------| @@ -212,10 +450,6 @@ ExtVideoPlayer{ VideoSettings{ SourceName('https://example.com/video') Gravity(.resizeAspectFill) // Video content fit - ErrorGroup{ - EColor(.red) // Error text color - EFontSize(18) // Error text font size - } } } ``` @@ -229,35 +463,64 @@ ExtVideoPlayer{ | HLS Streams | Yes | HLS streams are supported and can be used for live streaming purposes. | -## 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 { - ExtVideoPlayer( - { - 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) -## 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. +## HLS with Adaptive Quality -![The concept](https://github.com/The-Igor/swiftui-loop-videoplayer-example/blob/main/swiftui-loop-videoplayer-example/img/swiftui_video_hint.gif) +### How Adaptive Quality Switching Works -![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) +1. **Multiple Bitrates** + - The video is encoded in multiple quality levels (e.g., 240p, 360p, 720p, 1080p), each with different bitrates. + +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 index 03e2969..a0421f5 100644 --- a/Sources/swiftui-loop-videoplayer/ExtVideoPlayer.swift +++ b/Sources/swiftui-loop-videoplayer/ExtVideoPlayer.swift @@ -25,7 +25,7 @@ public struct ExtVideoPlayer: View{ @State private var currentTime: Double = 0.0 /// The current state of the player event, - @State private var playerEvent: PlayerEvent = .idle + @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() @@ -41,16 +41,12 @@ public struct ExtVideoPlayer: View{ /// - 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. - /// - eColor: The color to be used, with a default value of `.accentColor`. - /// - eFontSize: The font size to be used, with a default value of 17.0. /// - 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), - eColor: Color = .accentColor, - eFontSize: CGFloat = 17.0, command : Binding = .constant(.play) ) { self._command = command @@ -66,10 +62,6 @@ public struct ExtVideoPlayer: View{ if let timePublishing{ timePublishing } - ErrorGroup { - EColor(eColor) - EFontSize(eFontSize) - } } _settings = .constant(settings) @@ -102,22 +94,48 @@ public struct ExtVideoPlayer: View{ // MARK: - API + /// The body property defines the view hierarchy for the user interface. public var body: some View { - - LoopPlayerMultiPlatform( - settings: $settings, - command: $command, - timePublisher: timePublisher, - eventPublisher: eventPublisher + ExtPlayerMultiPlatform( + settings: $settings, + command: $command, + timePublisher: timePublisher, + eventPublisher: eventPublisher ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onReceive(timePublisher, perform: { time in - currentTime = time - }) - .onReceive(eventPublisher, perform: { event in - playerEvent = event - }) - .preference(key: CurrentTimePreferenceKey.self, value: currentTime) - .preference(key: PlayerEventPreferenceKey.self, value: playerEvent) + .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/enum/PlaybackCommand.swift b/Sources/swiftui-loop-videoplayer/enum/PlaybackCommand.swift index 9d172c1..1e8dc80 100644 --- a/Sources/swiftui-loop-videoplayer/enum/PlaybackCommand.swift +++ b/Sources/swiftui-loop-videoplayer/enum/PlaybackCommand.swift @@ -13,6 +13,10 @@ import CoreImage /// 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 @@ -20,8 +24,14 @@ public enum PlaybackCommand: Equatable { case pause /// Command to seek to a specific time in the video. - /// - Parameter time: The target position to seek to in the video, represented in seconds. - 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 @@ -85,16 +95,28 @@ public enum PlaybackCommand: Equatable { /// 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), + 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 - - case (.seek(let lhsTime), .seek(let rhsTime)): - return lhsTime == rhsTime + #if os(iOS) + case (.startPiP, .startPiP), (.stopPiP, .stopPiP): + return true + #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 diff --git a/Sources/swiftui-loop-videoplayer/enum/PlayerEvent.swift b/Sources/swiftui-loop-videoplayer/enum/PlayerEvent.swift index d215b0d..0398f7b 100644 --- a/Sources/swiftui-loop-videoplayer/enum/PlayerEvent.swift +++ b/Sources/swiftui-loop-videoplayer/enum/PlayerEvent.swift @@ -6,18 +6,118 @@ // 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 a state where the player is idle. - case idle - + /// 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 2090bdd..4c7c341 100644 --- a/Sources/swiftui-loop-videoplayer/enum/Setting.swift +++ b/Sources/swiftui-loop-videoplayer/enum/Setting.swift @@ -11,50 +11,60 @@ import SwiftUI import AVKit #endif -/// Settings for loop video player +/// Configuration settings for a loop video player. +/// These settings control various playback and UI behaviors. @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) -public enum Setting: Equatable, SettingsConvertible{ - +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 - - /// File name + + /// 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) - - /// 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. + + /// Sets subtitles for the video. + case subtitles(String) + + /// Enables Picture-in-Picture (PiP) mode support. + case pictureInPicture + + /// Defines the interval at which the player's current time should be published. case timePublishing(CMTime) - /// Video gravity + /// Sets the video gravity (e.g., aspect fit, aspect fill). case gravity(AVLayerVideoGravity = .resizeAspect) - /// Error text is resource is not found - case errorText(String) - - /// Size of the error text - case errorFontSize(CGFloat) - - /// Color of the error text - case errorColor(Color) - - /// Case name + /// Retrieves the name of the current case. var caseName: String { Mirror(reflecting: self).children.first?.label ?? "\(self)" } - - /// Associated value + + /// Retrieves the associated value of the case, if any. var associatedValue: Any? { - guard let firstChild = Mirror(reflecting: self).children.first else { return nil } - return firstChild.value } } diff --git a/Sources/swiftui-loop-videoplayer/enum/VPErrors.swift b/Sources/swiftui-loop-videoplayer/enum/VPErrors.swift index 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 index c78ba01..b25a068 100644 --- a/Sources/swiftui-loop-videoplayer/ext+/CMTime+.swift +++ b/Sources/swiftui-loop-videoplayer/ext+/CMTime+.swift @@ -9,7 +9,11 @@ import AVKit #endif -extension CMTime : SettingsConvertible{ +/// 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 b702e94..a9204a3 100644 --- a/Sources/swiftui-loop-videoplayer/fn/fn+.swift +++ b/Sources/swiftui-loop-videoplayer/fn/fn+.swift @@ -12,23 +12,77 @@ 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. @@ -46,21 +100,6 @@ fileprivate func extractExtension(from name: String) -> String? { return nil } -/// Detects and returns the appropriate error based on settings and asset. -/// - 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 { - return nil - } -} - /// 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. @@ -71,7 +110,7 @@ func detectError(settings: VideoSettings, asset: AVURLAsset?) -> VPErrors? { /// - contrast: A Float value representing the contrast adjustment to apply. /// /// - Returns: An array of CIFilter objects, including the original filters and the added brightness and contrast adjustments. -internal func combineFilters(_ filters: [CIFilter],_ brightness: Float,_ contrast: Float) -> [CIFilter] { +func combineFilters(_ filters: [CIFilter],_ brightness: Float,_ contrast: Float) -> [CIFilter] { var allFilters = filters if let filter = CIFilter(name: "CIColorControls", parameters: [kCIInputBrightnessKey: brightness]) { allFilters.append(filter) @@ -91,7 +130,7 @@ internal func combineFilters(_ filters: [CIFilter],_ brightness: Float,_ contra /// /// The function starts by clamping the source image to ensure coordinates remain within the image bounds, /// applies each filter in the provided array, and completes by returning the modified image to the composition request. -internal func handleVideoComposition(request: AVAsynchronousCIImageFilteringRequest, filters: [CIFilter]) { +func handleVideoComposition(request: AVAsynchronousCIImageFilteringRequest, filters: [CIFilter]) { // Start with the source image, ensuring it's clamped to avoid any coordinate issues var currentImage = request.sourceImage.clampedToExtent() @@ -106,3 +145,139 @@ internal func handleVideoComposition(request: AVAsynchronousCIImageFilteringRequ 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)) + + let scale = max(Int32(600), duration.timescale) + return CMTime(seconds: clamped, preferredTimescale: scale) +} + +/// Creates an `AVPlayerItem` with optional subtitle merging. +/// - Parameters: +/// - 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 index 1f0f803..816451f 100644 --- a/Sources/swiftui-loop-videoplayer/protocol/helpers/PlayerDelegateProtocol.swift +++ b/Sources/swiftui-loop-videoplayer/protocol/helpers/PlayerDelegateProtocol.swift @@ -6,30 +6,89 @@ // 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, *) -public protocol PlayerDelegateProtocol: AnyObject { +@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. - @MainActor 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. - @MainActor 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. - @MainActor 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/SettingsConvertible.swift b/Sources/swiftui-loop-videoplayer/protocol/helpers/SettingsConvertible.swift index a7a9fd6..5f55057 100644 --- a/Sources/swiftui-loop-videoplayer/protocol/helpers/SettingsConvertible.swift +++ b/Sources/swiftui-loop-videoplayer/protocol/helpers/SettingsConvertible.swift @@ -7,8 +7,9 @@ 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 diff --git a/Sources/swiftui-loop-videoplayer/protocol/player/AbstractPlayer.swift b/Sources/swiftui-loop-videoplayer/protocol/player/AbstractPlayer.swift index 9389671..677d931 100644 --- a/Sources/swiftui-loop-videoplayer/protocol/player/AbstractPlayer.swift +++ b/Sources/swiftui-loop-videoplayer/protocol/player/AbstractPlayer.swift @@ -7,21 +7,30 @@ import AVFoundation #if canImport(CoreImage) -import 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 @preconcurrency +@MainActor public protocol AbstractPlayer: AnyObject { - /// The delegate to be notified about errors encountered by the player. - var delegate: PlayerDelegateProtocol? { get set } + // MARK: - Properties - /// Retrieves the current item being played. - var currentItem : AVPlayerItem? { get } + #if os(iOS) + var pipController: AVPictureInPictureController? { get set } + #endif - /// The current asset being played, if available. - var currentAsset : AVURLAsset? { get } + /// 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 } @@ -38,10 +47,18 @@ public protocol AbstractPlayer: AnyObject { /// The queue player that plays the video items. var player: AVQueuePlayer? { get set } - /// Observes the status property of the new player item. - var statusObserver: NSKeyValueObservation? { get set } + // MARK: - Calculated properties - // Playback control methods + /// 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. @@ -50,11 +67,17 @@ public protocol AbstractPlayer: AnyObject { /// 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) + 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. @@ -76,7 +99,19 @@ public protocol AbstractPlayer: AnyObject { /// - 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) @@ -98,7 +133,8 @@ public protocol AbstractPlayer: AnyObject { /// Sets the playback command for the video player. func setCommand(_ value: PlaybackCommand) - func applyVideoComposition() + /// Updates the current playback asset, settings, and initializes playback or a specific action when the asset is ready. + func update(settings: VideoSettings) } extension AbstractPlayer{ @@ -136,117 +172,73 @@ extension AbstractPlayer{ /// 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() + player?.pause() } /// Clears all items from the player's queue. func clearPlayerQueue() { - guard let items = player?.items() else { return } - for item in items { - player?.remove(item) - } + player?.removeAllItems() } - /// Updates the current playback asset, settings, and initializes playback or a specific action when the asset is ready. - /// - /// This method sets a new asset to be played, optionally loops it, and can automatically start playback. - /// If provided, a callback is executed when the asset is ready to play. - /// - /// - Parameters: - /// - asset: The AVURLAsset to be loaded into the player. - /// - loop: A Boolean value indicating whether the video should loop. - /// - autoPlay: A Boolean value indicating whether playback should start automatically. Default is true. - /// - callback: An optional closure to be called when the asset is ready to play. - func update(asset: AVURLAsset, loop: Bool, autoPlay: Bool = true, callback: (() -> Void)? = nil) { - - guard let player = player else { return } - - let wasPlaying = player.rate != 0 - - if wasPlaying { - pause() - } - - if !player.items().isEmpty { - // Cleaning - unloop() - clearPlayerQueue() - removeAllFilters() - } - - let newItem = AVPlayerItem(asset: asset) - player.insert(newItem, after: nil) - - if loop { - self.loop() - } - - if let statusObserver{ - statusObserver.invalidate() - } + /// Determines whether the media queue of the player is empty. + func isEmptyQueue() -> Bool{ + player?.items().isEmpty ?? true + } + + /// Stop and clean player + func stop(){ - if let callback{ - statusObserver = newItem.observe(\.status, options: [.new, .old]) { [weak self] item, change in - guard item.status == .readyToPlay else { return } - callback() - self?.statusObserver?.invalidate() - self?.statusObserver = nil + pause() + + if !isEmptyQueue() { // Cleaning + if isLooping{ + unloop() } - } - - if autoPlay{ - player.play() + + 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. - /// 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) { + /// 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 player.currentItem?.status == .readyToPlay else{ - if let asset = currentAsset{ - update(asset: asset , loop: false, autoPlay: false){[weak self] in - self?.seek(to: time) - } - return - } - + guard let seekTime = getSeekTime(for: time, duration: duration) else { delegate?.didSeek(value: false, currentTime: time) return } - guard duration.value != 0 else{ - delegate?.didSeek(value: false, currentTime: time) - return - } - - let endTime = CMTimeGetSeconds(duration) - let seekTime : CMTime - - if time <= 0 { - // If the time is negative, seek to the start of the video - seekTime = .zero - } else if time >= endTime { - // If the time exceeds the video duration, seek to the end of the video - let endCMTime = CMTime(seconds: endTime, preferredTimescale: duration.timescale) - seekTime = endCMTime - } else { - // Otherwise, seek to the specified time - let seekCMTime = CMTime(seconds: time, preferredTimescale: duration.timescale) - seekTime = seekCMTime - } - - player.seek(to: seekTime){ [weak self] value in - let currentTime = CMTimeGetSeconds(player.currentTime()) - self?.delegate?.didSeek(value: value, currentTime: currentTime) + 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. @@ -292,6 +284,7 @@ extension AbstractPlayer{ } /// 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. @@ -319,6 +312,11 @@ extension AbstractPlayer{ #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() { @@ -327,7 +325,7 @@ extension AbstractPlayer{ } // Check if the video is already being looped - if playerLooper != nil { + if isLooping { return } @@ -338,7 +336,7 @@ extension AbstractPlayer{ /// 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 { + guard isLooping else { return // Not looped, no need to unloop } @@ -380,7 +378,6 @@ extension AbstractPlayer{ 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 @@ -404,7 +401,7 @@ extension AbstractPlayer{ /// 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. - func applyVideoComposition() { + private func applyVideoComposition() { guard let player = player else { return } let allFilters = combineFilters(filters, brightness, contrast) @@ -416,7 +413,7 @@ extension AbstractPlayer{ if wasPlaying { player.pause() } - + player.items().forEach{ item in let videoComposition = AVVideoComposition(asset: item.asset, applyingCIFiltersWithHandler: { request in @@ -453,50 +450,25 @@ extension AbstractPlayer{ } #endif } + + #if os(iOS) + func startPiP() { + guard let pipController = pipController else { return } -} + if !pipController.isPictureInPictureActive { + pipController.startPictureInPicture() -/// Cleans up resources associated with an AVQueuePlayer and its related components. -/// This function stops the player, invalidates and clears the observer, and removes all items from the player queue. -/// It also disables any looping mechanisms before setting the player and its components to nil, ensuring proper deinitialization. -/// -/// - Parameters: -/// - player: A reference to the AVQueuePlayer to be cleaned up. Modified directly to deallocate resources. -/// - playerLooper: A reference to the AVPlayerLooper associated with the player. It's disabled and set to nil. -/// - errorObserver: A reference to an NSKeyValueObservation monitoring the player, which is invalidated and set to nil. -internal func cleanUp( - player: inout AVQueuePlayer?, - playerLooper: inout AVPlayerLooper?, - errorObserver: inout NSKeyValueObservation?, - statusObserver: inout NSKeyValueObservation?, - timeObserver: inout Any? -) { - - errorObserver?.invalidate() - errorObserver = nil - - statusObserver?.invalidate() - statusObserver = nil - - player?.pause() - - playerLooper?.disableLooping() - playerLooper = nil - - guard let items = player?.items() else { return } - for item in items { - player?.remove(item) + } } - if let observerToken = timeObserver { - player?.removeTimeObserver(observerToken) - timeObserver = nil + func stopPiP() { + guard let pipController = pipController else { return } + + if pipController.isPictureInPictureActive { + // Stop PiP + pipController.stopPictureInPicture() + } } - player = nil - - #if DEBUG - print("Cleaned up.") #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/player/LoopingPlayerProtocol.swift b/Sources/swiftui-loop-videoplayer/protocol/player/LoopingPlayerProtocol.swift deleted file mode 100644 index 10f9925..0000000 --- a/Sources/swiftui-loop-videoplayer/protocol/player/LoopingPlayerProtocol.swift +++ /dev/null @@ -1,221 +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 @preconcurrency -public protocol LoopingPlayerProtocol: AbstractPlayer, LayerMakerProtocol{ - - #if canImport(UIKit) - var layer : CALayer { get } - #elseif canImport(AppKit) - var layer : CALayer? { get set } - var wantsLayer : Bool { get set } - #endif - - var playerLayer : AVPlayerLayer { get } - - /// 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 } - - /// Declare a variable to hold the time observer token outside the if statement - var timeObserver: Any? { get set } - - /// Initializes a new player view with specified video asset and configurations. - /// - /// - Parameters: - /// - asset: The `AVURLAsset` used for video playback. - /// - gravity: The `AVLayerVideoGravity` defining how the video content is displayed within the layer bounds. - /// - timePublishing: Optional `CMTime` that specifies a particular time for publishing or triggering an event. - /// - loop: A Boolean value that indicates whether the video should loop when playback reaches the end of the content. - init(asset: AVURLAsset, gravity: AVLayerVideoGravity, timePublishing: CMTime?, loop : Bool) - - /// 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) - - /// Responds to errors reported by the AVQueuePlayer. - /// - /// - Parameter player: The AVQueuePlayer that encountered an error. - func handlePlayerError(_ player: AVPlayer) -} - -internal extension LoopingPlayerProtocol { - - /// Sets up the player components with the specified media asset, display properties, and optional time publishing interval. - /// - /// - Parameters: - /// - asset: The AVURLAsset representing the video content. - /// - gravity: Determines how the video content is scaled or fit within the player view. - /// - timePublishing: Optional interval for publishing the current playback time; nil disables this feature. - func setupPlayerComponents( - asset: AVURLAsset, - gravity: AVLayerVideoGravity, - timePublishing: CMTime?, - loop: Bool - ) { - - let player = AVQueuePlayer(items: []) - self.player = player - - update(asset: asset, loop: loop) - - configurePlayer(player, gravity: gravity, timePublishing: timePublishing, loop: loop) - - setupObservers(for: player) - } - - /// Configures the provided AVQueuePlayer with specific properties for video playback. - /// - /// - Parameters: - /// - player: The AVQueuePlayer to be configured. - /// - gravity: The AVLayerVideoGravity determining how the video content should be scaled or fit within the player layer. - /// - timePublishing: Optional interval for publishing the current playback time; nil disables this feature. - func configurePlayer( - _ player: AVQueuePlayer, - gravity: AVLayerVideoGravity, - timePublishing: CMTime?, - loop : Bool - ) { - player.isMuted = true - playerLayer.player = player - playerLayer.videoGravity = gravity - #if canImport(UIKit) - playerLayer.backgroundColor = UIColor.clear.cgColor - layer.addSublayer(playerLayer) - layer.addSublayer(compositeLayer) - #elseif canImport(AppKit) - playerLayer.backgroundColor = NSColor.clear.cgColor - let layer = CALayer() - layer.addSublayer(playerLayer) - layer.addSublayer(compositeLayer) - self.layer = layer - self.wantsLayer = true - #endif - compositeLayer.frame = CGRect(x: 0, y: 0, width: bounds.width, height: bounds.height) - - if let timePublishing{ - timeObserver = player.addPeriodicTimeObserver(forInterval: timePublishing, queue: .main) { [weak self] time in - guard let self = self else{ return } - - self.delegate?.didPassedTime(seconds: time.seconds) - } - } - } - - /// 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 - self?.handlePlayerError(player) - } - } - - /// Removes observers for handling errors. - /// - /// This method ensures that the error observer is properly invalidated and the reference is cleared. - /// It is important to call this method to prevent memory leaks and remove any unwanted side effects - /// from obsolete observers. - func removeObservers() { - errorObserver?.invalidate() - errorObserver = nil - } - - /// 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)) - } - - - - /// Sets the playback command for the video player. - /// - Parameter value: The `PlaybackCommand` to set. This can be one of the following: - /// - `play`: Command to play the video. - /// - `pause`: Command to pause the video. - /// - `seek(to:)`: Command to seek to a specific time in the video. - /// - `begin`: Command to position the video at the beginning. - /// - `end`: Command to position the video at the end. - /// - `mute`: Command to mute the video. - /// - `unmute`: Command to unmute the video. - /// - `volume`: Command to adjust the volume of the video playback. - /// - `subtitles`: Command to set subtitles to a specified language or turn them off. - /// - `playbackSpeed`: Command to adjust the playback speed of the video. - /// - `loop`: Command to enable looping of the video playback. - /// - `unloop`: Command to disable looping of the video playback. - /// - `brightness`: Command to adjust the brightness of the video playback. - /// - `contrast`: Command to adjust the contrast of the video playback. - /// - `filter`: Command to apply a specific Core Image filter to the video. - /// - `removeAllFilters`: Command to remove all applied filters from the video playback. - /// - `audioTrack`: Command to select a specific audio track based on language code. - /// - `vector`: Sets a vector graphic operation on the video player. - /// - `removeAllVectors`: Clears all vector graphics from the video player. - 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() - 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() - } - } -} diff --git a/Sources/swiftui-loop-videoplayer/protocol/vector/ShapeLayerBuilderProtocol.swift b/Sources/swiftui-loop-videoplayer/protocol/vector/ShapeLayerBuilderProtocol.swift index 0a17f85..d3d8eb6 100644 --- a/Sources/swiftui-loop-videoplayer/protocol/vector/ShapeLayerBuilderProtocol.swift +++ b/Sources/swiftui-loop-videoplayer/protocol/vector/ShapeLayerBuilderProtocol.swift @@ -18,6 +18,7 @@ import QuartzCore @available(iOS 14, macOS 11, tvOS 14, *) public protocol ShapeLayerBuilderProtocol: Identifiable { + /// Unique identifier var id : UUID { get } /// Builds a CAShapeLayer using specified geometry. @@ -27,5 +28,4 @@ public protocol ShapeLayerBuilderProtocol: Identifiable { /// - 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 index 2e3c926..57d144b 100644 --- a/Sources/swiftui-loop-videoplayer/protocol/vector/VectorLayerProtocol.swift +++ b/Sources/swiftui-loop-videoplayer/protocol/vector/VectorLayerProtocol.swift @@ -21,12 +21,12 @@ import QuartzCore /// @available(iOS 14, macOS 11, tvOS 14, *) @MainActor -public protocol LayerMakerProtocol { +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 } + var compositeLayer: CALayer? { get set } /// The frame of the composite layer. /// @@ -51,6 +51,20 @@ public protocol LayerMakerProtocol { 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. /// @@ -61,14 +75,13 @@ extension LayerMakerProtocol{ func addVectorLayer(builder : any ShapeLayerBuilderProtocol, clear: Bool){ if clear{ removeAllVectors() } let layer = builder.build(with: (frame, bounds)) - compositeLayer.addSublayer(layer) + compositeLayer?.addSublayer(layer) } /// Removes all vector layers from the composite layer. @MainActor func removeAllVectors(){ - compositeLayer.sublayers?.forEach { $0.removeFromSuperlayer() } + compositeLayer?.sublayers?.forEach { $0.removeFromSuperlayer() } } - } 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 59e5205..0000000 --- a/Sources/swiftui-loop-videoplayer/protocol/view/LoopPlayerViewProtocol.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// LoopPlayerViewProtocol.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 -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 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, *) -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, timePublishing: settings.timePublishing, loop: settings.loop) - container.addSubview(player) - activateFullScreenConstraints(for: player, in: container) - return player - } - - return nil - } -} 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 83fe089..1bbc710 100644 --- a/Sources/swiftui-loop-videoplayer/settings/Ext.swift +++ b/Sources/swiftui-loop-videoplayer/settings/Ext.swift @@ -7,14 +7,18 @@ 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 a183c74..dd85e23 100644 --- a/Sources/swiftui-loop-videoplayer/settings/Gravity.swift +++ b/Sources/swiftui-loop-videoplayer/settings/Gravity.swift @@ -8,14 +8,17 @@ 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 index 2d89e55..bf59640 100644 --- a/Sources/swiftui-loop-videoplayer/settings/Loop.swift +++ b/Sources/swiftui-loop-videoplayer/settings/Loop.swift @@ -7,11 +7,14 @@ 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 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 56dd408..c909b70 100644 --- a/Sources/swiftui-loop-videoplayer/settings/SourceName.swift +++ b/Sources/swiftui-loop-videoplayer/settings/SourceName.swift @@ -7,6 +7,8 @@ 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 index 3db81fa..bd45ea6 100644 --- a/Sources/swiftui-loop-videoplayer/settings/TimePublishing.swift +++ b/Sources/swiftui-loop-videoplayer/settings/TimePublishing.swift @@ -8,14 +8,17 @@ 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{ - /// Video file extension + /// 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 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 32840b4..0000000 --- a/Sources/swiftui-loop-videoplayer/settings/errors/EColor.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// EText.swift -// -// -// Created by Igor Shelopaev 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 363f9e3..0000000 --- a/Sources/swiftui-loop-videoplayer/settings/errors/EFontSize.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// EFontSize.swift -// -// -// Created by Igor Shelopaev 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 9740bf1..0000000 --- a/Sources/swiftui-loop-videoplayer/settings/errors/ErrorGroup.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// ErrorGroup.swift -// -// -// Created by Igor Shelopaev 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 872b969..5c4f4a4 100644 --- a/Sources/swiftui-loop-videoplayer/utils/SettingsBuilder.swift +++ b/Sources/swiftui-loop-videoplayer/utils/SettingsBuilder.swift @@ -8,7 +8,7 @@ import SwiftUI import AVKit -// Result builder to construct an array of 'Setting' objects. +/// Result builder to construct an array of 'Setting' objects. @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) @resultBuilder public struct SettingsBuilder { diff --git a/Sources/swiftui-loop-videoplayer/utils/VideoSettings.swift b/Sources/swiftui-loop-videoplayer/utils/VideoSettings.swift index 3591333..d2c3f57 100644 --- a/Sources/swiftui-loop-videoplayer/utils/VideoSettings.swift +++ b/Sources/swiftui-loop-videoplayer/utils/VideoSettings.swift @@ -8,6 +8,7 @@ import SwiftUI import AVKit +/// Represents a structure for video settings. @available(iOS 14.0, macOS 11.0, tvOS 14.0, *) public struct VideoSettings: Equatable{ @@ -19,21 +20,34 @@ 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 @@ -45,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() @@ -56,15 +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) + + loop = settings.contains(.loop) - errorColor = settings.fetch(by : "errorColor", defaulted: .red) + pictureInPicture = settings.contains(.pictureInPicture) - errorFontSize = settings.fetch(by : "errorFontSize", defaulted: 17) + mute = settings.contains(.mute) - timePublishing = settings.fetch(by : "timePublishing", defaulted: nil) + notAutoPlay = settings.contains(.notAutoPlay) - loop = settings.contains(.loop) + 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 } } @@ -76,4 +162,3 @@ fileprivate func check(_ settings : [Setting]) -> Bool{ let set = Set(cases) return cases.count == set.count } - diff --git a/Sources/swiftui-loop-videoplayer/view/helpers/PlayerCoordinator.swift b/Sources/swiftui-loop-videoplayer/view/helpers/PlayerCoordinator.swift index 5f77a1d..781d72a 100644 --- a/Sources/swiftui-loop-videoplayer/view/helpers/PlayerCoordinator.swift +++ b/Sources/swiftui-loop-videoplayer/view/helpers/PlayerCoordinator.swift @@ -7,27 +7,27 @@ 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? - - /// A binding to an optional `VPErrors` instance, used to report errors back to the parent view. - @Binding private var error: VPErrors? - init( - _ error: Binding, timePublisher: PassthroughSubject, eventPublisher: PassthroughSubject ) { - self._error = error self.timePublisher = timePublisher self.eventPublisher = eventPublisher } @@ -35,7 +35,7 @@ internal class PlayerCoordinator: NSObject, PlayerDelegateProtocol { /// Deinitializes the coordinator and prints a debug message if in DEBUG mode. deinit { #if DEBUG - print("deinit Coordinator") + print("Deinit Coordinator") #endif } @@ -43,7 +43,7 @@ internal class PlayerCoordinator: NSObject, PlayerDelegateProtocol { /// 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 + eventPublisher.send(.error(error)) } /// Sets the last command applied to the player. @@ -72,4 +72,105 @@ internal class PlayerCoordinator: NSObject, PlayerDelegateProtocol { 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/modifier/OnPlayerEventChangeModifier.swift b/Sources/swiftui-loop-videoplayer/view/modifier/OnPlayerEventChangeModifier.swift index 5f3354e..2e94b73 100644 --- a/Sources/swiftui-loop-videoplayer/view/modifier/OnPlayerEventChangeModifier.swift +++ b/Sources/swiftui-loop-videoplayer/view/modifier/OnPlayerEventChangeModifier.swift @@ -7,26 +7,33 @@ import SwiftUI +/// Defines a custom `PreferenceKey` for handling player events within SwiftUI views. internal struct PlayerEventPreferenceKey: PreferenceKey { - public static var defaultValue: PlayerEvent = .idle - public static func reduce(value: inout PlayerEvent, nextValue: () -> PlayerEvent) { + /// 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 { - var onPlayerEventChange: (PlayerEvent) -> Void + /// 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) { event in - onPlayerEventChange(event) - } + .onPreferenceChange(PlayerEventPreferenceKey.self, perform: onPlayerEventChange) } } +/// Extends `View` to include a custom modifier for handling player event changes. public extension View { - func onPlayerEventChange(perform action: @escaping (PlayerEvent) -> Void) -> some 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 index acdf38e..bf3b694 100644 --- a/Sources/swiftui-loop-videoplayer/view/modifier/OnTimeChangeModifier.swift +++ b/Sources/swiftui-loop-videoplayer/view/modifier/OnTimeChangeModifier.swift @@ -7,25 +7,32 @@ 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) { time in - onTimeChange(time) - } + .onPreferenceChange(CurrentTimePreferenceKey.self, perform: onTimeChange) } } -public extension View{ +/// 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/ios/LoopingPlayerUIView.swift b/Sources/swiftui-loop-videoplayer/view/player/ios/LoopingPlayerUIView.swift deleted file mode 100644 index 584c809..0000000 --- a/Sources/swiftui-loop-videoplayer/view/player/ios/LoopingPlayerUIView.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// LoopingPlayerProtocol.swift -// -// -// Created by Igor Shelopaev 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 @preconcurrency -class LoopingPlayerUIView: UIView, LoopingPlayerProtocol { - - /// `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 let compositeLayer = CALayer() - - /// 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? - - /// 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? - - /// Observes the status property of the new player item. - internal var statusObserver: NSKeyValueObservation? - - /// The delegate to be notified about errors encountered by the player. - weak var delegate: PlayerDelegateProtocol? - - /// Initializes a new player view with specified video asset and configurations. - /// - /// - Parameters: - /// - asset: The `AVURLAsset` used for video playback. - /// - gravity: The `AVLayerVideoGravity` defining how the video content is displayed within the layer bounds. - /// - timePublishing: Optional `CMTime` that specifies a particular time for publishing or triggering an event. - /// - loop: A Boolean value that indicates whether the video should loop when playback reaches the end of the content. - required init(asset: AVURLAsset, gravity: AVLayerVideoGravity, timePublishing: CMTime?, loop: Bool){ - super.init(frame: .zero) - setupPlayerComponents( - asset: asset, gravity: gravity, timePublishing : timePublishing, loop: loop - ) - } - - 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. - deinit { - cleanUp(player: &player, playerLooper: &playerLooper, errorObserver: &errorObserver, statusObserver: &statusObserver, timeObserver: &timeObserver) - } -} -#endif diff --git a/Sources/swiftui-loop-videoplayer/view/player/ios/error/ErrorMsgViewIOS.swift b/Sources/swiftui-loop-videoplayer/view/player/ios/error/ErrorMsgViewIOS.swift deleted file mode 100644 index 4c3d559..0000000 --- a/Sources/swiftui-loop-videoplayer/view/player/ios/error/ErrorMsgViewIOS.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// ErrorMsgViewIOS.swift -// -// -// Created by Igor Shelopaev 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/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/mac/LoopingPlayerNSView.swift b/Sources/swiftui-loop-videoplayer/view/player/mac/LoopingPlayerNSView.swift deleted file mode 100644 index 610fe88..0000000 --- a/Sources/swiftui-loop-videoplayer/view/player/mac/LoopingPlayerNSView.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// LoopingPlayerNSView.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. -@available(macOS 11.0, *) -@MainActor @preconcurrency -class LoopingPlayerNSView: NSView, LoopingPlayerProtocol { - - /// `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 let compositeLayer = CALayer() - - /// 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? - - /// 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? - - /// Observes the status property of the new player item. - internal var statusObserver: NSKeyValueObservation? - - /// The delegate to be notified about errors encountered by the player. - weak var delegate: PlayerDelegateProtocol? - - /// Initializes a new player view with specified video asset and configurations. - /// - /// - Parameters: - /// - asset: The `AVURLAsset` used for video playback. - /// - gravity: The `AVLayerVideoGravity` defining how the video content is displayed within the layer bounds. - /// - timePublishing: Optional `CMTime` that specifies a particular time for publishing or triggering an event. - /// - loop: A Boolean value that indicates whether the video should loop when playback reaches the end of the content. - required init(asset: AVURLAsset, gravity: AVLayerVideoGravity, timePublishing: CMTime?, loop : Bool) { - super.init(frame: .zero) - setupPlayerComponents( - asset: asset, gravity: gravity, timePublishing: timePublishing, loop: loop - ) - } - - 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. - deinit { - cleanUp(player: &player, playerLooper: &playerLooper, errorObserver: &errorObserver, statusObserver: &statusObserver, timeObserver: &timeObserver) - } -} -#endif diff --git a/Sources/swiftui-loop-videoplayer/view/player/mac/error/ErrorMsgViewMacOS.swift b/Sources/swiftui-loop-videoplayer/view/player/mac/error/ErrorMsgViewMacOS.swift deleted file mode 100644 index 16b4453..0000000 --- a/Sources/swiftui-loop-videoplayer/view/player/mac/error/ErrorMsgViewMacOS.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// ErrorMsgViewMacOS.swift -// -// -// Created by Igor Shelopaev 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/main/LoopPlayerMultiPlatform.swift b/Sources/swiftui-loop-videoplayer/view/player/main/ExtPlayerMultiPlatform.swift similarity index 60% rename from Sources/swiftui-loop-videoplayer/view/main/LoopPlayerMultiPlatform.swift rename to Sources/swiftui-loop-videoplayer/view/player/main/ExtPlayerMultiPlatform.swift index d8ad04f..0d79ac1 100644 --- a/Sources/swiftui-loop-videoplayer/view/main/LoopPlayerMultiPlatform.swift +++ b/Sources/swiftui-loop-videoplayer/view/player/main/ExtPlayerMultiPlatform.swift @@ -1,5 +1,5 @@ // -// LoopPlayerMultiPlatform.swift +// ExtPlayerMultiPlatform.swift // // // Created by Igor Shelopaev on 05.08.24. @@ -8,10 +8,6 @@ import SwiftUI import Combine -#if canImport(AVKit) -import AVKit -#endif - #if canImport(UIKit) import UIKit #endif @@ -20,22 +16,17 @@ import UIKit import AppKit #endif -@available(iOS 14.0, macOS 11.0, tvOS 14.0, *) @MainActor -struct LoopPlayerMultiPlatform: LoopPlayerViewProtocol { +internal struct ExtPlayerMultiPlatform: ExtPlayerViewProtocol { #if canImport(UIKit) typealias View = UIView - typealias ErrorView = ErrorMsgViewIOS - - typealias PlayerView = LoopingPlayerUIView + typealias PlayerView = ExtPlayerUIView #elseif canImport(AppKit) - typealias View = NSView + typealias View = NSView - typealias ErrorView = ErrorMsgViewMacOS - - typealias PlayerView = LoopingPlayerNSView + typealias PlayerView = ExtPlayerNSView #endif /// A publisher that emits the current playback time as a `Double`. @@ -49,14 +40,7 @@ struct LoopPlayerMultiPlatform: LoopPlayerViewProtocol { /// 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 of `ExtPlayerView`. /// - Parameters: /// - settings: A binding to the video settings used by the player. @@ -73,118 +57,111 @@ struct LoopPlayerMultiPlatform: LoopPlayerViewProtocol { self.eventPublisher = eventPublisher 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, timePublisher: timePublisher, eventPublisher: eventPublisher) + PlayerCoordinator(timePublisher: timePublisher, eventPublisher: eventPublisher) } } #if canImport(UIKit) -extension LoopPlayerMultiPlatform: UIViewRepresentable{ +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 - @MainActor func makeUIView(context: Context) -> UIView { + func makeUIView(context: Context) -> UIView { let container = UIView() - if let player: PlayerView = makePlayerView( - container, - asset: asset){ + if let player: PlayerView = makePlayerView(container){ player.delegate = context.coordinator + #if os(iOS) + if settings.pictureInPicture{ + player.setupPiP(delegate: context.coordinator) + } + #endif } - - makeErrorView(container, error: error) - - return container + + 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) { + 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, loop: settings.loop) - } + + 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() } - - updateView(uiView, error: error) } } #endif #if canImport(AppKit) -extension LoopPlayerMultiPlatform: NSViewRepresentable{ +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. - @MainActor func makeNSView(context: Context) -> NSView { + func makeNSView(context: Context) -> NSView { let container = NSView() - if let player: PlayerView = makePlayerView( - container, - asset: asset){ + if let player: PlayerView = makePlayerView(container){ player.delegate = context.coordinator } - makeErrorView(container, error: error) - - return container + 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) { + 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, loop: settings.loop) - } + + 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 } - } - - 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) - - if asset == nil { - return newAsset } - if let newUrl = newAsset?.url, let oldUrl = asset?.url, newUrl != oldUrl{ - return newAsset + /// 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() + } } - - return nil } +#endif diff --git a/Tests/swiftui-loop-videoplayerTests/ext+/testURL.swift b/Tests/swiftui-loop-videoplayerTests/ext+/testURL.swift new file mode 100644 index 0000000..dd462b5 --- /dev/null +++ b/Tests/swiftui-loop-videoplayerTests/ext+/testURL.swift @@ -0,0 +1,75 @@ +// +// testURL+.swift +// swiftui-loop-videoplayer +// +// Created by Igor Shelopaev on 20.08.25. +// + +import XCTest +@testable import swiftui_loop_videoplayer + +final class testURL: XCTestCase { + + // MARK: - Positive cases (should pass) + + func testSampleVideoURLsPass() { + // Given: four sample URLs from the sandbox dictionary + let urls = [ + "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8" + ] + + // When/Then + for raw in urls { + let url = URL.validURLFromString(from: raw) + XCTAssertNotNil(url, "Expected to parse: \(raw)") + XCTAssertEqual(url?.scheme?.lowercased(), "https") + } + } + + func testTrimsWhitespace() { + let raw = " https://example.com/video.m3u8 " + let url = URL.validURLFromString(from: raw) + XCTAssertNotNil(url) + XCTAssertEqual(url?.host, "example.com") + XCTAssertEqual(url?.path, "/video.m3u8") + } + + func testIPv6AndLocalHosts() { + // IPv6 loopback + XCTAssertNotNil(URL.validURLFromString(from: "https://[::1]")) + // localhost + XCTAssertNotNil(URL.validURLFromString(from: "http://localhost")) + // IPv4 with port and query/fragment + XCTAssertNotNil(URL.validURLFromString(from: "http://127.0.0.1:8080/path?a=1#x")) + } + + func testIDNUnicodeHost() { + // Unicode host (IDN). URLComponents should handle this. + let url = URL.validURLFromString(from: "https://bücher.de") + XCTAssertNotNil(url) + XCTAssertEqual(url?.scheme, "https") + XCTAssertNotNil(url?.host) + } + + // MARK: - Negative cases (should fail) + + func testRejectsNonHTTP() { + XCTAssertNil(URL.validURLFromString(from: "ftp://example.com/file.mp4")) + XCTAssertNil(URL.validURLFromString(from: "mailto:user@example.com")) + XCTAssertNil(URL.validURLFromString(from: "file:///Users/me/movie.mp4")) + } + + func testRejectsInvalidPort() { + XCTAssertNil(URL.validURLFromString(from: "https://example.com:0")) + XCTAssertNil(URL.validURLFromString(from: "https://example.com:65536")) + XCTAssertNotNil(URL.validURLFromString(from: "https://example.com:65535")) + } + + func testRejectsMissingHost() { + XCTAssertNil(URL.validURLFromString(from: "https://")) + XCTAssertNil(URL.validURLFromString(from: "https:///path-only")) + } +} diff --git a/Tests/swiftui-loop-videoplayerTests/ext+/textArray.swift b/Tests/swiftui-loop-videoplayerTests/ext+/textArray.swift new file mode 100644 index 0000000..61d8bad --- /dev/null +++ b/Tests/swiftui-loop-videoplayerTests/ext+/textArray.swift @@ -0,0 +1,111 @@ +// +// textArray.swift +// swiftui-loop-videoplayer +// +// Created by Igor Shelopaev on 21.08.25. +// + +import XCTest +import AVFoundation +@testable import swiftui_loop_videoplayer + +final class SettingsTests: XCTestCase { + + // MARK: - events([PlayerEventFilter]?) + + func testFetch_Events_WithArray() { + let settings: [Setting] = [.events([.playing, .paused])] + let events: [PlayerEventFilter] = settings.fetch(by: "events", defaulted: []) + XCTAssertEqual(events, [.playing, .paused]) + } + + func testFetch_Events_NilAssociatedValue() { + let settings: [Setting] = [.events(nil)] + // Optional(nil) won't cast to [PlayerEventFilter] → returns default + let events: [PlayerEventFilter] = settings.fetch(by: "events", defaulted: []) + XCTAssertTrue(events.isEmpty) + } + + // MARK: - name / ext / subtitles + + func testFetch_Name_ReturnsStoredString() { + let settings: [Setting] = [.name("teaser")] + let value: String = settings.fetch(by: "name", defaulted: "") + XCTAssertEqual(value, "teaser") + } + + func testFetch_Ext_ReturnsStoredString() { + let settings: [Setting] = [.ext("mp4")] + let value: String = settings.fetch(by: "ext", defaulted: "mov") + XCTAssertEqual(value, "mp4") + } + + func testFetch_Subtitles_ReturnsStoredString() { + let settings: [Setting] = [.subtitles("de")] + let value: String = settings.fetch(by: "subtitles", defaulted: "en") + XCTAssertEqual(value, "de") + } + + // MARK: - Missing / mismatch + + func testFetch_ReturnsDefault_WhenNameMissing() { + let settings: [Setting] = [.name("teaser")] + let value: Int = settings.fetch(by: "fontSize", defaulted: 12) + XCTAssertEqual(value, 12) + } + + func testFetch_ReturnsDefault_WhenTypeMismatch() { + let settings: [Setting] = [.name("teaser")] + let value: Int = settings.fetch(by: "name", defaulted: 0) + XCTAssertEqual(value, 0) + } + + // MARK: - First match precedence + + func testFetch_PrefersFirstMatch_WhenMultipleWithSameName() { + let settings: [Setting] = [.name("first"), .name("second")] + let value: String = settings.fetch(by: "name", defaulted: "") + XCTAssertEqual(value, "first") + } + + // MARK: - Value-less cases → default + + func testFetch_Vector_ReturnsDefault() { + let settings: [Setting] = [.vector] + let value: Bool = settings.fetch(by: "vector", defaulted: false) + XCTAssertFalse(value) + } + + func testFetch_Loop_ReturnsDefault() { + let settings: [Setting] = [.loop] + let value: String = settings.fetch(by: "loop", defaulted: "no") + XCTAssertEqual(value, "no") + } + + func testFetch_PictureInPicture_ReturnsDefault() { + let settings: [Setting] = [.pictureInPicture] + let pip: Bool = settings.fetch(by: "pictureInPicture", defaulted: false) + XCTAssertFalse(pip) + } + + func testFetch_Mute_ReturnsDefault() { + let settings: [Setting] = [.mute] + let muted: Bool = settings.fetch(by: "mute", defaulted: false) + XCTAssertFalse(muted) + } + + // MARK: - timePublishing / gravity + + func testFetch_TimePublishing_CMTime() { + let t = CMTime(seconds: 0.5, preferredTimescale: 600) + let settings: [Setting] = [.timePublishing(t)] + let fetched: CMTime = settings.fetch(by: "timePublishing", defaulted: .zero) + XCTAssertEqual(CMTimeCompare(fetched, t), 0) + } + + func testFetch_Gravity_CustomAssociatedValue() { + let settings: [Setting] = [.gravity(.resizeAspectFill)] + let gravity: AVLayerVideoGravity = settings.fetch(by: "gravity", defaulted: .resize) + XCTAssertEqual(gravity, .resizeAspectFill) + } +} diff --git a/Tests/swiftui-loop-videoplayerTests/testPlayerInitialization.swift b/Tests/swiftui-loop-videoplayerTests/testPlayerInitialization.swift index 57d24b2..ae19f9c 100644 --- a/Tests/swiftui-loop-videoplayerTests/testPlayerInitialization.swift +++ b/Tests/swiftui-loop-videoplayerTests/testPlayerInitialization.swift @@ -21,8 +21,6 @@ final class testPlayerInitialization: XCTestCase { ext: "mov", gravity: .resizeAspectFill, timePublishing: CMTime(seconds: 1.5, preferredTimescale: 600), - eColor: .blue, - eFontSize: 20.0, command: commandBinding ) @@ -31,8 +29,6 @@ final class testPlayerInitialization: XCTestCase { XCTAssertEqual(playerView.settings.gravity, .resizeAspectFill) XCTAssertEqual(playerView.settings.timePublishing?.seconds, 1.5) XCTAssertEqual(playerView.settings.timePublishing?.timescale, 600) - XCTAssertEqual(playerView.settings.errorColor, .blue) - XCTAssertEqual(playerView.settings.errorFontSize, 20.0) XCTAssertEqual(playerView.command, playbackCommand) } @@ -45,8 +41,6 @@ final class testPlayerInitialization: XCTestCase { XCTAssertEqual(playerView.settings.gravity, .resizeAspect) // Default gravity XCTAssertNotNil(playerView.settings.timePublishing) // Default should not be nil XCTAssertEqual(playerView.settings.timePublishing?.seconds, 1) - XCTAssertEqual(playerView.settings.errorColor, .accentColor) // Default color - XCTAssertEqual(playerView.settings.errorFontSize, 17.0) // Default font size XCTAssertEqual(playerView.command, .play) // Default command } @@ -58,18 +52,12 @@ final class testPlayerInitialization: XCTestCase { Ext("mp8") // Set default extension here If not provided then mp4 is default Gravity(.resizeAspectFill) TimePublishing() - ErrorGroup{ - EColor(.accentColor) - EFontSize(27) - } } } XCTAssertEqual(playerView.settings.name, "swipe") XCTAssertEqual(playerView.settings.ext, "mp8") XCTAssertEqual(playerView.settings.gravity, .resizeAspectFill) XCTAssertNotEqual(playerView.settings.timePublishing, nil) - XCTAssertEqual(playerView.settings.errorColor, .accentColor) - XCTAssertEqual(playerView.settings.errorFontSize, 27) XCTAssertEqual(playerView.command, .play) } @@ -81,18 +69,12 @@ final class testPlayerInitialization: XCTestCase { Ext("mp8") Gravity(.resizeAspectFill) TimePublishing(CMTime(seconds: 2, preferredTimescale: 600)) - ErrorGroup { - EColor(.red) - EFontSize(15.0) - } } } XCTAssertEqual(playerView.settings.name, "swipe") XCTAssertEqual(playerView.settings.ext, "mp8") XCTAssertEqual(playerView.settings.gravity, .resizeAspectFill) XCTAssertEqual(playerView.settings.timePublishing?.seconds, 2) - XCTAssertEqual(playerView.settings.errorColor, .red) - XCTAssertEqual(playerView.settings.errorFontSize, 15.0) XCTAssertEqual(playerView.command, .play) } @@ -103,10 +85,6 @@ final class testPlayerInitialization: XCTestCase { Ext("mkv") Gravity(.resizeAspect) TimePublishing(CMTime(seconds: 1, preferredTimescale: 600)) - ErrorGroup { - EColor(.green) - EFontSize(12.0) - } } let settings = Binding.constant(initialSettings) let playerView = ExtVideoPlayer(settings: settings, command: .constant(.pause)) @@ -115,8 +93,6 @@ final class testPlayerInitialization: XCTestCase { XCTAssertEqual(settings.wrappedValue.ext, "mkv") XCTAssertEqual(settings.wrappedValue.gravity, .resizeAspect) XCTAssertEqual(settings.wrappedValue.timePublishing?.seconds, 1) - XCTAssertEqual(settings.wrappedValue.errorColor, .green) - XCTAssertEqual(settings.wrappedValue.errorFontSize, 12.0) XCTAssertEqual(playerView.command, .pause) } diff --git a/Tests/swiftui-loop-videoplayerTests/testVideoStreaming.swift b/Tests/swiftui-loop-videoplayerTests/testVideoStreaming.swift deleted file mode 100644 index 523625a..0000000 --- a/Tests/swiftui-loop-videoplayerTests/testVideoStreaming.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// testVideoStreaming.swift -// -// -// Created by Igor Shelopaev on 16.08.24. -// - -import XCTest -import SwiftUI -@testable import swiftui_loop_videoplayer - -final class testVideoStreaming: XCTestCase { - - func testLocalVideoStreaming() { - - guard let filePath = Bundle.module.path(forResource: "swipe", ofType: "mp4") else { - XCTFail("Missing file: swipe.mp4") - return - } - - let _ = ExtVideoPlayer(fileName: filePath) - // Use the filePath as needed for your tests - print("Video file path: \(filePath)") - - } -}