Skip to content

Commit b6d8f3e

Browse files
committed
lots of fixes
1 parent f79a998 commit b6d8f3e

1 file changed

Lines changed: 230 additions & 31 deletions

File tree

webapp/app.js

Lines changed: 230 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,10 @@
686686
var hls = null;
687687
var currentEntry = null;
688688

689+
// The last length this programme reported that was worth believing. See
690+
// duration() below.
691+
var knownDuration = 0;
692+
689693
/*
690694
* How long a stream is given to produce a frame.
691695
*
@@ -773,31 +777,104 @@
773777
function () { return !!window.Hls; });
774778
}
775779

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+
}
778850
}
779851

780852
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.
782858
return;
783859
}
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);
792867
}
793868

794869
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) {
799873
return 0;
800874
}
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;
801878
}
802879

803880
/* --------------------------------------------------------- stream info
@@ -1151,6 +1228,7 @@
11511228
}
11521229

11531230
currentEntry = entry;
1231+
knownDuration = 0;
11541232
mode = "player";
11551233
elPlayer.hidden = false;
11561234
elSpinner.hidden = false;
@@ -1184,23 +1262,59 @@
11841262
var resume = entry.live ? 0 : resumePos(entry.id);
11851263
var triedHlsJs = false;
11861264

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+
11871302
function started() {
11881303
// A metadata event from the source this one replaced would seek
11891304
// and play the wrong programme.
11901305
if (!playing(my)) {
11911306
return;
11921307
}
11931308
elSpinner.hidden = true;
1194-
if (resume > 5) {
1195-
try { video.currentTime = resume; } catch (e) { /* live */ }
1196-
toast("Resuming from " + fmtTime(resume));
1197-
}
11981309
var p = video.play();
11991310
if (p && p["catch"]) {
12001311
p["catch"](function (err) {
1201-
toast("Uppspelning blockerad: " + err.message, true);
1312+
toast("Playback blocked: " + err.message, true);
12021313
});
12031314
}
1315+
if (resume > 5) {
1316+
applyResume();
1317+
}
12041318
}
12051319

12061320
function attachNative() {
@@ -1227,7 +1341,11 @@
12271341
// A console has far less headroom than a desktop; holding
12281342
// half an hour of played-out segments is what turns a long
12291343
// 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
12311349
});
12321350
hls.on(window.Hls.Events.MANIFEST_PARSED, started);
12331351
hls.on(window.Hls.Events.ERROR, function (evt, data) {
@@ -1343,6 +1461,7 @@
13431461

13441462
function stop() {
13451463
savePos();
1464+
knownDuration = 0;
13461465
clearStall();
13471466
// Anything still resolving belongs to a programme that is no longer
13481467
// wanted, and must not attach itself once it arrives.
@@ -1392,30 +1511,77 @@
13921511
elDesc.hidden = !s;
13931512
}
13941513

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+
13951539
function updateOsd() {
1396-
var live = !isFinite(video.duration);
1540+
var live = isLive();
1541+
var dur = duration();
13971542
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));
13991547
elFill.style.width = pct + "%";
14001548
elHead.style.left = pct + "%";
14011549
elPos.textContent = live ? "LIVE" : fmtTime(video.currentTime);
1402-
elDur.textContent = live ? "" : fmtTime(video.duration);
1550+
elDur.textContent = live ? "" : fmtTime(dur);
14031551
}
14041552

14051553
function state(msg) {
14061554
elState.textContent = msg || "";
14071555
}
14081556

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+
14091574
// Seek nudges arrive faster than the stream can respond, so add them up and
14101575
// apply once the viewer stops pressing.
14111576
function seek(delta) {
1412-
if (!isFinite(video.duration)) {
1577+
var dur = duration();
1578+
if (!isFinite(dur)) {
14131579
toast("Cannot seek in a live stream");
14141580
return;
14151581
}
14161582
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)));
14191585
state((seekAccum > 0 ? "▶▶ +" : "◀◀ ") + fmtTime(Math.abs(seekAccum)) +
14201586
" → " + fmtTime(target));
14211587
showOsd(true);
@@ -1579,6 +1745,33 @@
15791745
toast("Only one audio track");
15801746
}
15811747

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+
15821775
video.addEventListener("timeupdate", updateOsd);
15831776
video.addEventListener("durationchange", updateOsd);
15841777
video.addEventListener("progress", updateOsd);
@@ -1593,15 +1786,21 @@
15931786
showOsd(false);
15941787
});
15951788
video.addEventListener("pause", function () {
1596-
state("Pausad");
1789+
state("Paused");
15971790
showOsd(true);
15981791
savePos();
15991792
});
16001793
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;
16041800
stop();
1801+
if (id) {
1802+
forgetPos(id);
1803+
}
16051804
});
16061805
video.addEventListener("error", function () {
16071806
var e = video.error;

0 commit comments

Comments
 (0)