Ehtisham Rauf
Ehtisham Rauf

Reputation: 1

Iphone Screen Mirroring: HLS Streaming Works in Safari and VLC, but Fails to Stream on Chromecast (Stuck on Loading Media)

I'm working on a project where I'm using AVAssetWriter to generate fragmented MPEG-4 files from CMSampleBuffer and serving them using HLS. The HLS stream works perfectly when tested in Safari and VLC, but when I attempt to cast the stream to Chromecast, it gets stuck on loading media without ever playing the video.

What I'm trying to do:

  1. I’m capturing a live iphone screen stream using ReplayKit and processing video data (CMSampleBuffer) through AVAssetWriter.
  2. The stream is converted into HLS format with initialization and segment files.
  3. The video segments are served over HTTP using the Swifter server.
  4. The generated HLS stream consists of init.mp4 (the initialization segment) and fragmented MPEG-4 files (.m4s video segments).

Relevant Code:

class SampleHandler: RPBroadcastSampleHandler {

    let assetWriter = AVAssetWriter(contentType: .mpeg4Movie)
    var input: AVAssetWriterInput!
    var sequence: Int = -1
    private let maxSegmentsInMemory = 50
    private var sequences: [(sequence: Int, data: Data, duration: Double)] = []

    private lazy var server = HttpServer()

    var m3u8: String {
        let header = """
        #EXTM3U
        #EXT-X-VERSION:6
        #EXT-X-TARGETDURATION:\(getTargetDuration())
        #EXT-X-MEDIA-SEQUENCE:\(sequences.first?.sequence ?? 0)
        #EXT-X-INDEPENDENT-SEGMENTS
        #EXT-X-MAP:URI="http://\(self.getWiFiAddress() ?? ""):8080/init.mp4"
        """

        let segmentCount = min(self.maxSegmentsInMemory, sequences.count)
        let segments = sequences.suffix(segmentCount).map { segment in
            "#EXTINF:\(segment.duration),\nhttp://\(self.getWiFiAddress() ?? ""):8080/files/sequence\(segment.sequence).m4s"
        }.joined(separator: "\n")

        return header + "\n" + segments
    }
    
    func setupAssetWriter() {
        assetWriter.shouldOptimizeForNetworkUse = true
        assetWriter.outputFileTypeProfile = .mpeg4AppleHLS
        assetWriter.preferredOutputSegmentInterval = CMTime(seconds: 5.0, preferredTimescale: 1)
        assetWriter.delegate = self

        let settings: [String: Any] = [
            AVVideoCodecKey: AVVideoCodecType.h264,
            AVVideoWidthKey: 720,
            AVVideoHeightKey: 1080,
            AVVideoCompressionPropertiesKey: [
                AVVideoAverageBitRateKey: 700000,
                AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel
            ]
        ]

        input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
        input.expectsMediaDataInRealTime = true
        assetWriter.add(input)
    }

    override func broadcastStarted(withSetupInfo setupInfo: [String: NSObject]?) {
        // Start the HTTP server
        do {
            try server.start(8080, forceIPv4: true, priority: .default)
            setupRoutes()
        } catch {
            print("Server failed to start: \(error)")
        }

        setupAssetWriter()
        assetWriter.initialSegmentStartTime = .zero
        assetWriter.startWriting()
        assetWriter.startSession(atSourceTime: .zero)
    }

    func setupRoutes() {
        // Serve the HLS playlist
        server["/hls.m3u8"] = { [weak self] request -> HttpResponse in
            guard let self = self else { return .notFound }
            let m3u8Data = self.m3u8.data(using: .utf8)!
            print("✅ M3U8 => \(self.m3u8) ✅")
            let body: HttpResponseBody = .data(m3u8Data, contentType: "application/vnd.apple.mpegurl")
            return .ok(body)
        }

        // Serve the initialization segment
        server["/init.mp4"] = { [weak self] request -> HttpResponse in
            guard let segmentData = self?.getSegmentData(for: "init.mp4") else {
                return .notFound // Return 404 if segment data not found
            }
            let body: HttpResponseBody = .data(segmentData, contentType: "video/mp4")
            return .ok(body)
        }

        // Serve video segments
        server["/files/:path"] = { [weak self] request -> HttpResponse in
            guard let self = self else { return .notFound }

            // Extract the sequence number from the path
            let segmentPath = request.path.replacingOccurrences(of: "/files/", with: "")
            guard let segmentData = self.getSegmentData(for: segmentPath) else {
                return .notFound // Return 404 if segment data not found
            }
            print("✅ SEGMENT PATH => \(segmentPath) \n SEGMENT DATA => \(segmentData) ✅")
            let body: HttpResponseBody = .data(segmentData, contentType: "video/MP2T")
            return .ok(body)
        }
    }

    override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) {
        switch sampleBufferType {
        case .video:
            if input.isReadyForMoreMediaData {
                input.append(sampleBuffer)
            }
        default:
            break
        }
    }

    override func broadcastFinished() {
        assetWriter.finishWriting {
            print("Finished writing all segments.")
        }
    }

    // Handling segment data
    func onSegmentData(data: Data, duration: Double) {
        sequence += 1

        // Handle the initial segment
        if sequence == 0 {
            saveSegment(data: data, filename: "init.mp4")
            return
        }

        // Save the segment data and duration
        let segmentFilename = "sequence\(sequence).m4s"
        saveSegment(data: data, filename: segmentFilename)

        // Append the new segment to the sequence list with duration
        sequences.append((sequence: sequence, data: data, duration: duration))

        // Remove older segments to keep only the latest in memory
        if sequences.count > maxSegmentsInMemory {
            sequences.removeFirst()
        }
    }

    func saveSegment(data: Data, filename: String) {
        let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)

        do {
            try data.write(to: fileURL)
        } catch {
            print("Failed to save segment: \(error)")
        }
    }

    func getSegmentData(for filename: String) -> Data? {
        let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
        return try? Data(contentsOf: fileURL)
    }
    // Compute the target duration as the max duration of the segments
    func getTargetDuration() -> Int {
        return Int((sequences.map { $0.duration }.max() ?? 1.0).rounded(.up))
    }
}

extension SampleHandler: AVAssetWriterDelegate {
    func assetWriter(_ writer: AVAssetWriter,
                     didOutputSegmentData segmentData: Data,
                     segmentType: AVAssetSegmentType,
                     segmentReport: AVAssetSegmentReport?) {
        // Retrieve the segment duration from the report
        let duration = (segmentReport?.trackReports.first?.duration.seconds ?? 1.0).rounded(.up)
        self.onSegmentData(data: segmentData, duration: duration)
    }
}

extension SampleHandler {
    func getWiFiAddress() -> String? {
        var address: String?
        var ifaddr: UnsafeMutablePointer<ifaddrs>?
        if getifaddrs(&ifaddr) == 0 {
            var ptr = ifaddr
            while ptr != nil {
                guard let interface = ptr?.pointee else { break }
                let addrFamily = interface.ifa_addr.pointee.sa_family
                if addrFamily == UInt8(AF_INET) {
                    if let name = String(validatingUTF8: interface.ifa_name),
                       name == "en0" {
                        var addr = interface.ifa_addr.pointee
                        var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
                        getnameinfo(&addr, socklen_t(interface.ifa_addr.pointee.sa_len),
                                    &hostname, socklen_t(hostname.count),
                                    nil, socklen_t(0), NI_NUMERICHOST)
                        address = String(cString: hostname)
                    }
                }
                ptr = ptr?.pointee.ifa_next
            }
            freeifaddrs(ifaddr)
        }
        return address
    }
}

Chromecast Logs

What I’ve tried:

My expectations: I would like to know:

  1. If there are any specific requirements or limitations when serving HLS content for Chromecast (e.g., certain segment formats or MIME types).
  2. Why the Chromecast fails to stream the HLS content even though it works in other players (Safari, VLC).
  3. Any solutions or improvements I can make to the HLS setup to ensure it works with Chromecast.

Upvotes: 0

Views: 81

Answers (1)

Brad
Brad

Reputation: 163528

Chromecast can play HLS, and recent Chromecasts from the last 5-7 years or so can play pretty much any profile of H.264 and some others. The issue you're having probably isn't due to compatibility of the media.

The key is in that error log...

Unsafe attempt to load URL http://192.168.1.109:8080/hls.m3u8 from frame with URL https://portal.kickstarthq.com/. Domains, protocols and ports must match.

I'm not entirely sure where the problem is, but you're probably running afoul of a Content Security Policy (CSP) defined somewhere, whether in your code or the Chromecast receiver SDK. Firstly, try configuring a policy that explicitly allows script and media element access to your HLS server origin.

Some other thoughts...

  • Somewhere along the way, I saw the Chromium folks arguing to block access to the local network from external hosts. I can't find the post about this now, but that might be part of the problem.

  • Google uses Shaka Player for Chromecast receivers, which is going to handle HLS with MediaSource Extensions. You might test your stream on a Shaka Player test page to rule out an issue there. If anything, it might make testing easier if you can reproduce your playback issues on another page.

  • I don't think Chromecast compatibility is the problem, but you could always try casting to the default receiver and see what happens. If it works, then you know playback is possible.

Upvotes: 0

Related Questions