[iOS/Swift 4] Cara Menggunakan Google Cast SDK v4

Google Cast SDK adalah software development kit yang dapat digunakan untuk menambahkan kemampuan casting pada aplikasi kita. Ketika Anda ingin konten di aplikasi Anda dapat ditampilkan pada sebuah perangkat layar yang lebih besar, Anda dapat melakukan ini dengan cast device yang tersedia dipasaran, contonya chromecast. Proses ini lah yang disebut dengan casting. Kebanyakan mobile app developer masih menggunakan Google Cast SDK versi 2, yang terbilang stabil, tetapi SDK ini mendapatkan major update pada versi 4. Banyak API yang di-deprecate, sehingga membuat SDK ini lebih sederhana namun lebih canggih.

Pada tutorial kali ini saya akan menunjukan cara menggunakan SDK versi 4 ini pada aplikasi iOS. Silahkan baca doumentasi-dokumentasi dari Google tentang SDK ini untuk informasi yang lebih lengkap, karena tutorial ini adalah intisari praktis dari dokumen-dokumen tersebut. Jenis video yang akan digunakan pada tutorial kali ini adalah non-DRM content. Pada tutorial ini akan dijelaskan cara untuk menginisiasi casting proses, memuat media ke casting device, mengendalikan media dan mendapatkan meta data media dari casting device setalah berhasil dimuat.

Dengan menggunakan data gambar yang diberikan langsung:

1. Buat proyek Xcode baru dengan pilihan template aplikasi "Single View Application".

2. Tutup file .xcodeproj, kemudian pasang Cocoapod seperti yang ditunjukkan pada tutorial [IOS/Swift 3] Cara Memasang Dan Menggunakan Cocoapod

3. Pasang google-cast-sdk dan SDWebImage pod versi terbaru (saat tutorial ini ditulis versi terbarunya adalah 4.0.0)

pod 'google-cast-sdk', '~> 4.3.3'
pod 'SDWebImage', '~> 4.0'

4. Buka file .xcworkspace yang dihasilkan dari proses pemasangan Cocoapod.

5. Kali ini saya tidak menggunakan file Storyboard untuk mendesain UI (user interface), tetapi menggunakan file XIB. Untuk alur antar ViewController diatur menggunakan coding-an.

6. Saya akan menjelaskan tentang file manager untuk mengatur segala hal yang berkaitan dengan Google Cast, mulai dari menginisiasi, membuat session sampai mengakhiri session. Buat sebuah file Swift kosong untuk file manager Google Cast, beri nama seperti CastManager.swift atau semacamnya.

7. Berikut ini adalah codingan untuk file manager Google Cast.

import Foundation
import GoogleCast

enum CastSessionStatus {
    case started
    case resumed
    case ended
    case failedToStart
    case alreadyConnected
}

protocol CastManagerAvailableDeviceDelegate: class {
    func reloadAvailableDeviceData()
}

protocol CastManagerPlaybackRelatedDelegate: class {
    func seekLocalPlayer(to time: TimeInterval)
    func updatePlaybackPostion(at time: TimeInterval)
}

protocol CastManagerDidUpdateMediaStatusDelegate: class {
    func gckDidUpdateMediaStatus(mediaInfo: GCKMediaInformation?)
}

class CastManager: NSObject {
    
    let kReceiverAppID = kGCKDefaultMediaReceiverApplicationID
    let kDebugLoggingEnabled = true
    let kCastChannelNamespace = "urn:x-cast:com.luthfifr.chromecast"
    
    static let shared = CastManager()
    
    weak var availableDeviceDelegate: CastManagerAvailableDeviceDelegate?
    weak var playbackRelatedDelegate: CastManagerPlaybackRelatedDelegate?
    weak var didUpdateMediaStatusDelegate: CastManagerDidUpdateMediaStatusDelegate?
    
    var sessionManager: GCKSessionManager!
    
    private var sessionStatusListener: ((CastSessionStatus) -> Void)?
    var sessionStatus: CastSessionStatus! {
        didSet {
            sessionStatusListener?(sessionStatus)
        }
    }
    private var gckMediaInformation: GCKMediaInformation?
    
    var discoveryManager: GCKDiscoveryManager!
    private var availableDevices = [GCKDevice]()
    var deviceCategory = String()
    
    private var gckRequest: GCKRequest?
    
    private var castChannel: CastChannel?
    
    private var activeTrackIDs: [NSNumber]?
    
    // MARK: - Init
    
    func initialise() {
        initialiseContext()
        initialiseDiscovery()
        createSessionManager()
        setupCastLogging()
        initialiseTimer()
        style()
        miniControllerStyle()
        styleConnectionController()
    }
    
    private func initialiseContext() {
        let criteria = GCKDiscoveryCriteria(applicationID: kReceiverAppID)
        let options = GCKCastOptions(discoveryCriteria: criteria)
        GCKCastContext.setSharedInstanceWith(options)
        GCKCastContext.sharedInstance().useDefaultExpandedMediaControls = true
    }
    
    private func createSessionManager() {
        sessionManager = GCKCastContext.sharedInstance().sessionManager
        sessionManager.add(self)
    }
    
    private func initialiseDiscovery() {
        discoveryManager = GCKCastContext.sharedInstance().discoveryManager
        discoveryManager.add(self)
        discoveryManager.passiveScan = true
        discoveryManager.startDiscovery()
    }
    
    private func setupCastLogging() {
        let logFilter = GCKLoggerFilter()
        let classesToLog = ["GCKDeviceScanner", "GCKDeviceProvider", "GCKDiscoveryManager", "GCKCastChannel", "GCKMediaControlChannel", "GCKUICastButton", "GCKUIMediaController", "NSMutableDictionary"]
        logFilter.setLoggingLevel(.verbose, forClasses: classesToLog)
        GCKLogger.sharedInstance().filter = logFilter
        enableLogging(with: kDebugLoggingEnabled)
    }
    
    private func enableLogging(with value: Bool) {
        if value {
            GCKLogger.sharedInstance().delegate = self
        } else {
            GCKLogger.sharedInstance().delegate = nil
        }
    }
    
    private func initialiseTimer() {
        if playbackTimer == nil {
            playbackTimer = Timer(timeInterval: 1.0, target: self, selector: #selector(updateCurrentPlaybackTime), userInfo: nil, repeats: true)
        } else {
            playbackTimer?.invalidate()
            playbackPosition = nil
        }
    }
    
    private func stopTimer() {
        playbackTimer?.invalidate()
        playbackPosition = nil
    }
    
    private func addRemoteMediaListerner() {
        guard let currSession = sessionManager.currentCastSession else {
            return
        }
        currSession.remoteMediaClient?.add(self)
    }
    
    private func removeRemoteMediaListener() {
        guard let currSession = sessionManager.currentCastSession else {
            return
        }
        currSession.remoteMediaClient?.remove(self)
    }
    
    func addSessionStatusListener(listener: @escaping (CastSessionStatus) -> Void) {
        self.sessionStatusListener = listener
    }
    
    //styling connection list view and expanded media control view
    private func style() {
        let castStyle = GCKUIStyle.sharedInstance()
        castStyle.castViews.backgroundColor = .black
        castStyle.castViews.bodyTextColor = .white
        castStyle.castViews.buttonTextColor = .white
        castStyle.castViews.headingTextColor = .white
        castStyle.castViews.captionTextColor = .white
        castStyle.castViews.iconTintColor = .white
        
        castStyle.apply()
    }
    
    private func styleConnectionController() {
        let castStyle = GCKUIStyle.sharedInstance()
        //castStyle.castViews.deviceControl.connectionController.buttonTextColor = .nodesColor
        castStyle.apply()
    }
    
    //Styling mini controller
    private func miniControllerStyle() {
        let castStyle = GCKUIStyle.sharedInstance()
        castStyle.castViews.mediaControl.miniController.backgroundColor = .darkGray
        castStyle.castViews.mediaControl.miniController.bodyTextColor = .white
        castStyle.castViews.mediaControl.miniController.buttonTextColor = .white
        castStyle.castViews.mediaControl.miniController.headingTextColor = .white
        castStyle.castViews.mediaControl.miniController.captionTextColor = .white
        castStyle.castViews.mediaControl.miniController.iconTintColor = .white
        
        castStyle.apply()
    }
    
    func getAvailableDevices() -> [GCKDevice] {
        return availableDevices
    }
    
    func getMediaInfo() -> GCKMediaInformation? {
        return gckMediaInformation
    }
    
    func setMediaInfo(with mediaInfo: GCKMediaInformation?) {
        self.gckMediaInformation = mediaInfo
    }
    
    func setActiveTracksID(with activeIDs: [NSNumber]?) {
        activeTrackIDs = activeIDs
    }
    
    func getActiveTracksID() -> [NSNumber]? {
        return activeTrackIDs
    }
    
    // MARK: - Build Meta
    
    func buildMediaInformation(contentID: String, title: String, duration: TimeInterval, streamType: GCKMediaStreamType, thumbnailUrl: String?, customData: Any?) -> GCKMediaInformation {
        let metadata = buildMetadata(title: title, thumbnailUrl: thumbnailUrl)
        
        let mediaInfoBuilder = GCKMediaInformationBuilder()
        mediaInfoBuilder.contentID = contentID
        mediaInfoBuilder.streamType = streamType
        mediaInfoBuilder.metadata = metadata
        mediaInfoBuilder.streamDuration = duration
        mediaInfoBuilder.customData = customData
        let mediaInfo = mediaInfoBuilder.build()
//        let mediaInfo = GCKMediaInformation(contentID: contentID, streamType: streamType, contentType: "", metadata: metadata, adBreaks: nil, adBreakClips: nil, streamDuration: duration, mediaTracks: nil, textTrackStyle: nil, customData: customData)
        
        return mediaInfo
    }
    
    private func buildMetadata(title: String, thumbnailUrl: String?) -> GCKMediaMetadata {
        let metadata = GCKMediaMetadata.init(metadataType: .movie)
        metadata.setString(title, forKey: kGCKMetadataKeyTitle)
        
        if let thumbnailUrl = thumbnailUrl, let url = URL(string: thumbnailUrl) {
            metadata.addImage(GCKImage.init(url: url, width: 480, height: 360))
        }
        
        return metadata
    }
    
    // MARK: - Start
    
    func startSelectedItemRemotely(_ mediaInfo: GCKMediaInformation, at time: TimeInterval, completion: (Bool) -> Void) {
        let castSession = sessionManager.currentCastSession
        
        if castSession != nil {
            let options = GCKMediaLoadOptions()
            options.playPosition = time
            gckRequest = castSession?.remoteMediaClient?.loadMedia(mediaInfo, with: options)
            gckRequest?.delegate = self
            completion(true)
            
            sessionStatus = .alreadyConnected
        } else {
            completion(false)
        }
    }
    
    // MARK: - Play/Resume
    
    func playSelectedItemRemotely(to time: TimeInterval?, completion: (Bool) -> Void) {
        let castSession = sessionManager.currentCastSession
        if castSession != nil {
            let remoteClient = castSession?.remoteMediaClient
            if let time = time {
                let options = GCKMediaSeekOptions()
                options.interval = time
                options.resumeState = .play
                gckRequest = remoteClient?.seek(with: options)
            } else {
                gckRequest = remoteClient?.play()
            }
            completion(true)
        } else {
            completion(false)
        }
    }
    
    // MARK: - Pause
    
    func pauseSelectedItemRemotely(at time: TimeInterval?, completion: (Bool) -> Void) {
        let castSession = sessionManager.currentCastSession
        if castSession != nil {
            let remoteClient = castSession?.remoteMediaClient
            if let time = time {
                let options = GCKMediaSeekOptions()
                options.interval = time
                options.resumeState = .pause
                gckRequest = remoteClient?.seek(with: options)
            } else {
                remoteClient?.pause()
            }
            completion(true)
        } else {
            completion(false)
        }
    }
    
    // MARK: - Update Current Time
    func getCurrentPlaybackTime(completion: (TimeInterval?) -> Void) {
        let castSession = sessionManager.currentCastSession
        if castSession != nil {
            let remoteClient = castSession?.remoteMediaClient
            let currentTime = remoteClient?.approximateStreamPosition()
            completion(currentTime)
        } else {
            completion(nil)
        }
    }
    
    @objc func updateCurrentPlaybackTime() {
        getCurrentPlaybackTime(completion: { currTime in
            guard let time = currTime else {
                return
            }
            
            playbackRelatedDelegate?.updatePlaybackPostion(at: time)
            
        })
    }
    
    // MARK: - Buffering status
    
    func getMediaPlayerState(completion: (GCKMediaPlayerState) -> Void) {
        if let castSession = sessionManager.currentCastSession,
            let remoteClient = castSession.remoteMediaClient,
            let mediaStatus = remoteClient.mediaStatus {
            completion(mediaStatus.playerState)
        }
        
        completion(GCKMediaPlayerState.unknown)
    }
}

// MARK: - GCKDiscoveryManagerListener
extension CastManager: GCKDiscoveryManagerListener {
    func didStartDiscovery(forDeviceCategory deviceCategory: String) {
        self.deviceCategory = deviceCategory
    }
    
    func didUpdateDeviceList() {
        print("\(discoveryManager.deviceCount) device(s) has been discovered")
    }
    
    func didInsert(_ device: GCKDevice, at index: UInt) {
        availableDevices.append(device)
        availableDeviceDelegate?.reloadAvailableDeviceData()
    }
    
    func didRemoveDevice(at index: UInt) {
        availableDevices.remove(at: Int(index))
        availableDeviceDelegate?.reloadAvailableDeviceData()
    }
}

// MARK: - Device Operation
extension CastManager {
    func connectToDevice(device: GCKDevice) {
        if discoveryManager.deviceCount == 0 && sessionManager.hasConnectedCastSession(){
            return
        }
        
        sessionManager.startSession(with: device)
    }
    
    func disconnectFromCurrentDevice() {
        if sessionManager.hasConnectedCastSession() {
            removeRemoteMediaListener()
            sessionManager.endSession()
        }
    }
}

// MARK: - GCKLoggerDelegate
extension CastManager: GCKLoggerDelegate {
    func logMessage(_ message: String, at level: GCKLoggerLevel, fromFunction function: String, location: String) {
        if (kDebugLoggingEnabled) {
            print(function + " - " + message)
        }
    }
}

// MARK: - GCKSessionManagerListener
extension CastManager: GCKSessionManagerListener {
    public func sessionManager(_ sessionManager: GCKSessionManager, didStart session: GCKSession) {
        print("sessionManager started")
        sessionStatus = .started
        if let castchannel = castChannel {
            sessionManager.currentCastSession?.add(castchannel)
        }
        addRemoteMediaListerner()
    }
    
    public func sessionManager(_ sessionManager: GCKSessionManager, didResumeSession session: GCKSession) {
        print("sessionManager resumed")
        sessionStatus = .resumed
    }
    
    public func sessionManager(_ sessionManager: GCKSessionManager, didEnd session: GCKSession, withError error: Error?) {
        print("sessionManager ended")
        sessionStatus = .ended
        if let castchannel = castChannel {
            sessionManager.currentCastSession?.remove(castchannel)
        }
    }
    
    public func sessionManager(_ sessionManager: GCKSessionManager, didFailToStart session: GCKSession, withError error: Error) {
        print("sessionManager failed to start")
        sessionStatus = .failedToStart
    }
    
    public func sessionManager(_ sessionManager: GCKSessionManager, didSuspend session: GCKSession, with reason: GCKConnectionSuspendReason) {
        print("sessionManager suspended")
        sessionStatus = .ended
    }
}

extension CastManager: GCKRemoteMediaClientListener {
    func remoteMediaClient(_ client: GCKRemoteMediaClient, didUpdate mediaStatus: GCKMediaStatus?) {
        didUpdateMediaStatusDelegate?.gckDidUpdateMediaStatus(mediaInfo: mediaStatus?.mediaInformation)
        setMediaInfo(with: mediaStatus?.mediaInformation)
        setActiveTracksID(with: mediaStatus?.activeTrackIDs)
    }
}

extension CastManager: GCKRequestDelegate {
    func requestDidComplete(_ request: GCKRequest) {
        print("success requestID = \(request.requestID.description)")
    }
    
    func request(_ request: GCKRequest, didFailWithError error: GCKError) {
        print("GCKRequrestError = \(error.localizedDescription)")
    }
    
    func request(_ request: GCKRequest, didAbortWith abortReason: GCKRequestAbortReason) {
        var reasonStr = String()
        switch abortReason {
        case .cancelled:
            reasonStr = "cancelled"
        case .replaced:
            reasonStr = "replaced"
        }
        print("GCKRequestAbortReason = \(reasonStr)")
    }
}

Penjelasan:

enum CastSessionStatus {
    case started
    case resumed
    case ended
    case failedToStart
    case alreadyConnected
}

Enum CastSessionStatus digunakan sebagai penanda status dari session pada cast device. Enum ini dipakai pada metode-metode dari GCKSessioManagerListener.

protocol CastManagerAvailableDeviceDelegate: class {
    func reloadAvailableDeviceData()
}

CastManagerAvailableDeviceDelegate digunakan untuk berkomunikasi dengan kelas yang bertugas untuk menampilkan daftar available devices. Metode reloadAvailableDeviceData akan dieksekusi setiap ada penambahan/pengurangan data pada array availableDevices.

protocol CastManagerPlaybackRelatedDelegate: class {
    func updatePlaybackPostion(at time: TimeInterval)
}

protocol CastManagerDidUpdateMediaStatusDelegate: class {
    func gckDidUpdateMediaStatus(mediaInfo: GCKMediaInformation?)
}

CastManagerPlaybackRelatedDelegate digunakan untuk mengendalikan nilai seeker pada cast device agar sesuai dengan yang diinginkan oleh pengguna melalui metode updatePlaybackPostion. CastManagerDidUpdateMediaStatusDelegate digunakan untuk menyimpan nilai dari GCKMediaInformation yang terupdate setelah konten berhasil di-load di cast device.

let kCastChannelNamespace = "urn:x-cast:com.luthfifr.chromecast"

Constant kCastChannelNamespace digunakan untuk menampung nama dari custom google cast channel, akan digunakan saat menginisialisasi GCKCastChannel.

static let shared = CastManager()

Singleton ini digunakan untuk mengakses properties and methods dari CastManager class dari kelas-kelas lain.

weak var availableDeviceDelegate: CastManagerAvailableDeviceDelegate?
weak var playbackRelatedDelegate: CastManagerPlaybackRelatedDelegate?
weak var didUpdateMediaStatusDelegate: CastManagerDidUpdateMediaStatusDelegate?

Baris kode ini menginisiasi delegates dari kelas ini dengan menentukan jenis reference terhadap ARC (Automatic Reference Counting). Baca lebih lanjut di halaman Krakendev.

var sessionManager: GCKSessionManager!
    
private var sessionStatusListener: ((CastSessionStatus) -> Void)?
var sessionStatus: CastSessionStatus! {
    didSet {
      sessionStatusListener?(sessionStatus)
    }
}
private var gckMediaInformation: GCKMediaInformation?
    
var discoveryManager: GCKDiscoveryManager!
private var availableDevices = [GCKDevice]()
var deviceCategory = String()
    
private var gckRequest: GCKRequest?
    
private var castChannel: CastChannel?
    
private var activeTrackIDs: [NSNumber]?

Variabel-variabel ini hanya dapat digunakan di dalam kelas file manager ini, kecuali yang tidak private. Variabel dengan tipe GCKSessionManager digunakan untuk meng-handle session pada cast device, karena pada versi 4 ini session di-handle otomatis oleh Google Cast SDK. Variabel dengan tipe GCKMediaInformation digunakan untuk menampung informasi-informasi terbaru dari media yang di-load pada cast device. Variabel dengan tipe GCKDiscoveryManager digunakan untuk meng-handle operasi untuk menemukan, terhubung dan memutuskan hubungan dengan cast device yang tersedia. Variabel dengan tipe array GCKDevice digunakan untuk menampung cast device yang tersedia untuk ditampilkan. Variabel dengan tipe GCKRequest digunakan untuk menampung hasil operasi dari mengirim request ke cast device, seperti loadMedia, play, pause, dll. Variabel dengan tipe CastChannel (custom class) digunakan untuk menampung hasil operasi dari custom GCKCastChannel. Variabel yang bernama activeTrackIDs digunakan untuk menampung trackID yang sedang terpilih, baik untuk subtitle ataup pun bahasa (audio), google menentukan tipe data untuk trackID adalah NSNUmber. Variabel playbackTimer (Timer) dan playbackPosition (TimeInterval) digunakan untuk keperluan playback. Keduanya saling terkait. TimeInterval adalah typealias dari tipe data Double.

func initialise() {
        initialiseContext()
        initialiseDiscovery()
        createSessionManager()
        setupCastLogging()
        initialiseTimer()
        style()
        miniControllerStyle()
        styleConnectionController()
    }

Metode initialise digunakan untuk memanggil metode-metode yang menginisialisasi semua hal dasar untuk memulai cast, termasuk metode untuk styling halaman mini controller dan halaman yang menampilkan daftar available cast device.

private func initialiseContext() {
        let criteria = GCKDiscoveryCriteria(applicationID: kReceiverAppID)
        let options = GCKCastOptions(discoveryCriteria: criteria)
        GCKCastContext.setSharedInstanceWith(options)
        GCKCastContext.sharedInstance().useDefaultExpandedMediaControls = true
    }

Metode initialiseContext digunakan untuk menentukan setting dasar untuk memulai cast. Parameter yang wajib memiliki nilai disini adalah application ID yang dapat dibuat di halaman chromecast developer. Pada saat inisiasi ini juga ditentukan pada metode useDefaultExpandedMediaControls apakah developer ingin menggunakan media controller default dari chromecast atau ingin membuat sendiri.

private func createSessionManager() {
        sessionManager = GCKCastContext.sharedInstance().sessionManager
        sessionManager.add(self)
    }
...
// MARK: - GCKSessionManagerListener
extension CastManager: GCKSessionManagerListener {
    public func sessionManager(_ sessionManager: GCKSessionManager, didStart session: GCKSession) {
        print("sessionManager started")
        sessionStatus = .started
        if let castchannel = castChannel {
            sessionManager.currentCastSession?.add(castchannel)
        }
        addRemoteMediaListerner()
    }
    
    public func sessionManager(_ sessionManager: GCKSessionManager, didResumeSession session: GCKSession) {
        print("sessionManager resumed")
        sessionStatus = .resumed
    }
    
    public func sessionManager(_ sessionManager: GCKSessionManager, didEnd session: GCKSession, withError error: Error?) {
        print("sessionManager ended")
        sessionStatus = .ended
        if let castchannel = castChannel {
            sessionManager.currentCastSession?.remove(castchannel)
        }
    }
    
    public func sessionManager(_ sessionManager: GCKSessionManager, didFailToStart session: GCKSession, withError error: Error) {
        print("sessionManager failed to start")
        sessionStatus = .failedToStart
    }
    
    public func sessionManager(_ sessionManager: GCKSessionManager, didSuspend session: GCKSession, with reason: GCKConnectionSuspendReason) {
        print("sessionManager suspended")
        sessionStatus = .ended
    }
}

Metode createSessionManager digunakan untuk membuat session manager dan memasang protocol stubs dari protocol GCKSessionManagerListener. Protocol tersebut memiliki metode-metode untuk memberitahu (notify) manager class ini tentang state dari suatu session. Nama-nama dari metodenya cukup straight forward jadi tidak perlu dijelaskan lagi. Jika ada notifikasi session berhasil dimulai, manager class akan men-set nilai variabel sessionStatus menjadi .started, kemudian memasang cast channel dan terakhir memasang remote media listener. Jika ada notifikasi session ter-pause dan kemudian di-resume secara otomatis maka nilai variable sessionStatus menjadi .resumed. Jika ada notifikasi session dihentikan (pengguna memutus sambungan chromecast) variable sessionStatus menjadi .ended dan cast channel akan dihapuskan. Jika ada notifikasi session gagal dimulai, variabel sessionStatus menjadi .failedToStart. Jika ada notifikasi session di-suspend, variabel sessionStatus menjadi .ended.

private func initialiseDiscovery() {
        discoveryManager = GCKCastContext.sharedInstance().discoveryManager
        discoveryManager.add(self)
        discoveryManager.passiveScan = true
        discoveryManager.startDiscovery()
    }
...
// MARK: - GCKDiscoveryManagerListener
extension CastManager: GCKDiscoveryManagerListener {
    func didStartDiscovery(forDeviceCategory deviceCategory: String) {
        self.deviceCategory = deviceCategory
    }
     
    func didUpdateDeviceList() {
        print("\(discoveryManager.deviceCount) device(s) has been discovered")
    }
     
    func didInsert(_ device: GCKDevice, at index: UInt) {
        availableDevices.append(device)
        availableDeviceDelegate?.reloadAvailableDeviceData()
    }
     
    func didRemoveDevice(at index: UInt) {
        availableDevices.remove(at: Int(index))
        availableDeviceDelegate?.reloadAvailableDeviceData()
    }
}

Metode ini menginisiasi discover manager yang bertugas untuk mengatur segala proses untuk menemukan cast device yang available. Pada metode ini developer harus men-set suatu properti yaitu passiveScan aktif atau tidak. Selain itu developer juga harus menambahkan manager class sebagai listener untuk protocol GCKDiscoveryManagerListener. Protocol ini akan memberitahukan kalau discover manager sudah mulai mencari, menemukan cast device baru yang available, lalu menambahkannya ke dalam daftar, atau ada cast device yang tidak lagi available dan sudah menhapusnya dari daftar.

private func setupCastLogging() {
        let logFilter = GCKLoggerFilter()
        let classesToLog = ["GCKDeviceScanner", "GCKDeviceProvider", "GCKDiscoveryManager", "GCKCastChannel", "GCKMediaControlChannel", "GCKUICastButton", "GCKUIMediaController", "NSMutableDictionary"]
        logFilter.setLoggingLevel(.verbose, forClasses: classesToLog)
        GCKLogger.sharedInstance().filter = logFilter
        enableLogging(with: kDebugLoggingEnabled)
    }
    
    private func enableLogging(with value: Bool) {
        if value {
            GCKLogger.sharedInstance().delegate = self
        } else {
            GCKLogger.sharedInstance().delegate = nil
        }
    }
...
extension CastManager: GCKLoggerDelegate {
    func logMessage(_ message: String, at level: GCKLoggerLevel, fromFunction function: String, location: String) {
        if (kDebugLoggingEnabled) {
            print(function + " - " + message)
        }
    }
}

Metode ini menginisiasi pencatatan di debugger. Developer harus menentukan aktifitas apa saja yang harus dicatat dalam bentuk array of string. Logger diaktifkan atau dimatikan berdasarkan nilai dari variabel kDebugLoggingEnabled. Jika logger diaktifkan maka manager class akan menjadi listener dari protocol GCKLoggerDelegate. Jika tidak diaktifkan maka delegate method dari GCKLogger di-set menjadi nil.

private func addRemoteMediaListerner() {
        guard let currSession = sessionManager.currentCastSession else {
            return
        }
        currSession.remoteMediaClient?.add(self)
    }
    
    private func removeRemoteMediaListener() {
        guard let currSession = sessionManager.currentCastSession else {
            return
        }
        currSession.remoteMediaClient?.remove(self)
    }
...
extension CastManager: GCKRemoteMediaClientListener {
    func remoteMediaClient(_ client: GCKRemoteMediaClient, didUpdate mediaStatus: GCKMediaStatus?) {
        didUpdateMediaStatusDelegate?.gckDidUpdateMediaStatus(mediaInfo: mediaStatus?.mediaInformation)
        setMediaInfo(with: mediaStatus?.mediaInformation)
    }
}

Metode addRemoteMediaListerner digunakan untuk membuat manager class sebagai listener dari protocol GCKRemoteMediaClientListener. Pada protocol tersebut metode yang digunakan adalah didUpdate mediaStatus untuk menyimpan mediaInformation terbaru dari cast device di manager class. Metode removeRemoteMediaListener digunakan untuk menghapuskan manager class sebagai listener.

//styling connection list view and expanded media control view
    private func style() {
        let castStyle = GCKUIStyle.sharedInstance()
        castStyle.castViews.backgroundColor = .black
        castStyle.castViews.bodyTextColor = .white
        castStyle.castViews.buttonTextColor = .white
        castStyle.castViews.headingTextColor = .white
        castStyle.castViews.captionTextColor = .white
        castStyle.castViews.iconTintColor = .white
        
        castStyle.apply()
    }
    
    private func styleConnectionController() {
        let castStyle = GCKUIStyle.sharedInstance()
        //castStyle.castViews.deviceControl.connectionController.buttonTextColor = .nodesColor
        castStyle.apply()
    }
    
    //Styling mini controller
    private func miniControllerStyle() {
        let castStyle = GCKUIStyle.sharedInstance()
        castStyle.castViews.mediaControl.miniController.backgroundColor = .darkGray
        castStyle.castViews.mediaControl.miniController.bodyTextColor = .white
        castStyle.castViews.mediaControl.miniController.buttonTextColor = .white
        castStyle.castViews.mediaControl.miniController.headingTextColor = .white
        castStyle.castViews.mediaControl.miniController.captionTextColor = .white
        castStyle.castViews.mediaControl.miniController.iconTintColor = .white
        
        castStyle.apply()
    }

Metode style, miniControllerStyle dan styleConnectionController digunakan untuk menentukan style (kebanyakan warna) untuk halaman mini controller, device connection list dan default media player.

// MARK: - Device Operation
extension CastManager {
    func connectToDevice(device: GCKDevice) {
        if discoveryManager.deviceCount == 0 && sessionManager.hasConnectedCastSession(){
            return
        }
         
        sessionManager.startSession(with: device)
    }
     
    func disconnectFromCurrentDevice() {
        if sessionManager.hasConnectedCastSession() {
            removeRemoteMediaListener()
            sessionManager.endSession()
        }
    }
}

Kedua metode ini digunakan untuk membuat sambungan ke cast device dan memutuskannya. Saat kedua metode ini diekskusi (secara terpisah tentunya), akan men-trigger perubahan nilai pada metode-metode GCKSessionManagerListener yang berkaitan. Karena, saat membuat sambungan ke cast device, itu artinya suatu session sedang dibuat, begitupun sebaliknya saat memutuskan sambungan dari cast device.

func getAvailableDevices() -> [GCKDevice] {
        return availableDevices
    }
     
    func getMediaInfo() -> GCKMediaInformation? {
        return gckMediaInformation
    }
     
    func setMediaInfo(with mediaInfo: GCKMediaInformation?) {
        self.gckMediaInformation = mediaInfo
    }
     
    func setActiveTracksID(with activeIDs: [NSNumber]?) {
        activeTrackIDs = activeIDs
    }
     
    func getActiveTracksID() -> [NSNumber]? {
        return activeTrackIDs
    }

Ini adalah fungsi-fungsi setter dan getter untuk properti-properti yang dimiliki oleh manager class.

// MARK: - Build Meta
     
    func buildMediaInformation(contentID: String, title: String, duration: TimeInterval, streamType: GCKMediaStreamType, thumbnailUrl: String?, customData: Any?) -> GCKMediaInformation {
        let metadata = buildMetadata(title: title, thumbnailUrl: thumbnailUrl)
         
        let mediaInfoBuilder = GCKMediaInformationBuilder()
        mediaInfoBuilder.contentID = contentID
        mediaInfoBuilder.streamType = streamType
        mediaInfoBuilder.metadata = metadata
        mediaInfoBuilder.streamDuration = duration
        mediaInfoBuilder.customData = customData
        let mediaInfo = mediaInfoBuilder.build()
//        let mediaInfo = GCKMediaInformation(contentID: contentID, streamType: streamType, contentType: "", metadata: metadata, adBreaks: nil, adBreakClips: nil, streamDuration: duration, mediaTracks: nil, textTrackStyle: nil, customData: customData)
         
        return mediaInfo
    }
     
    private func buildMetadata(title: String, thumbnailUrl: String?) -> GCKMediaMetadata {
        let metadata = GCKMediaMetadata.init(metadataType: .movie)
        metadata.setString(title, forKey: kGCKMetadataKeyTitle)
         
        if let thumbnailUrl = thumbnailUrl, let url = URL(string: thumbnailUrl) {
            metadata.addImage(GCKImage.init(url: url, width: 480, height: 360))
        }
         
        return metadata
    }

Kedua metode ini adalah salah satu yang terpenting. Mereka digunakan untuk menambahkan info tentang konten yang akan di-cast ke cast device. Metode buildMetadata men-set judul dan gambar thumbnail konten (jika ada) dan menyimpannya dalam format GCKMediaMetadata. Metadata ini digunakan sebagai bagian dari mediaInformation yang akan disampaikan ke cast device bersama dengan informasi lainnya, seperti ID konten, tipe streaming, durasi dan customData. CustomData bisa berisi data apa saja tergantung dari apa yang sudah ditentukan oleh cast developer, seperti authentication token, DRM info, dsb.

// MARK: - Start
     
    func startSelectedItemRemotely(_ mediaInfo: GCKMediaInformation, at time: TimeInterval, completion: (Bool) -> Void) {
        let castSession = sessionManager.currentCastSession
         
        if castSession != nil {
            let options = GCKMediaLoadOptions()
            options.playPosition = time
            gckRequest = castSession?.remoteMediaClient?.loadMedia(mediaInfo, with: options)
            gckRequest?.delegate = self
            completion(true)
             
            sessionStatus = .alreadyConnected
        } else {
            completion(false)
        }
    }
...
extension CastManager: GCKRequestDelegate {
    func requestDidComplete(_ request: GCKRequest) {
        print("success requestID = \(request.requestID.description)")
    }
     
    func request(_ request: GCKRequest, didFailWithError error: GCKError) {
        print("GCKRequrestError = \(error.localizedDescription)")
    }
     
    func request(_ request: GCKRequest, didAbortWith abortReason: GCKRequestAbortReason) {
        var reasonStr = String()
        switch abortReason {
        case .cancelled:
            reasonStr = "cancelled"
        case .replaced:
            reasonStr = "replaced"
        }
        print("GCKRequestAbortReason = \(reasonStr)")
    }
}

Metode ini digunakan untuk mulai meng-casting konten ke cast device, yang ditunjukan pada baris kode castSession?.remoteMediaClient?.loadMedia(mediaInfo, with: options). Nilai balik dari metode loadMedia disimpan di dalam variable gckRequest dan manager class dibuat sebagai listener dari GCKRequestDelegate agar status request dapat dimonitor.

// MARK: - Play/Resume
     
    func playSelectedItemRemotely(to time: TimeInterval?, completion: (Bool) -> Void) {
        let castSession = sessionManager.currentCastSession
        if castSession != nil {
            let remoteClient = castSession?.remoteMediaClient
            if let time = time {
                let options = GCKMediaSeekOptions()
                options.interval = time
                options.resumeState = .play
                gckRequest = remoteClient?.seek(with: options)
            } else {
                gckRequest = remoteClient?.play()
            }
            completion(true)
        } else {
            completion(false)
        }
    }
     
    // MARK: - Pause
     
    func pauseSelectedItemRemotely(at time: TimeInterval?, completion: (Bool) -> Void) {
        let castSession = sessionManager.currentCastSession
        if castSession != nil {
            let remoteClient = castSession?.remoteMediaClient
            if let time = time {
                let options = GCKMediaSeekOptions()
                options.interval = time
                options.resumeState = .pause
                gckRequest = remoteClient?.seek(with: options)
            } else {
                remoteClient?.pause()
            }
            completion(true)
        } else {
            completion(false)
        }
    }

Setalah konten berhasil di-load, konten akan otomatis dimainkan. Kedua metode ini digunakan setelah itu. Play dan pause sama-sama dapat diakses dari metode remoteMediaClient.

Tutorial ini hanya menjelaskan tentag manager class-nya. Untuk controller class dan yang lainnya silahkan disesuaikan dengan preferensi masing-masing. Untuk contoh source code yang lebih lengkap mengenai tutorial ini, silahkan lihat di Github Repo ChromecastV4.

Tingkat kesulitan: Menengah

Share this post to the world:
Facebooktwittergoogle_pluspinterestlinkedinFacebooktwittergoogle_pluspinterestlinkedin

Leave a Reply

Your email address will not be published. Required fields are marked *