]> git.ipfire.org Git - thirdparty/tvheadend.git/commitdiff
ui: Add category/genre/new icons and more details (#4594).
authorE.Smith <31170571+azlm8t@users.noreply.github.com>
Sat, 18 Nov 2017 17:14:52 +0000 (17:14 +0000)
committerJaroslav Kysela <perex@perex.cz>
Thu, 23 Nov 2017 08:00:38 +0000 (09:00 +0100)
We now display a separate column indicating if a programme is
new. This column also contains icons indicating the programme type
based on the category (from xmltv) or genre (from OTA).

This uses characters rather than icons since most modern fonts
supply glyphs that we can use. We avoid using too-modern glyphs
(such as Unicode-9.0, 2016) and try to use older ones that are
likely to be in Windows 7 or later. Unfortunately there is no
easy way to determine if a particular system contains a glyph,
but most systems revert to another font if necessary.

Also added a number of new details to the dialogs for EPG and DVR
to display previously shown time and cast/credit/keyword details
for DVR upcoming.

Additionally we display "Film (yyyy)" instead of "Film" if we
have the details to help the user differentiate remakes.

Issue: #4594.

src/webui/static/app/dvr.js
src/webui/static/app/epg.js
src/webui/static/app/ext.css
src/webui/static/app/tvheadend.js

index f3e999e19556b6bd2fe0a44bbe0bbb437df84929..2bacdb20f73e3795baa8085a526ecfb14432eb2e 100644 (file)
@@ -24,6 +24,12 @@ tvheadend.dvrDetails = function(uuid) {
         var autorec_caption = params[12].value;
         var timerec_caption = params[13].value;
         var image = params[14].value;
+        var copyright_year = params[15].value;
+        var credits = params[16].value;
+        var keyword = params[17].value;
+        var category = params[18].value;
+        var first_aired = params[19].value;
+        var genre = params[20].value;
         var content = '';
         var but;
 
@@ -39,8 +45,14 @@ tvheadend.dvrDetails = function(uuid) {
         if (duplicate)
             content += '<div class="x-epg-meta"><font color="red"><span class="x-epg-prefix">' + _('Will be skipped') + '<br>' + _('because it is a rerun of:') + '</span>' + tvheadend.niceDate(duplicate * 1000) + '</font></div>';
 
+        var icons = tvheadend.getContentTypeIcons({"category" : category, "genre" : genre});
+        if (icons)
+            content += '<div class="x-epg-icons">' + icons + '</div>';
+        var displayTitle = title;
+        if (copyright_year)
+            displayTitle += "&nbsp;(" + copyright_year + ")";
         if (title)
-            content += '<div class="x-epg-title">' + title + '</div>';
+            content += '<div class="x-epg-title">' + displayTitle + '</div>';
         if (subtitle)
             content += '<div class="x-epg-title">' + subtitle + '</div>';
         if (episode)
@@ -49,6 +61,9 @@ tvheadend.dvrDetails = function(uuid) {
             content += '<div class="x-epg-time"><span class="x-epg-prefix">' + _('Scheduled Start Time') + ':</span><span class="x-epg-body">' + tvheadend.niceDate(start_real * 1000) + '</span></div>';
         if (stop_real)
             content += '<div class="x-epg-time"><span class="x-epg-prefix">' + _('Scheduled Stop Time') + ':</span><span class="x-epg-body">' + tvheadend.niceDate(stop_real * 1000) + '</span></div>';
+        /* We have to *1000 here (and not in epg.js) since Date requires ms and epgStore has it already converted */
+        if (first_aired)
+            content += '<div class="x-epg-time"><span class="x-epg-prefix">' + _('First Aired') + ':</span><span class="x-epg-body">' + tvheadend.niceDateYearMonth(first_aired * 1000, start_real * 1000) + '</span></div>';
         if (duration)
             content += '<div class="x-epg-time"><span class="x-epg-prefix">' + _('Duration') + ':</span><span class="x-epg-body">' + parseInt(duration / 60) + ' ' + _('min') + '</span></div>';
         if (chicon) {
@@ -64,6 +79,11 @@ tvheadend.dvrDetails = function(uuid) {
             content += '<div class="x-epg-desc">' + desc + '</div>';
             content += '<hr class="x-epg-hr"/>';
         }
+        content += tvheadend.getDisplayCredits(credits);
+        if (keyword)
+          content += tvheadend.sortAndAddArray(keyword, _('Keywords'));
+        if (category)
+          content += tvheadend.sortAndAddArray(category, _('Categories'));
         if (status)
             content += '<div class="x-epg-meta"><span class="x-epg-prefix">' + _('Status') + ':</span><span class="x-epg-body">' + status + '</span></div>';
         if (filesize)
@@ -123,7 +143,8 @@ tvheadend.dvrDetails = function(uuid) {
             uuid: uuid,
             list: 'channel_icon,disp_title,disp_subtitle,episode,start_real,stop_real,' +
                   'duration,disp_description,status,filesize,comment,duplicate,' +
-                  'autorec_caption,timerec_caption,image'
+                  'autorec_caption,timerec_caption,image,copyright_year,credits,keyword,category,' +
+                  'first_aired,genre',
         },
         success: function(d) {
             d = json_decode(d);
@@ -200,21 +221,44 @@ tvheadend.filesizeRenderer = function(st) {
     }
 }
 
+
+tvheadend.displayDuplicate = function(value, meta, record) {
+  if (value == null)
+    return '';
+  var is_dup = record.data['duplicate'];
+  if (is_dup)
+    return "<span class='x-epg-duplicate'>" + value + "</span>";
+  else
+    return value;
+}
+
 /** Render an entry differently if it is a duplicate */
 tvheadend.displayWithDuplicateRenderer = function(value, meta, record) {
     return function() {
         return function(value, meta, record) {
-            if (value == null)
-                return '';
-            var is_dup = record.data['duplicate'];
-            if (is_dup)
-                return "<span class='x-epg-duplicate'>" + value + "</span>";
-            else
-                return value;
+          return tvheadend.displayDuplicate(value, meta, record);
         }
     }
 }
 
+tvheadend.displayWithYearAndDuplicateRenderer = function(value, meta, record) {
+  return function() {
+    return function(value, meta, record) {
+      value = tvheadend.getDisplayTitle(value, record);
+      return tvheadend.displayDuplicate(value, meta, record);
+    }
+  }
+}
+
+tvheadend.displayWithYearRenderer = function(value, meta, record) {
+  return function() {
+    return function(value, meta, record) {
+      value = tvheadend.getDisplayTitle(value, record);
+      return value;
+    }
+  }
+}
+
 /**
  *
  */
@@ -321,19 +365,34 @@ tvheadend.dvr_upcoming = function(panel, index) {
             }
         },
         del: true,
-        list: 'enabled,duplicate,disp_title,disp_subtitle,episode,channel,' +
+        list: 'category,enabled,duplicate,disp_title,disp_subtitle,episode,channel,' +
               'image,' +
+              'copyright_year,' +
               'start_real,stop_real,duration,pri,filesize,' +
-              'sched_status,errors,data_errors,config_name,owner,creator,comment',
+              'sched_status,errors,data_errors,config_name,owner,creator,comment,genre',
         columns: {
             disp_title: {
-              renderer: tvheadend.displayWithDuplicateRenderer()
+              renderer: tvheadend.displayWithYearAndDuplicateRenderer()
             },
             disp_subtitle: {
               renderer: tvheadend.displayWithDuplicateRenderer()
             },
             filesize: {
                 renderer: tvheadend.filesizeRenderer()
+            },
+            genre : {
+                renderer: function(vals, meta, record) {
+                    return function(vals, meta, record) {
+                      var r = [];
+                      Ext.each(vals, function(v) {
+                        v = tvheadend.contentGroupFullLookupName(v);
+                        if (v)
+                          r.push(v);
+                      });
+                      if (r.length < 1) return "";
+                      return r.join(',');
+                  }
+                }
             }
         },
         sort: {
@@ -341,7 +400,10 @@ tvheadend.dvr_upcoming = function(panel, index) {
           direction: 'ASC'
         },
         plugins: [actions],
-        lcol: [actions],
+        lcol: [
+            actions,
+            tvheadend.contentTypeAction,
+        ],
         tbar: [stopButton, abortButton],
         selected: selected,
         beforeedit: beforeedit
@@ -485,8 +547,12 @@ tvheadend.dvr_finished = function(panel, index) {
         del: false,
         list: 'disp_title,disp_subtitle,episode,channelname,' +
               'start_real,stop_real,duration,filesize,' +
-              'sched_status,errors,data_errors,playcount,url,config_name,owner,creator,comment',
+              'copyright_year,' +
+              'sched_status,errors,data_errors,playcount,url,config_name,owner,creator,comment,',
         columns: {
+            disp_title: {
+              renderer: tvheadend.displayWithYearRenderer(),
+            },
             filesize: {
                 renderer: tvheadend.filesizeRenderer()
             }
@@ -621,9 +687,14 @@ tvheadend.dvr_failed = function(panel, index) {
         delquestion: _('Do you really want to delete the selected recordings?') + '<br/><br/>' +
                      _('The associated file will be removed from storage.'),
         list: 'disp_title,disp_subtitle,episode,channelname,' +
+              'image,' +
+              'copyright_year,' +
               'start_real,stop_real,duration,filesize,status,' +
               'sched_status,errors,data_errors,playcount,url,config_name,owner,creator,comment',
         columns: {
+            disp_title: {
+              renderer: tvheadend.displayWithYearRenderer(),
+            },
             filesize: {
                 renderer: tvheadend.filesizeRenderer()
             }
@@ -706,8 +777,15 @@ tvheadend.dvr_removed = function(panel, index) {
         edit: { params: { list: tvheadend.admin ? "retention,owner,comment" : "retention,comment" } },
         del: true,
         list: 'disp_title,disp_subtitle,episode,channelname,' +
+              'image,' +
+              'copyright_year,' +
               'start_real,stop_real,duration,status,' +
               'sched_status,errors,data_errors,url,config_name,owner,creator,comment',
+        columns: {
+            disp_title: {
+              renderer: tvheadend.displayWithYearRenderer(),
+            },
+        },
         sort: {
           field: 'start_real',
           direction: 'ASC'
index 5b916a1accbf30d51017aa7053bd09c4df1922f9..5b39ba9f23e5e60e398c1b622651b6ffdacb89f3 100644 (file)
@@ -103,11 +103,14 @@ tvheadend.epgDetails = function(event) {
 
     if (chicon)
         content += '<div class="x-epg-left">';
+    var icons = tvheadend.getContentTypeIcons(event);
+    if (icons)
+        content += '<div class="x-epg-icons">' + icons + '</div>';
     content += '<div class="x-epg-title">' + event.title;
     if (event.subtitle)
         content += "&nbsp;:&nbsp;" + event.subtitle;
-    if (event.copyrightYear)
-        content += "&nbsp;(" + event.copyrightYear + ")";
+    if (event.copyright_year)
+        content += "&nbsp;(" + event.copyright_year + ")";
     content += '</div>';
     if (event.episodeOnscreen)
         content += '<div class="x-epg-title">' + event.episodeOnscreen + '</div>';
@@ -115,6 +118,8 @@ tvheadend.epgDetails = function(event) {
       content += '<div class="x-epg-time"><span class="x-epg-prefix">' + _('Start Time') + ':</span><span class="x-epg-body">' + tvheadend.niceDate(event.start) + '</span></div>';
     if (event.stop)
       content += '<div class="x-epg-time"><span class="x-epg-prefix">' + _('End Time') + ':</span><span class="x-epg-body">' + tvheadend.niceDate(event.stop) + '</span></div>';
+    if (event.first_aired)
+      content += '<div class="x-epg-time"><span class="x-epg-prefix">' + _('First Aired') + ':</span><span class="x-epg-body">' + tvheadend.niceDateYearMonth(event.first_aired, event.start) + '</span></div>';
     if (duration)
       content += '<div class="x-epg-time"><span class="x-epg-prefix">' + _('Duration') + ':</span><span class="x-epg-body">' + parseInt(duration / 60) + ' ' + _('min') + '</span></div>';
     if (chicon) {
@@ -131,58 +136,15 @@ tvheadend.epgDetails = function(event) {
       content += '<div class="x-epg-desc">' + event.description + '</div>';
     if (event.summary || event.description)
       content += '<hr class="x-epg-hr"/>';
-
-    // Helper function for common code to sort an array, convert to CSV and
-    // return the string to add to the content.
-    function sortAndAddArray(arr, title) {
-      arr.sort();
-      var csv = arr.join(", ");
-      if (csv)
-        return '<div class="x-epg-meta"><span class="x-epg-prefix">' + title + ':</span><span class="x-epg-body">' + csv + '</span></div>';
-      else
-        return '';
-    }
-
-    if (event.credits) {
-      // Our cast (credits) map contains details of actors, writers,
-      // etc. so split in to separate categories for displaying.
-      var castArr = [];
-      var crewArr = [];
-      var directorArr = [];
-      var writerArr = [];
-      var cast = ["actor", "guest", "presenter"];
-      // We use arrays here in case more tags in the future map on to
-      // director/writer, e.g., SchedulesDirect breaks it down in to
-      // writer, writer (adaptation) writer (screenplay), etc. but
-      // currently we just have them all as writer.
-      var director = ["director"];
-      var writer = ["writer"];
-
-      for (key in event.credits) {
-        var type = event.credits[key];
-        if (cast.indexOf(type) != -1)
-          castArr.push(key);
-        else if (director.indexOf(type) != -1)
-          directorArr.push(key);
-        else if (writer.indexOf(type) != -1)
-          writerArr.push(key);
-        else
-          crewArr.push(key);
-      };
-
-      content += sortAndAddArray(castArr, _('Starring'));
-      content += sortAndAddArray(directorArr, _('Director'));
-      content += sortAndAddArray(writerArr, _('Writer'));
-      content += sortAndAddArray(crewArr, _('Crew'));
-    }
+    content += tvheadend.getDisplayCredits(event.credits);
     if (event.keyword)
-      content += sortAndAddArray(event.keyword, _('Keywords'));
+      content += tvheadend.sortAndAddArray(event.keyword, _('Keywords'));
     if (event.category)
-      content += sortAndAddArray(event.category, _('Categories'));
+      content += tvheadend.sortAndAddArray(event.category, _('Categories'));
     if (event.starRating)
-      content += '<div class="x-epg-meta"><span class="x-epg-prefix">' + _('Star Rating') + ':</span><span class="x-epg-body">' + event.starRating + '</span></div>';
+      content += '<div class="x-epg-meta"><span class="x-epg-prefix">' + _('Star Rating') + ':</span><span class="x-epg-desc">' + event.starRating + '</span></div>';
     if (event.ageRating)
-      content += '<div class="x-epg-meta"><span class="x-epg-prefix">' + _('Age Rating') + ':</span><span class="x-epg-body">' + event.ageRating + '</span></div>';
+      content += '<div class="x-epg-meta"><span class="x-epg-prefix">' + _('Age Rating') + ':</span><span class="x-epg-desc">' + event.ageRating + '</span></div>';
     if (event.genre) {
       var genre = [];
       Ext.each(event.genre, function(g) {
@@ -488,12 +450,18 @@ tvheadend.epg = function() {
                 type: 'date',
                 dateFormat: 'U' /* unix time */
             },
+            {
+                name: 'first_aired',
+                type: 'date',
+                dateFormat: 'U' /* unix time */
+            },
             { name: 'starRating' },
             { name: 'credits' },
             { name: 'category' },
             { name: 'keyword' },
             { name: 'ageRating' },
-            { name: 'copyrightYear' },
+            { name: 'copyright_year' },
+            { name: 'new' },
             { name: 'genre' },
             { name: 'dvrUuid' },
             { name: 'dvrState' },
@@ -590,6 +558,7 @@ tvheadend.epg = function() {
                         return "";
                 }
             }),
+            tvheadend.contentTypeAction,
             {
                 width: 250,
                 id: 'title',
@@ -600,6 +569,7 @@ tvheadend.epg = function() {
                     var clickable = tvheadend.regexEscape(record.data['title']) !=
                                     epgStore.baseParams.title;
                     setMetaAttr(meta, record, value && clickable);
+                    value = tvheadend.getDisplayTitle(value, record);
                     return !value ? '' : (clickable ? lookup : '') + value;
                 },
                 listeners: { click: { fn: clicked } }
index 15ed817b3fd6e5e254497cd553357f69b41d25da..bcc40cb25d92691f57f7e73c0f29cf60819aacef 100644 (file)
     margin: 0;
 }
 
+.x-epg-icons {
+    float: left;
+    font-size: 20px;
+    margin-left: 0.5em;
+    letter-spacing: 0.2em;
+}
+
 .x-epg-title {
     margin: 5px;
     padding: 5px;
index 3c63cbae97deb434662a9571c15cc21615ff9f58..4691c31e1453e791f6e0f305dc90495257b69fab 100644 (file)
@@ -43,6 +43,264 @@ tvheadend.fromCSV = function(s) {
     return r;
 }
 
+// We have "major" and "minor" mappings since we want things like
+// "Movie" to be preferred to minor elements such as "Comedy" so we
+// always end up displaying "Movie-Comedy" rather than having "Movie"
+// sometimes hidden in middle of other icons.
+//
+// Although we can insert the characters here, we use the hex
+// values because editors don't always work well with these
+// characters.
+//
+// The comments refer to the official unicode name
+//
+// These categories should _not_ be subject to internationalization
+// since many non-English xmltv providers appear to supply English
+// words for categories, presumably for compatibility with
+// mapping to a genre.
+var catmap_major = {
+  "movie" : "&#x1f39e;",        // Film frames
+  "news" : "&#x1f4f0;",         // Newspaper
+  "series" : "&#x1f4fa;",       // Television
+  "sports" : "&#x1f3c5;",       // Sports medal
+};
+
+var catmap_minor = {
+  // These are taken from the frequent categories in SD and then
+  // sorted by name. They display reasonably well on a modern
+  // font.
+  "action" : "&#x1f3f9;",          // Bow and Arrow
+  "adults only" : "&#x1f51e;",     // No one under eighteen symbol
+  "adventure" : "&#x1f3f9;",       // Bow and Arrow
+  "animals" : "&#x1f43e;",         // Paw prints
+  "animated" : "&#x270f;&#xFE0F;", // Pencil
+  "art" : "&#x1f3a8;",             // Artist pallette
+  "auction" : "&#x1f4b8",          // Money with wings
+  "auto racing" : "&#x1f3ce;",     // Racing car
+  "auto" : "&#x1f3ce;",            // Racing car
+  "baseball" : "&#x26BE;",         // Baseball
+  "basketball" : "&#1f3c0;",       // Basketball and hoop
+  "boxing" : "&#x1f94a;",          // Boxing glove
+  "bus./financial" : "&#x1f4c8;",  // Chart with upwards trend
+  "children" : "&#x1f476;",        // Baby
+  "comedy" : "&#x1f600;",          // Grinning face
+  "computers" : "&#x1f4bb;",       // Personal computer
+  "community" : "&#x1f46a;",       // Family
+  "cooking" : "&#x1f52a;",         // Cooking knife
+  "crime drama" : "&#x1f46e;",     // Police officer
+  "dance" : "&#x1f483;",           // Dancer
+  "educational" : "&#x1f393;",     // Graduation cap
+  "fantasy" : "&#x1f984;",         // Unicorn face
+  "fashion" : "&#x1f460;",         // High heeled shoe
+  "figure skating" : "&#x26F8;",   // Ice skate
+  "fishing" : "&#x1f3a3;",         // Fishing pole and fish
+  "football" : "&#x1f3c8;",        // American Football (not soccer)
+  "game show" : "&#x1f3b2;",       // Game die
+  "gymnastics" : "&#x1f938",       // Person doing cartwheel
+  "history" : "&#x1f3f0;",         // Castle
+  "holiday" : "&#x1f6eb;",         // Airplane departure
+  "horror" : "&#x1f480;",          // Skull
+  "horse" : "&#x1f434;",           // Horse face
+  "house/garden" : "&#x1f3e1;",    // House with garden
+  "interview" : "&#x1f4ac;",       // Speech balloon
+  "law" : "&#x1f46e;",             // Police officer
+  "martial arts" : "&#x1f94b;",    // Martial arts uniform
+  "medical" : "&#x1f691;",         // Ambulance
+  "military" : "&#x1f396;",        // Military medal
+  "miniseries" : "&#x1f517;",      // Link symbol
+  "motorcycle" : "&#x1f3cd;",      // Racing motorcycle
+  "music" : "&#x1f3b5;",           // Musical note
+  "musical" : "&#x1f3b5;",         // Musical note
+  "mystery" : "&#x1f50d",          // Left pointing magnifying glass
+  "nature" : "&#x1f418;",          // Elephant
+  "paranormal" : "&#x1f47b;",      // Ghost
+  "poker" : "&#x1f0b1;",           // Playing card ace of hearts
+  "politics" : "&#x1f5f3;",        // Ballot box with ballot
+  "pro wrestling" : "&#x1f93c;",   // Wrestlers
+  "reality" : "&#x1f4f8;",         // Camera with flash
+  "religious" : "&#x1f6d0;",       // Place of worship
+  "romance" : "&#x2764;&#xfe0f;",  // Red Heart
+  "romantic comedy" : "&#x2764;&#xfe0f;", // Red Heart
+  "science fiction" : "&#x1f47d;", // Extraterrestrial alien
+  "science" : "&#x1f52c;",         // Microscope
+  "shopping" : "&#x1f6cd;",        // Shopping bags
+  "sitcom": "&#x1f600;",           // Grinning face
+  "skiing" : "&#x26f7;",           // Skier
+  "soap" : "&#x1f754;",            // Alchemical symbol for soap
+  "soccer" : "&#x26BD;",           // Soccer ball
+  "sports talk" : "&#x1f4ac;",     // Speech balloon
+  "spy": "&#x1f575;",              // Spy
+  "standup" : "&#x1f3a4;",         // Microphone
+  "swimming" : "&#x1f3ca;",        // Swimmer
+  "talk" : "&#x1f4ac;",            // Speech balloon
+  "technology" : "&#x1f4bb;",      // Personal computer
+  "tennis" : "&#x1f3be;",          // Tennis racquet and ball
+  "theater" : "&#1f3ad;",          // Performing arts
+  "travel" : "&#x1f6eb;",          // Airplane departure
+  "war" : "&#x1f396;",             // Military medal
+  "weather" : "&#x26c5;",          // Sun behind cloud
+  "weightlifting" : "&#x1f3cb;",   // Person lifting weights
+  "western" : "&#x1f335;",         // Cactus
+};
+
+//  These are mappings for OTA genres
+var genre_major = {
+  // And genre major-numbers in hex
+  "10" : "&#x1f4fa;",           // Television: can't distinguish movie / tv
+  "20" : "&#x1f4f0;",           // Newspaper
+  "30" : "&#x1f3b2;",           // Game die
+  "40" : "&#x1f3c5;",           // Sports medal
+  "50" : "&#x1f476;",           // Baby
+  "60" : "&#x1f3b5;",           // Musical note
+  "70" : "&#x1f3ad;",           // Performing arts
+  "80" : "&#x1f5f3;",           // Ballot box with ballot
+  "90" : "&#x1f393;",           // Graduation cap
+  "a0" : "&#x26fa;",            // Tent
+};
+
+var genre_minor = {
+  "11" : "&#x1f575;",           // Spy
+  "12" : "&#x1f3f9;",           // Bow and Arrow
+  "13" : "&#x1f47d;",           // Extraterrestrial alien
+  "14" : "&#x1f600;",           // Grinning face
+  "15" : "&#x1f754;",           // Alchemical symbol for soap
+  "16" : "&#x2764;&#xfe0f;",    // Red Heart
+  "18" : "&#x1f51e;",           // No one under eighteen symbol
+  "33" : "&#x1f4ac;",           // Speech balloon
+  "43" : "&#x26bd;",            // Soccer ball
+  "44" : "&#x1f3be;",           // Tennis racquet and ball
+  "73" : "&#x1f6d0;",           // Place of worship
+  "91" : "&#x1f418;",           // Elephant
+  "a1" : "&#x1f6eb;",           // Airplane departure
+  "a5" : "&#x1f52a;",           // Cooking knife
+  "a6" : "&#x1f6d2;",           // Shopping trolley
+  "a7" : "&#x1f3e1;",           // House with garden
+};
+
+tvheadend.uniqueArray = function(arr) {
+  var unique = [];
+  for ( var i = 0 ; i < arr.length ; ++i ) {
+    if ( unique.indexOf(arr[i]) == -1 )
+      unique.push(arr[i]);
+  }
+  return unique;
+}
+
+
+tvheadend.getContentTypeIcons = function(rec) {
+  var ret_major = [];
+  var ret_minor = [];
+  var cat = rec.category
+  if (cat && cat.length) {
+    cat.sort();
+    for ( var i = 0 ; i < cat.length ; ++i ) {
+      var v = cat[i];
+      v = v.toLowerCase();
+      var l = catmap_major[v];
+      if (l) ret_major.push(l);
+      l = catmap_minor[v];
+      if (l) ret_minor.push(l)
+    }
+  } else {
+    // Genre code
+    var gen = rec.genre;
+    if (gen) {
+      for (var i = 0; i < gen.length; ++i) {
+        var genre = parseInt(gen[i]);
+        if (genre) {
+          // Convert number to hex to make lookup easier to
+          // cross-reference with epg.c
+          var l = genre_major[(genre & 0xf0).toString(16)];
+          if (l) ret_major.push(l);
+          l = genre_minor[genre.toString(16)];
+          if (l) ret_minor.push(l)
+        }
+      }
+    }
+  }
+
+  var ret = "";
+  if (rec.new)
+    ret += "&#x1f195;";         // Squared New
+  return ret + tvheadend.uniqueArray(ret_major).join("") + tvheadend.uniqueArray(ret_minor).join("");
+}
+
+tvheadend.displayCategoryIcon = function(value, meta, record, ri, ci, store) {
+  if (value == null)
+    return '';
+  var icons = tvheadend.getContentTypeIcons(record.data);
+  if (icons.length < 1) return '';
+  return icons;
+}
+
+tvheadend.contentTypeAction = {
+  width: 75,
+  id: 'category',
+  header: _("Content Type"),
+  tooltip: _("Content Type"),
+  dataIndex: 'category',
+  renderer: tvheadend.displayCategoryIcon,
+};
+
+tvheadend.getDisplayTitle = function(title, record) {
+  if (!title) return title;
+  var year = record.data['copyright_year'];
+  if (year)
+    title += " (" + year + ")";
+  return title;
+}
+
+// Helper function for common code to sort an array, convert to CSV and
+// return the string to add to the content.
+tvheadend.sortAndAddArray = function (arr, title) {
+  arr.sort();
+  var csv = arr.join(", ");
+  if (csv)
+    return '<div class="x-epg-meta"><span class="x-epg-prefix">' + title + ':</span><span class="x-epg-desc">' + csv + '</span></div>';
+  else
+    return '';
+}
+
+tvheadend.getDisplayCredits = function(credits) {
+  if (!credits)
+    return "";
+  if (credits instanceof Array)
+    return "";
+
+  var content = "";
+  // Our cast (credits) map contains details of actors, writers,
+  // etc. so split in to separate categories for displaying.
+  var castArr = [];
+  var crewArr = [];
+  var directorArr = [];
+  var writerArr = [];
+  var cast = ["actor", "guest", "presenter"];
+  // We use arrays here in case more tags in the future map on to
+  // director/writer, e.g., SchedulesDirect breaks it down in to
+  // writer, writer (adaptation) writer (screenplay), etc. but
+  // currently we just have them all as writer.
+  var director = ["director"];
+  var writer = ["writer"];
+
+  for (var key in credits) {
+    var type = credits[key];
+    if (cast.indexOf(type) != -1)
+      castArr.push(key);
+    else if (director.indexOf(type) != -1)
+      directorArr.push(key);
+    else if (writer.indexOf(type) != -1)
+      writerArr.push(key);
+    else
+      crewArr.push(key);
+  };
+
+  content += tvheadend.sortAndAddArray(castArr, _('Starring'));
+  content += tvheadend.sortAndAddArray(directorArr, _('Director'));
+  content += tvheadend.sortAndAddArray(writerArr, _('Writer'));
+  content += tvheadend.sortAndAddArray(crewArr, _('Crew'));
+  return content;
+}
+
 /**
  * Change uilevel
  */
@@ -474,6 +732,48 @@ tvheadend.niceDate = function(dt) {
            '<div class="x-nice-time">' + d.toLocaleTimeString() + '</div>';
 }
 
+/* Date format when time is not needed, e.g., first_aired time is
+ * often 00:00.  Also takes a reference date so if the dt can be made
+ * nicer such as "Previous day" then we will use that instead.
+ */
+tvheadend.niceDateYearMonth = function(dt, refdate) {
+    var d = new Date(dt);
+    // If we have a reference date then we try and make the
+    // date nicer.
+    if  (refdate) {
+      var rd = new Date(refdate);
+      if (rd.getYear()  == d.getYear() &&
+          rd.getMonth() == d.getMonth() &&
+          rd.getDate()  == d.getDate()) {
+          var when;
+          if (rd.getHours()   == d.getHours() &&
+              rd.getMinutes() == d.getMinutes()) {
+              when = _("Premiere");
+          } else {
+              when = _("Same day");
+          }
+          return '<div class="x-nice-dayofweek">' + when + '</div>';
+      } else {
+        // Determine if it is previous day. We can't just subtract
+        // timestamps since a programme on at 8pm could have
+        // a previous shown timestamp of 00:00 on previous day,
+        // so would be > 86400 seconds ago. So, create temporary
+        // dates with timestamps of 00:00 and compare those.
+        var d0 = new Date(d);
+        var rd0 = new Date(rd);
+        d0.setHours(0);
+        d0.setMinutes(0);
+        rd0.setHours(0);
+        rd0.setMinutes(0);
+        if (Math.abs(d0 - rd0) <= (24 * 60 * 60 * 1000)) {
+          return '<div class="x-nice-dayofweek">' + _("Previous day") + '</div>';
+        }
+      }
+    }
+    return '<div class="x-nice-dayofweek">' + d.toLocaleString(tvheadend.language, {weekday: 'long'}) + '</div>' +
+           '<div class="x-nice-date">' + d.toLocaleDateString() + '</div>';
+}
+
 /*
  *
  */