Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
57a790a
WIP
JPKribs Dec 31, 2025
cef9409
Merge branch 'main' into transcodeFix
JPKribs Jan 2, 2026
c1b1282
Media Source Fix
JPKribs Jan 2, 2026
f89ca6b
Linting
JPKribs Jan 2, 2026
ddc7363
Appropriately kill the old screen when changing the track
JPKribs Jan 2, 2026
af63b20
Merge branch 'main' into transcodeFix
JPKribs Jan 2, 2026
71d436a
Merge branch 'main' into transcodeFix
JPKribs Jan 8, 2026
8ebdd03
Fix https://github.com/jellyfin/Swiftfin/issues/1889
JPKribs Jan 9, 2026
553c2af
Indexes don't change.
JPKribs Jan 10, 2026
d7b7e4f
Cleanup
JPKribs Jan 10, 2026
67fb4e3
Reset
JPKribs Jan 11, 2026
5f19ac2
Reset
JPKribs Jan 11, 2026
a68ff96
Reset
JPKribs Jan 11, 2026
d35d67d
Merge branch 'main' into transcodeFix
JPKribs Jan 12, 2026
5ac2aa3
Audio & Video functional. Subtitles are broken on transcode.
JPKribs Jan 12, 2026
e26ad2c
Merge branch 'main' into transcodeFix
JPKribs Feb 19, 2026
6d72d52
WIP
JPKribs Feb 19, 2026
6514481
Merge branch 'main' into transcodeFix
JPKribs Feb 19, 2026
4dd6a23
`MediaStreamType` `supportedCases`
JPKribs Feb 19, 2026
20a22ef
External Image Subs need to encode or be embedded. No external.
JPKribs Feb 20, 2026
6822e07
Subtitle Fixes - Still need `Disable different types of embedded subt…
JPKribs Feb 20, 2026
b95df67
WIP
JPKribs Feb 20, 2026
416501a
Temporary logging cleanup & remove external PGS from UI when not able…
JPKribs Feb 20, 2026
5934b36
Update players.md to reflect feature changes
JPKribs Feb 20, 2026
77bd5ff
Revise subtitle format support details in players.md
JPKribs Feb 20, 2026
662b8d8
Merge branch 'main' into transcodeFix
JPKribs Feb 20, 2026
2662349
Remove unused extension
JPKribs Feb 20, 2026
5c90110
Clarify XSub subtitle format playback notes
JPKribs Feb 20, 2026
e6f949b
Update player controls and miscellaneous features
JPKribs Feb 20, 2026
2a7bff0
Correct capitalization in player controls section
JPKribs Feb 20, 2026
26aef13
Rename H.264 to H.264/AVC in players documentation
JPKribs Feb 20, 2026
6a36f78
Remove media source selection as this is in it's own PR now
JPKribs Feb 20, 2026
c933f2e
Merge remote-tracking branch 'refs/remotes/origin/transcodeFix'
JPKribs Feb 20, 2026
2670230
Preserve current ticks during the track change.
JPKribs Feb 20, 2026
4ad9ad2
Set using user data instead of manual overrides
JPKribs Feb 20, 2026
9efbfa7
Remove unneccessary rebuilding when PGS is embedded.
JPKribs Feb 22, 2026
8664960
Merge branch 'main' into transcodeFix
JPKribs Feb 22, 2026
48ddbab
Merge branch 'main' into transcodeFix
JPKribs Feb 23, 2026
9e88d19
Merge branch 'main' into transcodeFix
JPKribs Feb 25, 2026
d2e1c23
Merge branch 'main' into transcodeFix
JPKribs Feb 27, 2026
028608f
Merge branch 'main' into transcodeFix
JPKribs Mar 2, 2026
6b604e3
Merge branch 'main' into transcodeFix
JPKribs Mar 2, 2026
f080a66
Merge branch 'main' into transcodeFix
JPKribs Mar 6, 2026
38f07d3
Merge branch 'main' into transcodeFix
JPKribs Mar 7, 2026
8fef3e3
Merge branch 'main' into transcodeFix
JPKribs Mar 8, 2026
8bee619
Merge branch 'main' into transcodeFix
JPKribs Mar 9, 2026
63aafb5
Merge branch 'main' into transcodeFix
JPKribs Mar 11, 2026
6c8b972
Update comments to be 1) cleaner and 2) at the top of functions/var i…
JPKribs Mar 11, 2026
957d5ef
Merge branch 'main' into transcodeFix
JPKribs Mar 13, 2026
58cbac2
Merge branch 'main' into transcodeFix
JPKribs Mar 13, 2026
5fce8ae
Merge branch 'main' into transcodeFix
JPKribs Mar 13, 2026
a19c531
Merge branch 'main' into transcodeFix
JPKribs Mar 14, 2026
cb17c56
Merge branch 'jellyfin:main' into transcodeFix
JPKribs Mar 27, 2026
df7bae6
Merge branch 'jellyfin:main' into transcodeFix
JPKribs Mar 29, 2026
78c94b4
wip
LePips Apr 10, 2026
6d5b939
Merge branch 'main' into jpkribs/transcodeFix
LePips Apr 11, 2026
3d92610
Merge branch 'main' into jpkribs/transcodeFix
LePips Apr 12, 2026
ff79b58
wip
LePips Apr 12, 2026
228a0f2
Merge branch 'jellyfin:main' into transcodeFix
JPKribs Apr 19, 2026
16a02d5
Merge branch 'jellyfin:main' into transcodeFix
JPKribs Apr 20, 2026
aff67d8
Handle local audio switching
JPKribs Apr 20, 2026
5edd83d
Linting
JPKribs Apr 20, 2026
489be6f
Merge branch 'jellyfin:main' into transcodeFix
JPKribs Apr 23, 2026
a863918
Merge branch 'main' into transcodeFix
JPKribs Apr 28, 2026
dde852c
Version bumps
JPKribs Apr 28, 2026
06768de
Merge branch 'jellyfin:main' into transcodeFix
JPKribs Apr 29, 2026
74673af
Merge branch 'jellyfin:main' into transcodeFix
JPKribs Apr 30, 2026
c574ceb
Merge branch 'jellyfin:main' into transcodeFix
JPKribs May 9, 2026
5de96cc
Merge branch 'jellyfin:main' into transcodeFix
JPKribs May 14, 2026
e8a84df
Merge branch 'jellyfin:main' into transcodeFix
JPKribs May 15, 2026
aa8f158
Merge branch 'jellyfin:main' into transcodeFix
JPKribs May 16, 2026
b3df230
Merge branch 'jellyfin:main' into transcodeFix
JPKribs May 16, 2026
411f06c
Merge branch 'jellyfin:main' into transcodeFix
JPKribs May 19, 2026
1493a66
Merge branch 'jellyfin:main' into transcodeFix
JPKribs May 19, 2026
c5386e7
Merge branch 'jellyfin:main' into transcodeFix
JPKribs May 20, 2026
e268071
Merge branch 'jellyfin:main' into transcodeFix
JPKribs May 20, 2026
65579de
Merge branch 'jellyfin:main' into transcodeFix
JPKribs May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 13 additions & 51 deletions Documentation/players.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ Swiftfin offers two player options: **Swiftfin** (VLCKit) and **Native** (AVPlay

| Feature | Swiftfin (VLCKit) | Native (AVPlayer) |
|----------------------------|-------------------|----------------|
| **External Audio Tracks** | ❌ | ❌ |
| **Framerate Matching** | ❌ | ✅ |
| **HDR to SDR Tonemapping** | ✅ [1] | 🔶 [2] |
| **Player Controls** | - Speed adjustment<br>- Aspect Fill<br>- Chapter Support<br>- Subtitle Support<br>- Audio Track Selection<br>- Customizable UI | - Speed adjustment<br>- Aspect Fill |
| **Player Controls** | - Speed Adjustment <br>- Aspect Fill <br>- Chapter Support <br>- Subtitle Support <br>- Trickplay Support <br>- Audio Track Selection <br>- Customizable UI | - Speed Adjustment <br>- Aspect Fill |
| **Picture-in-Picture** | ❌ | ✅ |
| **TLS Support** | 1.1, 1.2 [3] | 1.1, 1.2, 1.3 |
| **[Airplay Audio Output](https://support.apple.com/en-us/102357)** | 🔶 [4] | ✅ |
Expand Down Expand Up @@ -99,7 +100,7 @@ Swiftfin offers two player options: **Swiftfin** (VLCKit) and **Native** (AVPlay
| [FLV1](https://en.wikipedia.org/wiki/Sorenson_Spark) | ✅ | ❌ |
| [H.261](https://en.wikipedia.org/wiki/H.261) | ✅ | ❌ |
| [H.263](https://en.wikipedia.org/wiki/H.263) | ✅ | ❌ |
| [H.264](https://en.wikipedia.org/wiki/Advanced_Video_Coding) | ✅ | ✅ |
| [H.264/AVC](https://en.wikipedia.org/wiki/Advanced_Video_Coding) | ✅ | ✅ |
| [H.265/HEVC](https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding) | ✅ | ✅ [2] |
| [H.266/VVC](https://en.wikipedia.org/wiki/Versatile_Video_Coding) | ❌ [3] | ❌ |
| [MJPEG](https://en.wikipedia.org/wiki/Motion_JPEG) | ✅ | ✅ |
Expand Down Expand Up @@ -136,12 +137,12 @@ Swiftfin offers two player options: **Swiftfin** (VLCKit) and **Native** (AVPlay
|---------------------------------------------------------------------------------|-------------------|-------------------|
| [ASS](https://en.wikipedia.org/wiki/SubStation_Alpha#Advanced_SubStation_Alpha) | ✅ | ❌ |
| [CC_DEC](https://en.wikipedia.org/wiki/Closed_captioning) | ✅ | ✅ |
| [DVBSub](https://en.wikipedia.org/wiki/DVB_subtitles) | ✅ | 🔶 [1] |
| [DVDSub](https://en.wikipedia.org/wiki/VobSub) | ✅ | 🔶 [1] |
| [DVBSub](https://en.wikipedia.org/wiki/DVB_subtitles) | ✅ [1] | 🔶 [2] |
| [DVDSub](https://en.wikipedia.org/wiki/VobSub) | ✅ [1] | 🔶 [2] |
| [JacoSub](https://en.wikipedia.org/wiki/JACOsub) | ✅ | ❌ |
| [MOV_Text](https://en.wikipedia.org/wiki/MPEG-4_Part_17) | ✅ | ❌ |
| [MPL2](https://en.wikipedia.org/wiki/MPL2) | ✅ | ❌ |
| [PGSSub](https://en.wikipedia.org/wiki/Presentation_Graphic_Stream) | ✅ | 🔶 [1] |
| [PGSSub](https://en.wikipedia.org/wiki/Presentation_Graphic_Stream) | ✅ [1] | 🔶 [2] |
| [PJS](https://en.wikipedia.org/wiki/Phoenix_Subtitle) | ✅ | ❌ |
| [RealText](https://en.wikipedia.org/wiki/RealText) | ✅ | ❌ |
| [SAMI](https://en.wikipedia.org/wiki/SAMI) | ✅ | ❌ |
Expand All @@ -154,11 +155,12 @@ Swiftfin offers two player options: **Swiftfin** (VLCKit) and **Native** (AVPlay
| [TTML](https://en.wikipedia.org/wiki/Timed_Text_Markup_Language) | ✅ | ✅ |
| [VPlayer](https://en.wikipedia.org/wiki/VPlayer) | ✅ | ❌ |
| [VTT](https://en.wikipedia.org/wiki/WebVTT) | ✅ | ✅ |
| [XSub](https://en.wikipedia.org/wiki/XSUB) | ✅ | 🔶 [1] |
| [XSub](https://en.wikipedia.org/wiki/XSUB) | ✅ [1] | 🔶 [2] |

**Notes:**

- [1] Subtitle format requires server-side encoding for Native (AVPlayer) playback.
- [1] Subtitle format can be played if embedded in the container (MKV) but requres server-side encoding for playback is the source is an external file.
- [2] Subtitle format requires server-side encoding for playback.

- Subtitle track selection is not currently supported in Native (AVPlayer) due to issues with HLS file incompatibilities.

Expand Down Expand Up @@ -192,51 +194,11 @@ Swiftfin offers two player options: **Swiftfin** (VLCKit) and **Native** (AVPlay

---

## Track Selection

Swiftfin track selection is limited by compatibility with each player. In testing, as of Swiftfin 1.3, the following interactions have been tested.

✅ Working correctly </br>
🔶 Partially working with limitations </br>
❌ Not working

### Swiftfin Player

| File Configuration | DirectPlay | Transcode | Notes |
|---------------------------------------------------------|------------|-----------|------------------------------------------------|
| Internal Audio | ✅ | ✅ | |
| Internal Audio + Internal Subtitles | ✅ | 🔶 | - Subtitles do not work if Non-External *(DVDSUB)* |
| Internal Audio + External Subtitles | ✅ | ✅ | |
| Internal Audio + Internal Subtitles + External Subtitles| ✅ | 🔶 | - Subtitles do not work if Non-External *(DVDSUB)* |
| Multiple Internal Audio + Multiple Internal Subtitles | ✅ | 🔶 | - Subtitles do not work if Non-External *(DVDSUB)* |
| Multiple Internal Audio + Multiple External Subtitles | ✅ | ✅ | |
| Multiple Internal Audio + Internal Subtitles + External Subtitles | ✅ | 🔶 | - Subtitles do not work if Non-External *(DVDSUB)* |
| External Audio + Internal Audio + External Subtitles | ✅ | ✅ | - Cannot play external audio track if transcoding is required </br> - Subtitles do not work if Non-External *(DVDSUB)* |
| External Audio + Internal Audio + Internal Subtitles | ✅ | ✅ | - Cannot play external audio track if transcoding is required </br> - Subtitles do not work if Non-External *(DVDSUB)* |
| External Audio + Internal Audio + Internal Subtitles + External Subtitles | ✅ | ✅ | - Cannot play external audio track if transcoding is required </br> - Subtitles do not work if Non-External *(DVDSUB)* |

### Native Player

| File Configuration | DirectPlay | Transcode | Notes |
|--------------------------------------------------------|------------|-----------|------------------------------------------------|
| Internal Audio | ✅ | ✅ | |
| Internal Audio + Internal Subtitles | 🔶 | ❌ | - The default audio track will played </br> - subtitles cannot be selected. |
| Internal Audio + External Subtitles | 🔶 | ❌ | - The default audio track will played </br> - subtitles cannot be selected. |
| Internal Audio + Internal Subtitles + External Subtitles | 🔶 | ❌ | - The default audio track will played </br> - subtitles cannot be selected. |
| Multiple Internal Audio + Multiple Internal Subtitles | 🔶 | ❌ | - The default audio track will played </br> - subtitles cannot be selected. |
| Multiple Internal Audio + Multiple External Subtitles | 🔶 | ❌ | - The default audio track will played </br> - subtitles cannot be selected. |
| Multiple Internal Audio + Internal Subtitles + External Subtitles | 🔶 | ❌ | - The default audio track will played </br> - subtitles cannot be selected. |
| External Audio + Internal Audio + External Subtitles | 🔶 | ❌ | - The default audio track will played </br> - subtitles cannot be selected. |
| External Audio + Internal Audio + Internal Subtitles | 🔶 | ❌ | - The default audio track will played </br> - subtitles cannot be selected. |
| External Audio + Internal Audio + Internal Subtitles + External Subtitles | 🔶 | ❌ | - The default audio track will played </br> - subtitles cannot be selected. |

---

### Miscellaneous

| Feature | Swiftfin (VLCKit) | Native (AVPlayer) | Notes |
|-------------|-------------------|----------------|----------------|
| **External Display Support** | 🔶 | ✅ | Swiftfin Player can only be mirrored. As a result, the player will retain the source device dimensions. |
| **Energy Consumption** | 🔶 | ✅ | Swiftfin Player will use a software decoder if the media cannot be handled by iOS natively. This results in higher power consumption. |
| Feature | Swiftfin (VLCKit) | Native (AVPlayer) | Notes |
|------------------------------|-------------------|-------------------|---------------------------------------------------------------------------------------------------------------------------------------|
| **External Display Support** | 🔶 | ✅ | Swiftfin Player can only be mirrored. As a result, the player will retain the source device dimensions. |
| **Energy Consumption** | 🔶 | ✅ | Swiftfin Player will use a software decoder if the media cannot be handled by iOS natively. This results in higher power consumption. |

---
39 changes: 39 additions & 0 deletions Shared/Extensions/JellyfinAPI/DeviceProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,43 @@ extension DeviceProfile {

return deviceProfile
}

// MARK: - Playback Capability Queries

/// Whether any `DirectPlayProfile` allows media with this audio codec in the given container to be played directly.
func canPlay(type: DlnaProfileType, audioCodec: String?, container: String?) -> Bool {
(directPlayProfiles ?? []).contains { profile in
profile.type == type
&& profileContains(profile: profile.audioCodec, audioCodec)
&& profileContains(profile: profile.container, container)
}
}

/// Whether any `DirectPlayProfile` allows media with this video codec in the given container to be played directly.
func canPlay(type: DlnaProfileType, videoCodec: String?, container: String?) -> Bool {
(directPlayProfiles ?? []).contains { profile in
profile.type == type
&& profileContains(profile: profile.videoCodec, videoCodec)
&& profileContains(profile: profile.container, container)
}
}

/// Whether any `SubtitleProfile` allows this format to be delivered via the given method.
func canPlay(subtitleFormat: String?, method: SubtitleDeliveryMethod) -> Bool {
guard let subtitleFormat = subtitleFormat?.lowercased() else { return false }
return (subtitleProfiles ?? []).contains { profile in
profile.method == method
&& profile.format?.lowercased() == subtitleFormat
}
}

/// Parse & check membership like this is CSV as that's the format we send to the server.
private func profileContains(profile: String?, _ candidate: String?) -> Bool {
guard let profile else { return true }
guard let candidate = candidate?.lowercased() else { return false }
return profile
.lowercased()
.split(separator: ",")
.contains { $0.trimmingCharacters(in: .whitespaces) == candidate }
}
}
129 changes: 94 additions & 35 deletions Shared/Extensions/JellyfinAPI/MediaStream.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,55 +209,114 @@ extension MediaStream {

extension [MediaStream] {

/// Adjusts track indexes for a full set of media streams.
/// For non-transcode stream types:
/// Internal tracks (non-external) are ordered as: Video, Audio, Subtitles, then any others.
/// Their relative order within each group is preserved and indexes start at 0.
/// For transcode stream type:
/// Only the first internal video track and the first internal audio track are included, in that order.
/// In both cases, external tracks are appended in their original order with indexes continuing after internal tracks.
func adjustedTrackIndexes(for playMethod: PlayMethod, selectedAudioStreamIndex: Int) -> [MediaStream] {
let internalTracks = self.filter { !($0.isExternal ?? false) }
let externalTracks = self.filter { $0.isExternal ?? false }

var orderedInternal: [MediaStream] = []
/// Text-based external subtitles loaded as sidecar files. Image-based subtitles are excluded because the player silently drops them.
var sidecarSubtitles: [MediaStream] {
filter { $0.deliveryMethod == .external && $0.deliveryURL != nil && $0.isTextSubtitleStream == true }
}

let subtitleInternal = internalTracks.filter { $0.type == .subtitle }
/// Builds a mapping from Jellyfin's global stream indexes to VLC's container-position indexes.
///
/// Jellyfin assigns a single global index across all streams (video, audio, subtitle — internal and external).
/// VLC numbers tracks by their position within the actual media container, starting at 0.
/// This function produces `[JellyfinIndex: PlayerIndex]` so we can translate between the two.
///
/// Called at init time — before VLC has loaded the media. Sidecar subtitle indexes are estimated here
/// but finalized later by `resolveIndexMap` once the player reports its actual track list.
///
/// - `Transcode`: The HLS container has exactly 1 video (index 0) and 1 audio (index 1).
/// - `DirectPlay`: Jellyfin lists external tracks first, offsetting all internal container indexes by that count.
func buildIndexMap(
for playMethod: PlayMethod,
selectedAudioStreamIndex: Int
) -> [Int: Int] {
var indexMap: [Int: Int] = [:]

if playMethod == .transcode {
// Only include the first video and first audio track for transcode.
let videoInternal = internalTracks.filter { $0.type == .video }
let audioInternal = internalTracks.filter { $0.type == .audio }
var containerTracks: [MediaStream] = []

if let firstVideo = videoInternal.first {
orderedInternal.append(firstVideo)
let videoTracks = filter { $0.type == .video && !($0.isExternal == true) }
let audioTracks = filter { $0.type == .audio && !($0.isExternal == true) }

if let firstVideo = videoTracks.first {
containerTracks.append(firstVideo)
}
if let selectedAudio = audioTracks.first(where: { $0.index == selectedAudioStreamIndex }) {
containerTracks.append(selectedAudio)
}
if let selectedAudio = audioInternal.first(where: { $0.index == selectedAudioStreamIndex }) {
orderedInternal.append(selectedAudio)

for (newIndex, track) in containerTracks.enumerated() {
guard let oldIndex = track.index else { continue }
indexMap[oldIndex] = newIndex
}

orderedInternal += subtitleInternal
let playbackChildStartIndex = containerTracks.count
let sidecarSubtitles = self.sidecarSubtitles

for (offset, track) in sidecarSubtitles.enumerated() {
guard let oldIndex = track.index else { continue }
let playerIndex = playbackChildStartIndex + offset
indexMap[oldIndex] = playerIndex
}
} else {
let videoInternal = internalTracks.filter { $0.type == .video }
let audioInternal = internalTracks.filter { $0.type == .audio }
let externalCount = self.count(where: { $0.isExternal == true })
let internalTracks = self.filter { !($0.isExternal ?? false) }

orderedInternal = videoInternal + audioInternal + subtitleInternal
for track in internalTracks {
guard let oldIndex = track.index else { continue }
let playerIndex = oldIndex - externalCount
indexMap[oldIndex] = playerIndex
}
}

var newInternalTracks: [MediaStream] = []
for (index, var track) in orderedInternal.enumerated() {
track.index = index
newInternalTracks.append(track)
}
return indexMap
}

var newExternalTracks: [MediaStream] = []
let startingIndexForExternal = newInternalTracks.count
for (offset, var track) in externalTracks.enumerated() {
track.index = startingIndexForExternal + offset
newExternalTracks.append(track)
/// Updates the index map with real player indexes for sidecar subtitles.
///
/// Called after VLC reports its actual track list. Sidecar subtitles are loaded as "playback children"
/// at runtime, so their player-assigned indexes aren't known until the player is running.
/// This takes the existing map from `buildIndexMap` and fills in the sidecar entries.
///
/// - `Transcode`: HLS has no embedded subtitles, so all reported subtitle tracks are sidecars — matched sequentially.
/// - `DirectPlay`: Container subtitles are already mapped. The remaining unmapped tracks are sidecars.
static func resolveIndexMap(
into indexMap: [Int: Int],
playbackChildren: [MediaStream],
subtitleTracks: [(index: Int, title: String)],
isTranscoding: Bool
) -> [Int: Int] {
guard !playbackChildren.isEmpty else { return indexMap }

var updatedMap = indexMap

let playerIndexes = subtitleTracks
.map(\.index)
.filter { $0 >= 0 }
.sorted()

if isTranscoding {
for (offset, playerIndex) in playerIndexes.enumerated() {
guard offset < playbackChildren.count,
let jellyfinIndex = playbackChildren[offset].index
else { continue }
updatedMap[jellyfinIndex] = playerIndex
}
} else {
let mappedIndexes = Set(indexMap.values)
let unmappedIndexes = playerIndexes
.filter { !mappedIndexes.contains($0) }

let externalIndexes: [Int] = [Int](unmappedIndexes.suffix(playbackChildren.count))

for (offset, stream) in playbackChildren.enumerated() {
guard let jellyfinIndex = stream.index else { continue }
if offset < externalIndexes.count {
updatedMap[jellyfinIndex] = externalIndexes[offset]
}
}
}

return newInternalTracks + newExternalTracks
return updatedMap
}

var has4KVideo: Bool {
Expand Down
Loading
Loading