Lire des fichiers vidéo .ts (Transport Stream) avec AVPlayer, sans transcodage ni dépendance externe. Compatible iOS 17+.
Apple bloque nativement la lecture des fichiers .ts bruts par AVFoundation en dehors d'une structure HLS (.m3u8). Les approches classiques imposent :
- L'intégration d'outils C pour remuxer la vidéo en
.mp4avant lecture - Des dépendances lourdes type
ffmpeg - Une complexité qui alourdit le binaire et le code
TSPlayerKit élimine cette contrainte avec une approche 100% Swift, sans dépendance, en simulant un flux HLS virtuel servi directement depuis le disque.
Note iOS 17+ — Depuis iOS 17, le moteur HLS d'Apple rejette le chargement manuel des segments via
AVAssetResourceLoaderDelegate. TSPlayerKit utilise désormais un serveur HTTP local (basé sur le framework natifNetwork) — zéro dépendance externe.
┌─────────────────────────────────────────────────────┐
│ AVPlayer │
│ "Je veux lire http://127.0.0.1:[port]/playlist.m3u8"│
└──────────────┬──────────────────────────────────────┘
│ ① Requête HTTP GET standard
▼
┌─────────────────────────────────────────────────────┐
│ LocalHTTPServer (NWListener) │
│ Route GET /playlist.m3u8 │
│ → Retourne le .m3u8 en mémoire (200 OK) │
└──────────────┬──────────────────────────────────────┘
│ ② AVPlayer demande le segment TS via HTTP
▼
┌─────────────────────────────────────────────────────┐
│ LocalHTTPServer (NWListener) │
│ Route GET /segment.ts │
│ → Parse le Header "Range: bytes=start-end" │
│ → FileStreamer.readBytes(offset:, length:) │
│ → Retourne 206 Partial Content avec les octets │
└─────────────────────────────────────────────────────┘
- Serveur HTTP local —
LocalHTTPServerdémarre sur un port loopback aléatoire viaNWListener(frameworkNetwork, sans dépendance) - Playlist HLS virtuelle — Générée en mémoire, elle déclare le
.tscomme unique segment viahttp://127.0.0.1:[port]/segment.ts - Lecture disque à la volée —
FileStreamerlit les plages d'octets demandées par les requêtesRangeHTTP, avec support complet du seek
Dans ton Package.swift :
dependencies: [
.package(url: "https://github.com/Theorhd/TSPlayerKit.git", from: "1.0.0"),
],
targets: [
.target(
name: "MyApp",
dependencies: ["TSPlayerKit"]
),
]Ou dans Xcode : File → Add Packages… → colle l'URL du dépôt.
import TSPlayerKit
import AVFoundation
// 1. Créer un TSPlayerItem depuis un fichier local .ts
let tsFileURL = Bundle.main.url(forResource: "video", withExtension: "ts")!
let tsItem = try TSPlayerItem(tsFileURL: tsFileURL)
// 2. Créer l'AVPlayer et lire
let player = AVPlayer(playerItem: tsItem.playerItem)
player.play()import SwiftUI
import AVKit
import TSPlayerKit
struct VideoPlayerView: View {
let tsFileURL: URL
var body: some View {
if let tsItem = try? TSPlayerItem(tsFileURL: tsFileURL) {
VideoPlayer(player: AVPlayer(playerItem: tsItem.playerItem))
} else {
ContentUnavailableView(
"Impossible de lire la vidéo",
systemImage: "video.slash",
description: Text("Le fichier .ts n'a pas pu être ouvert.")
)
}
}
}import UIKit
import AVKit
import TSPlayerKit
func playVideo(from tsURL: URL) throws {
let tsItem = try TSPlayerItem(tsFileURL: tsURL)
let player = AVPlayer(playerItem: tsItem.playerItem)
let controller = AVPlayerViewController()
controller.player = player
present(controller, animated: true) {
player.play()
}
}// Pour une vidéo de 2 heures, augmente le targetDuration
// pour que la playlist reflète une durée plus réaliste
let tsItem = try TSPlayerItem(
tsFileURL: videoURL,
targetDuration: 7200.0 // 2 heures en secondes
)func downloadAndPlay(from remoteURL: URL) async throws {
// Télécharge le .ts localement
let (localURL, _) = try await URLSession.shared.download(from: remoteURL)
// Crée le player item
let tsItem = try TSPlayerItem(tsFileURL: localURL)
// Joue sur le thread principal
await MainActor.run {
let player = AVPlayer(playerItem: tsItem.playerItem)
player.play()
}
}Point d'entrée principal. Wrapper qui produit un AVPlayerItem configuré pour la lecture d'un fichier .ts.
public final class TSPlayerItem {
/// L'AVPlayerItem prêt à être lu par AVPlayer.
public let playerItem: AVPlayerItem
/// Crée un player item pour un fichier .ts local.
/// - Parameters:
/// - tsFileURL: L'URL locale du fichier .ts
/// - totalDuration: Durée totale déclarée dans le manifeste HLS (secondes)
/// - Throws: TSPlayerItemError si les segments sont invalides
public init(tsFileURL: URL, totalDuration: Double) throws
/// Mode multi-segments (concatenated TS) : tous les segments dans un seul fichier.
public init(tsFileURL: URL, segments: [SegmentInfo]) throws
/// Mode multi-fichiers : les segments répartis dans plusieurs fichiers .ts
/// (champ `file` de `SegmentInfo`).
public init(tsFilesDirectory: URL, segments: [SegmentInfo]) throws
/// Mode fMP4 : sert un répertoire de fichiers (`index.m3u8` + segments/init).
public init(fmp4Directory: URL) throws
}Erreurs lancées par les initialiseurs de TSPlayerItem (public depuis 1.2.0).
| Cas | Description |
|---|---|
.emptySegments |
Le tableau segments est vide |
.missingFileField |
Un segment multi-fichiers a un champ file à nil |
Erreurs pouvant survenir lors de la lecture du fichier.
| Cas | Description |
|---|---|
.cannotOpenFile(URL) |
Le fichier .ts n'existe pas ou est inaccessible en lecture |
.systemError(Error) |
Erreur système sous-jacente (permissions, E/S) |
.offsetOutOfRange(offset:fileSize:) |
L'offset demandé dépasse la taille du fichier |
.deinitialized |
Le streamer a été désalloué pendant une opération |
Sources/TSPlayerKit/
├── TSPlayerItem.swift ← API publique (wrapper lecture locale)
├── AdStrippingProxy.swift ← Proxy HLS anti-pub (v1.1.0)
├── HLSPlaylistCleaner.swift ← Détection de pubs + rewriting de playlist
├── RemotePlaylistFetcher.swift ← Fetch HTTP distant (playlists + segments)
├── SlateSegment.swift ← Segment placeholder embarqué (live)
├── Resources/slate.ts ← Slate MPEG-TS (2 s, noir + silence)
├── LocalHTTPServer.swift ← Serveur HTTP local (NWListener)
├── HLSManifestGenerator.swift ← Génération du .m3u8 virtuel
└── FileStreamer.swift ← Lecture disque asynchrone (FileHandle)
| Composant | Rôle |
|---|---|
TSPlayerItem |
Wrapper qui démarre le serveur, génère le manifest et assemble l'AVPlayerItem |
AdStrippingProxy |
Proxy HTTP local qui nettoie les pubs d'un flux HLS distant |
HLSPlaylistCleaner |
Détection des segments publicitaires (CUE, patterns URL, durées) et réécriture des playlists |
RemotePlaylistFetcher |
Fetch HTTP de playlists et segments depuis un CDN distant (via URLSession) |
SlateSegment |
Charge le segment placeholder embarqué servi à la place des pubs en live |
LocalHTTPServer |
Serveur HTTP local basé sur NWListener : sert le manifest et les segments TS via HTTP standard |
HLSManifestGenerator |
Génère la chaîne .m3u8 avec les URLs http://127.0.0.1:[port]/... |
FileStreamer |
Lecture thread-safe du fichier via FileHandle, avec support byte-range pour le seek |
| Plateforme | Version minimum |
|---|---|
| macOS | 12.0+ |
| iOS | 15.0+ (compatible iOS 17+) |
| tvOS | 15.0+ |
| visionOS | 1.0+ |
| Swift | 6.3+ |
| Frameworks | AVFoundation, Foundation, Network |
Depuis la version 1.1.0, TSPlayerKit inclut AdStrippingProxy, un proxy HTTP local qui nettoie les flux HLS en retirant les segments publicitaires avant de les passer à AVPlayer.
import TSPlayerKit
// 1. Créer un fetcher (avec les headers requis par la source)
let fetcher = RemotePlaylistFetcher(
userAgent: "Mozilla/5.0 ...",
extraHeaders: ["Client-Id": "xxx"]
)
// 2. Démarrer le proxy sur un flux distant
let streamURL = URL(string: "https://usher.ttvnw.net/...")!
let proxy = try AdStrippingProxy(remoteURL: streamURL, fetcher: fetcher)
// 3. Lire via le proxy (pense à garder une référence forte !)
let player = AVPlayer(playerItem: AVPlayerItem(asset: AVURLAsset(url: proxy.localURL)))
player.play()Fonctionnement : le proxy expose http://127.0.0.1:{port}/master.m3u8. AVPlayer lit depuis cette URL locale. Le proxy fetch le flux original, détecte et retire les pubs (tags SCTE35/CUE, patterns d'URL, heuristiques de durée), puis sert un playlist nettoyé.
Traitement des pubs selon le type de flux :
- VOD — les segments pubs sont supprimés de la playlist (le break est sauté).
- Live (TS) — les segments pubs sont remplacés par un segment placeholder local (« slate » : 2 s d'écran noir + silence, MPEG-TS embarqué en ressource). Pendant une pause publicitaire, la fenêtre glissante Twitch peut être 100 % pubs : tout supprimer produisait une playlist vide qu'AVPlayer abandonnait après 1,5 × TARGETDURATION (
CoreMediaErrorDomain -12888). Avec le slate, la playlist avance normalement et la lecture survit à la pause. - Live fMP4 (
#EXT-X-MAP) ou ressource slate indisponible — repli sur la suppression (comportement antérieur).
Le slate est servi localement sur /slate/{index}.ts (aucun aller-retour réseau) : l'index est l'index global du segment (URL stable d'un poll à l'autre — AVPlayer stoppe si l'URL d'un segment live change — et base du décalage de timestamps). Chaque copie est réécrite à la volée : layout PES standardisé et PTS décalé sur la timeline du contenu (index × durée), si bien que le run de slate est continu en timestamps et ne nécessite aucun EXT-X-DISCONTINUITY (un discontinuity à la live edge fait stall AVPlayer, CoreMediaErrorDomain -12312). Si le flux est chiffré (#EXT-X-KEY), le cleaner ferme la portée de la clé (METHOD=NONE) pendant le slate et la ré-ouvre à la reprise du contenu.
Modes de segments :
.stream(défaut) — le proxy fetch et relaye les bytes des segments.redirect— HTTP 302 vers le CDN original (moins de bandwidth, mais AVPlayer peut mal le gérer)
- Pas de chiffrement — Les fichiers doivent être en clair (pas de FairPlay DRM).
- Performances disque —
FileHandlelit de manière synchrone sur une queue dédiée. Pour des fichiers très volumineux (>10 Go), le seek peut introduire une latence perceptible. - Codecs supportés — Dépend des capacités d'
AVFoundationsur l'appareil. Les codecs non supportés par la plateforme ne seront pas lus. - Proxy HLS — La détection des pubs est conservatrice. Certaines pubs utilisant le SSAI sans marqueurs peuvent ne pas être détectées.
MIT — voir le fichier LICENSE.
Les contributions sont les bienvenues. Ouvre une issue pour discuter de ce que tu souhaites changer avant de soumettre une PR.
- Fork le dépôt
- Crée une branche (
git checkout -b feature/ma-fonctionnalite) - Commit tes changements
- Push et ouvre une Pull Request
Construit avec ❤️ pour la communauté Apple — zéro dépendance, 100% Swift.