From 824b0c0ca6315a527ecb31dc0b06396518a02d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20T=C3=B6rblom?= Date: Sun, 21 Jul 2013 14:58:32 +0200 Subject: [PATCH] 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. --- src/webui/static/tv.css | 126 +++++++--- src/webui/static/tv.html | 21 +- src/webui/static/tv.js | 531 ++++++++++++++++++++++++--------------- 3 files changed, 430 insertions(+), 248 deletions(-) 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