diff --git a/public/assets/my.js b/public/assets/my.js index 7ff05a2..1368645 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -1262,7 +1262,7 @@ const p = $('vidPlayer'); if (!p) return; const was = !!vidState.token && !vidState.done; try { p.pause(); p.ontimeupdate = null; p.onseeking = null; p.removeAttribute('src'); p.load(); } catch (e) {} - vidState.token = null; vidState.done = false; vidState.credited = false; vidState.maxSeen = 0; + vidState.token = null; vidState.done = false; vidState.credited = false; vidState.maxSeen = 0; vidState.needsTap = false; if ($('vidWrap')) $('vidWrap').hidden = true; if ($('vidBox')) { $('vidBox').hidden = false; if (was) $('vidBox').innerHTML = 'Video stopped when you left the tab. Tap Load a video to start a fresh one.'; } if ($('vidStartBtn')) { $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Load a video'; } @@ -1274,10 +1274,17 @@ if (!r || !r.ad) { // same done screen as Watch ads when the day's videos are finished (Marty, 2026-09-13) const capHit = !!(r && r.status && r.status.left <= 0), allWatched = !!(r && r.allWatched); - $('vidBox').innerHTML = (capHit || allWatched) - ? '
✓Videos done for today' + (capHit ? 'That is today’s video set (' + r.status.count + ' watched). Fresh videos tomorrow.' : 'You have watched every live video for today; each one pays once a day. New ones appear as members launch video campaigns.') + '
' - : 'No member videos are live right now. Check back when a campaign is running.'; - if ($('vidStartBtn')) $('vidStartBtn').hidden = capHit || allWatched; + // the tab can run dry while Shorts still has clips, and both count toward the same + // daily total, so say so instead of sending them away (Marty, 2026-09-22) + const toShorts = !capHit && r && r.otherFormat === 'shorts'; + $('vidBox').innerHTML = toShorts + ? '
✓That is every video on this tab' + + 'You have watched all ' + r.status.count + ' of today’s landscape videos. The rest are upright clips that play in Shorts, and they count toward the same daily ' + r.status.cap + '. You have ' + r.status.left + ' left.' + + 'Open Shorts
' + : (capHit || allWatched) + ? '
✓Videos done for today' + (capHit ? 'That is today’s video set (' + r.status.count + ' watched). Fresh videos tomorrow.' : 'You have watched every live video for today; each one pays once a day. New ones appear as members launch video campaigns.') + '
' + : 'No member videos are live right now. Check back when a campaign is running.'; + if ($('vidStartBtn')) $('vidStartBtn').hidden = capHit || allWatched || toShorts; $('vidBox').hidden = false; $('vidWrap').hidden = true; return; @@ -1301,7 +1308,11 @@ }; $('vidStartBtn').textContent = 'Playing…'; $('vidStartBtn').disabled = true; - try { await p.play(); } catch (e) { $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Tap to play'; } + vidState.needsTap = false; + // a browser that refuses to autoplay leaves the clip loaded and paused; the button then has to + // START it. Wired to loadVideoAd it threw the clip away and fetched another, so on a phone the + // video could never be played at all (Marty, 2026-09-22) + try { await p.play(); } catch (e) { vidState.needsTap = true; $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Tap to play'; } $('vidCta').href = ad.ctaUrl; $('vidCta').textContent = ad.ctaLabel || 'Learn more'; $('vidCta').hidden = false; @@ -1320,7 +1331,16 @@ $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Next video'; } - $('vidStartBtn').addEventListener('click', () => loadVideoAd()); + $('vidStartBtn').addEventListener('click', () => { + const p = $('vidPlayer'); + if (vidState.needsTap && p && p.src && !vidState.done) { // a clip is loaded and waiting on a tap: start it, never replace it + vidState.needsTap = false; + $('vidStartBtn').textContent = 'Playing…'; $('vidStartBtn').disabled = true; + p.play().catch(() => { vidState.needsTap = true; $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Tap to play'; }); + return; + } + loadVideoAd(); + }); // presence enforcement: pause the watch-to-earn video when the tab/window loses focus, // resume when it comes back — the viewer must stay on the page for the watch to complete document.addEventListener('visibilitychange', () => { diff --git a/public/my.html b/public/my.html index 133be9d..c9e9246 100644 --- a/public/my.html +++ b/public/my.html @@ -1070,7 +1070,7 @@ - + diff --git a/server.js b/server.js index b495fa8..ff69dc2 100644 --- a/server.js +++ b/server.js @@ -368,6 +368,24 @@ async function imageCheck(url) { return { ok: false, reason: 'That link is a web page, not an image. Paste the direct image link (it usually ends in .png or .jpg), or use Promo tools > Banners > Copy image URL.' }; } catch (e) { return { ok: false, reason: 'Could not load that image link. Paste the direct image link (ends in .png or .jpg), or use Promo tools > Banners > Copy image URL.' }; } } +// A video campaign's source has to actually exist. The create form only matched the URL +// shape, so a placeholder (yourdomain.com/....mp4) went live on 2026-09-11 and sat there for +// eleven days handing every Tier 1 viewer a black player they could never finish. +async function videoCheck(url) { + const u = String(url || '').trim(); + if (/^\/uploads\//.test(u)) return { ok: true }; // uploaded here, already on our disk + let h; + try { h = await headUrl(u); } + catch (e) { // no DNS, refused, timed out + return { ok: false, reason: 'We could not reach that video link, so members would just see a black player. Check the address, or upload the file here instead.' }; + } + if (h && (h.status === 404 || h.status === 410)) + return { ok: false, reason: 'That video link answers with HTTP ' + h.status + ' — the file is not there. Point the campaign at a file that loads, or upload it here.' }; + const ct = String((h && h.contentType) || '').split(';')[0].trim().toLowerCase(); + if (ct && !/^video\//.test(ct) && ct !== 'application/octet-stream' && ct !== 'binary/octet-stream') + return { ok: false, reason: 'That link returns ' + ct + ', not a video file. Paste the direct .mp4 or .webm link, or upload the file here.' }; + return { ok: true }; +} function headUrl(url) { return new Promise((resolve, reject) => { let u; try { u = new URL(url); } catch (e) { return reject(new Error('bad url')); } @@ -2637,7 +2655,15 @@ const server = http.createServer(async (req, res) => { const ad = await ads.serveVideo(Object.assign({ excludeEmail: s.email, orientation }, viewerGeo(req))); // never your own video if (!ad) { // distinguish "you have watched every live video today" from "nothing is live" (Marty, 2026-09-13) let allWatched = false; try { allWatched = !!(await ads.serveVideo(Object.assign({ excludeEmail: s.email, orientation, ignoreSeen: true }, viewerGeo(req)))); } catch (e) {} - return json(res, 200, { ad: null, status, allWatched }); + // this surface is empty but the other one may not be: the Watch tab holds the landscape + // videos and Shorts holds the portrait ones, and they share one daily cap, so a member + // who runs the tab dry still has clips waiting (Marty, 2026-09-22) + let otherFormat = null; + if (status.left > 0 && (orientation === 'landscape' || orientation === 'portrait')) { + const alt = orientation === 'landscape' ? 'portrait' : 'landscape'; + try { if (await ads.serveVideo(Object.assign({ excludeEmail: s.email, orientation: alt }, viewerGeo(req)))) otherFormat = alt === 'portrait' ? 'shorts' : 'watch'; } catch (e) {} + } + return json(res, 200, { ad: null, status, allWatched, otherFormat }); } const token = crypto.randomBytes(16).toString('hex'); videoTokens.set(s.email, { token, ts: Date.now(), id: ad.id, secs: ad.watchSecs }); @@ -2827,6 +2853,7 @@ const server = http.createServer(async (req, res) => { const fc = await frameCheck(b.targetUrl); if (!fc.ok) return json(res, 400, { error: fc.reason }); } + if (String(b.type) === 'video') { const vc = await videoCheck(b.videoUrl); if (!vc.ok) return json(res, 400, { error: vc.reason }); } // charge the best-funded of the member's positions (main + Qualified Start // wallets). A campaign burns from one member id, so the budget must fit inside it. const ids = (await myMemberIds(s)).ids;