|
686 | 686 | var hls = null; |
687 | 687 | var currentEntry = null; |
688 | 688 |
|
| 689 | + // The last length this programme reported that was worth believing. See |
| 690 | + // duration() below. |
| 691 | + var knownDuration = 0; |
| 692 | + |
689 | 693 | /* |
690 | 694 | * How long a stream is given to produce a frame. |
691 | 695 | * |
|
773 | 777 | function () { return !!window.Hls; }); |
774 | 778 | } |
775 | 779 |
|
776 | | - function posKey(id) { |
777 | | - return "tv4play.pos." + id; |
| 780 | + /* ----------------------------------------------------- resume positions |
| 781 | + * |
| 782 | + * The whole set of positions is kept as one localStorage record. |
| 783 | + * |
| 784 | + * The console runs this as a web app rather than in the browser proper, |
| 785 | + * and that context only hands out storage once the title asks for it -- |
| 786 | + * "downloadDataSize" in sce_sys/param.json. Without that entry a write is |
| 787 | + * accepted, throws nothing and keeps nothing, so a position would survive |
| 788 | + * being stopped and not survive being closed. |
| 789 | + * |
| 790 | + * One record rather than a key per programme: the old layout left a key |
| 791 | + * behind for every episode ever half-watched with nothing to ever collect |
| 792 | + * them, and there is no way to enumerate them back out to trim. It also |
| 793 | + * wrote them under tv4play's name, this having started life as that app. |
| 794 | + */ |
| 795 | + var POS_KEY = "plutotv.pos"; |
| 796 | + var POS_LIMIT = 120; |
| 797 | + |
| 798 | + var store = { |
| 799 | + read: function () { |
| 800 | + try { |
| 801 | + return window.localStorage.getItem(POS_KEY) || ""; |
| 802 | + } catch (e) { |
| 803 | + return ""; |
| 804 | + } |
| 805 | + }, |
| 806 | + write: function (s) { |
| 807 | + try { |
| 808 | + window.localStorage.setItem(POS_KEY, s); |
| 809 | + } catch (e) { /* revoked permission, full quota: nothing to do */ } |
| 810 | + } |
| 811 | + }; |
| 812 | + |
| 813 | + function readPositions() { |
| 814 | + var map; |
| 815 | + try { |
| 816 | + var raw = store.read(); |
| 817 | + map = raw ? JSON.parse(raw) : {}; |
| 818 | + } catch (e) { |
| 819 | + map = {}; |
| 820 | + } |
| 821 | + if (!map || typeof map !== "object") { |
| 822 | + map = {}; |
| 823 | + } |
| 824 | + return map; |
| 825 | + } |
| 826 | + |
| 827 | + function writePositions(map) { |
| 828 | + // Without a bound this record grows for every episode ever |
| 829 | + // half-watched. Keep the most recently touched. |
| 830 | + var ids = Object.keys(map); |
| 831 | + if (ids.length > POS_LIMIT) { |
| 832 | + ids.sort(function (a, b) { |
| 833 | + return (map[b].at || 0) - (map[a].at || 0); |
| 834 | + }); |
| 835 | + var trimmed = {}; |
| 836 | + ids.slice(0, POS_LIMIT).forEach(function (id) { |
| 837 | + trimmed[id] = map[id]; |
| 838 | + }); |
| 839 | + map = trimmed; |
| 840 | + } |
| 841 | + store.write(JSON.stringify(map)); |
| 842 | + } |
| 843 | + |
| 844 | + function forgetPos(id) { |
| 845 | + var map = readPositions(); |
| 846 | + if (map[id]) { |
| 847 | + delete map[id]; |
| 848 | + writePositions(map); |
| 849 | + } |
778 | 850 | } |
779 | 851 |
|
780 | 852 | function savePos() { |
781 | | - if (!currentEntry || !isFinite(video.duration) || video.duration < 300) { |
| 853 | + var dur = duration(); |
| 854 | + var t = video.currentTime; |
| 855 | + if (!currentEntry || !isFinite(dur) || dur < 300) { |
| 856 | + // The usual reason a position is never stored: the length is not |
| 857 | + // known, or the stream reports itself as live. |
782 | 858 | return; |
783 | 859 | } |
784 | | - try { |
785 | | - var t = video.currentTime; |
786 | | - if (t > 60 && t < video.duration - 90) { |
787 | | - localStorage.setItem(posKey(currentEntry.id), String(Math.floor(t))); |
788 | | - } else { |
789 | | - localStorage.removeItem(posKey(currentEntry.id)); |
790 | | - } |
791 | | - } catch (e) { /* private mode, no storage: not worth a message */ } |
| 860 | + var map = readPositions(); |
| 861 | + if (t > 60 && t < dur - 90) { |
| 862 | + map[currentEntry.id] = {t: Math.floor(t), at: Date.now()}; |
| 863 | + } else { |
| 864 | + delete map[currentEntry.id]; |
| 865 | + } |
| 866 | + writePositions(map); |
792 | 867 | } |
793 | 868 |
|
794 | 869 | function resumePos(id) { |
795 | | - try { |
796 | | - var v = parseInt(localStorage.getItem(posKey(id)), 10); |
797 | | - return isNaN(v) ? 0 : v; |
798 | | - } catch (e) { |
| 870 | + var map = readPositions(); |
| 871 | + var rec = map[id]; |
| 872 | + if (!rec) { |
799 | 873 | return 0; |
800 | 874 | } |
| 875 | + // Older records were a bare number. |
| 876 | + var t = typeof rec === "number" ? rec : rec.t; |
| 877 | + return (typeof t === "number" && isFinite(t) && t > 0) ? t : 0; |
801 | 878 | } |
802 | 879 |
|
803 | 880 | /* --------------------------------------------------------- stream info |
|
1151 | 1228 | } |
1152 | 1229 |
|
1153 | 1230 | currentEntry = entry; |
| 1231 | + knownDuration = 0; |
1154 | 1232 | mode = "player"; |
1155 | 1233 | elPlayer.hidden = false; |
1156 | 1234 | elSpinner.hidden = false; |
|
1184 | 1262 | var resume = entry.live ? 0 : resumePos(entry.id); |
1185 | 1263 | var triedHlsJs = false; |
1186 | 1264 |
|
| 1265 | + /* |
| 1266 | + * Restoring a position is not one assignment to currentTime. At the |
| 1267 | + * moment a stream first reports itself ready the seekable range is |
| 1268 | + * often still empty, and an assignment made then is dropped on the |
| 1269 | + * floor -- which is what left playback at the start while the toast |
| 1270 | + * claimed otherwise. So ask repeatedly until the element agrees, and |
| 1271 | + * only announce it once it has actually happened. |
| 1272 | + */ |
| 1273 | + function applyResume() { |
| 1274 | + var tries = 0; |
| 1275 | + |
| 1276 | + function attempt() { |
| 1277 | + if (!playing(my)) { |
| 1278 | + return; |
| 1279 | + } |
| 1280 | + // Nothing but the seek can have moved the clock this far this |
| 1281 | + // soon: a saved position is never less than a minute in, and |
| 1282 | + // the window below is five seconds wide. |
| 1283 | + if (video.currentTime >= resume - 2) { |
| 1284 | + toast("Resuming from " + fmtTime(resume)); |
| 1285 | + return; |
| 1286 | + } |
| 1287 | + if (tries > 20) { |
| 1288 | + // Say nothing rather than something untrue. The saved |
| 1289 | + // position is left alone, so the next attempt still has it. |
| 1290 | + return; |
| 1291 | + } |
| 1292 | + tries++; |
| 1293 | + try { |
| 1294 | + video.currentTime = resume; |
| 1295 | + } catch (e) { /* not seekable yet; the retry covers it */ } |
| 1296 | + setTimeout(attempt, 250); |
| 1297 | + } |
| 1298 | + |
| 1299 | + attempt(); |
| 1300 | + } |
| 1301 | + |
1187 | 1302 | function started() { |
1188 | 1303 | // A metadata event from the source this one replaced would seek |
1189 | 1304 | // and play the wrong programme. |
1190 | 1305 | if (!playing(my)) { |
1191 | 1306 | return; |
1192 | 1307 | } |
1193 | 1308 | elSpinner.hidden = true; |
1194 | | - if (resume > 5) { |
1195 | | - try { video.currentTime = resume; } catch (e) { /* live */ } |
1196 | | - toast("Resuming from " + fmtTime(resume)); |
1197 | | - } |
1198 | 1309 | var p = video.play(); |
1199 | 1310 | if (p && p["catch"]) { |
1200 | 1311 | p["catch"](function (err) { |
1201 | | - toast("Uppspelning blockerad: " + err.message, true); |
| 1312 | + toast("Playback blocked: " + err.message, true); |
1202 | 1313 | }); |
1203 | 1314 | } |
| 1315 | + if (resume > 5) { |
| 1316 | + applyResume(); |
| 1317 | + } |
1204 | 1318 | } |
1205 | 1319 |
|
1206 | 1320 | function attachNative() { |
|
1227 | 1341 | // A console has far less headroom than a desktop; holding |
1228 | 1342 | // half an hour of played-out segments is what turns a long |
1229 | 1343 | // programme into a stall. |
1230 | | - backBufferLength: 30 |
| 1344 | + backBufferLength: 30, |
| 1345 | + // Start the fetching where the viewer left off rather than |
| 1346 | + // loading from the top and seeking afterwards. -1 is the |
| 1347 | + // engine's own "wherever the stream says to begin". |
| 1348 | + startPosition: resume > 5 ? resume : -1 |
1231 | 1349 | }); |
1232 | 1350 | hls.on(window.Hls.Events.MANIFEST_PARSED, started); |
1233 | 1351 | hls.on(window.Hls.Events.ERROR, function (evt, data) { |
|
1343 | 1461 |
|
1344 | 1462 | function stop() { |
1345 | 1463 | savePos(); |
| 1464 | + knownDuration = 0; |
1346 | 1465 | clearStall(); |
1347 | 1466 | // Anything still resolving belongs to a programme that is no longer |
1348 | 1467 | // wanted, and must not attach itself once it arrives. |
|
1392 | 1511 | elDesc.hidden = !s; |
1393 | 1512 | } |
1394 | 1513 |
|
| 1514 | + /* |
| 1515 | + * WebKit's HLS reports a length of its own invention often enough to |
| 1516 | + * matter: Infinity on a stream that is not live, or a number in the |
| 1517 | + * millions. Both defeat the "at least five minutes long" test in savePos() |
| 1518 | + * without ever raising anything, so an episode would simply never store a |
| 1519 | + * position. Latch the last sane value and answer with that. |
| 1520 | + */ |
| 1521 | + var MAX_DURATION = 24 * 3600; |
| 1522 | + |
| 1523 | + function duration() { |
| 1524 | + var d = video.duration; |
| 1525 | + if (isFinite(d) && d > 0 && d < MAX_DURATION) { |
| 1526 | + knownDuration = d; |
| 1527 | + return d; |
| 1528 | + } |
| 1529 | + // Infinity is how a live stream reports itself and has to survive, but |
| 1530 | + // only until something sane has been seen: once a programme has given |
| 1531 | + // a real length, a later Infinity is the same glitch by another name. |
| 1532 | + return knownDuration || d; |
| 1533 | + } |
| 1534 | + |
| 1535 | + function isLive() { |
| 1536 | + return !isFinite(duration()); |
| 1537 | + } |
| 1538 | + |
1395 | 1539 | function updateOsd() { |
1396 | | - var live = !isFinite(video.duration); |
| 1540 | + var live = isLive(); |
| 1541 | + var dur = duration(); |
1397 | 1542 | var pct = live ? 100 |
1398 | | - : (video.duration ? (video.currentTime / video.duration) * 100 : 0); |
| 1543 | + : (dur ? (video.currentTime / dur) * 100 : 0); |
| 1544 | + // A seek landing past the latched length would otherwise push the |
| 1545 | + // head off the end of the bar. |
| 1546 | + pct = Math.max(0, Math.min(100, pct)); |
1399 | 1547 | elFill.style.width = pct + "%"; |
1400 | 1548 | elHead.style.left = pct + "%"; |
1401 | 1549 | elPos.textContent = live ? "LIVE" : fmtTime(video.currentTime); |
1402 | | - elDur.textContent = live ? "" : fmtTime(video.duration); |
| 1550 | + elDur.textContent = live ? "" : fmtTime(dur); |
1403 | 1551 | } |
1404 | 1552 |
|
1405 | 1553 | function state(msg) { |
1406 | 1554 | elState.textContent = msg || ""; |
1407 | 1555 | } |
1408 | 1556 |
|
| 1557 | + // Keep a target inside what the stream will actually serve. Asking for a |
| 1558 | + // position past the seekable range is one of the ways WebKit's HLS ends up |
| 1559 | + // reporting a nonsense duration, and it is never what the viewer wanted |
| 1560 | + // anyway. |
| 1561 | + function clampSeekable(t) { |
| 1562 | + var sk = video.seekable; |
| 1563 | + if (!sk || !sk.length) { |
| 1564 | + return t; |
| 1565 | + } |
| 1566 | + var first = sk.start(0); |
| 1567 | + var last = sk.end(sk.length - 1); |
| 1568 | + if (!isFinite(first) || !isFinite(last) || last <= first) { |
| 1569 | + return t; |
| 1570 | + } |
| 1571 | + return Math.max(first, Math.min(last - 1, t)); |
| 1572 | + } |
| 1573 | + |
1409 | 1574 | // Seek nudges arrive faster than the stream can respond, so add them up and |
1410 | 1575 | // apply once the viewer stops pressing. |
1411 | 1576 | function seek(delta) { |
1412 | | - if (!isFinite(video.duration)) { |
| 1577 | + var dur = duration(); |
| 1578 | + if (!isFinite(dur)) { |
1413 | 1579 | toast("Cannot seek in a live stream"); |
1414 | 1580 | return; |
1415 | 1581 | } |
1416 | 1582 | seekAccum += delta; |
1417 | | - var target = Math.max(0, |
1418 | | - Math.min(video.duration - 1, video.currentTime + seekAccum)); |
| 1583 | + var target = clampSeekable( |
| 1584 | + Math.max(0, Math.min(dur - 1, video.currentTime + seekAccum))); |
1419 | 1585 | state((seekAccum > 0 ? "▶▶ +" : "◀◀ ") + fmtTime(Math.abs(seekAccum)) + |
1420 | 1586 | " → " + fmtTime(target)); |
1421 | 1587 | showOsd(true); |
|
1579 | 1745 | toast("Only one audio track"); |
1580 | 1746 | } |
1581 | 1747 |
|
| 1748 | + /* |
| 1749 | + * Pause and stop are the tidy ways out and both save on their way. The |
| 1750 | + * console's own way out is neither: closing the title from the dashboard, |
| 1751 | + * or a crash, takes the app down without either firing, and a position |
| 1752 | + * only ever written on a clean exit is one that is missing exactly when |
| 1753 | + * resume was most wanted. So the clock also writes itself down as it runs. |
| 1754 | + * |
| 1755 | + * timeupdate arrives about four times a second, which is far more often |
| 1756 | + * than this needs, so it is throttled to a write every fifteen seconds. |
| 1757 | + * That is the most that can be lost, and localStorage is cheap enough at |
| 1758 | + * that rate not to matter. |
| 1759 | + */ |
| 1760 | + var SAVE_EVERY = 15000; |
| 1761 | + var lastSave = 0; |
| 1762 | + |
| 1763 | + video.addEventListener("timeupdate", function () { |
| 1764 | + if (!currentEntry || video.paused) { |
| 1765 | + return; |
| 1766 | + } |
| 1767 | + var now = Date.now(); |
| 1768 | + if (now - lastSave < SAVE_EVERY) { |
| 1769 | + return; |
| 1770 | + } |
| 1771 | + lastSave = now; |
| 1772 | + savePos(); |
| 1773 | + }); |
| 1774 | + |
1582 | 1775 | video.addEventListener("timeupdate", updateOsd); |
1583 | 1776 | video.addEventListener("durationchange", updateOsd); |
1584 | 1777 | video.addEventListener("progress", updateOsd); |
|
1593 | 1786 | showOsd(false); |
1594 | 1787 | }); |
1595 | 1788 | video.addEventListener("pause", function () { |
1596 | | - state("Pausad"); |
| 1789 | + state("Paused"); |
1597 | 1790 | showOsd(true); |
1598 | 1791 | savePos(); |
1599 | 1792 | }); |
1600 | 1793 | video.addEventListener("ended", function () { |
1601 | | - if (currentEntry) { |
1602 | | - try { localStorage.removeItem(posKey(currentEntry.id)); } catch (e) {} |
1603 | | - } |
| 1794 | + // After stop(), not before: stop() saves the position on its way out, |
| 1795 | + // so forgetting first only had the save write it straight back. In |
| 1796 | + // practice the clock sits at the end by then and savePos() drops it |
| 1797 | + // anyway, but a stream that reports itself ended early would otherwise |
| 1798 | + // keep a stale point. |
| 1799 | + var id = currentEntry ? currentEntry.id : null; |
1604 | 1800 | stop(); |
| 1801 | + if (id) { |
| 1802 | + forgetPos(id); |
| 1803 | + } |
1605 | 1804 | }); |
1606 | 1805 | video.addEventListener("error", function () { |
1607 | 1806 | var e = video.error; |
|
0 commit comments