transcoding: redesigned the 10-foot javascript client.

navigation is done using a keyboard (no mouse scrolling or touch events
are supported). double clicking a channel (or pressing the enter key
while being selected) will tune to a channel. the channel list can be
hidden by pressing esc or backspace, and brought back by pressing enter.
page-up and page-down are also supported.
This commit is contained in:
John Törblom 2013-07-21 14:58:32 +02:00
parent 9259102090
commit 824b0c0ca6
3 changed files with 430 additions and 248 deletions

View file

@ -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%;
}
.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;
}

View file

@ -2,33 +2,14 @@
<html>
<head>
<title>Tvheadend</title>
<link rel="stylesheet" type="text/css" href="tv.css">
<script type="text/javascript" src="extjs/adapter/ext/ext-base.js"></script>
<script type="text/javascript" src="extjs/ext-all.js"></script>
<script type="text/javascript" src="tv.js"></script>
<script type="text/javascript">
Ext.onReady(tv.app.init, tv.app);
</script>
<link rel="stylesheet" type="text/css" href="tv.css">
</head>
<body>
<div id="tv_statusBar">
<img id="sb_channelLogo" src="htslogo.png">
<table>
<tr>
<td id="sb_channelName"></td>
<td id="sb_currentTime"></td>
</tr>
<tr>
<td id="sb_nowTitle"></td>
<td id="sb_nowDuration"></td>
</tr>
<tr>
<td id="sb_nextTitle"></td>
<td id="sb_nextDuration"></td>
</tr>
</table>
</div>
<video id="tv_videoPlayer"></video>
</body>
</html>

View file

@ -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(
'<tpl for=".">',
'<div class="tv-list-item" id="chid_{chid}">',
'<img src="{ch_icon}" title="{name}">{name}',
'</div>',
'</tpl>'),
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