mlbserver 2026.5.27 → 2026.7.22-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/index.js +1089 -396
  2. package/package.json +1 -1
  3. package/session.js +15 -0
  4. package/style.css +1119 -0
package/index.js CHANGED
@@ -251,6 +251,24 @@ function corsMiddleware(req, res, next) {
251
251
  httpAttach(multiview_app, corsMiddleware)
252
252
  multiview_app.listen(multiview_port)
253
253
 
254
+ // Listen for stylesheet requests
255
+ app.get('/style.css', async function (req, res) {
256
+ if (!(await protect(req, res))) return
257
+
258
+ session.requestlog('style.css', req, true)
259
+
260
+ var body = await session.getCSS()
261
+
262
+ if (!body) {
263
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
264
+ res.end('style.css not found')
265
+ return
266
+ }
267
+
268
+ res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8' })
269
+ res.end(body)
270
+ })
271
+
254
272
  // Listen for clear cache requests
255
273
  app.get('/clearcache', async function(req, res) {
256
274
  if ( ! (await protect(req, res)) ) return
@@ -1248,6 +1266,7 @@ app.get('/gamechangerplaylist.m3u8', async function(req, res) {
1248
1266
  session.debuglog(game_changer_title + 'checking for new segments')
1249
1267
  let u = streamURL + '_' + GAMECHANGER_RESOLUTIONS[resolution].url_bandwidth + 'K.m3u8'
1250
1268
  headers['x-cdn-token'] = streamURLToken
1269
+ headers.gzip = true
1251
1270
  requestRetry(u, headers, function(err, response) {
1252
1271
  session.debuglog(game_changer_title + 'requested ' + u)
1253
1272
  if (err) return res.error(err)
@@ -1472,7 +1491,7 @@ app.get('/login', async function(req, res) {
1472
1491
  const redirect = req.query.redirect || '/';
1473
1492
  const error = req.query.error ? '<p style="color:red;">Invalid username or password</p>' : '';
1474
1493
  res.writeHead(200, {'Content-Type': 'text/html'});
1475
- res.end(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Login - ${appname}</title><style>body{color:lightgray;background-color:black;font-family:Arial,Helvetica,sans-serif;}input{background-color:black;color:lightgray;border:1px solid lightgray;}button{background-color:black;color:lightgray;border:1px solid lightgray;}</style></head><body><h1>Login to ${appname}</h1>${error}<form method="POST" action="${http_root}/login"><input type="hidden" name="redirect" value="${redirect}"><p>Username: <input type="text" name="username" required></p><p>Password: <input type="password" name="password" required></p><p><button type="submit">Login</button></p></form></body></html>`);
1494
+ res.end(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Login - ${appname}</title><style>html,body{background-color:#0e0e0e;color:LightGray;font-family:Verdana,Tahoma,Roboto,sans-serif}h1{margin:0;;color:goldenrod}button{color:#000;background-color:lightgray;min-width:3rem;border:none;padding:1px 5px 1px 5px;text-align:center;text-decoration:none;display:inline-block;font-size:.9rem;border-radius:4px;cursor:pointer;margin:2px}</style></head><body><h1>Login to ${appname}</h1>${error}<form method="POST" action="${http_root}/login"><input type="hidden" name="redirect" value="${redirect}"><p>Username: <input type="text" name="username" required></p><p>Password: <input type="password" name="password" required></p><p><button type="submit">Login</button></p></form></body></html>`);
1476
1495
  } catch (e) {
1477
1496
  session.log('login get request error : ' + e.message);
1478
1497
  res.end('login get request error, check log');
@@ -1643,6 +1662,7 @@ app.get('/', async function(req, res) {
1643
1662
  if ( req.query.mediaType ) {
1644
1663
  mediaType = req.query.mediaType
1645
1664
  }
1665
+ var mediaTypeTV = mediaType
1646
1666
  var resolution = VALID_RESOLUTIONS[0]
1647
1667
  if ( req.query.resolution ) {
1648
1668
  resolution = req.query.resolution
@@ -1695,15 +1715,9 @@ app.get('/', async function(req, res) {
1695
1715
  content_protect_b = '&content_protect=' + content_protect
1696
1716
  }
1697
1717
 
1698
- var body = '<!DOCTYPE html><html><head><meta charset="UTF-8"><meta http-equiv="Content-type" content="text/html;charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"><title>' + appname + '</title><link rel="icon" href="favicon.svg' + content_protect_a + '"><style type="text/css">input[type=text],input[type=button]{-webkit-appearance:none;-webkit-border-radius:0}body{width:480px;color:lightgray;background-color:black;font-family:Arial,Helvetica,sans-serif;-webkit-text-size-adjust:none}a{color:darkgray}button{color:lightgray;background-color:black}button.default{color:black;background-color:lightgray}table{width:100%;pad}table,th,td{border:1px solid darkgray;border-collapse:collapse}th,td{padding:5px}.tinytext,textarea,input[type="number"]{font-size:.8em}textarea{width:380px}.freegame,.freegame a{color:green}.blackout,.blackout a{text-decoration:line-through}'
1699
-
1700
- // Highlights CSS
1701
- body += '.modal{display:none;position:fixed;z-index:1;left:0;top:0;width:100%;height:100%;overflow:hidden;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)}.modal-content{position:absolute;top:100px;bottom:20px;left:50%;transform:translateX(-50%);background-color:#fefefe;padding:10px;border:1px solid #888;width:360px;overflow-y:auto;color:black}#highlights{overflow-y:auto;}#highlights a{color:black}.close{color:black;float:right;font-size:28px;font-weight:bold;}#highlights a:hover,#highlights a:focus,.close:hover,.close:focus{color:gray;text-decoration:none;cursor:pointer;}'
1702
-
1703
- // Tooltip CSS
1704
- body += '.tooltip{position:relative;display:inline-block;border-bottom: 1px dotted gray;}.tooltip .tooltiptext{font-size:.8em;visibility:hidden;width:360px;background-color:gray;color:white;text-align:left;padding:5px;border-radius:6px;position:absolute;z-index:1;top:100%;left:75%;margin-left:-30px;}.tooltip:hover .tooltiptext{visibility:visible;}'
1718
+ var body = '<!DOCTYPE html><html><head><meta charset="UTF-8"><meta http-equiv="Content-type" content="text/html;charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"><title>' + appname + '</title><link rel="icon" href="favicon.svg' + content_protect_a + '"><link rel="stylesheet" href="/style.css' + content_protect_a + '">'
1705
1719
 
1706
- body += '</style><script type="text/javascript">' + "\n";
1720
+ body += '<script type="text/javascript">' + "\n";
1707
1721
 
1708
1722
  // Define option variables in page
1709
1723
  body += 'var date="' + gameDate + '";var level="' + level + '";var org="' + org + '";var mediaType="' + mediaType + '";var resolution="' + resolution + '";var audio_track="' + audio_track + '";var captions="' + captions + '";var force_vod="' + force_vod + '";var inning_half="' + inning_half + '";var inning_number="' + inning_number + '";var skip="' + skip + '";var skip_adjust="' + skip_adjust + '";var pad="' + pad + '";var linkType="' + linkType + '";var startFrom="' + startFrom + '";var scores="' + scores + '";var controls="' + controls + '";var scan_mode="' + scan_mode + '";var content_protect="' + content_protect + '";' + "\n"
@@ -1726,28 +1740,80 @@ app.get('/', async function(req, res) {
1726
1740
  body += '</script></head><body><h1>' + appname + '</h1>' + "\n"
1727
1741
 
1728
1742
  if (argv.login_page) {
1729
- body += '<p><a href="' + http_root + '/logout">Logout</a></p>' + "\n"
1743
+ body += '<p><form action="' + http_root + '/logout" method="GET"><button type="submit">Logout</button></form></p>' + "\n"
1730
1744
  }
1731
-
1732
- body += '<p><span class="tooltip tinytext">Touch or hover over an option name for more details</span></p>' + "\n"
1745
+ body +=`<div class="section">`
1746
+ body += '<div class="menuContainer settingsContainer">'
1747
+ body += '<div class="cardMenuHeader">Options</div>'
1748
+ body += '<div class="menuContent settingsContent">'
1733
1749
 
1734
1750
  todayUTCHours -= 4
1735
- body += '<p><span class="tooltip">Date<span class="tooltiptext">"today" lasts until ' + todayUTCHours + ' AM EST. Home page will default to yesterday between ' + todayUTCHours + ' AM - ' + (YESTERDAY_UTC_HOURS - 4) + ' AM EST.</span></span>: <input type="date" id="gameDate" value="' + gameDate + '"/> '
1751
+ const settingsInfoText = {
1752
+ dateInfo: '"today" lasts until ' + todayUTCHours + ' AM EST. Home page will default to yesterday between ' + todayUTCHours + ' AM - ' + (YESTERDAY_UTC_HOURS - 4) + ' AM EST.',
1753
+ levelInfo: 'Major or minor league level.',
1754
+ orgInfo: 'Major league parent organization.',
1755
+ mediaTypeInfo: 'Video is TV broadcasts, Audio is English radio, and Spanish is Spanish radio (not available for all games).',
1756
+ linkTypeInfo: 'Embed will play in your browser (with AirPlay support), Stream will give you a stream URL to open directly in media players like Kodi or VLC, Chromecast is a desktop browser-based casting site, Advanced will play in your desktop browser with some extra tools and debugging information (Advanced may require you to disable insecure / mixed content blocking in your browser), and Download will prompt your browser to save the stream to a TS (Transport Stream) file.<br><br>NOTE: Chromecast may not be able to resolve local domain names; if so, you can simply access this page (and thus the streams) using an IP address instead.',
1757
+ videoControlsInfo: 'Choose whether to show or hide controls on the embedded video page. Helpful to avoid timeline spoilers.',
1758
+ startFromInfo: 'For the embedded player only: Beginning will start playback at the beginning of the stream (may be 1 hour before game time for live games), and Live will start at the live point (if the event is live -- archive games should always start at the beginning). You can still seek anywhere.',
1759
+ inningInfo: 'For video streams only: choose the inning to start with (and the score to display, if applicable). Inning number is relative -- for example, selecting inning 7 here will show inning 7 for scheduled 9-inning games, but inning 5 for scheduled 7-inning games, for example. If an inning number is specified, seeking to an earlier point will not be possible. Default is the beginning of the stream. To use with radio, set the video track to "None".',
1760
+ scoresInfo: 'Choose whether to show scores on this web page. Combine this with the inning option to only show scores through the specified inning.',
1761
+ videoInfo: 'For video streams only: you can manually specifiy a video track (resolution) to use. Adaptive will let your client choose. Best will select either 1080p60 (MLB) or 720p60 (MiLB). 504p is default for multiview (see below).<br/><br/>None will allow to remove the video tracks, if you just want to listen to the audio while using the "start at inning" or "skip breaks" options enabled.',
1762
+ audioInfo: 'For video streams only: you can manually specifiy which audio track to include. Some media players can accept them all and let you choose. Not all tracks are available for all games, and injected tracks may not work with skip options below.<br/><br/>If you select "none" for video above, picking an audio track here will make it an audio-only feed that supports the inning start and skip breaks options.',
1763
+ captionsInfo: 'For video streams only: you can disable the caption track, if one is present. This is handy if you do not want to disable it in your player each time.',
1764
+ skipInfo: 'For video streams only (use the video "none" option above to apply it to audio streams): you can remove all breaks, idle time, non-action pitches, or only commercial breaks from the stream (useful to make your own "condensed games").<br/><br/>NOTES: skip timings are only generated when the stream is loaded -- so for live games, it will only skip up to the time you loaded the stream. Also, commercial skip will not work on pre-2024 games, or on MiLB games -- use skip breaks instead.',
1765
+ skipAdjustInfo: 'Seconds to adjust the skip time video segments, if necessary. Try a negative number if the plays are ending before the video segments begin; use a positive number if the video segments are ending before the play happens.',
1766
+ padInfo: 'You can pad archive streams with random extra time at the end, to help conceal timeline spoilers.',
1767
+ forceVodInfo: 'For streams only: if your client does not support seeking in mlbserver live streams, turning this on will make the stream look like a VOD stream instead, allowing the client to start at the beginning and allowing the user to seek within it. You will need to reload the stream to watch/view past the current time, though.'
1768
+ }
1769
+
1770
+ function settingInfoLabel(label, id) {
1771
+ return '<div class="settingLabel is-flex"><span>' + label + '</span><span class="info infoPadding" data-target="#' + id + '">?</span></div>'
1772
+ }
1773
+
1774
+ function settingInlineInfoLabel(label, id) {
1775
+ return '<span class="settingInlineLabel is-flex"><span>' + label + '</span><span class="info infoPadding" data-target="#' + id + '">?</span></span>'
1776
+ }
1777
+
1778
+ function settingInfo(id) {
1779
+ return '<div id="' + id + '" class="infoContainer"><div class="infoContent">' + settingsInfoText[id] + '</div></div>'
1780
+ }
1781
+
1782
+ body += '<div class="settingsGroup">'
1783
+ body += '<div class="settingRow">' + settingInfoLabel('Date', 'dateInfo') + '<div class="settingControl"><input type="date" id="gameDate" value="' + gameDate + '"/> '
1736
1784
  for (var i = 0; i < VALID_DATES.length; i++) {
1737
1785
  body += '<button '
1738
1786
  if ( ((VALID_DATES[i] == VALID_DATES[0]) && (gameDate == today)) || ((VALID_DATES[i] == VALID_DATES[1]) && (gameDate == yesterday)) ) body += 'class="default" '
1739
1787
  body += 'onclick="date=\'' + VALID_DATES[i] + '\';reload()">' + VALID_DATES[i] + '</button> '
1740
1788
  }
1741
- body += '</p>' + "\n" + '<p><span class="tinytext">Updated ' + session.getCacheUpdatedDate(cache_name) + '</span></p>' + "\n"
1789
+ body += '</div></div>' + "\n" + settingInfo('dateInfo') + '<div class="settingsMeta"><span>Updated ' + session.getCacheUpdatedDate(cache_name) + '</span></div>' + "\n"
1742
1790
 
1743
- body += '<p><span class="tooltip">Level<span class="tooltiptext">Major or minor league level</span></span>: '
1791
+ body += '<div class="settingRow">' + settingInfoLabel('Score', 'scoresInfo') + '<div class="settingControl">'
1792
+ for (var i = 0; i < VALID_SCORES.length; i++) {
1793
+ body += '<button '
1794
+ if ( scores == VALID_SCORES[i] ) body += 'class="default" '
1795
+ body += 'onclick="scores=\'' + VALID_SCORES[i] + '\';reload()">' + VALID_SCORES[i] + '</button> '
1796
+ }
1797
+ body += '</div></div>' + settingInfo('scoresInfo')
1798
+
1799
+ body += '<div class="settingRow">' + settingInfoLabel('Media Type', 'mediaTypeInfo') + '<div class="settingControl">'
1800
+ for (var i = 0; i < VALID_MEDIA_TYPES.length; i++) {
1801
+ body += '<button '
1802
+ if ( mediaType == VALID_MEDIA_TYPES[i] ) body += 'class="default" '
1803
+ body += 'onclick="mediaType=\'' + VALID_MEDIA_TYPES[i] + '\';reload()">' + VALID_MEDIA_TYPES[i] + '</button> '
1804
+ }
1805
+ body += '</div></div>' + "\n" + settingInfo('mediaTypeInfo')
1806
+
1807
+ body += '<details class="settingsDisclosure"><summary>Show more filters</summary><div class="settingsDisclosureContent">'
1808
+ body += '<div class="settingRow">' + settingInfoLabel('Level', 'levelInfo') + '<div class="settingControl">'
1744
1809
  for (const [key, value] of Object.entries(levels)) {
1745
1810
  body += '<button '
1746
1811
  if ( level == key ) body += 'class="default" '
1747
1812
  body += 'onclick="org=\'' + default_org + '\';level=\'' + key + '\';reload()">' + key + '</button> '
1748
1813
  }
1749
1814
 
1750
- body += ' or <span class="tooltip">Org<span class="tooltiptext">Major league parent organization</span></span>: '
1815
+ body += '<span class="settingsDivider">or</span>'
1816
+ body += settingInlineInfoLabel('Org', 'orgInfo') + ' '
1751
1817
  body += '<select id="org" onchange="level=\'' + default_org + '\';org=this.value;reload()">'
1752
1818
  body += '<option value="' + default_org + '">' + default_org + '</option>'
1753
1819
  var orgs = session.getOrgs()
@@ -1756,48 +1822,22 @@ app.get('/', async function(req, res) {
1756
1822
  if ( org == orgs[i] ) body += ' selected'
1757
1823
  body += '>' + orgs[i] + '</option> '
1758
1824
  }
1759
- body += '</select></p>' + "\n"
1825
+ body += '</select></div></div>' + settingInfo('levelInfo') + settingInfo('orgInfo') + '</div></details></div>' + "\n"
1760
1826
 
1761
- body += '<p><span class="tooltip">Media Type<span class="tooltiptext">Video is TV broadcasts, Audio is English radio, and Spanish is Spanish radio (not available for all games).</span></span>: '
1762
- for (var i = 0; i < VALID_MEDIA_TYPES.length; i++) {
1763
- body += '<button '
1764
- if ( mediaType == VALID_MEDIA_TYPES[i] ) body += 'class="default" '
1765
- body += 'onclick="mediaType=\'' + VALID_MEDIA_TYPES[i] + '\';reload()">' + VALID_MEDIA_TYPES[i] + '</button> '
1766
- }
1767
- body += '</p>' + "\n"
1768
-
1769
- body += '<p><span class="tooltip">Link Type<span class="tooltiptext">Embed will play in your browser (with AirPlay support), Stream will give you a stream URL to open directly in media players like Kodi or VLC, Chromecast is a desktop browser-based casting site, Advanced will play in your desktop browser with some extra tools and debugging information (Advanced may require you to disable insecure / mixed content blocking in your browser), and Download will prompt your browser to save the stream to a TS (Transport Stream) file.<br><br>NOTE: Chromecast may not be able to resolve local domain names; if so, you can simply access this page (and thus the streams) using an IP address instead.</span></span>: '
1770
- for (var i = 0; i < VALID_LINK_TYPES.length; i++) {
1771
- body += '<button '
1772
- if ( linkType == VALID_LINK_TYPES[i] ) body += 'class="default" '
1773
- body += 'onclick="linkType=\'' + VALID_LINK_TYPES[i] + '\';reload()">' + VALID_LINK_TYPES[i] + '</button> '
1774
- }
1775
- body += '</p>' + "\n"
1827
+ body += '<div class="settingsGroup">'
1776
1828
 
1777
- body += '<p>'
1778
1829
  if ( linkType == VALID_LINK_TYPES[0] ) {
1779
- body += '<span class="tooltip">Video Controls<span class="tooltiptext">Choose whether to show or hide controls on the embedded video page. Helpful to avoid timeline spoilers.</span></span>: '
1780
- for (var i = 0; i < VALID_CONTROLS.length; i++) {
1781
- body += '<button '
1782
- if ( controls == VALID_CONTROLS[i] ) body += 'class="default" '
1783
- body += 'onclick="controls=\'' + VALID_CONTROLS[i] + '\';reload()">' + VALID_CONTROLS[i] + '</button> '
1784
- }
1785
- body += '</p>' + "\n"
1786
-
1787
- body += '<p><span class="tooltip">Start From<span class="tooltiptext">For the embedded player only: Beginning will start playback at the beginning of the stream (may be 1 hour before game time for live games), and Live will start at the live point (if the event is live -- archive games should always start at the beginning). You can still seek anywhere.</span></span>: '
1830
+ body += '<div class="settingRow">' + settingInfoLabel('Start From', 'startFromInfo') + '<div class="settingControl">'
1788
1831
  for (var i = 0; i < VALID_START_FROM.length; i++) {
1789
1832
  body += '<button '
1790
1833
  if ( startFrom == VALID_START_FROM[i] ) body += 'class="default" '
1791
1834
  body += 'onclick="startFrom=\'' + VALID_START_FROM[i] + '\';reload()">' + VALID_START_FROM[i] + '</button> '
1792
1835
  }
1793
- body += "\n"
1794
- if ( mediaType == VALID_MEDIA_TYPES[0] ) {
1795
- body += 'or '
1796
- }
1836
+ body += '</div></div>' + settingInfo('startFromInfo')
1797
1837
  }
1798
1838
 
1799
1839
  if ( mediaType == VALID_MEDIA_TYPES[0] ) {
1800
- body += '<span class="tooltip">Inning<span class="tooltiptext">For video streams only: choose the inning to start with (and the score to display, if applicable). Inning number is relative -- for example, selecting inning 7 here will show inning 7 for scheduled 9-inning games, but inning 5 for scheduled 7-inning games, for example. If an inning number is specified, seeking to an earlier point will not be possible. Default is the beginning of the stream. To use with radio, set the video track to "None".</span></span>: '
1840
+ body += '<div class="settingRow">' + settingInfoLabel('Inning', 'inningInfo') + '<div class="settingControl">'
1801
1841
  body += '<select id="inning_half" onchange="inning_half=this.value;reload()">'
1802
1842
  for (var i = 0; i < VALID_INNING_HALF.length; i++) {
1803
1843
  body += '<option value="' + VALID_INNING_HALF[i] + '"'
@@ -1815,23 +1855,119 @@ app.get('/', async function(req, res) {
1815
1855
  }
1816
1856
  body += '</select>'
1817
1857
  }
1818
- body += '</p>' + "\n"
1858
+ if ( mediaType == VALID_MEDIA_TYPES[0] ) {
1859
+ body += '</div></div>' + "\n"
1860
+ body += settingInfo('inningInfo')
1861
+ }
1819
1862
 
1820
- body += '<p><span class="tooltip">Scores<span class="tooltiptext">Choose whether to show scores on this web page. Combine this with the inning option to only show scores through the specified inning.</span></span>: '
1821
- for (var i = 0; i < VALID_SCORES.length; i++) {
1863
+ body += '<details class="settingsDisclosure"><summary>Show more stream options</summary><div class="settingsDisclosureContent">'
1864
+
1865
+ body += '<div class="settingRow">' + settingInfoLabel('Link Type', 'linkTypeInfo') + '<div class="settingControl">'
1866
+ for (var i = 0; i < VALID_LINK_TYPES.length; i++) {
1822
1867
  body += '<button '
1823
- if ( scores == VALID_SCORES[i] ) body += 'class="default" '
1824
- body += 'onclick="scores=\'' + VALID_SCORES[i] + '\';reload()">' + VALID_SCORES[i] + '</button> '
1868
+ if ( linkType == VALID_LINK_TYPES[i] ) body += 'class="default" '
1869
+ body += 'onclick="linkType=\'' + VALID_LINK_TYPES[i] + '\';reload()">' + VALID_LINK_TYPES[i] + '</button> '
1870
+ }
1871
+ body += '</div></div>' + "\n" + settingInfo('linkTypeInfo')
1872
+
1873
+ if ( linkType == VALID_LINK_TYPES[0] ) {
1874
+ body += '<div class="settingRow">' + settingInfoLabel('Video Controls', 'videoControlsInfo') + '<div class="settingControl">'
1875
+ for (var i = 0; i < VALID_CONTROLS.length; i++) {
1876
+ body += '<button '
1877
+ if ( controls == VALID_CONTROLS[i] ) body += 'class="default" '
1878
+ body += 'onclick="controls=\'' + VALID_CONTROLS[i] + '\';reload()">' + VALID_CONTROLS[i] + '</button> '
1879
+ }
1880
+ body += '</div></div>' + "\n" + settingInfo('videoControlsInfo')
1881
+ }
1882
+
1883
+ if ( mediaType == VALID_MEDIA_TYPES[0] ) {
1884
+ body += '<div class="settingRow">' + settingInfoLabel('Video', 'videoInfo') + '<div class="settingControl">'
1885
+ body += '<button '
1886
+ if ( resolution == 'best' ) body += 'class="default" '
1887
+ body += 'onclick="resolution=\'best\';reload()">best</button> '
1888
+ for (var i = 0; i < VALID_RESOLUTIONS.length; i++) {
1889
+ body += '<button '
1890
+ if ( resolution == VALID_RESOLUTIONS[i] ) body += 'class="default" '
1891
+ body += 'onclick="resolution=\'' + VALID_RESOLUTIONS[i] + '\';reload()">' + VALID_RESOLUTIONS[i]
1892
+ if ( DISPLAY_BANDWIDTHS[i] != '' ) {
1893
+ body += '<br/><span>' + DISPLAY_BANDWIDTHS[i] + '</span>'
1894
+ }
1895
+ body += '</button> '
1896
+ }
1897
+ body += '</div></div>' + settingInfo('videoInfo')
1898
+
1899
+ body += '<div class="settingRow">' + settingInfoLabel('Audio', 'audioInfo') + '<div class="settingControl">'
1900
+ for (var i = 0; i < VALID_AUDIO_TRACKS.length; i++) {
1901
+ body += '<button '
1902
+ if ( audio_track == VALID_AUDIO_TRACKS[i] ) body += 'class="default" '
1903
+ body += 'onclick="audio_track=\'' + VALID_AUDIO_TRACKS[i] + '\';reload()">' + DISPLAY_AUDIO_TRACKS[i] + '</button> '
1904
+ }
1905
+ body += '</div></div>' + "\n" + settingInfo('audioInfo')
1906
+
1907
+ body += '<div class="settingRow">' + settingInfoLabel('Captions', 'captionsInfo') + '<div class="settingControl">'
1908
+ for (var i = 0; i < VALID_CAPTIONS.length; i++) {
1909
+ body += '<button '
1910
+ if ( captions == VALID_CAPTIONS[i] ) body += 'class="default" '
1911
+ body += 'onclick="captions=\'' + VALID_CAPTIONS[i] + '\';reload()">' + VALID_CAPTIONS[i] + '</button> '
1912
+ }
1913
+ body += '</div></div>' + settingInfo('captionsInfo')
1914
+
1915
+ body += '<div class="settingRow">' + settingInfoLabel('Skip', 'skipInfo') + '<div class="settingControl">'
1916
+ for (var i = 0; i < VALID_SKIP.length; i++) {
1917
+ body += '<button '
1918
+ if ( skip == VALID_SKIP[i] ) body += 'class="default" '
1919
+ body += 'onclick="skip=\'' + VALID_SKIP[i] + '\';reload()">' + VALID_SKIP[i] + '</button> '
1920
+ }
1921
+ if ( skip != VALID_SKIP[0] ) {
1922
+ body += settingInlineInfoLabel('Skip Adjust', 'skipAdjustInfo') + ' <input type="number" id="skip_adjust" value="' + skip_adjust + '" step="1" onchange="setTimeout(function(){skip_adjust=document.getElementById(\'skip_adjust\').value;reload()},750)" onblur="skip_adjust=this.value;reload()" style="vertical-align:top;font-size:.8em;width:3em"/>'
1923
+ }
1924
+ body += '</div></div>' + "\n" + settingInfo('skipInfo')
1925
+ if ( skip != VALID_SKIP[0] ) body += settingInfo('skipAdjustInfo')
1926
+ }
1927
+
1928
+ body += '<div class="settingRow">' + settingInfoLabel('Pad', 'padInfo') + '<div class="settingControl">'
1929
+ for (var i = 0; i < VALID_PAD.length; i++) {
1930
+ body += '<button '
1931
+ if ( pad == VALID_PAD[i] ) body += 'class="default" '
1932
+ body += 'onclick="pad=\'' + VALID_PAD[i] + '\';reload()">' + VALID_PAD[i] + '</button> '
1933
+ }
1934
+ body += '</div></div>' + "\n" + settingInfo('padInfo')
1935
+
1936
+ if ( (linkType == VALID_LINK_TYPES[1]) && (gameDate == today) ) {
1937
+ body += '<div class="settingRow">' + settingInfoLabel('VOD', 'forceVodInfo') + '<div class="settingControl">'
1938
+ for (var i = 0; i < VALID_FORCE_VOD.length; i++) {
1939
+ body += '<button '
1940
+ if ( force_vod == VALID_FORCE_VOD[i] ) body += 'class="default" '
1941
+ body += 'onclick="force_vod=\'' + VALID_FORCE_VOD[i] + '\';reload()">' + VALID_FORCE_VOD[i] + '</button> '
1942
+ }
1943
+ body += '<span>(if client does not support seeking in live streams)</span></div></div>' + "\n" + settingInfo('forceVodInfo')
1944
+ }
1945
+
1946
+ body += '</div></details></div></div></div></div>'
1947
+
1948
+ const subscriptionInfoText = {
1949
+ masn: 'MASN live stream for entitled subscribers. <a href="https://support.mlb.com/s/article/MASN-In-Market-Offering">See here for more information</a>.',
1950
+ mlbn: 'MLB Network live stream is now available in the USA for paid MLBTV subscribers or as a paid add-on, in addition to authenticated TV subscribers. <a href="https://support.mlb.com/s/article/MLB-Network-Streaming-FAQ">See here for more information</a>.',
1951
+ snla: 'SNLA live stream for entitled subscribers. <a href="https://support.mlb.com/s/article/SNLA-Plus-Subscription-Packages">See here for more information</a>.',
1952
+ sny: 'SNY live stream for entitled subscribers. <a href="https://support.mlb.com/s/article/SNY-In-Market-Offering">See here for more information</a>.',
1953
+ bigInning: 'Big Inning is the live look-in and highlights show. <a href="https://support.mlb.com/s/article/What-Is-MLB-Big-Inning">See here for more information</a>.',
1954
+ gameChanger: 'The game changer stream will automatically switch between the highest leverage active live non-blackout games, and should be available whenever there are such games available. Does not support adaptive bitrate switching, will default to 720p60 resolution if not specified.',
1955
+ streamFinder: 'The stream finder stream will automatically switch between games according to your uploaded preferences. This stream is not affiliated with Baseball Reference, do not contact them for support. Visit <a href="http://bit.ly/bbrefsf">http://bit.ly/bbrefsf</a> to create and export your preferences, then upload and save them to mlbserver <a href="#streamfinder">below</a>. Does not support adaptive bitrate switching, will default to 720p60 resolution if not specified.'
1825
1956
  }
1826
- body += '</p>' + "\n"
1827
1957
 
1828
- body += "<table>" + "\n"
1958
+ function subscriptionInfoLabel(label, id) {
1959
+ return '<span class="subscriptionLabel"><span>' + label + '</span><span class="info infoPadding" data-target="#' + id + '">?</span></span>'
1960
+ }
1961
+
1962
+ function subscriptionInfo(id, textKey) {
1963
+ return '<div id="' + id + '" class="infoContainer subscriptionInfoContainer"><div class="infoContent">' + subscriptionInfoText[textKey] + '</div></div>'
1964
+ }
1829
1965
 
1830
1966
  // Rename some parameters before display links
1831
1967
  var mediaFeedType = 'mediaFeedType'
1832
1968
  var language = 'en'
1833
1969
  if ( mediaType == VALID_MEDIA_TYPES[0] ) {
1834
- mediaType = 'MLBTV'
1970
+ mediaTypeTV = 'MLBTV'
1835
1971
  } else if ( mediaType == VALID_MEDIA_TYPES[2] ) {
1836
1972
  //mediaType = VALID_MEDIA_TYPES[1]
1837
1973
  language = 'es'
@@ -1857,10 +1993,107 @@ app.get('/', async function(req, res) {
1857
1993
 
1858
1994
  let entitlements = await session.getEntitlements()
1859
1995
 
1996
+ body += `<script>
1997
+ document.addEventListener("DOMContentLoaded", function () {
1998
+ var infoButtons = document.getElementsByClassName("info");
1999
+
2000
+ for (var i = 0; i < infoButtons.length; i++) {
2001
+ infoButtons[i].addEventListener("click", function () {
2002
+ var target = this.getAttribute("data-target");
2003
+
2004
+ if (!target) {
2005
+ return;
2006
+ }
2007
+
2008
+ var content = document.querySelector(target);
2009
+
2010
+ if (!content) {
2011
+ return;
2012
+ }
2013
+
2014
+ this.classList.toggle("active");
2015
+
2016
+ if (content.style.maxHeight) {
2017
+ content.style.maxHeight = null;
2018
+ } else {
2019
+ content.style.maxHeight = content.scrollHeight + "px";
2020
+ }
2021
+ });
2022
+ }
2023
+
2024
+ var closeButtons = document.getElementsByClassName("infoClose");
2025
+
2026
+ for (var j = 0; j < closeButtons.length; j++) {
2027
+ closeButtons[j].addEventListener("click", function () {
2028
+ var target = this.getAttribute("data-target");
2029
+
2030
+ if (!target) {
2031
+ return;
2032
+ }
2033
+
2034
+ var content = document.querySelector(target);
2035
+
2036
+ if (!content) {
2037
+ return;
2038
+ }
2039
+
2040
+ content.style.maxHeight = null;
2041
+
2042
+ var opener = document.querySelector('.info[data-target="' + target + '"]');
2043
+
2044
+ if (opener) {
2045
+ opener.classList.remove("active");
2046
+ }
2047
+ });
2048
+ }
2049
+ });
2050
+ </script>`
2051
+
2052
+
2053
+ let entitledMASN = (entitlements.includes('MASN_110'))
2054
+
2055
+ let entitledMLBN = (entitlements.includes('MLBN') || entitlements.includes('EXECMLB') || entitlements.includes('MLBTVMLBNADOBEPASS'))
2056
+
2057
+ let entitledSNLA = (entitlements.includes('SNLA_119'))
2058
+
2059
+ let entitledSNY = (entitlements.includes('SNY_121'))
2060
+
2061
+ let entitledBigInning = ( (entitlements.length > 0) && cache_data.dates && cache_data.dates[0] && (cache_data.dates[0].date >= today) && cache_data.dates[0].games && (cache_data.dates[0].games.length > 1) && cache_data.dates[0].games[0] && (cache_data.dates[0].games[0].seriesDescription == 'Regular Season') )
2062
+
2063
+ let entitledChangerFinder = ((gameDate >= today) && cache_data.dates && cache_data.dates[0] && cache_data.dates[0].games && (cache_data.dates[0].games.length > 1))
2064
+
2065
+ let hasEntitledStreams = entitledMASN || entitledMLBN || entitledSNLA || entitledSNY || entitledBigInning || entitledChangerFinder
2066
+
2067
+ let subscriptionCardVis
2068
+ if (hasEntitledStreams) {subscriptionCardVis = ''} else {subscriptionCardVis ='is-invisible'}
2069
+
2070
+
2071
+ body += `<script>
2072
+ function toast() {
2073
+ var x = document.getElementById("snackbar");
2074
+ x.className = "show";
2075
+ setTimeout(function(){ x.className = x.className.replace("show", ""); }, 3000);
2076
+ }
2077
+ </script>`
2078
+ body +=`<div class="section">`
2079
+ body += `<div class="cardContainer ${subscriptionCardVis}">
2080
+ <div class="cardHeader flex-between">
2081
+ <div class="">
2082
+ <span class="time">Subscription streams</span>
2083
+ </div>
2084
+ </div>
2085
+ <div class="gameContent space-around">
2086
+ <div class="prePostLegend">
2087
+ <span><span class="prePostIndicator prePostLegendIndicator" title="Pregame show"><span class="prePostCircle prePostCircleActive"></span><span class="prePostCircle prePostCircleInactive"></span></span>Pregame show</span>
2088
+ <span><span class="prePostIndicator prePostLegendIndicator" title="Postgame show"><span class="prePostCircle prePostCircleInactive"></span><span class="prePostCircle prePostCircleActive"></span></span>Postgame show</span>
2089
+ </div>`
2090
+
2091
+
2092
+
1860
2093
  // MASN live stream for entitled subscribers
1861
2094
  try {
1862
- if ( entitlements.includes('MASN_110') ) {
1863
- body += '<tr><td><span class="tooltip">MASN<span class="tooltiptext">MASN live stream for entitled subscribers. <a href="https://support.mlb.com/s/article/MASN-In-Market-Offering">See here for more information</a>.</span></span></td><td>'
2095
+ if ( entitledMASN ) {
2096
+ body += '<div class="flex-between subscriptionStream">' + subscriptionInfoLabel('MASN', 'masnInfo')
1864
2097
  let querystring = '?event=MASN'
1865
2098
  let multiviewquerystring = querystring + '&resolution=' + DEFAULT_MULTIVIEW_RESOLUTION
1866
2099
  if ( linkType == VALID_LINK_TYPES[0] ) {
@@ -1875,9 +2108,10 @@ app.get('/', async function(req, res) {
1875
2108
  }
1876
2109
  querystring += content_protect_b
1877
2110
  multiviewquerystring += content_protect_b
1878
- body += '<a href="' + thislink + querystring + '">MASN</a>'
1879
- body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + '/stream.m3u8' + multiviewquerystring + '" onclick="addmultiview(this)">'
1880
- body += '</td></tr>' + "\n"
2111
+ body += '<span><a href="' + thislink + querystring + '">MASN</a>'
2112
+ body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + '/stream.m3u8' + multiviewquerystring + '" onclick="addmultiview(this)"></span>'
2113
+ body += '</div>' + "\n"
2114
+ body += subscriptionInfo('masnInfo', 'masn')
1881
2115
  } // end entitlements check
1882
2116
  } catch (e) {
1883
2117
  session.debuglog('MASN detect error : ' + e.message)
@@ -1885,8 +2119,8 @@ app.get('/', async function(req, res) {
1885
2119
 
1886
2120
  // MLB Network live stream for eligible USA subscribers
1887
2121
  try {
1888
- if ( entitlements.includes('MLBN') || entitlements.includes('EXECMLB') || entitlements.includes('MLBTVMLBNADOBEPASS') ) {
1889
- body += '<tr><td><span class="tooltip">MLB Network<span class="tooltiptext">MLB Network live stream is now available in the USA for paid MLBTV subscribers or as a paid add-on, in addition to authenticated TV subscribers. <a href="https://support.mlb.com/s/article/MLB-Network-Streaming-FAQ">See here for more information</a>.</span></span></td><td>'
2122
+ if ( entitledMLBN || entitlements.includes('EXECMLB') || entitlements.includes('MLBTVMLBNADOBEPASS') ) {
2123
+ body += '<div class="flex-between subscriptionStream">' + subscriptionInfoLabel('MLB Network', 'mlbnInfo')
1890
2124
  let querystring = '?event=MLBN'
1891
2125
  let multiviewquerystring = querystring + '&resolution=' + DEFAULT_MULTIVIEW_RESOLUTION
1892
2126
  if ( linkType == VALID_LINK_TYPES[0] ) {
@@ -1901,9 +2135,10 @@ app.get('/', async function(req, res) {
1901
2135
  }
1902
2136
  querystring += content_protect_b
1903
2137
  multiviewquerystring += content_protect_b
1904
- body += '<a href="' + thislink + querystring + '">MLB Network</a>'
1905
- body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + '/stream.m3u8' + multiviewquerystring + '" onclick="addmultiview(this)">'
1906
- body += '</td></tr>' + "\n"
2138
+ body += '<span><a href="' + thislink + querystring + '">MLB Network</a>'
2139
+ body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + '/stream.m3u8' + multiviewquerystring + '" onclick="addmultiview(this)"></span>'
2140
+ body += '</div>' + "\n"
2141
+ body += subscriptionInfo('mlbnInfo', 'mlbn')
1907
2142
  } // end entitlements check
1908
2143
  } catch (e) {
1909
2144
  session.debuglog('MLB Network detect error : ' + e.message)
@@ -1911,8 +2146,8 @@ app.get('/', async function(req, res) {
1911
2146
 
1912
2147
  // SNLA live stream for entitled subscribers
1913
2148
  try {
1914
- if ( entitlements.includes('SNLA_119') ) {
1915
- body += '<tr><td><span class="tooltip">SportsNet LA<span class="tooltiptext">SNLA live stream for entitled subscribers. <a href="https://support.mlb.com/s/article/SNLA-Plus-Subscription-Packages">See here for more information</a>.</span></span></td><td>'
2149
+ if ( entitledSNLA ) {
2150
+ body += '<div class="flex-between subscriptionStream">' + subscriptionInfoLabel('SportsNet LA', 'snlaInfo')
1916
2151
  let querystring = '?event=SNLA'
1917
2152
  let multiviewquerystring = querystring + '&resolution=' + DEFAULT_MULTIVIEW_RESOLUTION
1918
2153
  if ( linkType == VALID_LINK_TYPES[0] ) {
@@ -1927,9 +2162,10 @@ app.get('/', async function(req, res) {
1927
2162
  }
1928
2163
  querystring += content_protect_b
1929
2164
  multiviewquerystring += content_protect_b
1930
- body += '<a href="' + thislink + querystring + '">SNLA</a>'
1931
- body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + '/stream.m3u8' + multiviewquerystring + '" onclick="addmultiview(this)">'
1932
- body += '</td></tr>' + "\n"
2165
+ body += '<span><a href="' + thislink + querystring + '">SNLA</a>'
2166
+ body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + '/stream.m3u8' + multiviewquerystring + '" onclick="addmultiview(this)"></span>'
2167
+ body += '</div>' + "\n"
2168
+ body += subscriptionInfo('snlaInfo', 'snla')
1933
2169
  } // end entitlements check
1934
2170
  } catch (e) {
1935
2171
  session.debuglog('SNLA detect error : ' + e.message)
@@ -1937,8 +2173,8 @@ app.get('/', async function(req, res) {
1937
2173
 
1938
2174
  // SNY live stream for entitled subscribers
1939
2175
  try {
1940
- if ( entitlements.includes('SNY_121') ) {
1941
- body += '<tr><td><span class="tooltip">SNY<span class="tooltiptext">SNY live stream for entitled subscribers. <a href="https://support.mlb.com/s/article/SNY-In-Market-Offering">See here for more information</a>.</span></span></td><td>'
2176
+ if ( entitledSNY ) {
2177
+ body += '<div class="flex-between subscriptionStream">' + subscriptionInfoLabel('SNY', 'snyInfo')
1942
2178
  let querystring = '?event=SNY'
1943
2179
  let multiviewquerystring = querystring + '&resolution=' + DEFAULT_MULTIVIEW_RESOLUTION
1944
2180
  if ( linkType == VALID_LINK_TYPES[0] ) {
@@ -1953,9 +2189,10 @@ app.get('/', async function(req, res) {
1953
2189
  }
1954
2190
  querystring += content_protect_b
1955
2191
  multiviewquerystring += content_protect_b
1956
- body += '<a href="' + thislink + querystring + '">SNY</a>'
1957
- body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + '/stream.m3u8' + multiviewquerystring + '" onclick="addmultiview(this)">'
1958
- body += '</td></tr>' + "\n"
2192
+ body += '<span><a href="' + thislink + querystring + '">SNY</a>'
2193
+ body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + '/stream.m3u8' + multiviewquerystring + '" onclick="addmultiview(this)"></span>'
2194
+ body += '</div>' + "\n"
2195
+ body += subscriptionInfo('snyInfo', 'sny')
1959
2196
  } // end entitlements check
1960
2197
  } catch (e) {
1961
2198
  session.debuglog('SNY detect error : ' + e.message)
@@ -1968,7 +2205,7 @@ app.get('/', async function(req, res) {
1968
2205
  }
1969
2206
  }
1970
2207
 
1971
- if ( (mediaType == 'MLBTV') && ((level_ids == levels['MLB']) || level_ids.startsWith(levels['MLB'] + ',')) ) {
2208
+ if ( (mediaTypeTV == 'MLBTV') && ((level_ids == levels['MLB']) || level_ids.startsWith(levels['MLB'] + ',')) ) {
1972
2209
  // Recap Rundown beginning in 2023, disabled because it stopped working
1973
2210
  /*if ( (gameDate <= yesterday) && (gameDate >= '2023-03-31') && cache_data.dates && cache_data.dates[0] && cache_data.dates[0].games && (cache_data.dates[0].games.length > 0) ) {
1974
2211
  body += '<tr><td><span class="tooltip">VOD<span class="tooltiptext">Recap Rundown plays all of a day\'s recaps in order.</span></span></td><td>'
@@ -1985,14 +2222,16 @@ app.get('/', async function(req, res) {
1985
2222
 
1986
2223
  // Big Inning
1987
2224
  var big_inning
1988
- if ( (entitlements.length > 0) && cache_data.dates && cache_data.dates[0] && (cache_data.dates[0].date >= today) && cache_data.dates[0].games && (cache_data.dates[0].games.length > 1) && cache_data.dates[0].games[0] && (cache_data.dates[0].games[0].seriesDescription == 'Regular Season') ) {
2225
+ if ( entitledBigInning ) {
1989
2226
  // Scraped Big Inning schedule
1990
2227
  big_inning = await session.getBigInningSchedule(gameDate)
1991
2228
  }
1992
2229
  if ( big_inning ) {
2230
+ let bigInningRendered = false
1993
2231
  for (var i = 0; i < big_inning.length; i++) {
1994
- if ( big_inning[i].start ) {
1995
- body += '<tr><td><span class="tooltip">' + new Date(big_inning[i].start).toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + ' - ' + new Date(big_inning[i].end).toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + '<span class="tooltiptext">Big Inning is the live look-in and highlights show. <a href="https://support.mlb.com/s/article/What-Is-MLB-Big-Inning">See here for more information</a>.</span></span></td><td>'
2232
+ if ( big_inning[i].start && !bigInningRendered ) {
2233
+ body += '<div class="flex-between subscriptionStream">' + "\n"
2234
+ body += '<span class="tinytext">' + new Date(big_inning[i].start).toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + ' - ' + new Date(big_inning[i].end).toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + '</span>'
1996
2235
  let compareStart = new Date(big_inning[i].start)
1997
2236
  compareStart.setMinutes(compareStart.getMinutes()-10)
1998
2237
  let compareEnd = new Date(big_inning[i].end)
@@ -2012,18 +2251,20 @@ app.get('/', async function(req, res) {
2012
2251
  }
2013
2252
  querystring += content_protect_b
2014
2253
  multiviewquerystring += content_protect_b
2015
- body += '<a href="' + thislink + querystring + '">Big Inning</a>'
2016
- body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + '/stream.m3u8' + multiviewquerystring + '" onclick="addmultiview(this)">'
2254
+ body += '<span>' + subscriptionInfoLabel('<a href="' + thislink + querystring + '">Big Inning</a>', 'bigInningInfo')
2255
+ body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + '/stream.m3u8' + multiviewquerystring + '" onclick="addmultiview(this)"></span>'
2017
2256
  } else {
2018
- body += 'Big Inning'
2257
+ body += subscriptionInfoLabel('Big Inning', 'bigInningInfo')
2019
2258
  }
2020
- body += '</td></tr>' + "\n"
2259
+ body += '</div>' + "\n"
2260
+ body += subscriptionInfo('bigInningInfo', 'bigInning')
2261
+ bigInningRendered = true
2021
2262
  }
2022
2263
  }
2023
2264
  }
2024
-
2265
+
2025
2266
  // Game Changer and Stream Finder
2026
- if ( (gameDate >= today) && cache_data.dates && cache_data.dates[0] && cache_data.dates[0].games && (cache_data.dates[0].games.length > 1) ) {
2267
+ if ( entitledChangerFinder ) {
2027
2268
  let gameIndexes = await session.get_first_and_last_games(cache_data.dates[0].games, blackouts)
2028
2269
  if ( (typeof gameIndexes.firstGameIndex !== 'undefined') && (typeof gameIndexes.lastGameIndex !== 'undefined') && (gameIndexes.firstGameIndex !== gameIndexes.lastGameIndex) ) {
2029
2270
  let compareStart = new Date(cache_data.dates[0].games[gameIndexes.firstGameIndex].gameDate)
@@ -2033,7 +2274,8 @@ app.get('/', async function(req, res) {
2033
2274
  compareEnd.setHours(compareEnd.getHours()+4)
2034
2275
  }
2035
2276
  compareEnd.setHours(compareEnd.getHours()+4)
2036
- body += '<tr><td><span class="tooltip">' + compareStart.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + ' - ' + compareEnd.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + '<span class="tooltiptext">The game changer stream will automatically switch between the highest leverage active live non-blackout games, and should be available whenever there are such games available. Does not support adaptive bitrate switching, will default to 720p60 resolution if not specified.</span></span></td><td>'
2277
+ body += '<div class="flex-between subscriptionStream">' + "\n"
2278
+ body += '<span class="tinytext">' + compareStart.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + ' - ' + compareEnd.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + '</span>'
2037
2279
  if ( (currentDate >= compareStart) && (currentDate < compareEnd) ) {
2038
2280
  let streamURL = server + '/gamechanger.m3u8'
2039
2281
  let multiviewquerystring = '/gamechanger.m3u8?resolution=' + DEFAULT_MULTIVIEW_RESOLUTION + content_protect_b
@@ -2049,14 +2291,15 @@ app.get('/', async function(req, res) {
2049
2291
  if ( linkType == VALID_LINK_TYPES[4] ) {
2050
2292
  streamURL += '&filename=' + gameDate + ' Game Changer'
2051
2293
  }
2052
- body += '<a href="' + streamURL + '">Game Changer</a>'
2053
- body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + multiviewquerystring + '" onclick="addmultiview(this, [], excludeTeams)">'
2294
+ body += '<span>' + subscriptionInfoLabel('<a href="' + streamURL + '">Game Changer</a>', 'gameChangerInfo')
2295
+ body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + multiviewquerystring + '" onclick="addmultiview(this, [], excludeTeams)"></span>'
2054
2296
  } else {
2055
- body += 'Game Changer'
2297
+ body += subscriptionInfoLabel('Game Changer', 'gameChangerInfo')
2056
2298
  }
2057
- body += '</td></tr>' + "\n"
2058
-
2059
- body += '<tr><td><span class="tooltip">' + compareStart.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + ' - ' + compareEnd.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + '<span class="tooltiptext">The stream finder stream will automatically switch between games according to your uploaded preferences. This stream is not affiliated with Baseball Reference, do not contact them for support. Visit <a href="http://bit.ly/bbrefsf">http://bit.ly/bbrefsf</a> to create and export your preferences, then upload and save them to mlbserver <a href="#streamfinder">below</a>. Does not support adaptive bitrate switching, will default to 720p60 resolution if not specified.</span></span></td><td>'
2299
+ body += '</div>' + "\n"
2300
+ body += subscriptionInfo('gameChangerInfo', 'gameChanger')
2301
+ body += '<div class="flex-between subscriptionStream">' + "\n"
2302
+ body += '<span class="tinytext">' + compareStart.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + ' - ' + compareEnd.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + '</span>'
2060
2303
  if ( (currentDate >= compareStart) && (currentDate < compareEnd) ) {
2061
2304
  let streamURL = server + '/gamechanger.m3u8?streamFinder=on'
2062
2305
  let multiviewquerystring = '/gamechanger.m3u8?streamFinder=on&resolution=' + DEFAULT_MULTIVIEW_RESOLUTION + content_protect_b
@@ -2072,15 +2315,17 @@ app.get('/', async function(req, res) {
2072
2315
  if ( linkType == VALID_LINK_TYPES[4] ) {
2073
2316
  streamURL += '&filename=' + gameDate + ' Stream Finder'
2074
2317
  }
2075
- body += '<a href="' + streamURL + '">Stream Finder</a>'
2076
- body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + multiviewquerystring + '" onclick="addmultiview(this, [], excludeTeams)">'
2318
+ body += '<span>' + subscriptionInfoLabel('<a href="' + streamURL + '">Stream Finder</a>', 'streamFinderInfo')
2319
+ body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + multiviewquerystring + '" onclick="addmultiview(this, [], excludeTeams)"></span>'
2077
2320
  } else {
2078
- body += 'Stream Finder'
2321
+ body += subscriptionInfoLabel('Stream Finder', 'streamFinderInfo')
2079
2322
  }
2080
- body += '</td></tr>' + "\n"
2323
+ body += '</div>'
2324
+ body += subscriptionInfo('streamFinderInfo', 'streamFinder')
2081
2325
  }
2082
2326
  }
2083
2327
  }
2328
+ body += `</div></div>`
2084
2329
 
2085
2330
  if ( cache_data.dates && cache_data.dates[0] && cache_data.dates[0].games ) {
2086
2331
  for (var j = 0; j < cache_data.dates[0].games.length; j++) {
@@ -2088,43 +2333,70 @@ app.get('/', async function(req, res) {
2088
2333
 
2089
2334
  let game_started = false
2090
2335
 
2091
- let awayteam = cache_data.dates[0].games[j].teams['away'].team.abbreviation
2092
- let awayteam_abbr
2336
+ let parentAwayVis = 'is-invisible'
2337
+ let awayteam = cache_data.dates[0].games[j].teams['away'].team.clubName
2338
+ let awayteam_abbr = cache_data.dates[0].games[j].teams['away'].team.abbreviation
2093
2339
  if ( cache_data.dates[0].games[j].teams['away'].team.sport.name != 'Major League Baseball' ) {
2094
2340
  awayteam = cache_data.dates[0].games[j].teams['away'].team.shortName
2095
2341
  let parentOrgName = cache_data.dates[0].games[j].teams['away'].team.parentOrgName
2096
- if (parentOrgName != 'Office of the Commissioner') awayteam += ' (' + session.getParent(parentOrgName) + ')'
2342
+ if (parentOrgName != 'Office of the Commissioner') parentAwayVis = '';
2097
2343
  awayteam_abbr = cache_data.dates[0].games[j].teams['away'].team.abbreviation
2098
2344
  awayteam_level = session.getLevelNameFromSportId(cache_data.dates[0].games[j].teams['away'].team.sport.id)
2099
2345
  }
2100
- let hometeam = cache_data.dates[0].games[j].teams['home'].team.abbreviation
2101
- let hometeam_abbr
2346
+ let parentHomeVis = 'is-invisible'
2347
+ let hometeam = cache_data.dates[0].games[j].teams['home'].team.clubName
2348
+ let hometeam_abbr = cache_data.dates[0].games[j].teams['home'].team.abbreviation
2102
2349
  if ( cache_data.dates[0].games[j].teams['home'].team.sport.name != 'Major League Baseball' ) {
2103
2350
  hometeam = cache_data.dates[0].games[j].teams['home'].team.shortName
2104
2351
  let parentOrgName = cache_data.dates[0].games[j].teams['home'].team.parentOrgName
2105
- if (parentOrgName != 'Office of the Commissioner') hometeam += ' (' + session.getParent(parentOrgName) + ')'
2352
+ if (parentOrgName != 'Office of the Commissioner') parentHomeVis = '';
2106
2353
  hometeam_abbr = cache_data.dates[0].games[j].teams['home'].team.abbreviation
2107
2354
  hometeam_level = session.getLevelNameFromSportId(cache_data.dates[0].games[j].teams['home'].team.sport.id)
2108
2355
  }
2109
2356
 
2110
- let teams = ""
2357
+ let awayDisplay = awayteam
2358
+ let homeDisplay = hometeam
2359
+ /*
2111
2360
  if ( awayteam_abbr ) {
2112
- teams += '<span class="tooltip">' + awayteam + '<span class="tooltiptext">Team Abbreviation: ' + awayteam_abbr + ', level ' + awayteam_level + '</span></span>'
2113
- } else {
2114
- teams += awayteam
2361
+ awayDisplay =
2362
+ '<span class="tooltip">' +
2363
+ awayteam +
2364
+ '<span class="tooltiptext left">' +
2365
+ awayteam_abbr + ' (' + awayteam_level + ')' +
2366
+ '</span></span>'
2115
2367
  }
2116
- teams += " @ "
2368
+
2117
2369
  if ( hometeam_abbr ) {
2118
- teams += '<span class="tooltip">' + hometeam + '<span class="tooltiptext">Team Abbreviation: ' + hometeam_abbr + ', level ' + hometeam_level + '</span></span>'
2119
- } else {
2120
- teams += hometeam
2370
+ homeDisplay =
2371
+ '<span class="tooltip">' +
2372
+ hometeam +
2373
+ '<span class="tooltiptext right">' +
2374
+ hometeam_abbr + ' (' + hometeam_level + ')' +
2375
+ '</span></span>'
2121
2376
  }
2122
- let filename_teams = awayteam + " @ " + hometeam
2123
- let pitchers = ""
2124
- let state = "<br/>"
2377
+ */
2378
+
2379
+ let filename_teams = awayteam + ' @ ' + hometeam
2380
+ let awayPitcher = ''
2381
+ let homePitcher = ''
2382
+ let state = ''
2383
+
2384
+ let awayscore = ''
2385
+ let homescore = ''
2386
+
2387
+ let noteworthyTag = ''
2388
+ let noteworthyTooltip = ''
2389
+ let noteworthyCSS = ''
2390
+ let tooltipCSS = ''
2391
+ let tooltipTextCSS = ''
2392
+
2393
+ let streamSource = {
2394
+ homeTV: '',
2395
+ awayTV: '',
2396
+ }
2125
2397
 
2126
2398
  if ( cache_data.dates[0].games[j].status.startTimeTBD == true ) {
2127
- state += "Time TBD"
2399
+ state = 'Time TBD'
2128
2400
  } else {
2129
2401
  let startTime = new Date(cache_data.dates[0].games[j].gameDate)
2130
2402
  state += startTime.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true })
@@ -2138,8 +2410,8 @@ app.get('/', async function(req, res) {
2138
2410
  relative_inning = relative_inning < 0 ? 0 : relative_inning
2139
2411
  //if ( (scores == VALID_SCORES[1]) && (cache_data.dates[0].games[j].gameUtils.isLive || cache_data.dates[0].games[j].gameUtils.isFinal) && !cache_data.dates[0].games[j].gameUtils.isCancelled && !cache_data.dates[0].games[j].gameUtils.isPostponed ) {
2140
2412
  if ( (scores == VALID_SCORES[1]) && (abstractGameState != 'Preview') && (detailedState != 'Postponed') ) {
2141
- let awayscore = ''
2142
- let homescore = ''
2413
+ awayscore = ''
2414
+ homescore = ''
2143
2415
  if ( (inning_number != VALID_INNING_NUMBER[0]) && cache_data.dates[0].games[j].linescore && cache_data.dates[0].games[j].linescore.innings ) {
2144
2416
  awayscore = 0
2145
2417
  homescore = 0
@@ -2169,59 +2441,86 @@ app.get('/', async function(req, res) {
2169
2441
  }
2170
2442
  }
2171
2443
  if ( display_inning != '' ) {
2172
- state = "<br/>" + display_inning
2444
+ state = display_inning
2173
2445
  }
2174
2446
  } else {
2175
2447
  awayscore = cache_data.dates[0].games[j].teams['away'].score
2176
2448
  homescore = cache_data.dates[0].games[j].teams['home'].score
2177
- //if ( cache_data.dates[0].games[j].gameUtils.isLive && !cache_data.dates[0].games[j].gameUtils.isFinal ) {
2449
+ //if ( cache_data.dates[0].games[j].gameUtils.isLive && !cache_data.dates[0].games[j].gameUtils.isFinal ) {
2178
2450
  if ( abstractGameState == 'Live' ) {
2179
- state = "<br/>" + cache_data.dates[0].games[j].linescore.inningHalf.substr(0,1) + cache_data.dates[0].games[j].linescore.currentInning
2451
+ state = cache_data.dates[0].games[j].linescore.inningHalf.substr(0,1) + cache_data.dates[0].games[j].linescore.currentInning
2180
2452
  //} else if ( cache_data.dates[0].games[j].gameUtils.isFinal ) {
2181
2453
  } else if ( abstractGameState == 'Final' ) {
2182
- state = "<br/>" + detailedState
2454
+ state = detailedState
2183
2455
  }
2184
2456
  if ( cache_data.dates[0].games[j].flags ) {
2185
2457
  if ( cache_data.dates[0].games[j].flags.perfectGame == true ) {
2186
- state += "<br/>Perfect Game"
2458
+ noteworthyTag = 'PG'
2459
+ noteworthyTooltip = 'Perfect Game'
2460
+ noteworthyCSS = 'tag noteworthy'
2461
+ tooltipCSS = 'tooltip'
2462
+ tooltipTextCSS = 'tooltiptext'
2187
2463
  } else if ( cache_data.dates[0].games[j].flags.noHitter == true ) {
2188
- state += "<br/>No-Hitter"
2464
+ noteworthyTag = 'NH'
2465
+ noteworthyTooltip = 'No-Hitter'
2466
+ noteworthyCSS = 'tag noteworthy'
2467
+ tooltipCSS = 'tooltip'
2468
+ tooltipTextCSS = 'tooltiptext'
2469
+ } else {
2470
+ noteworthyTag = ''
2189
2471
  }
2190
2472
  }
2191
2473
  }
2192
- teams = awayteam + " " + awayscore + " @ " + hometeam + " " + homescore
2193
- //} else if ( cache_data.dates[0].games[j].gameUtils.isCancelled || cache_data.dates[0].games[j].gameUtils.isPostponed || cache_data.dates[0].games[j].gameUtils.isSuspended ) {
2474
+ //} else if ( cache_data.dates[0].games[j].gameUtils.isCancelled || cache_data.dates[0].games[j].gameUtils.isPostponed || cache_data.dates[0].games[j].gameUtils.isSuspended ) {
2194
2475
  } else if ( detailedState == 'Postponed' ) {
2195
- state = "<br/>" + detailedState
2196
- //} else if ( cache_data.dates[0].games[j].gameUtils.isDelayed ) {
2476
+ state = detailedState
2477
+ //} else if ( cache_data.dates[0].games[j].gameUtils.isDelayed ) {
2197
2478
  } else if ( detailedState.startsWith('Delayed') ) {
2198
- state += "<br/>" + detailedState
2479
+ state = detailedState
2199
2480
  }
2200
2481
 
2201
- var filename = gameDate + ' ' + filename_teams + ' '
2482
+ var filename = gameDate + ' ' + filename_teams + ' '
2483
+
2484
+ let scheduleVis = 'is-invisible'
2485
+ let doubleHeaderVis = 'is-invisible'
2486
+ let inningsVis = 'is-invisible'
2487
+ let blackoutVis = 'is-invisible'
2488
+ let blackoutToolTipVis = 'is-invisible'
2489
+ let blackoutType = ''
2490
+ let blackoutExpiry = ''
2491
+
2492
+ let scheduleDesc = ''
2493
+ let gameNo = ''
2494
+ let resumeText = ''
2495
+
2496
+ let highlightLink = ''
2497
+ let condensedLink = ''
2498
+ let recapLink = ''
2202
2499
 
2203
2500
  if ( cache_data.dates[0].games[j].doubleHeader != 'N' ) {
2204
- state += "<br/>Game " + cache_data.dates[0].games[j].gameNumber
2501
+ gameNo = cache_data.dates[0].games[j].gameNumber
2502
+ doubleHeaderVis = ''
2205
2503
  filename += 'Game ' + cache_data.dates[0].games[j].gameNumber + ' '
2206
2504
  }
2207
2505
  if ( cache_data.dates[0].games[j].description ) {
2208
- state += "<br/>" + cache_data.dates[0].games[j].description
2506
+ scheduleDesc = cache_data.dates[0].games[j].description
2507
+ scheduleVis = ''
2209
2508
  }
2210
2509
  if ( scheduledInnings != '9' ) {
2211
- state += "<br/>" + scheduledInnings + "-inning game"
2510
+ inningsVis = ''
2212
2511
  }
2213
2512
  var resumeStatus = false
2214
2513
  if ( cache_data.dates[0].games[j].resumeGameDate || cache_data.dates[0].games[j].resumedFromDate ) {
2215
- state += '<br/>Resum'
2514
+ let resumeText = ''
2216
2515
  let resumeDate
2217
2516
  if ( cache_data.dates[0].games[j].resumeGameDate ) {
2218
2517
  resumeDate = new Date(cache_data.dates[0].games[j].resumeDate)
2219
- state += 'ing on'
2518
+ resumeText += 'Resuming on'
2220
2519
  } else {
2221
2520
  resumeDate = new Date(cache_data.dates[0].games[j].resumedFrom)
2222
- state += 'ed from'
2521
+ resumeText += 'Resumed from'
2223
2522
  }
2224
- state += '<br/>' + resumeDate.toLocaleString('default', { month: 'long', day: 'numeric' })
2523
+ resumeText += '' + resumeDate.toLocaleString('default', { month: 'long', day: 'numeric' })
2225
2524
  // Also show the status by the media links, if one of them is live
2226
2525
  resumeStatus = 'archived'
2227
2526
  if ( (typeof cache_data.dates[0].games[j].broadcasts) != 'undefined' ) {
@@ -2235,61 +2534,82 @@ app.get('/', async function(req, res) {
2235
2534
  }
2236
2535
 
2237
2536
  if ( (cache_data.dates[0].games[j].teams['away'].probablePitcher && cache_data.dates[0].games[j].teams['away'].probablePitcher.fullName) || (cache_data.dates[0].games[j].teams['home'].probablePitcher && cache_data.dates[0].games[j].teams['home'].probablePitcher.fullName) ) {
2238
- pitchers = "<br/>"
2239
2537
  if ( cache_data.dates[0].games[j].teams['away'].probablePitcher && cache_data.dates[0].games[j].teams['away'].probablePitcher.fullName ) {
2240
2538
  if ( cache_data.dates[0].games[j].teams['away'].team.sport.name != 'Major League Baseball' ) {
2241
- pitchers += cache_data.dates[0].games[j].teams['away'].probablePitcher.fullName
2539
+ awayPitcher = getLastName(cache_data.dates[0].games[j].teams['away'].probablePitcher.fullName)
2242
2540
  } else {
2243
- pitchers += getLastName(cache_data.dates[0].games[j].teams['away'].probablePitcher.fullName)
2541
+ awayPitcher = getLastName(cache_data.dates[0].games[j].teams['away'].probablePitcher.fullName)
2244
2542
  }
2245
- pitchers += '</a>'
2246
2543
  } else {
2247
- pitchers += 'TBD'
2544
+ awayPitcher = 'TBD'
2248
2545
  }
2249
- pitchers += ' vs '
2250
2546
  if ( cache_data.dates[0].games[j].teams['home'].probablePitcher && cache_data.dates[0].games[j].teams['home'].probablePitcher.fullName ) {
2251
2547
  if ( cache_data.dates[0].games[j].teams['home'].team.sport.name != 'Major League Baseball' ) {
2252
- pitchers += cache_data.dates[0].games[j].teams['home'].probablePitcher.fullName
2548
+ homePitcher = getLastName(cache_data.dates[0].games[j].teams['home'].probablePitcher.fullName)
2253
2549
  } else {
2254
- pitchers += getLastName(cache_data.dates[0].games[j].teams['home'].probablePitcher.fullName)
2550
+ homePitcher = getLastName(cache_data.dates[0].games[j].teams['home'].probablePitcher.fullName)
2255
2551
  }
2256
- pitchers += '</a>'
2257
2552
  } else {
2258
- pitchers += 'TBD'
2553
+ homePitcher = 'TBD'
2259
2554
  }
2260
2555
  }
2261
2556
 
2262
- body += '<tr'
2557
+ function hexToRgba(hex, alpha) {
2558
+ const r = parseInt(hex.substring(0, 2), 16)
2559
+ const g = parseInt(hex.substring(2, 4), 16)
2560
+ const b = parseInt(hex.substring(4, 6), 16)
2561
+ return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + alpha + ')'
2562
+ }
2563
+
2263
2564
  let fav_style = ''
2264
- if ( argv.free && cache_data.dates[0].games[j].broadcasts && cache_data.dates[0].games[j].broadcasts[0] && cache_data.dates[0].games[j].broadcasts[0].freeGame ) {
2265
- body += ' class="freegame"'
2565
+ let freeGameVis = 'is-invisible'
2566
+ let favoritVis = 'is-invisible'
2567
+ let condensedCSS = 'videos shortButton'
2568
+ let recapCSS = 'videos shortButton'
2569
+ let highlightCSS = 'videos regularButton'
2570
+ let backgroundCSS = 'backgroundRegular'
2571
+
2572
+ if ( argv.free && cache_data.dates[0].games[j].broadcasts && cache_data.dates[0].games[j].broadcasts[0] && cache_data.dates[0].games[j].broadcasts[0].freeGame ) {
2573
+ freeGameVis = ''
2574
+
2266
2575
  } else if ( session.credentials.fav_teams.includes(cache_data.dates[0].games[j].teams['away'].team.abbreviation) || session.credentials.fav_teams.includes(cache_data.dates[0].games[j].teams['home'].team.abbreviation) ) {
2267
2576
  let fav_team = cache_data.dates[0].games[j].teams['away'].team.abbreviation
2268
2577
  if ( session.credentials.fav_teams.includes(cache_data.dates[0].games[j].teams['home'].team.abbreviation) ) {
2269
2578
  fav_team = cache_data.dates[0].games[j].teams['home'].team.abbreviation
2270
2579
  }
2271
- fav_style = ' style="color:#' + TEAM_COLORS[fav_team][0] + ';background:#' + TEAM_COLORS[fav_team][1] + ';"'
2272
- body += fav_style
2580
+
2581
+ favoritVis = ''
2582
+ let color1 = hexToRgba(TEAM_COLORS[fav_team][1], 0.2)
2583
+ let color2 = hexToRgba(TEAM_COLORS[fav_team][0], 0)
2584
+
2585
+ fav_style = ' style="background-image: linear-gradient(' + '0deg, ' + color1 + ', ' + color2 + '); border: 2px solid #' + TEAM_COLORS[fav_team][1] + '!important;; border-style: outset; border-radius: 3px;"'
2273
2586
  }
2274
- let description = ''
2587
+
2588
+ let gameLevel = session.getLevelNameFromSportId(cache_data.dates[0].games[j].teams['home'].team.sport.id);
2589
+ let gameLevelClass = 'level-' + gameLevel.toLowerCase().replace('+', '-plus')
2590
+
2275
2591
  if ( cache_data.dates[0].games[j].seriesDescription != 'Regular Season' ) {
2276
- description += cache_data.dates[0].games[j].seriesDescription + ': '
2592
+ gameLevel = cache_data.dates[0].games[j].seriesDescription
2277
2593
  }
2278
- body += '><td>' + description + teams + pitchers + state + '</td>'
2279
2594
 
2280
2595
  // Check if Winter League / MiLB game first
2281
- if ( (cache_data.dates[0].games[j].teams['away'].team.sport.id != levels['MLB']) && (cache_data.dates[0].games[j].teams['home'].team.sport.id != levels['MLB']) && (mediaType == 'MLBTV') ) {
2282
- body += "<td>"
2596
+ if ( (cache_data.dates[0].games[j].teams['away'].team.sport.id != levels['MLB']) && (cache_data.dates[0].games[j].teams['home'].team.sport.id != levels['MLB']) && (mediaTypeTV == 'MLBTV') ) {
2597
+ recapCSS = 'is-invisible'
2598
+ condensedCSS = 'is-invisible'
2599
+ highlightCSS = 'is-invisible'
2283
2600
  if ( cache_data.dates[0].games[j].broadcasts ) {
2284
2601
  let broadcastName = 'N/A'
2285
2602
  for (var k = 0; k < cache_data.dates[0].games[j].broadcasts.length; k++) {
2286
2603
  if ( cache_data.dates[0].games[j].broadcasts[k].name != 'Audio' ) {
2287
- broadcastName = mediaType
2604
+ broadcastName = mediaTypeTV
2288
2605
  break
2289
2606
  }
2290
2607
  }
2291
2608
  if ( broadcastName == 'N/A' ) {
2292
- body += broadcastName
2609
+ streamSource.homeTV +=
2610
+ '<span>' +
2611
+ '<span class="streamAction streamActionInactive">' + broadcastName + '</span>' +
2612
+ '</span>'
2293
2613
  } else {
2294
2614
  // Check if game should be live
2295
2615
  if ( (cache_data.dates[0].games[j].status.detailedState != 'Postponed') && (cache_data.dates[0].games[j].status.detailedState != 'Cancelled') ) {
@@ -2335,21 +2655,30 @@ app.get('/', async function(req, res) {
2335
2655
  }
2336
2656
  querystring += content_protect_b
2337
2657
  multiviewquerystring += content_protect_b
2338
- body += '<a href="' + thislink + querystring + '">' + broadcastName + '</a>'
2339
- body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + '/stream.m3u8' + multiviewquerystring + '" onclick="addmultiview(this)">'
2658
+
2659
+ streamSource.homeTV +=
2660
+ '<span>' +
2661
+ '<input type="checkbox" value="http://127.0.0.1:' +
2662
+ session.data.port +
2663
+ '/stream.m3u8' +
2664
+ multiviewquerystring +
2665
+ '" onclick="addmultiview(this)">' +
2666
+ '<span>' +
2667
+ '<a class="streamAction" href="' + thislink + querystring + '">' + broadcastName + '</a>' +
2668
+ '</span>' +
2669
+ '</span>'
2340
2670
  } else {
2341
- body += broadcastName
2671
+ streamSource.homeTV +=
2672
+ '<span>' +
2673
+ '<span class="streamAction streamActionInactive">' + broadcastName + '</span>' +
2674
+ '</span>'
2342
2675
  }
2343
2676
  }
2344
2677
  }
2345
2678
  }
2346
- body += "</td>"
2347
2679
  } else {
2348
2680
  // Begin MLB games
2349
- if ( (typeof cache_data.dates[0].games[j].broadcasts) == 'undefined' ) {
2350
- body += "<td></td>"
2351
- } else {
2352
- body += "<td>"
2681
+ if ( (typeof cache_data.dates[0].games[j].broadcasts) != 'undefined' ) {
2353
2682
  for (var k = 0; k < cache_data.dates[0].games[j].broadcasts.length; k++) {
2354
2683
  let broadcast = cache_data.dates[0].games[j].broadcasts[k]
2355
2684
  if ( broadcast.availableForStreaming ) {
@@ -2359,47 +2688,43 @@ app.get('/', async function(req, res) {
2359
2688
  } else if ( broadcast.language == 'es' ) {
2360
2689
  mediaTitle = 'Spanish'
2361
2690
  }
2362
- if ( mediaTitle == mediaType ) {
2363
- // for video, check that it's not in-market
2364
- /*if ( (mediaType == 'MLBTV') && await session.check_in_market(cache_data.dates[0].games[j].content.media.epg[k].items[x]) ) {
2365
- continue
2366
- }*/
2691
+ if ( mediaTitle == mediaTypeTV ) {
2692
+ // for video, check that it's not in-market
2693
+ /*if ( (mediaTypeTV == 'MLBTV') && await session.check_in_market(cache_data.dates[0].games[j].content.media.epg[k].items[x]) ) {
2694
+ continue
2695
+ }*/
2367
2696
 
2368
2697
  // check if language is not set (video) or it matches requested language
2369
2698
  if ( broadcast.language == language ) {
2370
- let teamabbr
2371
-
2372
- if ( broadcast.isNational ) {
2373
- teamabbr = 'NATIONAL'
2374
- } else {
2375
- teamabbr = hometeam
2376
- if ( broadcast.homeAway == 'away' ) {
2377
- teamabbr = awayteam
2378
- }
2379
- }
2380
2699
  let station = broadcast.callSign
2381
-
2382
- if ( pre_post_shows.pregame_shows && pre_post_shows.pregame_shows[broadcast.mediaId] ) {
2383
- station = '/' + station
2384
- }
2385
- if ( pre_post_shows.postgame_shows && pre_post_shows.postgame_shows[broadcast.mediaId] ) {
2386
- station += '/'
2700
+ let hasPregameShow = pre_post_shows.pregame_shows && pre_post_shows.pregame_shows[broadcast.mediaId]
2701
+ let hasPostgameShow = pre_post_shows.postgame_shows && pre_post_shows.postgame_shows[broadcast.mediaId]
2702
+ let prePostIndicator = ''
2703
+
2704
+ if ( hasPregameShow || hasPostgameShow ) {
2705
+ let prePostTitle = 'Pregame show'
2706
+ if ( hasPregameShow && hasPostgameShow ) {
2707
+ prePostTitle = 'Pre- & postgame shows'
2708
+ } else if ( hasPostgameShow ) {
2709
+ prePostTitle = 'Postgame show'
2710
+ }
2711
+ prePostIndicator =
2712
+ '<span class="prePostIndicator" title="' + prePostTitle + '">' +
2713
+ '<span class="prePostCircle ' + (hasPregameShow ? 'prePostCircleActive' : 'prePostCircleInactive') + '"></span>' +
2714
+ '<span class="prePostCircle ' + (hasPostgameShow ? 'prePostCircleActive' : 'prePostCircleInactive') + '"></span>' +
2715
+ '</span>'
2387
2716
  }
2388
2717
 
2389
- // display blackout tooltip, if necessary
2390
2718
  if ( blackouts[gamePk] && blackouts[gamePk].blackout_feeds && blackouts[gamePk].blackout_feeds.includes(broadcast.mediaId) ) {
2391
- body += '<span class="tooltip"><span class="blackout">' + teamabbr + '</span><span class="tooltiptext">' + blackouts[gamePk].blackout_type
2392
- if ( blackouts[gamePk].blackout_type != 'Not entitled' ) {
2393
- body += ' video blackout until approx. 2.5 hours after the game'
2394
- if ( blackouts[gamePk].blackoutExpiry ) {
2395
- body += ' (~' + blackouts[gamePk].blackoutExpiry.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true }) + ')'
2396
- }
2719
+ blackoutVis = ''
2720
+ blackoutType = blackouts[gamePk].blackout_type
2721
+
2722
+ if ( blackoutType != 'Not entitled' ) {
2723
+ blackoutToolTipVis = ''
2724
+ blackoutExpiry = blackouts[gamePk].blackoutExpiry.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric', hour12: true })
2725
+ // officially ~90 minutes, but more likely ~150 minutes or ~2.5 hours after the game ends
2397
2726
  }
2398
- body += '</span></span>'
2399
- } else {
2400
- body += teamabbr
2401
2727
  }
2402
- body += ': '
2403
2728
 
2404
2729
  if ( broadcast.mediaState && broadcast.mediaState.mediaStateCode && ((broadcast.mediaState.mediaStateCode == 'MEDIA_ON') || (broadcast.mediaState.mediaStateCode == 'MEDIA_ARCHIVE') || (abstractGameState == 'Final')) ) {
2405
2730
  let gameTime = new Date(cache_data.dates[0].games[j].gameDate)
@@ -2408,10 +2733,12 @@ app.get('/', async function(req, res) {
2408
2733
  game_started = true
2409
2734
  }
2410
2735
  let mediaId = broadcast.mediaId
2411
- if ( (mediaType == 'MLBTV') && (gameDate == today) && session.cache.media && session.cache.media[mediaId] && session.cache.media[mediaId].blackout && session.cache.media[mediaId].blackoutExpiry && (new Date(session.cache.media[mediaId].blackoutExpiry) > new Date()) ) {
2412
- body += '<span class="blackout">' + station + '</span>'
2736
+ if ( (mediaTypeTV == 'MLBTV') && (gameDate == today) && session.cache.media && session.cache.media[mediaId] && session.cache.media[mediaId].blackout && session.cache.media[mediaId].blackoutExpiry && (new Date(session.cache.media[mediaId].blackoutExpiry) > new Date()) ) {
2737
+ streamSource.awayTV +=
2738
+ '<span class="streamAction streamActionInactive blackoutstation">' + station + '</span>' +
2739
+ prePostIndicator
2413
2740
  } else {
2414
- let querystring
2741
+ let querystring
2415
2742
  querystring = '?mediaId=' + mediaId
2416
2743
 
2417
2744
  let multiviewquerystring = querystring + '&resolution=' + DEFAULT_MULTIVIEW_RESOLUTION
@@ -2427,7 +2754,7 @@ app.get('/', async function(req, res) {
2427
2754
  if ( startFrom != VALID_START_FROM[0] ) querystring += '&startFrom=' + startFrom
2428
2755
  if ( controls != VALID_CONTROLS[0] ) querystring += '&controls=' + controls
2429
2756
  }
2430
- if ( mediaType == 'MLBTV' ) {
2757
+ if ( mediaTypeTV == 'MLBTV' ) {
2431
2758
  if ( inning_half != VALID_INNING_HALF[0] ) querystring += '&inning_half=' + inning_half
2432
2759
  if ( inning_number != VALID_INNING_NUMBER[0] ) querystring += '&inning_number=' + relative_inning
2433
2760
  if ( skip != VALID_SKIP[0] ) querystring += '&skip=' + skip
@@ -2448,263 +2775,481 @@ app.get('/', async function(req, res) {
2448
2775
  }
2449
2776
  querystring += content_protect_b
2450
2777
  multiviewquerystring += content_protect_b
2451
- stationlink = '<a' + fav_style + ' href="' + thislink + querystring + '">' + station + '</a>'
2452
-
2453
- if ( blackouts[gamePk] && blackouts[gamePk].blackout_feeds && blackouts[gamePk].blackout_feeds.includes(broadcast.mediaId) ) {
2454
- body += '<span class="blackout">' + stationlink + '</span>'
2455
- } else {
2456
- body += stationlink
2457
- }
2458
- if ( mediaType == 'MLBTV' ) {
2459
- body += '<input type="checkbox" value="http://127.0.0.1:' + session.data.port + '/stream.m3u8' + multiviewquerystring + '" onclick="addmultiview(this, [\'' + awayteam + '\', \'' + hometeam + '\'])">'
2460
- }
2461
- if ( resumeStatus ) {
2462
- body += '('
2463
- // for suspended games that haven't finished yet, we can simply use the mediaState to determine the status
2464
- if ( resumeStatus == 'live' ) {
2465
- if ( broadcast.mediaState.mediaStateCode == 'MEDIA_ARCHIVE' ) {
2466
- body += '1'
2467
- } else {
2468
- body += '2'
2469
- }
2470
- // otherwise, for completed games, we need to check the airings data
2778
+ stationlink = '<a class="streamAction" href="' + thislink + querystring + '">' + station + '</a>'
2779
+
2780
+ let streamStationHtml = stationlink + prePostIndicator
2781
+ let resumeStreamSuffix = ''
2782
+ // For suspended games that are currently live, mark whether this feed is part 1 or part 2.
2783
+ if ( resumeStatus == 'live' ) {
2784
+ if ( broadcast.mediaState.mediaStateCode == 'MEDIA_ARCHIVE' ) {
2785
+ resumeStreamSuffix = '(1)'
2471
2786
  } else {
2472
- /*airings_data = await session.getAiringsData('', cache_data.dates[0].games[j].gamePk)
2473
- if ( airings_data.data && airings_data.data.Airings && (airings_data.data.Airings.length > 0) ) {
2474
- for (var y = 0; y < airings_data.data.Airings.length; y++) {
2475
- if ( airings_data.data.Airings[y].contentId == cache_data.dates[0].games[j].content.media.epg[k].items[x].contentId ) {
2476
- if ( (cache_data.dates[0].games[j].resumeDate && (cache_data.dates[0].games[j].resumeDate == airings_data.data.Airings[y].startDate)) || (cache_data.dates[0].games[j].resumedFrom && (cache_data.dates[0].games[j].gameDate == airings_data.data.Airings[y].startDate)) ) {
2477
- body += '2'
2478
- } else {
2479
- body += '1'
2480
- }
2481
- break
2482
- }
2483
- }
2484
- }*/
2787
+ resumeStreamSuffix = '(2)'
2485
2788
  }
2486
- body += ')'
2487
2789
  }
2790
+
2791
+ if (broadcast.homeAway == 'home') {
2792
+ streamSource.homeTV += '<span>'
2793
+
2794
+ if ( mediaTypeTV == 'MLBTV' ) {
2795
+ streamSource.homeTV +=
2796
+ '<input type="checkbox" value="http://127.0.0.1:' +
2797
+ session.data.port +
2798
+ '/stream.m3u8' +
2799
+ multiviewquerystring +
2800
+ '" onclick="addmultiview(this, [\'' +
2801
+ awayteam +
2802
+ '\', \'' +
2803
+ hometeam +
2804
+ '\'])">'
2805
+ }
2806
+
2807
+ streamSource.homeTV +=
2808
+ streamStationHtml +
2809
+ resumeStreamSuffix
2810
+
2811
+ streamSource.homeTV +=
2812
+ '</span>'
2813
+ } else if (broadcast.homeAway == 'away') {
2814
+ streamSource.awayTV += '<span>'
2815
+
2816
+ if ( mediaTypeTV == 'MLBTV' ) {
2817
+ streamSource.awayTV +=
2818
+ '<input type="checkbox" value="http://127.0.0.1:' +
2819
+ session.data.port +
2820
+ '/stream.m3u8' +
2821
+ multiviewquerystring +
2822
+ '" onclick="addmultiview(this, [\'' +
2823
+ awayteam +
2824
+ '\', \'' +
2825
+ hometeam +
2826
+ '\'])">'
2827
+ }
2828
+
2829
+ streamSource.awayTV +=
2830
+ streamStationHtml +
2831
+ resumeStreamSuffix
2832
+
2833
+ streamSource.awayTV +=
2834
+
2835
+ '</span>'
2836
+ }
2488
2837
  }
2489
2838
  // add YouTube link where available
2490
- /*if ( (mediaType == 'MLBTV') && cache_data.dates[0].games[j].content.media.epg[k].items[x].youtube && cache_data.dates[0].games[j].content.media.epg[k].items[x].youtube.videoId ) {
2839
+ /*if ( (mediaTypeTV == 'MLBTV') && cache_data.dates[0].games[j].content.media.epg[k].items[x].youtube && cache_data.dates[0].games[j].content.media.epg[k].items[x].youtube.videoId ) {
2491
2840
  body += '<a' + fav_style + ' href="https://www.youtube.com/watch?v=' + cache_data.dates[0].games[j].content.media.epg[k].items[x].youtube.videoId + '" target="_blank">' + station + '&UpperRightArrow;</a>'
2492
2841
  }*/
2493
2842
  } else {
2843
+ let inactiveStation = station
2844
+
2494
2845
  if ( blackouts[gamePk] && blackouts[gamePk].blackout_feeds && blackouts[gamePk].blackout_feeds.includes(broadcast.mediaId) ) {
2495
- body += '<s>' + station + '</s>'
2846
+ inactiveStation = '<span class="streamAction streamActionInactive blackoutstation">' + station + '</span>' + prePostIndicator
2847
+ backgroundCSS = 'backgroundBlackout'
2496
2848
  } else {
2497
- body += station
2849
+ inactiveStation = '<span class="streamAction streamActionInactive">' + station + '</span>' + prePostIndicator
2498
2850
  }
2499
- } // end media active check
2500
- body += ', '
2501
- } // end streaming available check
2851
+
2852
+ if (broadcast.homeAway == 'home') {streamSource.homeTV +=
2853
+ '<div>' +
2854
+ '<div>' +
2855
+ inactiveStation +
2856
+ '</div>' +
2857
+ '</div>' } else if (broadcast.homeAway == 'away') {streamSource.awayTV +=
2858
+ '<div>' +
2859
+ '<div>' +
2860
+ inactiveStation +
2861
+ '</div>' +
2862
+ '</div>' }
2863
+ }
2864
+ }
2502
2865
  }
2503
2866
  }
2504
2867
  }
2505
- if ( body.substr(-2) == ', ' ) {
2506
- body = body.slice(0, -2)
2507
- }
2508
- //if ( (mediaType == 'MLBTV') && (game_started) && cache_data.dates[0].games[j].content && cache_data.dates[0].games[j].content.summary && cache_data.dates[0].games[j].content.summary.hasHighlightsVideo ) {
2509
- if ( (mediaType == 'MLBTV') && (game_started) ) {
2510
- body += '<br/><a' + fav_style + ' href="#" onclick="showhighlights(\'' + cache_data.dates[0].games[j].gamePk + '\',\'' + gameDate + '\'); return false;">Highlights</a>'
2511
- }
2512
- }
2513
- body += "</td>"
2514
- body += "</tr>" + "\n"
2515
- }
2516
- }
2517
- }
2518
- body += "</table>" + "\n"
2519
-
2520
- if ( (Object.keys(blackouts).length > 0) ) {
2521
- body += '<span class="tooltip tinytext"><span class="blackout">strikethrough</span> indicates a live blackout or non-entitled content<span class="tooltiptext">Tap or hover over the team abbreviation to see an estimate of when the blackout will be lifted (officially ~90 minutes, but more likely ~150 minutes or ~2.5 hours after the game ends).</span></span>' + "\n"
2522
- if ( (Object.keys(pre_post_shows).length > 0) ) {
2523
- body += '<br/>'
2524
- }
2525
- }
2526
-
2527
- if ( (pre_post_shows.pregame_shows && (Object.keys(pre_post_shows.pregame_shows).length > 0)) || (pre_post_shows.postgame_shows && (Object.keys(pre_post_shows.postgame_shows).length > 0)) ) {
2528
- body += '<span class="tooltip tinytext">/slashes/ indicates a live pre- and/or post-game show<span class="tooltiptext">A /slash before the station indicates a pre-game show; a slash/ after the station indicates a post-game show. Pre- and post-game shows are only available live.</span></span>' + "\n"
2529
- if ( argv.free ) {
2530
- body += '<br/>'
2531
- }
2532
- }
2533
2868
 
2534
- if ( argv.free ) {
2535
- body += '<span class="freegame tooltip tinytext">green indicates a free game<span class="tooltiptext">Free games are available to anyone with an account, no subscription necessary. Blackouts still apply.</span></span>' + "\n"
2536
- }
2537
-
2538
- // Rename parameter back before displaying further links
2539
- if ( mediaType == 'MLBTV' ) {
2540
- mediaType = VALID_MEDIA_TYPES[0]
2541
- }
2869
+ if (mediaTypeTV == 'MLBTV' && game_started) {
2870
+ highlightLink +=
2871
+ '<a' +
2872
+ ' href="#" onclick="showhighlights(\'' +
2873
+ cache_data.dates[0].games[j].gamePk +
2874
+ '\',\'' +
2875
+ gameDate +
2876
+ '\'); return false;">Highlights</a>'
2877
+ } else { highlightLink += `Highlights`
2878
+ highlightCSS = 'videosInactive regularButton'
2879
+ }
2880
+
2881
+ if (mediaTypeTV == 'MLBTV' && abstractGameState == 'Final' && (detailedState != 'Postponed') ) {
2882
+ condensedLink +=
2883
+ '<a' +
2884
+ ' href="#" onclick="showcondensed(\'' +
2885
+ cache_data.dates[0].games[j].gamePk +
2886
+ '\',\'' +
2887
+ gameDate +
2888
+ '\'); return false;">Cond.</a>'
2889
+ } else { condensedLink += `Cond.`
2890
+ condensedCSS = 'videosInactive shortButton'}
2891
+
2892
+ if (mediaTypeTV == 'MLBTV' && abstractGameState == 'Final' && (detailedState != 'Postponed')) {
2893
+ recapLink +=
2894
+ '<a' +
2895
+ ' href="#" onclick="showrecap(\'' +
2896
+ cache_data.dates[0].games[j].gamePk +
2897
+ '\',\'' +
2898
+ gameDate +
2899
+ '\'); return false;">Recap</a>'
2900
+ } else {
2901
+ recapLink += `Recap`
2902
+ recapCSS = 'videosInactive shortButton'
2903
+ }
2542
2904
 
2543
- if ( mediaType == VALID_MEDIA_TYPES[0] ) {
2544
- body += '<p><span class="tooltip">Video<span class="tooltiptext">For video streams only: you can manually specifiy a video track (resolution) to use. Adaptive will let your client choose. Best will select either 1080p60 (MLB) or 720p60 (MiLB). 504p is default for multiview (see below).<br/><br/>None will allow to remove the video tracks, if you just want to listen to the audio while using the "start at inning" or "skip breaks" options enabled.</span></span>: '
2545
- body += '<button '
2546
- if ( resolution == 'best' ) body += 'class="default" '
2547
- body += 'onclick="resolution=\'best\';reload()">best</button> '
2548
- for (var i = 0; i < VALID_RESOLUTIONS.length; i++) {
2549
- body += '<button '
2550
- if ( resolution == VALID_RESOLUTIONS[i] ) body += 'class="default" '
2551
- body += 'onclick="resolution=\'' + VALID_RESOLUTIONS[i] + '\';reload()">' + VALID_RESOLUTIONS[i]
2552
- if ( DISPLAY_BANDWIDTHS[i] != '' ) {
2553
- body += '<br/><span class="tinytext">' + DISPLAY_BANDWIDTHS[i] + '</span>'
2554
2905
  }
2555
- body += '</button> '
2556
- }
2557
- body += '</p>' + "\n"
2558
-
2559
- body += '<p><span class="tooltip">Audio<span class="tooltiptext">For video streams only: you can manually specifiy which audio track to include. Some media players can accept them all and let you choose. Not all tracks are available for all games, and injected tracks may not work with skip options below.<br/><br/>If you select "none" for video above, picking an audio track here will make it an audio-only feed that supports the inning start and skip breaks options.</span></span>: '
2560
- for (var i = 0; i < VALID_AUDIO_TRACKS.length; i++) {
2561
- body += '<button '
2562
- if ( audio_track == VALID_AUDIO_TRACKS[i] ) body += 'class="default" '
2563
- body += 'onclick="audio_track=\'' + VALID_AUDIO_TRACKS[i] + '\';reload()">' + DISPLAY_AUDIO_TRACKS[i] + '</button> '
2564
- }
2565
- body += '</p>' + "\n"
2566
-
2567
- body += '<p><span class="tooltip">Captions<span class="tooltiptext">For video streams only: you can disable the caption track, if one is present. This is handy if you do not want to disable it in your player each time.</span></span>: '
2568
- for (var i = 0; i < VALID_CAPTIONS.length; i++) {
2569
- body += '<button '
2570
- if ( captions == VALID_CAPTIONS[i] ) body += 'class="default" '
2571
- body += 'onclick="captions=\'' + VALID_CAPTIONS[i] + '\';reload()">' + VALID_CAPTIONS[i] + '</button> '
2572
2906
  }
2573
- body += '</p>' + "\n"
2907
+ let pitcherVis = 'is-invisible'
2908
+ if (awayPitcher || homePitcher) { pitcherVis ='' }
2909
+ body +=`
2910
+ <div class="cardContainer ${backgroundCSS} ${gameLevelClass}" ${fav_style}>
2911
+
2912
+ <div class="cardHeader flex-between">
2913
+ <div class="flex-between">
2914
+ <div class="tag level ${gameLevelClass}">${gameLevel}</div>
2915
+
2916
+ </div>
2917
+ <div class="cardTags">
2918
+ <span class="tooltip ${scheduleVis}">
2919
+ <span class="tag schedule">SCD
2920
+ <span class="tooltiptext"> ${scheduleDesc}<br>${resumeText}</span>
2921
+ </span>
2922
+ </span>
2923
+ <span class="tooltip ${doubleHeaderVis}">
2924
+ <span class="tag gameno">G${gameNo}
2925
+ <span class="tooltiptext"> Game ${gameNo}</span>
2926
+ </span>
2927
+ </span>
2928
+ <span class="tooltip ${inningsVis}">
2929
+ <span class="tag innings">${scheduledInnings}IN
2930
+ <span class="tooltiptext">${scheduledInnings}-inning game </span>
2931
+ </span>
2932
+ </span>
2933
+ <span class="tooltip ${blackoutVis}">
2934
+ <span class="tag blackout">BLACKOUT
2935
+ <span class="tooltiptext">${blackoutType}
2936
+ <span class="${blackoutToolTipVis}"> video blackout until approx. ${blackoutExpiry}</span>
2937
+ </span>
2938
+ </span>
2939
+ </span>
2940
+ <span class="tooltip ${freeGameVis}">
2941
+ <span class="tag freegame">Free
2942
+ <span class="tooltiptext">Free Game</span>
2943
+ </span>
2944
+ </span>
2945
+ <span class="tooltip ${favoritVis}">
2946
+ <span class="tag favorite">FAV
2947
+ <span class="tooltiptext">Favorite team</span>
2948
+ </span>
2949
+ </span>
2950
+ <span class="${tooltipCSS}">
2951
+ <span class="${noteworthyCSS}">${noteworthyTag}
2952
+ <span class="${tooltipTextCSS}">${noteworthyTooltip}</span>
2953
+ </span>
2954
+ </span>
2955
+ <span class="time">${state}</span>
2956
+ </div>
2957
+ </div>
2958
+
2959
+ <div class="gameContent">
2960
+
2961
+ <div class="row ${gameLevel}">
2962
+ <div class="team">
2963
+ <span class="pitcher">${awayPitcher}</span>
2964
+ <span class="teamName">${awayteam}</span>
2965
+
2966
+
2967
+ </div>
2968
+ <div class="score">
2969
+ <span class="">${awayscore}</span>
2970
+ </div>
2971
+
2972
+ </div>
2973
+ <div class="parentOrg ${parentAwayVis}">` + cache_data.dates[0].games[j].teams['away'].team.parentOrgName + `</div>
2974
+
2975
+ <div class="row ${gameLevel}">
2976
+ <div class="team">
2977
+ <span class="pitcher">${homePitcher}</span>
2978
+ <span class="teamName">${hometeam}</span>
2979
+
2980
+
2981
+ </div>
2982
+ <div class="score">
2983
+ <span class="">${homescore}</span>
2984
+ </div>
2985
+ </div>
2986
+ <div class="parentOrg ${parentHomeVis}">` + cache_data.dates[0].games[j].teams['home'].team.parentOrgName + `</div>
2987
+
2988
+
2989
+
2990
+
2991
+ </div>
2992
+ <div class="streamContent">
2993
+ <span class="">${streamSource.awayTV}</span>
2994
+ <span class="flex-center ${highlightCSS}">★ ${highlightLink}</span>
2995
+ </div>
2996
+
2997
+ <div class="streamContent">
2998
+ <span class="">${streamSource.homeTV}</span>
2999
+ <div class="flex-center">
3000
+ <span class="flex-center ${recapCSS}">${recapLink}</span>
3001
+ <span class="flex-center ${condensedCSS}">${condensedLink}</span>
3002
+ </div>
3003
+ </div>
3004
+
2574
3005
 
2575
- body += '<p><span class="tooltip">Skip<span class="tooltiptext">For video streams only (use the video "none" option above to apply it to audio streams): you can remove all breaks, idle time, non-action pitches, or only commercial breaks from the stream (useful to make your own "condensed games").<br/><br/>NOTES: skip timings are only generated when the stream is loaded -- so for live games, it will only skip up to the time you loaded the stream. Also, commercial skip will not work on pre-2024 games, or on MiLB games -- use skip breaks instead.</span></span>: '
2576
- for (var i = 0; i < VALID_SKIP.length; i++) {
2577
- body += '<button '
2578
- if ( skip == VALID_SKIP[i] ) body += 'class="default" '
2579
- body += 'onclick="skip=\'' + VALID_SKIP[i] + '\';reload()">' + VALID_SKIP[i] + '</button> '
2580
- }
2581
- if ( skip != VALID_SKIP[0] ) {
2582
- body += '<br><span class="tooltip">Skip Adjust<span class="tooltiptext">Seconds to adjust the skip time video segments, if necessary. Try a negative number if the plays are ending before the video segments begin; use a positive number if the video segments are ending before the play happens.</span></span>: <input type="number" id="skip_adjust" value="' + skip_adjust + '" step="1" onchange="setTimeout(function(){skip_adjust=document.getElementById(\'skip_adjust\').value;reload()},750)" onblur="skip_adjust=this.value;reload()" style="vertical-align:top;font-size:.8em;width:3em"/>'
2583
- }
2584
- body += '</p>' + "\n"
3006
+ </div>`
2585
3007
  }
3008
+ }
2586
3009
 
2587
- body += '<p><span class="tooltip">Pad<span class="tooltiptext">You can pad archive streams with random extra time at the end, to help conceal timeline spoilers.</span></span>: '
2588
- for (var i = 0; i < VALID_PAD.length; i++) {
2589
- body += '<button '
2590
- if ( pad == VALID_PAD[i] ) body += 'class="default" '
2591
- body += 'onclick="pad=\'' + VALID_PAD[i] + '\';reload()">' + VALID_PAD[i] + '</button> '
2592
- }
2593
- body += '</p>' + "\n"
3010
+ body +=`</div>`
2594
3011
 
2595
- if ( (linkType == VALID_LINK_TYPES[1]) && (gameDate == today) ) {
2596
- body += '<p><span class="tooltip">Force VOD<span class="tooltiptext">For streams only: if your client does not support seeking in mlbserver live streams, turning this on will make the stream look like a VOD stream instead, allowing the client to start at the beginning and allowing the user to seek within it. You will need to reload the stream to watch/view past the current time, though.</span></span>: '
2597
- for (var i = 0; i < VALID_FORCE_VOD.length; i++) {
2598
- body += '<button '
2599
- if ( force_vod == VALID_FORCE_VOD[i] ) body += 'class="default" '
2600
- body += 'onclick="force_vod=\'' + VALID_FORCE_VOD[i] + '\';reload()">' + VALID_FORCE_VOD[i] + '</button> '
2601
- }
2602
- body += '<span class="tinytext">(if client does not support seeking in live streams)</span></p>' + "\n"
2603
- }
3012
+ body +=`<div class="section">`
3013
+
2604
3014
 
2605
3015
  if ( mediaType == VALID_MEDIA_TYPES[0] ) {
2606
- body += '<table><tr><td><table><tr><td>1</td><td>2</tr><tr><td>3</td><td>4</td></tr></table><td><span class="tooltip">Multiview / Alternate Audio / Sync<span class="tooltiptext">For video streams only: create a new live stream combining 1-4 separate video streams, using the layout shown at left (if more than 1 video stream is selected). Check the boxes next to feeds above to add/remove them, then click "Start" when ready, "Stop" when done watching, or "Restart" to stop and start with the currently selected streams. May take up to 15 seconds after starting before it is ready to play.<br/><br/>No video scaling is performed: defaults to 540p video for each stream, which can combine to make one 1080p stream. Audio defaults to English (TV) audio. If you specify a different audio track instead, you can use the box after each URL below to adjust the sync in seconds (use positive values if audio is early and the audio stream needs to be padded with silence at the beginning to line up with the video; negative values if audio is late, and audio needs to be trimmed from the beginning.)<br/><br/>TIP #1: You can enter just 1 video stream here, at any resolution, to take advantage of the audio sync or alternate audio features without using multiview -- a single video stream will not be re-encoded and will be presented at its full resolution.<br/><br/>TIP #2: You can also manually enter streams from other sources like <a href="https://www.npmjs.com/package/milbserver" target="_blank">milbserver</a> in the boxes below. Make sure any manually entered streams have the desired resolution.<br/><br/>WARNING #1: if the mlbserver process dies or restarts while multiview is active, the ffmpeg encoding process will be orphaned and must be killed manually.<br/><br/>WARNING #2: If you did not specify a hardware encoder for ffmpeg on the command line, this will use your server CPU for encoding. Either way, your system may not be able to keep up with processing 4 video streams at once. Try fewer streams if you have perisistent trouble.</span></span>: <a id="startmultiview" href="" onclick="startmultiview(this);return false">Start'
3016
+ body += '<div class="menuContainer"><div class="cardMenuHeader">Multiview / Alternate Audio / Sync</div><div class="menuContent multiviewContent">' +
3017
+ '<div class="multiviewHeader"><div class="is-flex"><span>For video streams only: create a new live stream combining 1-4 separate video streams, using the layout shown at left (if more than 1 video stream is selected). Check the boxes next to feeds above to add/remove them, then click "Start" when ready, "Stop" when done watching, or "Restart" to stop and start with the currently selected streams.&nbsp;<span class="info infoPadding multiviewInfoButton" data-target="#multiviewInfo">?</span></span></div><div class="multiviewActions"><a id="startmultiview" class="multiviewAction multiviewActionPrimary" href="" onclick="startmultiview(this);return false">Start'
2607
3018
  if ( ffmpeg_status ) body += 'ed'
2608
- body += '</a> | <a id="stopmultiview" href="" onclick="stopmultiview(this);return false">Stop'
3019
+ body += '</a><a id="stopmultiview" class="multiviewAction" href="" onclick="stopmultiview(this);return false">Stop'
2609
3020
  if ( !ffmpeg_status ) body += 'ped'
2610
- body += '</a><br/>' + "\n"
2611
- body += '<span class="tinytext">(check boxes next to games to add, then click "Start";<br/>must click "Stop" link above when done, or manually kill ffmpeg)</span></td></tr><tr><td colspan="2">' + "\n"
3021
+ body += '</a></div></div>' + "\n" +
3022
+ '<div class="multiviewIntro"><div class="square-grid"><div>1</div><div>2</div><div>3</div><div>4</div></div>'
3023
+
3024
+ body += '<span>Check boxes next to games to add, then click Start. Click Stop when done, or manually kill ffmpeg.</span></div>' + ''
3025
+ body += `
3026
+ <div id="multiviewInfo" class="infoContainer">
3027
+ <div class="">
3028
+ <div class="infoContent">
3029
+ May take up to 15 seconds after starting before it is ready to play.<br/><br/>No video scaling is performed: defaults to 540p video for each stream, which can combine to make one 1080p stream. Audio defaults to English (TV) audio. If you specify a different audio track instead, you can use the box after each URL below to adjust the sync in seconds (use positive values if audio is early and the audio stream needs to be padded with silence at the beginning to line up with the video; negative values if audio is late, and audio needs to be trimmed from the beginning.)<br/><br/>TIP #1: You can enter just 1 video stream here, at any resolution, to take advantage of the audio sync or alternate audio features without using multiview -- a single video stream will not be re-encoded and will be presented at its full resolution.<br/><br/>TIP #2: You can also manually enter streams from other sources like <a href="https://www.npmjs.com/package/milbserver" target="_blank">milbserver</a> in the boxes below. Make sure any manually entered streams have the desired resolution.<br/><br/>WARNING #1: if the mlbserver process dies or restarts while multiview is active, the ffmpeg encoding process will be orphaned and must be killed manually.<br/><br/>WARNING #2: If you did not specify a hardware encoder for ffmpeg on the command line, this will use your server CPU for encoding. Either way, your system may not be able to keep up with processing 4 video streams at once. Try fewer streams if you have perisistent trouble.
3030
+ </div>
3031
+ </div>
3032
+
3033
+ </div>
3034
+ `
3035
+
2612
3036
  for (var i=1; i<=4; i++) {
2613
- body += i + ': <textarea id="multiview' + i + '" rows=2 cols=60 oninput="this.value=stream_substitution(this.value)"></textarea>'
2614
- body += '<input type="number" id="sync' + i + '" value="0.0" step=".1" style="vertical-align:top;font-size:.8em;width:3em"/>'
2615
- body += '<br/>' + "\n"
3037
+ body += '<div class="multiviewStreamRow"><label for="multiview' + i + '">' + i + ':</label><textarea id="multiview' + i + '" oninput="this.value=stream_substitution(this.value)"></textarea>'
3038
+ body += '<input type="number" id="sync' + i + '" value="0.0" step=".1"/></div>' + "\n"
2616
3039
  }
2617
- body += '<input type="checkbox" id="dvr"/> <span class="tooltip">DVR: allow pausing/seeking multiview<span class="tooltiptext">If this is enabled, it will use more disk space but you will be able to pause and seek in the multiview stream. Not necessary if you are strictly watching live.</span></span><br/>' + "\n"
2618
- body += '<input type="checkbox" id="faster" onchange="if (this.checked){document.getElementById(\'dvr\').checked=true}"/> <span class="tooltip">Encode faster than real-time<span class="tooltiptext">Implies DVR. Not necessary for live streams (which are only delivered in real-time), but if you want to seek ahead in archive streams using multiview, you may want to enable this. WARNING: ffmpeg may approach 100% CPU usage if you use this while combining multiple archive video streams in multiview.</span></span><br/>' + "\n"
2619
- body += '<input type="checkbox" id="reencode"/> <span class="tooltip">Re-encode all audio<span class="tooltiptext">Uses more CPU. Generally only necessary if you need the multiview stream to continue after one of the individual streams has ended. (Any streams with sync adjustments above will automatically be re-encoded, regardless of this setting.)</span></span><br/>' + "\n"
2620
- body += '<input type="checkbox" id="park_audio"/> <span class="tooltip">Park audio: filter out announcers<span class="tooltiptext">Implies re-encoding all audio. If this is enabled, an extra audio filter is applied to remove the announcer voices.</span></span><br/>' + "\n"
2621
- body += '<hr><span class="tooltip">Alternate audio URL and sync<span class="tooltiptext">Optional: you can also include a separate audio-only URL as an additional alternate audio track. Archive games will likely require a very large negative sync value, as the radio broadcasts may not be trimmed like the video archives.</span></span>:<br/><textarea id="audio_url" rows=2 cols=60 oninput="this.value=stream_substitution(this.value)"></textarea><input id="audio_url_seek" type="number" value="0" style="vertical-align:top;font-size:.8em;width:4em"/>'
2622
- body += '<hr>Watch: <a href="' + http_root + '/embed.html?msrc=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Embed</a> | <a href="' + http_root + '/stream.m3u8?src=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Stream</a> | <a href="' + http_root + '/chromecast.html?msrc=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Chromecast</a> | <a href="' + http_root + '/advanced.html?msrc=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Advanced</a> | <a href="' + http_root + '/download.ts?src=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '&filename=' + gameDate + ' Multiview">Download</a><br/><span class="tinytext">Kodi STRM files: <a href="' + http_root + '/kodi.strm?src=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Matrix/19+</a> (<a href="' + http_root + '/kodi.strm?version=18&src=' + encodeURIComponent(multiview_stream_url) + content_protect_b + '">Leia/18</a>)</span>'
2623
- body += '</td></tr></table><br/>' + "\n"
3040
+ body += `
3041
+ <div class="is-flex">
3042
+ <input type="checkbox" id="dvr"/>
3043
+ <span>DVR: allow pausing/seeking multiview</span>
3044
+ <div class="info infoPadding" data-target="#dvrInfo">?</div>
3045
+ </div>
3046
+ <div id="dvrInfo" class="infoContainer">
3047
+ <div class="infoContent">
3048
+ If this is enabled, it will use more disk space but you will be able to pause and seek in the multiview stream. Not necessary if you are strictly watching live.
3049
+ </div>
3050
+ </div>
3051
+
3052
+ <div class="is-flex">
3053
+ <input type="checkbox" id="faster" onchange="if (this.checked){document.getElementById('dvr').checked=true}"/>
3054
+ <span>Encode faster than real-time</span>
3055
+ <div class="info infoPadding" data-target="#fasterInfo">?</div>
3056
+ </div>
3057
+ <div id="fasterInfo" class="infoContainer">
3058
+ <div class="infoContent">
3059
+ Implies DVR. Not necessary for live streams (which are only delivered in real-time), but if you want to seek ahead in archive streams using multiview, you may want to enable this. WARNING: ffmpeg may approach 100% CPU usage if you use this while combining multiple archive video streams in multiview.
3060
+ </div>
3061
+ </div>
3062
+
3063
+ <div class="is-flex">
3064
+ <input type="checkbox" id="reencode"/>
3065
+ <span>Re-encode all audio</span>
3066
+ <div class="info infoPadding" data-target="#reencodeInfo">?</div>
3067
+ </div>
3068
+ <div id="reencodeInfo" class="infoContainer">
3069
+ <div class="infoContent">
3070
+ Uses more CPU. Generally only necessary if you need the multiview stream to continue after one of the individual streams has ended. Any streams with sync adjustments above will automatically be re-encoded, regardless of this setting.
3071
+ </div>
3072
+ </div>
3073
+
3074
+ <div class="is-flex">
3075
+ <input type="checkbox" id="park_audio"/>
3076
+ <span>Park audio: filter out announcers</span>
3077
+ <div class="info infoPadding" data-target="#parkAudioInfo">?</div>
3078
+ </div>
3079
+ <div id="parkAudioInfo" class="infoContainer">
3080
+ <div class="infoContent">
3081
+ Implies re-encoding all audio. If this is enabled, an extra audio filter is applied to remove the announcer voices.
3082
+ </div>
3083
+ </div>`
3084
+
3085
+ body += `
3086
+ <div class="is-flex">
3087
+ <span>Alternate audio URL and sync</span>
3088
+ <div class="info infoPadding" data-target="#altAudio">?</div>
3089
+ </div>
3090
+ <div id="altAudio" class="infoContainer">
3091
+ <div class="infoContent">
3092
+ Optional: you can also include a separate audio-only URL as an additional alternate audio track. Archive games will likely require a very large negative sync value, as the radio broadcasts may not be trimmed like the video archives.
3093
+ </div>
3094
+ </div>
3095
+ <div class="multiviewAudioRow">
3096
+ <textarea id="audio_url" oninput="this.value=stream_substitution(this.value)"></textarea>
3097
+ <input id="audio_url_seek" type="number" value="0"/>
3098
+ </div>
3099
+ <div class="multiviewWatchLinks">
3100
+ <a class="channelLink" href="${http_root}/embed.html?msrc=${encodeURIComponent(multiview_stream_url)}${content_protect_b}">Embed</a>
3101
+ <a class="channelLink" href="${http_root}/stream.m3u8?src=${encodeURIComponent(multiview_stream_url)}${content_protect_b}">Stream</a>
3102
+ <a class="channelLink" href="${http_root}/chromecast.html?msrc=${encodeURIComponent(multiview_stream_url)}${content_protect_b}">Chromecast</a>
3103
+ <a class="channelLink" href="${http_root}/advanced.html?msrc=${encodeURIComponent(multiview_stream_url)}${content_protect_b}">Advanced</a>
3104
+ <a class="channelLink" href="${http_root}/download.ts?src=${encodeURIComponent(multiview_stream_url)}${content_protect_b}&filename=${gameDate} Multiview">Download</a>
3105
+ </div>
3106
+ <div class="multiviewWatchLinks">
3107
+ <span class="channelNote">Kodi STRM files:</span>
3108
+ <a class="channelLink" href="${http_root}/kodi.strm?src=${encodeURIComponent(multiview_stream_url)}${content_protect_b}">Matrix/19+</a>
3109
+ <a class="channelLink" href="${http_root}/kodi.strm?version=18&src=${encodeURIComponent(multiview_stream_url)}${content_protect_b}">Leia/18</a>
3110
+ </div>
3111
+ </div></div>
3112
+ `
2624
3113
 
2625
- body += '<table><tr><td><p><a name="streamfinder"/><span class="tooltip">Stream Finder Settings<span class="tooltiptext">Automatically switches between games according to your preferences. This program is not affiliated with Baseball Reference, do not contact them for support.</span></span></p><p><a download="mlbserverStreamFinder.txt" href="' + http_root + '/downloadsettings' + content_protect_a + '">Click to Download Currently Stored Settings</a></p><p><b><u>Step 1</b></u><br/>Export and download your desired Stream Finder settings at this link:<br/><a href="https://www.baseball-reference.com/stream-finder.shtml" target="_blank">https://www.baseball-reference.com/stream-finder.shtml</a></p><form method="POST" enctype="multipart/form-data" action="' + http_root + '/upload' + content_protect_a + '"><p><b><u>Step 2</b></u><br/>Click this button and select the settings file you just downloaded:<br/><input name="file" type="file" onchange="form.submit()"/></p></form></td></tr></table><br/>' + "\n"
2626
- }
2627
-
2628
- body += '<table><tr><td>' + "\n"
2629
-
2630
- body += '<p><span class="tooltip">Live Channel Playlist, XMLTV Guide, ICS Calendar<span class="tooltiptext">Allows you to generate a M3U playlist of channels, and an XML file of guide listings for those channels, to import into TV/DVR/PVR software like Tvheadend or Jellyfin. You can also subscribe to the calendar links in your preferred calendar program/service to set up event notifications.<br/><br/>NOTE: May be helpful to specify a resolution above.</span></span>:</p>' + "\n"
2631
-
2632
- body += '<p><span class="tooltip">Scan Mode<span class="tooltiptext">During setup, some TV/DVR/PVR software will attempt to load all stream URLs. Turning Scan Mode ON will return a sample stream for all stream requests, thus satisfying that software without overloading mlbserver or excluding streams which aren\'t currently live. Once the channels are set up, turning Scan Mode OFF will restore normal stream behavior.<br/><br/>WARNING: Be sure your TV/DVR/PVR software doesn\'t periodically scan all channels automatically or you might overload mlbserver.</span></span>: '
3114
+ body += `
3115
+ <div class="menuContainer">
3116
+ <div class="cardMenuHeader">Stream Finder Settings</div>
3117
+ <div class="menuContent streamFinderContent">
3118
+ <div>Automatically switches between games according to your preferences. This program is not affiliated with Baseball Reference, do not contact them for support.</div>
3119
+ <div>
3120
+ <a class="channelLink" download="mlbserverStreamFinder.txt" href="${http_root}/downloadsettings${content_protect_a}">Download current settings</a>
3121
+ </div>
3122
+ <div class="streamFinderSteps">
3123
+ <div class="streamFinderStep">
3124
+ <div class="streamFinderStepNumber">1</div>
3125
+ <div class="streamFinderStepContent">
3126
+ <div class="streamFinderStepTitle">Export settings</div>
3127
+ <div class="streamFinderStepText">Create and export your Stream Finder settings at Baseball Reference.</div>
3128
+ <a class="channelLink" href="https://www.baseball-reference.com/stream-finder.shtml" target="_blank">Open Stream Finder</a>
3129
+ </div>
3130
+ </div>
3131
+ <div class="streamFinderStep">
3132
+ <div class="streamFinderStepNumber">2</div>
3133
+ <div class="streamFinderStepContent">
3134
+ <div class="streamFinderStepTitle">Upload settings</div>
3135
+ <div class="streamFinderStepText">Select the settings file you just downloaded.</div>
3136
+ <form method="POST" enctype="multipart/form-data" action="${http_root}/upload${content_protect_a}">
3137
+ <input name="file" type="file" onchange="form.submit()"/>
3138
+ </form>
3139
+ </div>
3140
+ </div>
3141
+ </div>
3142
+ </div>
3143
+ </div>` + "\n"
3144
+ }
3145
+ body +=`</div>`
3146
+ const channelInfoText = {
3147
+ scanMode: 'During setup, some TV/DVR/PVR software will attempt to load all stream URLs. Turning Scan Mode ON will return a sample stream for all stream requests, thus satisfying that software without overloading mlbserver or excluding streams which are not currently live. Once the channels are set up, turning Scan Mode OFF will restore normal stream behavior. WARNING: Be sure your TV/DVR/PVR software does not periodically scan all channels automatically or you might overload mlbserver.',
3148
+ allChannels: 'Will include all entitled live MLB broadcasts (games plus Big Inning, Game Changer, and Multiview, as well as MASN, MLB Network, SNLA, and/or SNY as appropriate). If favorite team(s) have been provided, it will also include affiliate games for those organizations. Channels/games subject to blackout will be omitted by default.',
3149
+ byTeam: 'Including a team (MLB only, by abbreviation, in a comma-separated list if more than 1) will include all of its broadcasts, or if that team is not broadcasting the game, it will include the national broadcast or opponent broadcast if available. It will also include affiliate games for those organizations. Channels/games subject to blackout will be omitted by default.',
3150
+ byTeamRadio: 'Same as By team, but defaults to that team radio track, if available.',
3151
+ byTeamSpanish: 'Same as By team, but defaults to that team Spanish radio track, if available.',
3152
+ includeBlackouts: 'Adds an optional parameter to include channels/games subject to blackout, although you may not be able to play those games.',
3153
+ excludeTeam: 'Excluding a team (MLB only, by abbreviation, in a comma-separated list if more than 1) will exclude every game involving that team. Blackouts are already excluded without this parameter.',
3154
+ winterLeagues: 'Winter leagues include the Arizona Fall League, Dominican Winter League aka Liga de Beisbol Dominicano, and Mexican Winter League aka Liga Mexicana del Pacifico. Live stream only, does not support starting from the beginning or certain innings, skip options, etc.',
3155
+ masn: 'MASN live stream for entitled subscribers. See https://support.mlb.com/s/article/MASN-In-Market-Offering for more information.',
3156
+ mlbn: 'MLB Network live stream is now available in the USA for paid MLBTV subscribers or as a paid add-on, in addition to authenticated TV subscribers. See https://support.mlb.com/s/article/MLB-Network-Streaming-FAQ for more information.',
3157
+ snla: 'SNLA live stream for entitled subscribers. See https://support.mlb.com/s/article/SNLA-Plus-Subscription-Packages for more information.',
3158
+ sny: 'SNY live stream for entitled subscribers. See https://support.mlb.com/s/article/SNY-In-Market-Offering for more information.',
3159
+ bigInning: 'Big Inning is the live look-in and highlights show. See https://www.mlb.com/live-stream-games/big-inning for more information.',
3160
+ gameChanger: 'The game changer stream will automatically switch between the highest leverage active live non-blackout games, and should be available whenever there are such games available. Does not support adaptive bitrate switching, will default to best resolution if not specified.',
3161
+ streamFinder: 'The stream finder stream will automatically switch between games according to your uploaded preferences. This stream is not affiliated with Baseball Reference, do not contact them for support. Visit http://bit.ly/bbrefsf to create and export your preferences, then upload and save them to mlbserver above. Does not support adaptive bitrate switching, will default to best resolution if not specified.',
3162
+ multiviewChannel: 'Requires starting and stopping the multiview stream from the web interface.',
3163
+ freeGames: 'Only includes games marked as free. Blackouts still apply. Channels/games subject to blackout will be omitted by default.',
3164
+ includeOrgs: 'Including an organization (by MLB team abbreviation, in a comma-separated list if more than 1) will include all of its affiliate broadcasts, or if that affiliate is not broadcasting the game, it will include the opponent broadcast if available. If this option is not specified, but favorite team(s) have been provided, affiliate games for those organizations will be included anyway.',
3165
+ includeLevels: 'Including a level (AAA, AA, A+ encoded as A%2B, or A, in a comma-separated list if more than 1) will include all of its broadcasts, and exclude all other levels.',
3166
+ includeTeamsInTitles: 'Adds an optional parameter to include team names in the ICS/XML titles. A value of "channels" will format the titles in the style of the legacy Channels container.',
3167
+ offAir: 'Adds an optional parameter to create "Off Air" events in the XML guide, listing the time of the next game on that channel. A value of "channels" will format the events in the style of the legacy Channels container.'
3168
+ }
3169
+
3170
+ function channelInfo(label, id) {
3171
+ return `<div class="channelLabel"><span>${label}</span><div class="info infoPadding" data-target="#${id}">?</div></div><div id="${id}" class="infoContainer"><div class="infoContent">${channelInfoText[id]}</div></div>`
3172
+ }
3173
+
3174
+ body += `
3175
+ <div class="section">`
3176
+
3177
+ body += '<div class="menuContainer"><div class="cardMenuHeader">Live Channel Playlist, XMLTV Guide, ICS Calendar</div><div class="menuContent channelContent">' + "\n"
3178
+ body += '<span> Allows you to generate a M3U playlist of channels, and an XML file of guide listings for those channels, to import into TV/DVR/PVR software like Tvheadend or Jellyfin. You can also subscribe to the calendar links in your preferred calendar program/service to set up event notifications. NOTE: May be helpful to specify a resolution above.</span>'
3179
+
3180
+ body += '<div class="channelScanMode"><div>' + channelInfo('Scan Mode', 'scanMode') + '</div><div class="channelLinks">'
2633
3181
  for (var i = 0; i < VALID_SCAN_MODES.length; i++) {
2634
3182
  body += '<button '
2635
3183
  if ( scan_mode == VALID_SCAN_MODES[i] ) body += 'class="default" '
2636
3184
  body += 'onclick="scan_mode=\'' + VALID_SCAN_MODES[i] + '\';reload()">' + VALID_SCAN_MODES[i] + '</button> '
2637
3185
  }
2638
- body += ' <span class="tinytext">(ON plays sample for all stream requests)</span></p>' + "\n"
3186
+ body += ' <span class="channelNote">(ON plays sample for all stream requests)</span></div></div>' + "\n"
2639
3187
 
2640
3188
  if ( !req.query.resolution ) {
2641
3189
  resolution = 'best'
2642
3190
  }
2643
3191
 
2644
- body += '<p><span class="tooltip">All<span class="tooltiptext">Will include all entitled live MLB broadcasts (games plus Big Inning, Game Changer, and Multiview, as well as MASN, MLB Network, SNLA, and/or SNY as appropriate). If favorite team(s) have been provided, it will also include affiliate games for those organizations. Channels/games subject to blackout will be omitted by default. See below for an additional option to override that.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + content_protect_b + '">channels.m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + content_protect_b + '">guide.xml</a> and <a href="' + http_root + '/calendar.ics?mediaType=' + mediaType + content_protect_b + '">calendar.ics</a></p>' + "\n"
3192
+ body += '<div class="channelRow"><div>' + channelInfo('All', 'allChannels') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + content_protect_b + '">xml</a><a class="channelLink" href="' + http_root + '/calendar.ics?mediaType=' + mediaType + content_protect_b + '">ics</a></div></div>' + "\n"
2645
3193
 
2646
3194
  let include_teams = 'ath,atl'
2647
3195
  if ( (session.credentials.fav_teams.length > 0) && (session.credentials.fav_teams[0].length > 0) ) {
2648
3196
  include_teams = session.credentials.fav_teams.toString()
2649
3197
  }
2650
- body += '<p><span class="tooltip">By team<span class="tooltiptext">Including a team (MLB only, by abbreviation, in a comma-separated list if more than 1) will include all of its broadcasts, or if that team is not broadcasting the game, it will include the national broadcast or opponent\'s broadcast if available. It will also include affiliate games for those organizations. Channels/games subject to blackout will be omitted by default. See below for an additional option to override that.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=' + include_teams + content_protect_b + '">channels.m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=' + include_teams + content_protect_b + '">guide.xml</a> and <a href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=' + include_teams + content_protect_b + '">calendar.ics</a></p>' + "\n"
3198
+ body += '<div class="channelRow"><div>' + channelInfo('By team', 'byTeam') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=' + include_teams + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=' + include_teams + content_protect_b + '">xml</a><a class="channelLink" href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=' + include_teams + content_protect_b + '">ics</a></div></div>' + "\n"
2651
3199
 
2652
- body += '<p><span class="tooltip">By team w/ radio<span class="tooltiptext">Same as above, but defaults to that team\'s radio track, if available.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=' + include_teams + '&audio_track=radio' + content_protect_b + '">channels.m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&audio_track=radio' + content_protect_b + '">guide.xml</a> and <a href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&audio_track=radio' + content_protect_b + '">calendar.ics</a></p>' + "\n"
3200
+ body += '<div class="channelRow"><div>' + channelInfo('By team w/ radio', 'byTeamRadio') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=' + include_teams + '&audio_track=radio' + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&audio_track=radio' + content_protect_b + '">xml</a><a class="channelLink" href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&audio_track=radio' + content_protect_b + '">ics</a></div></div>' + "\n"
2653
3201
 
2654
- body += '<p><span class="tooltip">By team w/ Spanish<span class="tooltiptext">Same as above, but defaults to that team\'s Spanish radio track, if available.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=' + include_teams + '&audio_track=spanish' + content_protect_b + '">channels.m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&audio_track=spanish' + content_protect_b + '">guide.xml</a> and <a href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&audio_track=spanish' + content_protect_b + '">calendar.ics</a></p>' + "\n"
3202
+ body += '<div class="channelRow"><div>' + channelInfo('By team w/ Spanish', 'byTeamSpanish') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=' + include_teams + '&audio_track=spanish' + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&audio_track=spanish' + content_protect_b + '">xml</a><a class="channelLink" href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&audio_track=spanish' + content_protect_b + '">ics</a></div></div>' + "\n"
2655
3203
 
2656
- body += '<p><span class="tooltip">Include blackouts<span class="tooltiptext">An optional parameter added to the URL will include channels/games subject to blackout (although you may not be able to play those games).</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=' + include_teams + '&includeBlackouts=true' + content_protect_b + '">channels.m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&includeBlackouts=true' + content_protect_b + '">guide.xml</a> and <a href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&includeBlackouts=true' + content_protect_b + '">calendar.ics</a></p>' + "\n"
3204
+ body += '<div class="channelRow"><div>' + channelInfo('Include blackouts', 'includeBlackouts') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=' + include_teams + '&includeBlackouts=true' + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&includeBlackouts=true' + content_protect_b + '">xml</a><a class="channelLink" href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&includeBlackouts=true' + content_protect_b + '">ics</a></div></div>' + "\n"
2657
3205
 
2658
3206
  let exclude_teams = 'ath,atl'
2659
- body += '<p><span class="tooltip">Exclude a team<span class="tooltiptext">Excluding a team (MLB only, by abbreviation, in a comma-separated list if more than 1) will exclude every game involving that team. Note that blackouts are already excluded without the need to specify this parameter.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&excludeTeams=' + exclude_teams + content_protect_b + '">m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&excludeTeams=' + exclude_teams + content_protect_b + '">xml</a> and <a href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&excludeTeams=' + exclude_teams + content_protect_b + '">ics</a></p>' + "\n"
3207
+ body += '<div class="channelRow"><div>' + channelInfo('Exclude a team', 'excludeTeam') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&excludeTeams=' + exclude_teams + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&excludeTeams=' + exclude_teams + content_protect_b + '">xml</a><a class="channelLink" href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&excludeTeams=' + exclude_teams + content_protect_b + '">ics</a></div></div>' + "\n"
2660
3208
 
2661
- body += '<p><span class="tooltip">Include (or exclude) Winter Leagues<span class="tooltiptext">Winter leagues include the Arizona Fall League, Dominican Winter League aka Liga de Beisbol Dominicano, and Mexican Winter League aka Liga Mexicana del Pacífico. Live stream only, does not support starting from the beginning or certain innings, skip options, etc.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=winter' + content_protect_b + '">m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=winter' + content_protect_b + '">xml</a> and <a href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=winter' + content_protect_b + '">ics</a></p>' + "\n"
3209
+ body += '<div class="channelRow"><div>' + channelInfo('Include (or exclude) Winter Leagues', 'winterLeagues') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=winter' + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=winter' + content_protect_b + '">xml</a><a class="channelLink" href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=winter' + content_protect_b + '">ics</a></div></div>' + "\n"
2662
3210
 
2663
3211
  if ( entitlements.includes('MASN_110') ) {
2664
- body += '<p><span class="tooltip">Include (or exclude) MASN<span class="tooltiptext">MASN live stream for entitled subscribers. <a href="https://support.mlb.com/s/article/MASN-In-Market-Offering">See here for more information</a>.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=masn' + content_protect_b + '">m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=masn' + content_protect_b + '">xml</a></p>' + "\n"
3212
+ body += '<div class="channelRow"><div>' + channelInfo('Include (or exclude) MASN', 'masn') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=masn' + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=masn' + content_protect_b + '">xml</a></div></div>' + "\n"
2665
3213
  }
2666
3214
 
2667
3215
  if ( entitlements.includes('MLBN') || entitlements.includes('EXECMLB') || entitlements.includes('MLBTVMLBNADOBEPASS') ) {
2668
- body += '<p><span class="tooltip">Include (or exclude) MLB Network<span class="tooltiptext">MLB Network live stream is now available in the USA for paid MLBTV subscribers or as a paid add-on, in addition to authenticated TV subscribers. <a href="https://support.mlb.com/s/article/MLB-Network-Streaming-FAQ">See here for more information</a>.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=mlbn' + content_protect_b + '">m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=mlbn' + content_protect_b + '">xml</a></p>' + "\n"
3216
+ body += '<div class="channelRow"><div>' + channelInfo('Include (or exclude) MLB Network', 'mlbn') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=mlbn' + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=mlbn' + content_protect_b + '">xml</a></div></div>' + "\n"
2669
3217
  }
2670
3218
 
2671
3219
  if ( entitlements.includes('SNLA_119') ) {
2672
- body += '<p><span class="tooltip">Include (or exclude) SportsNet LA<span class="tooltiptext">SNLA live stream for entitled subscribers. <a href="https://support.mlb.com/s/article/SNLA-Plus-Subscription-Packages">See here for more information</a>.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=snla' + content_protect_b + '">m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=snla' + content_protect_b + '">xml</a></p>' + "\n"
3220
+ body += '<div class="channelRow"><div>' + channelInfo('Include (or exclude) SportsNet LA', 'snla') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=snla' + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=snla' + content_protect_b + '">xml</a></div></div>' + "\n"
2673
3221
  }
2674
3222
 
2675
3223
  if ( entitlements.includes('SNY_121') ) {
2676
- body += '<p><span class="tooltip">Include (or exclude) SNY<span class="tooltiptext">SNY live stream for entitled subscribers. <a href="https://support.mlb.com/s/article/SNY-In-Market-Offering">See here for more information</a>.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=sny' + content_protect_b + '">m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=sny' + content_protect_b + '">xml</a></p>' + "\n"
3224
+ body += '<div class="channelRow"><div>' + channelInfo('Include (or exclude) SNY', 'sny') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=sny' + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=sny' + content_protect_b + '">xml</a></div></div>' + "\n"
2677
3225
  }
2678
3226
 
2679
- body += '<p><span class="tooltip">Include (or exclude) Big Inning<span class="tooltiptext">Big Inning is the live look-in and highlights show. <a href="https://www.mlb.com/live-stream-games/big-inning">See here for more information</a>.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=biginning' + content_protect_b + '">m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=biginning' + content_protect_b + '">xml</a> and <a href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=biginning' + content_protect_b + '">ics</a></p>' + "\n"
3227
+ body += '<div class="channelRow"><div>' + channelInfo('Include (or exclude) Big Inning', 'bigInning') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=biginning' + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=biginning' + content_protect_b + '">xml</a><a class="channelLink" href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=biginning' + content_protect_b + '">ics</a></div></div>' + "\n"
2680
3228
 
2681
3229
  let gamechanger_resolution = resolution
2682
3230
  if ( gamechanger_resolution == VALID_RESOLUTIONS[0] ) {
2683
3231
  gamechanger_resolution = 'best'
2684
3232
  }
2685
- body += '<p><span class="tooltip">Include (or exclude) Game Changer<span class="tooltiptext">The game changer stream will automatically switch between the highest leverage active live non-blackout games, and should be available whenever there are such games available. Does not support adaptive bitrate switching, will default to best resolution if not specified.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + gamechanger_resolution + '&includeTeams=gamechanger' + content_protect_b + '">m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=gamechanger' + content_protect_b + '">xml</a> and <a href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=gamechanger' + content_protect_b + '">ics</a></p>' + "\n"
3233
+ body += '<div class="channelRow"><div>' + channelInfo('Include (or exclude) Game Changer', 'gameChanger') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + gamechanger_resolution + '&includeTeams=gamechanger' + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=gamechanger' + content_protect_b + '">xml</a><a class="channelLink" href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=gamechanger' + content_protect_b + '">ics</a></div></div>' + "\n"
2686
3234
 
2687
- body += '<p><span class="tooltip">Include (or exclude) Stream Finder<span class="tooltiptext">The stream finder stream will automatically switch between games according to your uploaded preferences. This stream is not affiliated with Baseball Reference, do not contact them for support. Visit <a href="http://bit.ly/bbrefsf">http://bit.ly/bbrefsf</a> to create and export your preferences, then upload and save them to mlbserver <a href="#streamfinder">above</a>. Does not support adaptive bitrate switching, will default to best resolution if not specified.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + gamechanger_resolution + '&includeTeams=streamfinder' + content_protect_b + '">m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=streamfinder' + content_protect_b + '">xml</a> and <a href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=streamfinder' + content_protect_b + '">ics</a></p>' + "\n"
3235
+ body += '<div class="channelRow"><div>' + channelInfo('Include (or exclude) Stream Finder', 'streamFinder') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + gamechanger_resolution + '&includeTeams=streamfinder' + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=streamfinder' + content_protect_b + '">xml</a><a class="channelLink" href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=streamfinder' + content_protect_b + '">ics</a></div></div>' + "\n"
2688
3236
 
2689
- body += '<p><span class="tooltip">Include (or exclude) Multiview<span class="tooltiptext">Requires starting and stopping the multiview stream from the web interface.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&includeTeams=multiview' + content_protect_b + '">m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=multiview' + content_protect_b + '">xml</a> and <a href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=multiview' + content_protect_b + '">ics</a></p>' + "\n"
3237
+ body += '<div class="channelRow"><div>' + channelInfo('Include (or exclude) Multiview', 'multiviewChannel') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&includeTeams=multiview' + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=multiview' + content_protect_b + '">xml</a><a class="channelLink" href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=multiview' + content_protect_b + '">ics</a></div></div>' + "\n"
2690
3238
 
2691
3239
  if ( argv.free ) {
2692
- body += '<p><span class="tooltip">Free games only<span class="tooltiptext">Only includes games marked as free. Blackouts still apply. Channels/games subject to blackout will be omitted by default.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=free' + content_protect_b + '">m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=free' + content_protect_b + '">xml</a> and <a href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=free' + content_protect_b + '">ics</a></p>' + "\n"
3240
+ body += '<div class="channelRow"><div>' + channelInfo('Free games only', 'freeGames') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=free' + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=free' + content_protect_b + '">xml</a><a class="channelLink" href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=free' + content_protect_b + '">ics</a></div></div>' + "\n"
2693
3241
  }
2694
3242
 
2695
- body += '<p><span class="tooltip">Include affiliates by org<span class="tooltiptext">Including an organization (by MLB team abbreviation, in a comma-separated list if more than 1) will include all of its affiliate broadcasts, or if that affiliate is not broadcasting the game, it will include the opponent\'s broadcast if available. If this option is not specified, but favorite team(s) have been provided, affiliate games for those organizations will be included anyway.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeOrgs=ath,atl' + content_protect_b + '">m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeOrgs=ath' + content_protect_b + '">xml</a> and <a href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeOrgs=ath' + content_protect_b + '">ics</a></p>' + "\n"
2696
-
2697
- body += '<p><span class="tooltip">Include by level<span class="tooltiptext">Including a level (AAA, AA, A+ encoded as A%2B, or A, in a comma-separated list if more than 1) will include all of its broadcasts, and exclude all other levels.</span></span>: <a href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeLevels=a%2B,aaa' + content_protect_b + '">m3u</a> and <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeLevels=a%2B,aaa' + content_protect_b + '">xml</a> and <a href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeLevels=a%2B,aaa' + content_protect_b + '">ics</a></p>' + "\n"
3243
+ body += '<div class="channelRow"><div>' + channelInfo('Include affiliates by org', 'includeOrgs') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeOrgs=ath,atl' + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeOrgs=ath' + content_protect_b + '">xml</a><a class="channelLink" href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeOrgs=ath' + content_protect_b + '">ics</a></div></div>' + "\n"
2698
3244
 
2699
- body += '<p><span class="tooltip">Include teams in titles<span class="tooltiptext">An optional parameter added to the URL will include team names in the ICS/XML titles. A value of "channels" will format the titles in the style of the <a href="https://community.getchannels.com/t/mlb-tv-for-channels/27492">legacy Channels container</a>.</span></span>: <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&includeTeamsInTitles=true' + content_protect_b + '">guide.xml</a> or <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&includeTeamsInTitles=channels' + content_protect_b + '">legacy</a>, and <a href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&includeTeamsInTitles=true' + content_protect_b + '">calendar.ics</a></p>' + "\n"
3245
+ body += '<div class="channelRow"><div>' + channelInfo('Include by level', 'includeLevels') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeLevels=a%2B,aaa' + content_protect_b + '">m3u</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeLevels=a%2B,aaa' + content_protect_b + '">xml</a><a class="channelLink" href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeLevels=a%2B,aaa' + content_protect_b + '">ics</a></div></div>' + "\n"
2700
3246
 
2701
- body += '<p><span class="tooltip">Create Off Air events between games<span class="tooltiptext">An optional parameter added to the URL will create "Off Air" events in the XML guide, listing the time of the next game on that channel. A value of "channels" will format the events in the style of the <a href="https://community.getchannels.com/t/mlb-tv-for-channels/27492">legacy Channels container</a>.</span></span>: <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&offAir=true' + content_protect_b + '">guide.xml</a> or <a href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&offAir=channels' + content_protect_b + '">legacy</a></p>' + "\n"
3247
+ body += '<div class="channelRow"><div>' + channelInfo('Include teams in titles', 'includeTeamsInTitles') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&includeTeamsInTitles=true' + content_protect_b + '">xml</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&includeTeamsInTitles=channels' + content_protect_b + '">legacy</a><a class="channelLink" href="' + http_root + '/calendar.ics?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&includeTeamsInTitles=true' + content_protect_b + '">ics</a></div></div>' + "\n"
2702
3248
 
2703
- body += '</td></tr></table><br/>' + "\n"
3249
+ body += '<div class="channelRow"><div>' + channelInfo('Create Off Air events between games', 'offAir') + '</div><div class="channelLinks"><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&offAir=true' + content_protect_b + '">xml</a><a class="channelLink" href="' + http_root + '/guide.xml?mediaType=' + mediaType + '&includeTeams=' + include_teams + '&offAir=channels' + content_protect_b + '">legacy</a></div></div>' + "\n"
3250
+ body += '</div></div>' + "\n"
2704
3251
 
2705
- body += '<table><tr><td>' + "\n"
2706
- body += '<p><span class="tooltip">Example links<span class="tooltiptext">Some examples how to generate predictable links.</span></span>:</p>' + "\n"
2707
- body += '<p>' + "\n"
3252
+ body += '<div class="menuContainer"><div class="cardMenuHeader">Example links</div><div class="menuContent">'
2708
3253
  let example_types = [ ['embed.html', 'Embed'], ['stream.m3u8', 'Stream'], ['chromecast.html', 'Chromecast'], ['kodi.strm', 'Kodi'] ]
2709
3254
 
2710
3255
  let example_team = 'ath'
@@ -2742,10 +3287,19 @@ app.get('/', async function(req, res) {
2742
3287
  }
2743
3288
  }
2744
3289
  }
2745
- body += '</p>' + "\n"
2746
3290
 
2747
3291
  include_teams = 'ath,atl'
2748
- body += '<p><span class="tooltip">Game Changer by team examples<span class="tooltiptext">Game Changer supports specifying certain teams to include or exclude. Useful for following a group of teams.</span></span>:</p>' + "\n"
3292
+ body += `<br>
3293
+ <div class="is-flex">
3294
+ <span>Game Changer by team examples:</span>
3295
+ <div class="info infoPadding" data-target="#gameChangerExamplesInfo">?</div>
3296
+ </div>
3297
+ <div id="gameChangerExamplesInfo" class="infoContainer">
3298
+ <div class="infoContent">
3299
+ Game Changer supports specifying certain teams to include or exclude. Useful for following a group of teams.
3300
+ </div>
3301
+ </div>
3302
+ `
2749
3303
  body += '<p>' + "\n"
2750
3304
  let gamechanger_streamURL = server + '/gamechanger.m3u8?resolution=best' + content_protect_b
2751
3305
  let gamechanger_types = ['in', 'ex']
@@ -2753,19 +3307,65 @@ app.get('/', async function(req, res) {
2753
3307
  let example_streamURL = gamechanger_streamURL + '&' + gamechanger_types[i] + 'cludeTeams=' + include_teams
2754
3308
  body += '&bull; ' + gamechanger_types[i] + 'clude: <a href="' + http_root + '/embed.html?src=' + encodeURIComponent(example_streamURL) + '&startFrom=' + VALID_START_FROM[1] + content_protect_b + '">Embed</a> | <a href="' + example_streamURL + '">Stream</a> | <a href="' + http_root + '/chromecast.html?src=' + encodeURIComponent(example_streamURL) + content_protect_b + '">Chromecast</a> | <a href="' + http_root + '/advanced.html?src=' + encodeURIComponent(example_streamURL) + content_protect_b + '">Advanced</a> | <a href="' + http_root + '/kodi.strm?src=' + encodeURIComponent(example_streamURL) + content_protect_b + '">Kodi</a><br/>' + "\n"
2755
3309
  }
3310
+ body += '</p>' + "\n"
2756
3311
 
2757
- body += '<p><span class="tooltip">Comskip link examples<span class="tooltiptext">You can generate a <a href="https://github.com/erikkaashoek/Comskip">Comskip</a>-style file to automatically skip sections (breaks, idle time, or non-action pitches) of games you record using DVR software when watched in compatible players. For example, if you record a game from your local OTA channel using Tvheadend, you can then fetch one of these Comskip files, put it in the same directory with the same name as your recorded video file, and Kodi will automatically skip those sections while you watch the video.<br><br>Specifying the team and broadcast_start_timestamp in the URL is required! For the timestamp, use the time your DVR software began the recording. This should be your local time in YYYY-MM-DDTHH:MM:SS format.<br><br>Specifying a skip_adjust value in the URL is recommended, to adjust for broadcast delays. This will vary across different channels and different video sources.<br><br>For the txt file format, specifying the video frame rate (fps) in the URL is also required. This will commonly be either 30, 59.94, or 60, depending on your video source.<br><br>Optionally, setting pad to "on" will generate random extra skips at the end, to help avoid timeline spoilers.</span></span>: <a href="' + http_root + '/comskip.edl?team=CHC&date=2025-10-01&pad=on&skip=pitches&skip_adjust=11&broadcast_start_timestamp=2025-10-01T14:00:00' + content_protect_a + '">comskip.edl</a> or <a href="' + http_root + '/comskip.txt?team=CHC&date=2025-10-01&pad=on&skip=pitches&skip_adjust=11&broadcast_start_timestamp=2025-10-01T14:00:00&fps=59.94' + content_protect_a + '">comskip.txt</a></p>' + "\n"
3312
+ body += `
3313
+ <div class="is-flex">
3314
+ <span>Comskip link examples:</span>
3315
+ <div class="info infoPadding" data-target="#comskipExamplesInfo">?</div>
3316
+ </div>
3317
+ <div id="comskipExamplesInfo" class="infoContainer">
3318
+ <div class="infoContent">
3319
+ You can generate a <a href="https://github.com/erikkaashoek/Comskip">Comskip</a>-style file to automatically skip sections (breaks, idle time, or non-action pitches) of games you record using DVR software when watched in compatible players. For example, if you record a game from your local OTA channel using Tvheadend, you can then fetch one of these Comskip files, put it in the same directory with the same name as your recorded video file, and Kodi will automatically skip those sections while you watch the video.<br><br>Specifying the team and broadcast_start_timestamp in the URL is required! For the timestamp, use the time your DVR software began the recording. This should be your local time in YYYY-MM-DDTHH:MM:SS format.<br><br>Specifying a skip_adjust value in the URL is recommended, to adjust for broadcast delays. This will vary across different channels and different video sources.<br><br>For the txt file format, specifying the video frame rate (fps) in the URL is also required. This will commonly be either 30, 59.94, or 60, depending on your video source.<br><br>Optionally, setting pad to "on" will generate random extra skips at the end, to help avoid timeline spoilers.
3320
+ </div>
3321
+ </div>
3322
+ <p><a href="${http_root}/comskip.edl?team=CHC&date=2025-10-01&pad=on&skip=pitches&skip_adjust=11&broadcast_start_timestamp=2025-10-01T14:00:00${content_protect_a}">comskip.edl</a> or <a href="${http_root}/comskip.txt?team=CHC&date=2025-10-01&pad=on&skip=pitches&skip_adjust=11&broadcast_start_timestamp=2025-10-01T14:00:00&fps=59.94${content_protect_a}">comskip.txt</a></p>
3323
+ `
2758
3324
 
2759
- body += '<p><span class="tooltip">MPEG-TS examples<span class="tooltiptext">Experimental feature: a MPEGTS output format where you can adjust the audio sync with a URL parameter. Useful if the radio track is a consistent number of seconds ahead or behind the video track. Use positive sync values if radio is early, or negative values if radio is late.</span></span>: <a href="' + http_root + '/stream.ts?team=' + example_team + content_protect_a + '">Stream</a> or <a href="' + http_root + '/stream.ts?team=' + example_team + '&audio_track=radio&sync=2.3' + content_protect_a + '">Stream w/ radio sync</a></p>' + "\n"
2760
-
2761
- body += '</p></td></tr></table><br/>' + "\n"
2762
-
2763
- body += '<p><span class="tooltip">Sample video<span class="tooltiptext">A sample stream. Useful for testing and troubleshooting.</span></span>: <a href="' + http_root + '/embed.html' + content_protect_a + '">Embed</a> | <a href="' + http_root + '/stream.m3u8' + content_protect_a + '">Stream</a> | <a href="' + http_root + '/chromecast.html' + content_protect_a + '">Chromecast</a> | <a href="' + http_root + '/advanced.html' + content_protect_a + '">Advanced</a></p>' + "\n"
2764
-
2765
- body += '<p><span class="tooltip">Bookmarklets for MLB.com<span class="tooltiptext">If you watch at MLB.com, drag these bookmarklets to your bookmarks toolbar and use them to hide parts of the interface.</span></span>: <a href="javascript:(function(){let x=document.querySelector(\'#mlbtv-stats-panel\');if(x.style.display==\'none\'){x.style.display=\'initial\';}else{x.style.display=\'none\';}})();">Boxscore</a> | <a href="javascript:(function(){let x=document.querySelector(\'.mlbtv-header-container\');if(x.style.display==\'none\'){let y=document.querySelector(\'.mlbtv-players-container\');y.style.display=\'none\';x.style.display=\'initial\';setTimeout(function(){y.style.display=\'initial\';},15);}else{x.style.display=\'none\';}})();">Scoreboard</a> | <a href="javascript:(function(){let x=document.querySelector(\'.mlbtv-container--footer\');if(x.style.display==\'none\'){let y=document.querySelector(\'.mlbtv-players-container\');y.style.display=\'none\';x.style.display=\'initial\';setTimeout(function(){y.style.display=\'initial\';},15);}else{x.style.display=\'none\';}})();">Linescore</a> | <a href="javascript:(function(){let x=document.querySelector(\'#mlbtv-stats-panel\');if(x.style.display==\'none\'){x.style.display=\'initial\';}else{x.style.display=\'none\';}x=document.querySelector(\'.mlbtv-header-container\');if(x.style.display==\'none\'){x.style.display=\'initial\';}else{x.style.display=\'none\';}x=document.querySelector(\'.mlbtv-container--footer\');if(x.style.display==\'none\'){let y=document.querySelector(\'.mlbtv-players-container\');y.style.display=\'none\';x.style.display=\'initial\';setTimeout(function(){y.style.display=\'initial\';},15);}else{x.style.display=\'none\';}})();">All</a></p>' + "\n"
3325
+ body += `
3326
+ <div class="is-flex">
3327
+ <span>MPEG-TS examples:</span>
3328
+ <div class="info infoPadding" data-target="#mpegTsExamplesInfo">?</div>
3329
+ </div>
3330
+ <div id="mpegTsExamplesInfo" class="infoContainer">
3331
+ <div class="infoContent">
3332
+ Experimental feature: a MPEGTS output format where you can adjust the audio sync with a URL parameter. Useful if the radio track is a consistent number of seconds ahead or behind the video track. Use positive sync values if radio is early, or negative values if radio is late.
3333
+ </div>
3334
+ </div>
3335
+ <p><a href="${http_root}/stream.ts?team=${example_team}${content_protect_a}">Stream</a> or <a href="${http_root}/stream.ts?team=${example_team}&audio_track=radio&sync=2.3${content_protect_a}">Stream w/ radio sync</a></p>
3336
+ `
3337
+
3338
+ body += '<br/>' + "\n"
3339
+
3340
+ body += `
3341
+ <div class="is-flex">
3342
+ <span>Sample video:</span>
3343
+ <div class="info infoPadding" data-target="#sampleVideoInfo">?</div>
3344
+ </div>
3345
+ <div id="sampleVideoInfo" class="infoContainer">
3346
+ <div class="infoContent">
3347
+ A sample stream. Useful for testing and troubleshooting.
3348
+ </div>
3349
+ </div>
3350
+ <p><a href="${http_root}/embed.html${content_protect_a}">Embed</a> | <a href="${http_root}/stream.m3u8${content_protect_a}">Stream</a> | <a href="${http_root}/chromecast.html${content_protect_a}">Chromecast</a> | <a href="${http_root}/advanced.html${content_protect_a}">Advanced</a></p>
3351
+ `
3352
+
3353
+ body += `
3354
+ <div class="is-flex">
3355
+ <span>Bookmarklets for MLB.com:</span>
3356
+ <div class="info infoPadding" data-target="#bookmarkletsInfo">?</div>
3357
+ </div>
3358
+ <div id="bookmarkletsInfo" class="infoContainer">
3359
+ <div class="infoContent">
3360
+ If you watch at MLB.com, drag these bookmarklets to your bookmarks toolbar and use them to hide parts of the interface.
3361
+ </div>
3362
+ </div>
3363
+ `
3364
+ body += '<p><a href="javascript:(function(){let x=document.querySelector(\'#mlbtv-stats-panel\');if(x.style.display==\'none\'){x.style.display=\'initial\';}else{x.style.display=\'none\';}})();">Boxscore</a> | <a href="javascript:(function(){let x=document.querySelector(\'.mlbtv-header-container\');if(x.style.display==\'none\'){let y=document.querySelector(\'.mlbtv-players-container\');y.style.display=\'none\';x.style.display=\'initial\';setTimeout(function(){y.style.display=\'initial\';},15);}else{x.style.display=\'none\';}})();">Scoreboard</a> | <a href="javascript:(function(){let x=document.querySelector(\'.mlbtv-container--footer\');if(x.style.display==\'none\'){let y=document.querySelector(\'.mlbtv-players-container\');y.style.display=\'none\';x.style.display=\'initial\';setTimeout(function(){y.style.display=\'initial\';},15);}else{x.style.display=\'none\';}})();">Linescore</a> | <a href="javascript:(function(){let x=document.querySelector(\'#mlbtv-stats-panel\');if(x.style.display==\'none\'){x.style.display=\'initial\';}else{x.style.display=\'none\';}x=document.querySelector(\'.mlbtv-header-container\');if(x.style.display==\'none\'){x.style.display=\'initial\';}else{x.style.display=\'none\';}x=document.querySelector(\'.mlbtv-container--footer\');if(x.style.display==\'none\'){let y=document.querySelector(\'.mlbtv-players-container\');y.style.display=\'none\';x.style.display=\'initial\';setTimeout(function(){y.style.display=\'initial\';},15);}else{x.style.display=\'none\';}})();">All</a></p>' + "\n"
2766
3365
 
2767
3366
  // Print version
2768
3367
  body += '<p class="tinytext">Version ' + version + ' (<a href="' + http_root + '/clearcache">clear session and cache</a>)</p>' + "\n"
3368
+ body += '</div></div></div>'
2769
3369
 
2770
3370
  // Datepicker functions
2771
3371
  body += '<script>var datePicker=document.getElementById("gameDate");function changeDate(e){date=datePicker.value;reload()}function removeDate(e){datePicker.removeEventListener("change",changeDate,false);datePicker.addEventListener("blur",changeDate,false);if(e.keyCode===13){date=datePicker.value;reload()}}datePicker.addEventListener("change",changeDate,false);datePicker.addEventListener("keypress",removeDate,false)</script>' + "\n"
@@ -2775,6 +3375,33 @@ app.get('/', async function(req, res) {
2775
3375
 
2776
3376
  // Highlights modal functions
2777
3377
  body += `<script type="text/javascript">
3378
+ `
3379
+ // Highlights modal positioning
3380
+ body += `var pageScrollY = 0;
3381
+
3382
+ function openModal() {
3383
+ pageScrollY = window.scrollY || document.documentElement.scrollTop;
3384
+ document.body.style.position = "fixed";
3385
+ document.body.style.top = "-" + pageScrollY + "px";
3386
+ document.body.style.left = "0";
3387
+ document.body.style.right = "0";
3388
+ document.body.style.width = "100%";
3389
+ document.body.style.overflow = "hidden";
3390
+ modal.style.display = "flex";
3391
+ }
3392
+
3393
+ function closeModal() {
3394
+ document.body.style.position = "";
3395
+ document.body.style.top = "";
3396
+ document.body.style.left = "";
3397
+ document.body.style.right = "";
3398
+ document.body.style.width = "";
3399
+ document.body.style.overflow = "";
3400
+ window.scrollTo(0, pageScrollY);
3401
+ modal.style.display = "none";
3402
+ }
3403
+ `
3404
+ body += `
2778
3405
  var modal = document.getElementById("myModal");
2779
3406
  var highlightsModal = document.getElementById("highlights");
2780
3407
  var span = document.getElementsByClassName("close")[0];
@@ -2806,7 +3433,7 @@ function parsehighlightsresponse(responsetext) {
2806
3433
  }
2807
3434
  modaltext += "</ul>";
2808
3435
  highlightsModal.innerHTML = modaltext;
2809
- modal.style.display = "block"
3436
+ openModal();
2810
3437
  } catch (e) {
2811
3438
  alert("Error processing highlights: " + e.message)
2812
3439
  }
@@ -2815,12 +3442,77 @@ function showhighlights(gamePk, gameDate) {
2815
3442
  makeGETRequest("` + http_root + `/highlights?gamePk=" + gamePk + "&gameDate=" + gameDate + "` + content_protect_b + `", parsehighlightsresponse);
2816
3443
  return false
2817
3444
  }
2818
- span.onclick = function() {
2819
- modal.style.display = "none";
2820
- }
2821
- `
3445
+ span.onclick = closeModal;`
3446
+
3447
+ body += 'window.onclick = function(event) { if (event.target == modal) {closeModal()} }</script>' + "\n"
3448
+
3449
+ // Find recaps and condensed games from highlights
3450
+ body += `<script type="text/javascript">
3451
+
3452
+ function parseHighlight(responsetext, keyword) {
3453
+ try {
3454
+ var highlights = JSON.parse(responsetext);
3455
+ var captions_parameter = '';
3456
+ if (captions == 'disabled') {
3457
+ captions_parameter = '&captions=disabled';
3458
+ }
3459
+ var highlightExists = null;
3460
+ var hls_url = '';
3461
+ var mp4_url = '';
3462
+ if (highlights && (highlights.length > 0)) {
3463
+
3464
+ for (var i = 0; i < highlights.length; i++) {
3465
+ var keywordsAll = highlights[i].keywordsAll || [];
3466
+
3467
+ var hasKeyword = keywordsAll.some(function(item) {
3468
+ return item && (item.value && item.value === keyword)
3469
+ });
3470
+ if (highlights[i].headline && hasKeyword) {
3471
+ highlightExists = highlights[i];
3472
+ for (var j = 0; j < highlights[i].playbacks.length; j++) {
3473
+ if (highlights[i].playbacks[j].name === 'HTTP_CLOUD_WIRED_60') {
3474
+ hls_url = highlights[i].playbacks[j].url;
3475
+ } else if (highlights[i].playbacks[j].name === 'mp4Avc') {
3476
+ mp4_url = highlights[i].playbacks[j].url;
3477
+ }
3478
+ }
3479
+ break;
3480
+ }
3481
+ }
3482
+ if (!highlightExists || !hls_url) {
3483
+ toast();
3484
+ return;
3485
+ } { window.location.href = "` + link + `?highlight_src=" + encodeURIComponent(hls_url) + "&resolution=" + resolution + captions_parameter + "` + content_protect_b + `"; }
3486
+
3487
+
3488
+ }
3489
+ } catch (e) {
3490
+
3491
+ {toast()}
3492
+ }
3493
+ }
3494
+
3495
+ function showHighlightByKeyword(gamePk, gameDate, keyword) {
3496
+ makeGETRequest(
3497
+ "` + http_root + `/highlights?gamePk=" + gamePk + "&gameDate=" + gameDate + "` + content_protect_b + `",
3498
+ function(response) {
3499
+ parseHighlight(response, keyword);
3500
+ }
3501
+ );
3502
+ return false;
3503
+ }
3504
+
3505
+ function showcondensed(gamePk, gameDate) {
3506
+ return showHighlightByKeyword(gamePk, gameDate, 'MLBCOM_CONDENSED_GAME');
3507
+ }
3508
+
3509
+ function showrecap(gamePk, gameDate) {
3510
+ return showHighlightByKeyword(gamePk, gameDate, 'MLBCOM_GAME_RECAP');
3511
+ }
2822
3512
 
2823
- body += 'window.onclick = function(event) { if (event.target == modal) { modal.style.display = "none"; } }</script>' + "\n"
3513
+ </script>`
3514
+ body +=`</div>`
3515
+ body += '<div id="snackbar">Not yet available. Come back later.</div>'
2824
3516
 
2825
3517
  body += "</body></html>"
2826
3518
 
@@ -3330,6 +4022,7 @@ app.get('/highlights', async function(req, res) {
3330
4022
  if ( req.query.gamePk && req.query.gameDate ) {
3331
4023
  highlightsData = await session.getHighlightsData(req.query.gamePk, req.query.gameDate)
3332
4024
  }
4025
+ res.writeHead(200, {'Content-Type': 'application/json; charset=utf-8'});
3333
4026
  res.end(JSON.stringify(highlightsData))
3334
4027
  } catch (e) {
3335
4028
  session.log('highlights request error : ' + e.message)