thibauts/node-castv2-client

Refresh Image Without Blanking Screen

lambdaknight opened this issue · 19 comments

I'm trying to cast a webpage that displays stats from a headless box to my Chromecast using Node.js. My current implementation involves using PhantomJS in an external process to render the webpage to an image and then cast that image to the Chromecast using node-castv2-client, and then repeat that in an interval. Here is my code as it stands:

/*
 * Written by Turing Eret
 * Based on code example by Thibaut Séguy from
 * https://github.com/thibauts/node-castv2-client/blob/master/README.md
 */

var Client = require('castv2-client').Client;
var DefaultMediaReceiver  = require('castv2-client').DefaultMediaReceiver;
var mdns = require('mdns');
var http = require('http');
var internalIp = require('internal-ip');
var router = require('router');
var path = require('path');
var fs = require('fs');
var mime = require('mime');
var finalhandler = require('finalhandler');

var port = 8800;

var prefixPath = startServer();

var browser = mdns.createBrowser(mdns.tcp('googlecast'));

browser.on('serviceUp', function(service) {
  console.log('found device "%s" at %s:%d', service.name, service.addresses[0], service.port);
  if(service.name == process.argv[2]) {
    onDeviceFound(service.addresses[0]);
  }
  browser.stop();
});

browser.start();

function onDeviceFound(host) {
  var client = new Client();

  var statsFile = 'stats.png';

  var statsImagePath = prefixPath + statsFile;

  client.connect(host, function() {
    console.log('connected, launching app ...');

    client.launch(DefaultMediaReceiver, function(err, player) {
      var media = {
        contentId: statsImagePath,
        contentType: 'image/png'
      };

      player.on('status', function(status) {
        console.log('status broadcast playerState=%s', status.playerState);
      });

      console.log('app "%s" launched, loading media %s ...', player.session.displayName, media.contentId);

      var loadStats = function() {
        // renderStats('stats.png'); //Will eventually reload and rerender the page.
        player.load(media, { autoplay: true }, function(err, status) {
          console.log('media loaded playerState=%s', status.playerState);
        });        
      }

      loadStats();   

      setInterval(function() {
        console.log('Executing interval function.');
        loadStats();
      }, 5000); //Set to 5 seconds for debug purposes.

    });
  });

  client.on('error', function(err) {
    console.log('Error: %s', err.message);
    client.close();
  });

}

function startServer() {
  var route = router();
  var ip = internalIp();
  var prefix = 'http://' + ip + ':' + port + '/';

  route.all('/:path', function(req, res) {
    serveFile(req, res, req.params.path, prefix);
  });

  http.createServer(function(req, res) {
    route(req, res, finalhandler(req, res))
  }).listen(port)
  console.log('Started simple webserver at %s', prefix);

  return prefix;
}

function serveFile(req, res, filePath, prefix) {
  console.log('Serving file: %s', filePath)
  var stat = fs.statSync(filePath);
  var total = stat.size;
  var range = req.headers.range;
  var type = mime.lookup(filePath);

  res.setHeader('Content-Type', type);
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Refresh', '5;url=' + prefix + filePath);

  if (!range) {
    res.setHeader('Content-Length', total);
    res.statusCode = 200;
    return fs.createReadStream(filePath).pipe(res);
  }

  var part = rangeParser(total, range)[0];
  var chunksize = (part.end - part.start) + 1;
  var file = fs.createReadStream(filePath, {start: part.start, end: part.end});

  res.setHeader('Content-Range', 'bytes ' + part.start + '-' + part.end + '/' + total);
  res.setHeader('Accept-Ranges', 'bytes');
  res.setHeader('Content-Length', chunksize);
  res.statusCode = 206;

  return file.pipe(res);
};

Now, this code has two issues. First off, whenever the interval hits, the Chromecast redisplays the image, but the screen blanks out before doing so. Ideally, it wouldn't blank out like that. Second, when the Chromecast redraws the designated image, I can see that it is not being reloaded from the webserver, which means that even if the image changes, nothing will change on screen. How do I fix this? Thanks.

I fear the DefaultMediaReceiver UI flow has a loading screen that cannot be bypassed. If calling load blanks the screen I don't see a way around... As you probably know you could make your own app and use it to display either the webpage or its rendered counterpart, though.

For your second problem, have you tried removing the refresh header ? Anyway you'll probably be able to force load to refresh its cache by adding a random parameter to the URL and changing it on each request.

// On each call to loadStats
statsImagePath += '?rnd=' + Math.random();

I saw something about a media queue in the docs for Chromecast. Would it be possible to add another instance of the image to the queue and then tell the Chromecast to move to the next image in the queue?

I don't know this API. There are good chances this is a pure client-side thing. The only way to know would be to sniff the traffic of an app using this feature. I will provide directions if you want to undertake this task.

I'm also interested on implementing the QueueItem. I'm trying to create an app that could do some slide show on chromecast using your module. Is there a way so that we could implement the QueueItem?

Here's the SDK
https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.QueueItem

I see 2 ways to tackle this, build a sender app that uses the SDK feature and sniff the traffic as described here, or try to implement the SDK functions by trial and error, knowing that this API probably follows closely the calling patterns of the media controller.

This may be an easy target. Compare the LoadRequest SDK documentation to the implementation of load

A queueLoad function would fit well besides the regular load :)

So I tried to make a queueLoad function. I successfully added queue items, then it loaded to the chromecast. My problem is that the playerState value is idle, and I could not change the state to playing.

Here's my code:

MediaController.prototype.queueLoad = function(mediaList, options, callback) {
  var self = this;
  // var mediaList = [
  //   {
  //     autoplay : true,
  //     preloadTime : 3,
  //     activeTrackIds : [],
  //     playbackDuration: 10,
  //     media: {
  //       contentId: "http://www.slidesjs.com/examples/standard/img/example-slide-1.jpg",
  //       contentType: "image/jpeg"
  //     }
  //   },
  //   {
  //     autoplay : true,
  //     preloadTime : 3,
  //     activeTrackIds : [],
  //     playbackDuration: 10,
  //     media: {
  //       contentId: "http://www.slidesjs.com/examples/standard/img/example-slide-2.jpg",
  //       contentType: "image/jpeg"
  //     }
  //   },
  //   {
  //     autoplay : true,
  //     preloadTime : 3,
  //     activeTrackIds : [],
  //     playbackDuration: 10,
  //     media: {
  //       contentId: "http://www.slidesjs.com/examples/standard/img/example-slide-3.jpg",
  //       contentType: "image/jpeg"
  //     }
  //   }
  // ];

  if(typeof options === 'function' || typeof options === 'undefined') {
    callback = options;
    options = {};
  }


  var data = { type: 'QUEUE_LOAD' };
  data.items = mediaList;
  data.repeatMode = (typeof options.repeatMode !== 'undefined') ? options.repeatMode : "REPEAT_OFF";

  self.request(data, function(err, response) {

    console.log("Error: " + util.inspect(err))
    console.log("Response: " + util.inspect(response))

    if(err) return callback(err);
    if(response.type === 'LOAD_FAILED') {
      return callback(new Error('Load failed'));
    }
    var status = response.status[0];
    self.sessionRequest({ type: 'PLAY' }, callback(null, status));
    // callback(null, status);
  });

}

Awesome :) Have you tried to play the exact same media one by one with the regular load? Maybe the server doesn't handle CORS correctly ? In that case you could try to host the images on a local server. I don't have time to lookup now but I think there's an example expressjs static server with CORS somewhere in the issues. Ask me if you don't find it.

Maybe remove activeTrackIds completely ?

I can't find playbackDurationin QueueItem but I see a duration field in MediaInfo.

Maybe add a streamType: 'BUFFERED' along contentId and contentType ?

Yes I did try to play the exact same media by the regular load. It was the same, the playerState value is IDLE.

Also when I will send a self.play(callback) after the QUEUE_LOAD. I will have an error Error: Invalid request: INVALID_MEDIA_SESSION_ID

playbackDuration is in QueueItem playbackDuration

Ok so the problem is not related to loadQueue per se. You'll have to serve your files from a CORS enabled server. Something along these lines may work :

var express = require('express');
var cors = require('cors');
var app = express();

app.use(cors());
app.use('/', express.static(__dirname));

app.listen(8000, '0.0.0.0');

So I've hosted the images from a CORS enable server that you provided the code. The same issue was found the playerState is idle and if I tried to request for play I will received an Error: Invalid request: INVALID_MEDIA_SESSION_ID

Is there a way to retain the media session id?

That's very strange. Have you tried to play a single image with the regular load ? Please post the full code here if this doesn't work so I can try it at home.

Here is my complete code of the test application I've created. I already know my chromecast host, so I set it to connect it directly.

var Client                = require('castv2-client').Client;
var DefaultMediaReceiver  = require('castv2-client').DefaultMediaReceiver;
var util                  = require("util");

function playFile(media, player){
    player.load(media, { autoplay: true }, function(err, status) {
      console.log('media loaded playerState=%s', status.playerState);

    setTimeout(function() {
      console.log("STOPPING FILE")
      player.stop(function(err, status) {
          console.log("STOP ERROR: " + util.inspect(err))
          console.log("STOP STATUS: " + util.inspect(status))
      });
    }, 3000);
  });
}

function connectToDevice(host) {

  var client = new Client();

  client.connect(host, function() {
    console.log('connected, launching app ...');

    client.launch(DefaultMediaReceiver, function(err, player) {

      var mediaList = [
        {
          autoplay : true,
          preloadTime : 3,
          activeTrackIds : [],
          playbackDuration: 10,
          media: {
            contentId: "http://10.1.2.132:9500/example-slide-1.jpg",
            contentType: "image/jpeg",
            streamType: 'BUFFERED'
          }
        },
        {
          autoplay : true,
          preloadTime : 3,
          activeTrackIds : [],
          playbackDuration: 10,
          media: {
            contentId: "http://10.1.2.132:9500/example-slide-2.jpg",
            contentType: "image/jpeg",
            streamType: 'BUFFERED'
          }
        },
        {
          autoplay : true,
          preloadTime : 3,
          activeTrackIds : [],
          playbackDuration: 10,
          media: {
            contentId: "http://10.1.2.132:9500/example-slide-3.jpg",
            contentType: "image/jpeg",
            streamType: 'BUFFERED'
          }
        }
      ];

      console.log('app "%s" launched, loading medias...', player.session.displayName);

      player.on('status', function(status) {
        console.log('status broadcast = %s', util.inspect(status));
      });

      // loads multiple items
      // player.loadQueue(mediaList, {repeatMode: "REPEAT_ALL"}, function(err, status) {
      //   console.log("Load QUEUE: " + util.inspect(status));
      // })

      // Playing Single images every 10 seconds
      var i = 0;
      playFile(mediaList[i].media, player)
      setInterval(function() {
        i++;
        if(i == mediaList.length)
          i = 0;

        playFile(mediaList[i].media, player)
      }, 10000)

    });

  });

  client.on('error', function(err) {
    console.log('Error: %s', err.message);
    client.close();
  });

}

connectToDevice("10.1.2.69");

Code for loadQueue on Media Controller

MediaController.prototype.loadQueue = function(mediaList, options, callback) {
  var self = this;

  if(typeof options === 'function' || typeof options === 'undefined') {
    callback = options;
    options = {};
  }

  var data = { type: 'QUEUE_LOAD' };
  data.items = mediaList;
  data.repeatMode = (typeof options.repeatMode !== 'undefined') ? options.repeatMode : "REPEAT_OFF";

  self.request(data, function(err, response) {
    if(err) return callback(err);
    if(response.type === 'LOAD_FAILED') {
      return callback(new Error('Load failed'));
    }
    var status = response.status[0];
    callback(null, status);
  });

}

Code for Default-Media-Receiver for loadQueue

DefaultMediaReceiver.prototype.loadQueue = function(media, options, callback) {
  this.media.loadQueue.apply(this.media, arguments);
};

Thank you. I will try to run your code as soon as possible (could take a few days as I don't have as much free time as I'd like to !)

Any updates on this one? =)

I haven't had time to run it yet, and I won't be able to do it today either. I'll try to do it tomorrow. Don't hesitate to bump the issue if I don't give news.

So I tried to make it work this morning, but without any luck. I consistently get INVALID_MEDIA_SESSION_ID whatever I do on an image queue, and even after loading solo images (with a straight load).

I tried with a video queue and it works pretty well, though the preload time doesn't seem to be obeyed. I have been able to start one video, then a second after playbackDuration and making it start at startTime but even though I asked for a preloadTime of 15 seconds the preload happened only after the first video playbackDurationwas elapsed. But no INVALID_MEDIA_SESSION_ID with video queues.

I tried to mix video and images and had pretty strange results. I suspect something is broken on the Chromecast end.

I tried to implement QUEUE_NEXT too, and trigger it at regular intervals after QUEUE_LOAD with an image-only queue but same outcome : INVALID_MEDIA_SESSION_ID.

I don't know where to go next. A boring but probably effective solution would be to implement it with a regular sender using the SDK. Either it works and you can sniff the trafic and duplicate, or it doesn't and you can ask the SDK maintainers for help or file a bug.

Sorry for the response delay @jeffdagala. I'll try to help again if I can.

We had the same issues encountered. I also don't know what happened why I do consistently get INVALID_MEDIA_SESSION_ID even if I would get the status first before sending QUEUE_NEXT on the QUEUE_LOAD with an image only queues.

Hi guys, I ran into the same problem and was looking around for a solution. I saw the following here:

Note: The styled media receiver and default media receiver do not support a queue of
images; only a queue of audio or video streams is supported in the styled and default
receivers.

So I guess it won't be possible without a custom receiver... but anyway, have you come up with some solution?

Closing for inactivity.