diff --git a/src/webui/static/tv.css b/src/webui/static/tv.css
index b6b935ff..1dd44b4a 100644
--- a/src/webui/static/tv.css
+++ b/src/webui/static/tv.css
@@ -1,43 +1,113 @@
body {
+ font-size : 150%;
+ color : #b2afa8;
background: black;
- font-size: 150%;
}
-#tv_videoPlayer {
- z-index: 0;
- width:100%;
- margin-right:auto;
- margin-left:auto;
- display:block;
+.x-hide-display {
+ display: none !important;
}
-#tv_channelList {
- z-index: 1;
-}
-
-#tv_statusBar {
+.x-panel {
+ z-index : 1;
position: fixed;
- bottom: 5%;
+
+ padding: 5px;
+
border-radius: 10px;
- background: rgba(128, 128, 128, 0.7);
- width: 80%;
- left: 10%;
- z-index: 1;
+ border : double 5px #000000;
+
+ box-shadow: 10px 10px 15px #000000;
+
+ background:
+ radial-gradient(black 15%, transparent 16%) 0 0,
+ radial-gradient(black 15%, transparent 16%) 8px 8px,
+ radial-gradient(rgba(255,255,255,.1) 15%, transparent 20%) 0 1px,
+ radial-gradient(rgba(255,255,255,.1) 15%, transparent 20%) 8px 9px;
+
+ background-color: rgba(28,28,28,0.7);
+ background-size : 16px 16px;
}
-#tv_statusBar table {
- padding: 5px;
+.x-panel-header {
+ text-align: center;
+ border-bottom: solid 2px black;
+
+ margin-left : 10px;
+ margin-right: 10px;
+}
+
+.x-panel-header-text {
+ font-size : 150%;
+ font-weight : bold;
+}
+
+.tv-list {
+ overflow: hidden;
+ outline : 0;
+}
+
+.tv-list-item {
+ padding : 5px;
+ height : 64px;
+ font-size : 120%;
white-space: nowrap;
}
-#sb_channelLogo {
- width: 128px;
- height: 128px;
- float: left;
- margin: 5px;
- cursor: pointer;
+.tv-list-item img {
+ height : 100%;
+ width : 64px;
+ display: inline;
+
+ vertical-align: middle;
+
+ padding-left : 10px;
+ padding-right: 10px;
}
-#sb_channelName, #sb_nowTitle, #sb_nextTitle {
- width: 100%;
-}
\ No newline at end of file
+.tv-list-item-selected {
+ color : white;
+ border : solid 1px #4180eb;
+ border-radius: 10px;
+ background : linear-gradient(#4180eb 0%, #366dab 100%);
+}
+
+.tv-video-player {
+ z-index : 0;
+ position : fixed;
+ margin-right: auto;
+ margin-left : auto;
+
+ width : 100%;
+ height: 100%;
+}
+
+.tv-channel-list {
+ z-index : 1;
+ position: fixed;
+ top : 8%;
+ left : 5%;
+ height : 80%;
+}
+
+.tv-video-idle {
+ background-color : #000;
+ background-image : url(../docresources/tvheadendlogo.png);
+ background-repeat : no-repeat;
+ background-attachment: fixed;
+ background-position : center;
+}
+
+.tv-video-loading {
+ background-image : url(./img/spinner_black_bg.gif);
+ background-repeat : no-repeat;
+ background-attachment: fixed;
+ background-position : center;
+}
+
+.tv-video-error {
+ background-image : url(./img/error.png);
+ background-repeat : no-repeat;
+ background-attachment: fixed;
+ background-position : center;
+}
diff --git a/src/webui/static/tv.html b/src/webui/static/tv.html
index f8763eb4..a0f74c3f 100644
--- a/src/webui/static/tv.html
+++ b/src/webui/static/tv.html
@@ -2,33 +2,14 @@
Tvheadend
+
-
-
-
-

-
-
- |
- |
-
-
- |
- |
-
-
- |
- |
-
-
-
-
diff --git a/src/webui/static/tv.js b/src/webui/static/tv.js
index ffce15a0..57637d7d 100644
--- a/src/webui/static/tv.js
+++ b/src/webui/static/tv.js
@@ -1,4 +1,7 @@
+Ext.namespace('tv');
+Ext.namespace('tv.ui');
+
if (VK_LEFT === undefined)
var VK_LEFT = 0x25;
@@ -26,86 +29,23 @@ if (VK_STOP === undefined)
if (VK_BACK === undefined)
var VK_BACK = 0xa6;
-Ext.namespace('tv');
+if (VK_ESCAPE === undefined)
+ var VK_ESCAPE = 0x1b;
-tv.baseUrl = '../';
+if (VK_BACKSPACE === undefined)
+ var VK_BACKSPACE = 0x08;
-tv.channelTags = new Ext.data.JsonStore({
- autoLoad : true,
- root : 'entries',
- fields : [ 'identifier', 'name' ],
- id : 'identifier',
- sortInfo : {
- field : 'name',
- direction : "ASC"
- },
- url : tv.baseUrl + 'channeltags',
- baseParams : {
- op : 'listTags'
- }
-});
+if (VK_SPACE === undefined)
+ var VK_SPACE = 0x20;
-tv.channels = new Ext.data.JsonStore({
- autoLoad : true,
- root : 'entries',
- fields : ['ch_icon', 'number', 'name', 'chid', 'tags'],
- id : 'chid',
- sortInfo : {
- field : 'number',
- direction : "ASC"
- },
- url : tv.baseUrl + "channels",
- baseParams : {
- op : 'list'
- }
-});
+if (VK_PAGE_UP === undefined)
+ var VK_PAGE_UP = 0x21;
-tv.epg = new Ext.data.JsonStore({
- url : tv.baseUrl + "epg",
- bufferSize : 300,
- root : 'entries',
- fields : [ 'id' ,
- 'channel',
- 'channelid',
- 'title',
- 'subtitle',
- 'episode',
- 'description',
- 'chicon',
- {
- name : 'start',
- type : 'date',
- dateFormat : 'U' // unix time
- },
- {
- name : 'end',
- type : 'date',
- dateFormat : 'U' // unix time
- },
- 'duration',
- 'contenttype',
- 'schedstate',
- 'serieslink',
- ],
- baseParams : {
- limit : 2
- }
-});
+if (VK_PAGE_DOWN === undefined)
+ var VK_PAGE_DOWN = 0x22;
-tv.ui = function() {
- //private space
-
- // public space
- return {
- };
-
-}(); // end of ui
-
-
-
-tv.playback = function() {
- //private space
+tv.ui.VideoPlayer = Ext.extend(Ext.Panel, (function() {
var profiles = {
pass: {
@@ -158,34 +98,14 @@ tv.playback = function() {
}
};
- // public space
return {
- getProfile: function() {
- var vid = document.createElement('video');
+ constructor: function(config) {
+ this.params = {};
+ tv.ui.VideoPlayer.superclass.constructor.call(this, config);
- // chrome can handle h264+aac within mkv, given that h264 codecs are available
- if(Ext.isChrome &&
- vid.canPlayType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"') == 'probably')
- return profiles['mkv'];
-
- for (var key in profiles)
- if(vid.canPlayType(profiles[key].mimetype) == 'probably')
- return profiles[key];
-
- for (var key in profiles)
- if(vid.canPlayType(profiles[key].mimetype) == 'maybe')
- return profiles[key];
-
- return {};
- },
-
- getUrl: function(chid, config) {
- var config = config || {}
- var params = {}
-
- Ext.apply(params, this.getProfile(), {
+ Ext.applyIf(this.params, {
transcode : 0,
- resolution: 384,
+ resolution: 288,
channels : 0, // same as source
bandwidth : 0, // same as source
language : '', // same as source
@@ -195,15 +115,60 @@ tv.playback = function() {
muxer : '', // default dvr config
playlist : false // don't use m3u8 playlist
});
- Ext.apply(params, config);
-
- var url = tv.baseUrl;
+ },
+
+ initComponent: function() {
+ Ext.apply(this, {
+ baseCls : 'tv-video-player',
+ bodyStyle : 'background-color:#000;color:#fff',
+ html : '',
+ bufferLength: 3000, //ms
+
+ listeners: {
+ beforedestroy: {
+ fn: function(dv, items) {
+ this.video = null;
+ }
+ },
+ bodyresize: {
+ fn: function(panel, width, height) {
+ this.video.setSize(width, height);
+ }
+ },
+ render: {
+ fn: function() {
+ this.video = this.body.createChild({
+ tag : 'video',
+ width : '100%',
+ height : '100%',
+ html : "Your browser doesn't support html5 video"
+ });
+ this.source = this.video.createChild({tag: 'source'});
+ this.source.dom.addEventListener('error', this.error.bind(this));
+
+ this.stop();
+
+ var self = this;
+ this.video.dom.addEventListener('loadeddata', function() {
+ setTimeout(function() {
+ self.play();
+ }, self.bufferLength); //buffer 3000ms
+ });
+ }
+ }
+ }
+ });
+ tv.ui.VideoPlayer.superclass.initComponent.apply(this, arguments);
+ },
+
+ _getUrl: function(chid, params) {
+ var url = '../';
if(params.playlist)
url += 'playlist/channelid/'
else
url += 'stream/channelid/'
-
+
url += chid;
url += "?transcode=" + new Number(params.transcode);
url += "&mux=" + params.muxer;
@@ -213,126 +178,292 @@ tv.playback = function() {
url += "&resolution=" + params.resolution;
url += "&bandwidth=" + params.bandwidth;
url += "&language=" + params.language;
-
+
return url;
+ },
+
+ _getProfile: function() {
+ var el = this.video.dom;
+
+ // chrome can handle h264+aac within mkv, given that h264 codecs are available
+ if(Ext.isChrome &&
+ el.canPlayType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"') == 'probably')
+ return profiles['mkv'];
+
+ for (var key in profiles)
+ if(el.canPlayType(profiles[key].mimetype) == 'probably')
+ return profiles[key];
+
+ for (var key in profiles)
+ if(el.canPlayType(profiles[key].mimetype) == 'maybe')
+ return profiles[key];
+
+ return {};
+ },
+
+ error: function() {
+ var url = this.source.dom.src;
+ if(url && url != document.location.href) {
+ this.body.removeClass('tv-video-loading');
+ this.body.removeClass('tv-video-idle');
+ this.body.addClass('tv-video-error');
+ this.video.hide();
+ }
+ },
+
+ stop: function() {
+ this.body.removeClass('tv-video-loading');
+ this.body.removeClass('tv-video-error');
+ this.body.addClass('tv-video-idle');
+ this.source.dom.src = '';
+ this.video.dom.load();
+ },
+
+ play: function() {
+ this.body.removeClass('tv-video-loading');
+ this.body.removeClass('tv-video-idle');
+ this.body.removeClass('tv-video-error');
+
+ this.video.show();
+ this.video.dom.play();
+ },
+
+ zapTo: function(chid, config) {
+ var config = config || {}
+ var params = {}
+
+ Ext.apply(params, this._getProfile(), this.params);
+ Ext.apply(params, config);
+
+ this.video.hide();
+ this.stop();
+
+ this.body.removeClass('tv-video-idle');
+ this.body.removeClass('tv-video-error');
+ this.body.addClass('tv-video-loading');
+
+ this.source.dom.src = this._getUrl(chid, params);
+ this.video.dom.load();
}
};
+}()));
-}(); // end of player
-tv.app = function() {
- //private space
+tv.ui.ChannelList = Ext.extend(Ext.DataView, {
- var updateClock = new Ext.util.DelayedTask(function() {
- var clock = Ext.get('sb_currentTime');
- clock.update(new Date().toLocaleTimeString());
- updateClock.delay(1000);
- });
+ initComponent: function() {
+ Ext.apply(this, {
+ cls: 'tv-list',
+ overClass: 'tv-list-item-over',
+ selectedClass: 'tv-list-item-selected',
+ itemSelector:'div.tv-list-item',
+ singleSelect: true,
+ tpl: new Ext.XTemplate(
+ '',
+ '',
+ '

{name}',
+ '
',
+ ''),
- var currentChannel = -1;
- var initKeyboard = function() {
+ listeners: {
+ selectionchange: {
+ fn: function(dv, items) {
+ if(items.length == 0)
+ return;
- var channelLogoEl = document.getElementById('sb_channelLogo');
- channelLogoEl.onclick = function() {
- currentChannel += 1;
- ch = tv.channels.getAt(currentChannel);
- onChannelZap(ch.data);
- }
+ var node = this.getNode(items[0]);
+ node = Ext.get(node);
+ node.scrollIntoView(this.el);
+ }
+ },
+ dblclick: {
+ fn: function() {
+ this.fireEvent('naventer');
+ }
+ }
+ }
+ });
- document.onkeyup = function(e) {
- switch(e.keyCode) {
+ this.addEvents(
+ 'navup',
+ 'navdown',
+ 'navleft',
+ 'navright',
+ 'navback',
+ 'naventer'
+ );
+ tv.ui.ChannelList.superclass.initComponent.apply(this, arguments);
+ },
+
+ visibleItems: function() {
+ var nodes = this.getNodes(0, 0);
+ if(nodes.length == 0)
+ return 0;
+
+ var height = this.getTemplateTarget().getHeight();
+ var itemHeight = Ext.get(nodes[0]).getHeight()
+ return Math.floor(height / itemHeight);
+ },
+
+ onRender : function(ct, position) {
+ tv.ui.ChannelList.superclass.onRender.call(this, ct, position);
+ Ext.dd.ScrollManager.register(this.el);
+
+ this.getTemplateTarget().set({tabindex: Ext.id(undefined, '0')});
+ this.getTemplateTarget().on('keydown', function(e) {
+ switch(e.getKey()) {
+
+ case VK_LEFT:
+ this.fireEvent('navleft');
+ break;
+
+ case VK_RIGHT:
+ this.fireEvent('navright');
+ break;
case VK_UP:
- var max = tv.channels.getTotalCount() - 1;
- if(max < 0)
- return;
-
- else if(currentChannel < max)
- currentChannel += 1;
-
- else
- currentChannel = max;
-
- ch = tv.channels.getAt(currentChannel);
- onChannelZap(ch.data);
+ this.fireEvent('navup', 1);
break;
case VK_DOWN:
- if(tv.channels.getTotalCount() == 0)
- return;
-
- else if(currentChannel < 1)
- currentChannel = 0;
-
- else
- currentChannel -= 1;
-
- ch = tv.channels.getAt(currentChannel);
- onChannelZap(ch.data);
+ this.fireEvent('navdown', 1);
break;
+
+ case VK_PAGE_UP:
+ var cnt = this.visibleItems();
+ this.fireEvent('navup', cnt);
+ break;
+
+ case VK_PAGE_DOWN:
+ var cnt = this.visibleItems();
+ this.fireEvent('navdown', cnt);
+ break;
+
+ case VK_SPACE:
+ case VK_ENTER:
+ this.fireEvent('naventer');
+ break;
+
+ case VK_BACKSPACE:
+ case VK_ESCAPE:
+ case VK_BACK:
+ this.fireEvent('navback');
+ break;
+
+ default:
+ return false;
}
- };
- };
- var onChannelZap = function(ch) {
- var channelNameEl = document.getElementById('sb_channelName');
- channelNameEl.innerHTML = ch.name;
+ e.stopEvent();
+ return true;
- var channelLogoEl = document.getElementById('sb_channelLogo');
- if(ch.ch_icon) {
- channelLogoEl.src = ch.ch_icon;
- channelLogoEl.style = '';
- } else {
- channelLogoEl.src = '';
- channelLogoEl.style = 'display: none';
- }
-
- tv.epg.setBaseParam('channel', new String(ch.name));
- tv.epg.load();
-
- var videoPlayer = document.getElementById('tv_videoPlayer');
-
- setTimeout(function() {
- videoPlayer.src = tv.playback.getUrl(ch.chid);
- videoPlayer.load();
- videoPlayer.play();
- }, 1);
+ }.bind(this));
}
+});
- var initDataStores = function() {
- tv.epg.on('load', function() {
- var nowTitle = document.getElementById('sb_nowTitle');
- var nowDuration = document.getElementById('sb_nowDuration');
- var nextTitle = document.getElementById('sb_nextTitle');
- var nextDuration = document.getElementById('sb_nextDuration');
- var event = undefined;
-
- if(tv.epg.getTotalCount() < 1) {
- nowTitle.innerHTML = '';
- nowDuration.innerHTML = '';
- } else {
- event = tv.epg.getAt(0).data;
- nowTitle.innerHTML = event.title;
- nowDuration.innerHTML = event.start.toLocaleTimeString();
- }
-
- if(tv.epg.getTotalCount() < 2) {
- nextTitle.innerHTML = '';
- nextDuration.innerHTML = '';
- } else {
- event = tv.epg.getAt(1).data;
- nextTitle.innerHTML = event.title;
- nextDuration.innerHTML = event.start.toLocaleTimeString();
- }
- });
- };
- // public space
+tv.app = function() {
return {
init: function() {
- initDataStores();
- initKeyboard();
- updateClock.delay(1);
+
+ var videoPlayer = new tv.ui.VideoPlayer({
+ params: {
+ resolution: 384
+ },
+ renderTo: Ext.getBody()
+ });
+
+ var chList = new tv.ui.ChannelList({
+ store: new Ext.data.JsonStore({
+ autoLoad : true,
+ root : 'entries',
+ fields : ['ch_icon', 'number', 'name', 'chid'],
+ id : 'chid',
+ sortInfo : {
+ field : 'number',
+ direction : "ASC"
+ },
+ url : "../channels",
+ baseParams : {
+ op : 'list'
+ }
+ })
+ });
+
+ var chListPanel = new Ext.Panel({
+ title:'Channels',
+ items: chList,
+ cls: 'tv-channel-list',
+ renderTo: Ext.getBody()
+ });
+
+ window.onresize = function() {
+ var h = chListPanel.el.getHeight();
+ h -= chListPanel.header.getHeight();
+ h -= 25;
+
+ chList.setHeight(h);
+ };
+
+ chListPanel.on('show', function() {
+ window.onresize();
+ });
+
+ chList.on('navback', function() {
+ chListPanel.hide();
+ chList.blur();
+ });
+
+ chList.on('naventer', function() {
+ var indices = this.getSelectedIndexes();
+ if(indices.length == 0)
+ return;
+
+ var item = this.store.getAt(indices[0]);
+
+ videoPlayer.zapTo(item.data.chid);
+ chListPanel.hide();
+ chList.blur();
+ });
+
+ chList.on('navup', function(cnt) {
+ var indices = chList.getSelectedIndexes();
+ if(indices.length == 0)
+ this.select(this.store.getTotalCount() - 1);
+ else if(indices[0] - cnt >= 0)
+ this.select(indices[0] - cnt);
+ else
+ this.select(0);
+ });
+
+ chList.on('navdown', function(cnt) {
+ var indices = chList.getSelectedIndexes();
+ if(indices.length == 0)
+ this.select(0);
+ else if(indices[0] + cnt < this.store.getTotalCount())
+ this.select(indices[0] + cnt);
+ else
+ this.select(this.store.getTotalCount() - 1);
+ });
+
+ chList.on('navleft', function() {
+
+ });
+
+ chList.on('navright', function() {
+
+ });
+
+ var nav = new Ext.KeyNav(Ext.getDoc(), {
+ 'enter': function(e) {
+ chListPanel.show();
+ chList.focus();
+ },
+ 'scope': this
+ });
+
+ chListPanel.show();
+ chList.focus();
}
};
}(); // end of app