Skip to content

Commit 56e4606

Browse files
committed
added subtitles support
1 parent 2ff8ca5 commit 56e4606

File tree

7 files changed

+179
-21
lines changed

7 files changed

+179
-21
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Please note that using videos from URLs requires ensuring that you have the righ
6262
|---------------|-----------------------------------------------------------------------------------------------------|---------|
6363
| **SourceName** | The URL or local filename of the video. | - |
6464
| **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" |
65-
| **Subtitles** | The URL or local filename of the WebVTT (.vtt) subtitles file to be merged with the video. With a straightforward AVMutableComposition approach, 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. | - |
65+
| **Subtitles** | The URL or local filename of the WebVTT (.vtt) subtitles file to be merged with the video. With a straightforward AVMutableComposition approach, 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 the implementation in the example app (Video8.swift) | - |
6666
| **Gravity** | How the video content should be resized to fit the player's bounds. | .resizeAspect |
6767
| **TimePublishing** | Specifies the interval at which the player publishes the current playback time. | - |
6868
| **Loop** | Whether the video should automatically restart when it reaches the end. If not explicitly passed, the video will not loop. | false |

Sources/swiftui-loop-videoplayer/enum/Setting.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ public enum Setting: Equatable, SettingsConvertible{
3636
/// File extension
3737
case ext(String)
3838

39+
/// Subtitles
40+
case subtitles(String)
41+
3942
/// A CMTime value representing the interval at which the player's current time should be published.
4043
/// If set, the player will publish periodic time updates based on this interval.
4144
case timePublishing(CMTime)

Sources/swiftui-loop-videoplayer/fn/fn+.swift

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,102 @@ internal func handleVideoComposition(request: AVAsynchronousCIImageFilteringRequ
106106
request.finish(with: currentImage, context: nil)
107107
}
108108

109+
/// Retrieves an `AVURLAsset` for the subtitles specified in `VideoSettings`.
110+
/// - Parameter settings: The `VideoSettings` object containing details about the subtitle file (e.g., name and extension).
111+
/// - Returns: An optional `AVURLAsset` for the subtitle file, or `nil` if the file does not exist.
112+
func subtitlesFor(_ settings: VideoSettings) -> AVURLAsset? {
113+
let name = settings.subtitles
114+
let ext = "vtt"
115+
116+
if name.isEmpty { return nil }
117+
118+
// Attempt to create a URL directly from the provided video name string
119+
if let url = URL.validURLFromString(name) {
120+
return AVURLAsset(url: url)
121+
// If direct URL creation fails, attempt to locate the video in the main bundle using the name and extension
122+
} else if let fileUrl = Bundle.main.url(forResource: name, withExtension: ext) {
123+
return AVURLAsset(url: fileUrl)
124+
}
125+
126+
return nil
127+
}
128+
129+
/// Merges a video asset with an external WebVTT subtitle file into an AVMutableComposition.
130+
/// Returns a new AVAsset that has both the video/audio and subtitle tracks.
131+
///
132+
/// - Note:
133+
/// - This method supports embedding external subtitles (e.g., WebVTT) into video files
134+
/// that can handle text tracks, such as MP4 or QuickTime (.mov).
135+
/// - Subtitles are added as a separate track within the composition and will not be rendered
136+
/// (burned-in) directly onto the video frames. Styling, position, and size cannot be customized.
137+
///
138+
/// - Parameters:
139+
/// - videoAsset: The video asset (e.g., an MP4 file) to which the subtitles will be added.
140+
/// - subtitleAsset: The WebVTT subtitle asset to be merged with the video.
141+
///
142+
/// - Returns: A new AVAsset with the video, audio, and subtitle tracks combined.
143+
/// Returns `nil` if an error occurs during the merging process or if subtitles are unavailable.
144+
func mergeAssetWithSubtitles(videoAsset: AVURLAsset, subtitleAsset: AVURLAsset) -> AVAsset? {
145+
// Create a new composition
146+
let composition = AVMutableComposition()
147+
148+
// 1) Copy the VIDEO track (and AUDIO track if available) from the original video
149+
do {
150+
// VIDEO
151+
if let videoTrack = videoAsset.tracks(withMediaType: .video).first {
152+
let compVideoTrack = composition.addMutableTrack(
153+
withMediaType: .video,
154+
preferredTrackID: kCMPersistentTrackID_Invalid
155+
)
156+
try compVideoTrack?.insertTimeRange(
157+
CMTimeRange(start: .zero, duration: videoAsset.duration),
158+
of: videoTrack,
159+
at: .zero
160+
)
161+
}
162+
// AUDIO (if your video has an audio track)
163+
if let audioTrack = videoAsset.tracks(withMediaType: .audio).first {
164+
let compAudioTrack = composition.addMutableTrack(
165+
withMediaType: .audio,
166+
preferredTrackID: kCMPersistentTrackID_Invalid
167+
)
168+
try compAudioTrack?.insertTimeRange(
169+
CMTimeRange(start: .zero, duration: videoAsset.duration),
170+
of: audioTrack,
171+
at: .zero
172+
)
173+
}
174+
} catch {
175+
#if DEBUG
176+
print("Error adding video/audio tracks: \(error)")
177+
#endif
178+
return nil
179+
}
180+
181+
// 2) Find the TEXT track in the subtitle asset
182+
guard let textTrack = subtitleAsset.tracks(withMediaType: .text).first else {
183+
#if DEBUG
184+
print("No text track found in subtitle file.")
185+
#endif
186+
return composition // Return just the video/audio if no text track
187+
}
188+
189+
// 3) Insert the subtitle track into the composition
190+
do {
191+
let compTextTrack = composition.addMutableTrack(
192+
withMediaType: .text,
193+
preferredTrackID: kCMPersistentTrackID_Invalid
194+
)
195+
try compTextTrack?.insertTimeRange(
196+
CMTimeRange(start: .zero, duration: videoAsset.duration),
197+
of: textTrack,
198+
at: .zero
199+
)
200+
} catch {
201+
#if DEBUG
202+
print("Error adding text track: \(error)")
203+
#endif
204+
}
205+
206+
return composition
207+
}

Sources/swiftui-loop-videoplayer/protocol/player/LoopingPlayerProtocol.swift

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,6 @@ public protocol LoopingPlayerProtocol: AbstractPlayer, LayerMakerProtocol{
3737
/// ensuring that all playback errors are managed and reported appropriately.
3838
var errorObserver: NSKeyValueObservation? { get set }
3939

40-
41-
4240
/// An optional observer for monitoring changes to the player's `timeControlStatus` property.
4341
var timeControlObserver: NSKeyValueObservation? { get set }
4442

@@ -139,7 +137,8 @@ internal extension LoopingPlayerProtocol {
139137
for item in items {
140138
player?.remove(item)
141139
}
142-
}
140+
}
141+
143142

144143
/// Updates the current playback asset, settings, and initializes playback or a specific action when the asset is ready.
145144
///
@@ -150,12 +149,14 @@ internal extension LoopingPlayerProtocol {
150149
/// - asset: The AVURLAsset to be loaded into the player.
151150
/// - settings: The `VideoSettings` struct that includes all necessary configurations like gravity, loop, and mute.
152151
/// - callback: An optional closure to be called when the asset is ready to play.
153-
func update(asset: AVURLAsset, settings: VideoSettings, callback: ((AVPlayerItem.Status) -> Void)? = nil) {
154-
152+
func update(
153+
asset: AVURLAsset,
154+
settings: VideoSettings,
155+
callback: ((AVPlayerItem.Status) -> Void)? = nil
156+
) {
155157
guard let player = player else { return }
156158

157159
currentSettings = settings
158-
159160
player.pause()
160161

161162
if !player.items().isEmpty {
@@ -165,17 +166,31 @@ internal extension LoopingPlayerProtocol {
165166
removeAllFilters()
166167
}
167168

168-
let newItem = AVPlayerItem(asset: asset)
169+
let newItem: AVPlayerItem
170+
171+
// try to retrieve the .vtt subtitle
172+
if let subtitleAsset = subtitlesFor(settings),
173+
let mergedAsset = mergeAssetWithSubtitles(videoAsset: asset, subtitleAsset: subtitleAsset) {
174+
// Create a new AVPlayerItem from the merged asset
175+
newItem = AVPlayerItem(asset: mergedAsset)
176+
}else{
177+
// Create a new AVPlayerItem from the merged asset
178+
newItem = AVPlayerItem(asset: asset)
179+
}
180+
181+
// Insert the new item into the player queue
169182
player.insert(newItem, after: nil)
170183

184+
// Loop if required
171185
if settings.loop {
172186
loop()
173187
}
174188

175-
// Set up state item status observer
189+
// Observe status changes
176190
setupStateItemStatusObserver(newItem: newItem, callback: callback)
177-
178-
if !settings.notAutoPlay{
191+
192+
// Autoplay if allowed
193+
if !settings.notAutoPlay {
179194
play()
180195
}
181196
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//
2+
// Subtitles.swift
3+
// swiftui-loop-videoplayer
4+
//
5+
// Created by Igor Shelopaev on 07.01.25.
6+
//
7+
8+
/// Represents a structure that holds the name of subtitles, conforming to `SettingsConvertible`.
9+
///
10+
/// Important:
11+
/// - When using `.vtt` subtitles, a file-based container format such as MP4 or QuickTime (`.mov`)
12+
/// generally supports embedding those subtitles as a `.text` track.
13+
/// - Formats like HLS (`.m3u8`) typically reference `.vtt` files externally rather than merging them
14+
/// into a single file.
15+
/// - Attempting to merge `.vtt` subtitles into an HLS playlist via `AVMutableComposition` won't work;
16+
/// instead, you’d attach the `.vtt` as a separate media playlist in the HLS master manifest.
17+
@available(iOS 14.0, macOS 11.0, tvOS 14.0, *)
18+
public struct Subtitles : SettingsConvertible{
19+
20+
/// Video file name
21+
let value : String
22+
23+
// MARK: - Life circle
24+
25+
/// Initializes a new instance with a specific video file name.
26+
/// - Parameter value: The string representing the video file name.
27+
public init(_ value: String) { self.value = value }
28+
29+
/// Fetch settings
30+
@_spi(Private)
31+
public func asSettings() -> [Setting] {
32+
[.subtitles(value)]
33+
}
34+
}

Sources/swiftui-loop-videoplayer/utils/VideoSettings.swift

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ public struct VideoSettings: Equatable{
2020
/// Video extension
2121
public let ext: String
2222

23+
/// Subtitles
24+
public let subtitles: String
25+
2326
/// Loop video
2427
public let loop: Bool
2528

@@ -69,9 +72,10 @@ public struct VideoSettings: Equatable{
6972
/// - errorColor: The color used for error messages.
7073
/// - errorFontSize: The font size for error messages.
7174
/// - errorWidgetOff: A Boolean indicating whether the error widget should be turned off.
72-
public init(name: String, ext: String, loop: Bool, mute: Bool, notAutoPlay: Bool, timePublishing: CMTime?, gravity: AVLayerVideoGravity, errorColor: Color, errorFontSize: CGFloat, errorWidgetOff: Bool) {
75+
public init(name: String, ext: String, subtitles: String, loop: Bool, mute: Bool, notAutoPlay: Bool, timePublishing: CMTime?, gravity: AVLayerVideoGravity, errorColor: Color, errorFontSize: CGFloat, errorWidgetOff: Bool) {
7376
self.name = name
7477
self.ext = ext
78+
self.subtitles = subtitles
7579
self.loop = loop
7680
self.mute = mute
7781
self.notAutoPlay = notAutoPlay
@@ -94,6 +98,8 @@ public struct VideoSettings: Equatable{
9498

9599
ext = settings.fetch(by : "ext", defaulted: "mp4")
96100

101+
subtitles = settings.fetch(by : "subtitles", defaulted: "")
102+
97103
gravity = settings.fetch(by : "gravity", defaulted: .resizeAspect)
98104

99105
errorColor = settings.fetch(by : "errorColor", defaulted: .red)
@@ -117,21 +123,21 @@ public extension VideoSettings {
117123

118124
/// Returns a new instance of VideoSettings with loop set to false and notAutoPlay set to true, keeping other settings unchanged.
119125
var GetSettingsWithNotAutoPlay : VideoSettings {
120-
VideoSettings(name: self.name, ext: self.ext, loop: self.loop, mute: self.mute, notAutoPlay: true, timePublishing: self.timePublishing, gravity: self.gravity, errorColor: self.errorColor, errorFontSize: self.errorFontSize, errorWidgetOff: self.errorWidgetOff)
126+
VideoSettings(name: self.name, ext: self.ext, subtitles: self.subtitles, loop: self.loop, mute: self.mute, notAutoPlay: true, timePublishing: self.timePublishing, gravity: self.gravity, errorColor: self.errorColor, errorFontSize: self.errorFontSize, errorWidgetOff: self.errorWidgetOff)
121127
}
122128

123129
/// Checks if the asset has changed based on the provided settings and current asset.
124130
/// - Parameters:
125131
/// - asset: The current asset being played.
126132
/// - Returns: A new `AVURLAsset` if the asset has changed, or `nil` if the asset remains the same.
127-
func getAssetIfDifferent(than asset: AVURLAsset?) -> AVURLAsset?{
133+
func getAssetIfDifferent(_ settings : VideoSettings?) -> AVURLAsset?{
128134
let newAsset = assetFor(self)
129135

130-
if asset == nil {
131-
return newAsset
132-
}
136+
guard let settings = settings else{ return newAsset }
137+
138+
let oldAsset = assetFor(settings)
133139

134-
if let newUrl = newAsset?.url, let oldUrl = asset?.url, newUrl != oldUrl{
140+
if let newUrl = newAsset?.url, let oldUrl = oldAsset?.url, newUrl != oldUrl{
135141
return newAsset
136142
}
137143

Sources/swiftui-loop-videoplayer/view/main/LoopPlayerMultiPlatform.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,9 @@ extension LoopPlayerMultiPlatform: UIViewRepresentable{
114114
/// - context: The context for the view
115115
@MainActor func updateUIView(_ uiView: UIView, context: Context) {
116116
let player = uiView.findFirstSubview(ofType: PlayerView.self)
117-
if let player {
118-
if let asset = settings.getAssetIfDifferent(than: player.currentAsset) {
117+
118+
if let player{
119+
if let asset = settings.getAssetIfDifferent(player.currentSettings) {
119120
player.update(asset: asset, settings: settings)
120121
}
121122

@@ -163,7 +164,7 @@ extension LoopPlayerMultiPlatform: NSViewRepresentable{
163164
@MainActor func updateNSView(_ nsView: NSView, context: Context) {
164165
let player = nsView.findFirstSubview(ofType: PlayerView.self)
165166
if let player {
166-
if let asset = settings.getAssetIfDifferent(than: player.currentAsset){
167+
if let asset = settings.getAssetIfDifferent(player.currentSettings){
167168
player.update(asset: asset, settings: settings)
168169
}
169170
// Check if command changed before applying it

0 commit comments

Comments
 (0)