From 74d40c4b1a6084c7c610edc8ac692ebd39bf1c77 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 23:34:07 +0100 Subject: [PATCH] Show one-source events' pictures and play their clips in the News panel A feed card shows its event's picture whatever the number of sources, so a lone Telegram post opening with a video gets its preview; the globe's picture cards still need two. An event picture may carry its post's clip (an HTTPS MP4 or WebM file): the card marks it with a play sign, and opened, it plays with controls and sound, loading nothing before. An expired link drops the clip. --- changelog.d/1-news-layer.md | 4 ++- src/layers/news/model.js | 20 +++++++++++---- src/layers/news/model.test.mjs | 44 ++++++++++++++++++++++++++++++++ src/layers/news/records.js | 16 ++++++++++-- src/ui/news/NewsCard.vue | 34 ++++++++++++++++++++++--- src/ui/newsPanel.test.mjs | 46 ++++++++++++++++++++++++++++++++++ src/ui/styles/news.css | 19 ++++++++++++++ 7 files changed, 171 insertions(+), 12 deletions(-) diff --git a/changelog.d/1-news-layer.md b/changelog.d/1-news-layer.md index 4e65d9d10..d8925eaeb 100644 --- a/changelog.d/1-news-layer.md +++ b/changelog.d/1-news-layer.md @@ -3,7 +3,9 @@ in the panel to the last 12, 6, 3 or 1 hours with the hotspot counts read for that window, coloured by severity only, with hotspot beacons, outlines with disputed areas, picture cards for major events and a - continuous feed whose cards fly the globe to the event. The panel's Local + continuous feed whose cards fly the globe to the event. A feed card shows its + event's picture even when one source reports it, and an open card plays the + post's clip on request. The panel's Local section lists every report within 500 km of the view below 3,000 km, and the map shows those instead of the hotspots' news while it is chosen. Core serves no news itself: set `GEV_NEWS_ORIGIN` to a news server and the local server forwards the layer's three read routes there. diff --git a/src/layers/news/model.js b/src/layers/news/model.js index 64e39e8ff..686040e1b 100644 --- a/src/layers/news/model.js +++ b/src/layers/news/model.js @@ -460,18 +460,28 @@ export function sidesLine(report, now = Date.now()) { }); } -/** The event's picture for a card: two or more sources, and the publisher named. */ +/** + * The event's picture, the publisher named, and its clip when it has one. The + * panel shows it for any event; the globe's picture cards are for events of + * two or more sources (isCardEvent). + */ export function eventPicture(report) { - const event = report?.event; - if (!event?.image || event.size < NEWS_CARD_MIN_SOURCES) return null; - return { url: event.image.url, credit: event.image.publisher || '' }; + const image = report?.event?.image; + if (!image) return null; + return { + url: image.url, + credit: image.publisher || '', + ...(image.video ? { video: image.video } : {}), + }; } /** What an event's picture card on the globe shows. */ export function cardView(report, now = Date.now()) { const event = report.event; const sides = eventSides(report, now).map(sideLabel); - const picture = eventPicture(report); + // A globe card's picture needs two or more sources, as the card does. + const picture = + report?.event?.size >= NEWS_CARD_MIN_SOURCES ? eventPicture(report) : null; return { title: report.titleEn || report.title, meta: [ diff --git a/src/layers/news/model.test.mjs b/src/layers/news/model.test.mjs index a0ba16b0e..ed439ebe5 100644 --- a/src/layers/news/model.test.mjs +++ b/src/layers/news/model.test.mjs @@ -157,6 +157,32 @@ test('payloads are validated record by record; empty and malformed payloads', () }); }); +test("an event's clip is kept only as an HTTPS file a browser plays itself", () => { + const clip = (video) => + normalizeFeedPayload({ + reports: [ + { + id: 'r', + title: 'T', + url: 'https://x.test/r', + publishedAt: '2026-10-28T09:00:00Z', + event: { + size: 1, + image: { url: 'https://cdn.test/p.jpg', publisher: 'P', video }, + }, + }, + ], + }).reports[0].event.image.video; + assert.equal( + clip('https://cdn.test/v.mp4?token=a'), + 'https://cdn.test/v.mp4?token=a', + ); + assert.equal(clip('https://cdn.test/v.webm'), 'https://cdn.test/v.webm'); + assert.equal(clip('http://cdn.test/v.mp4'), undefined); + assert.equal(clip('https://cdn.test/v.m3u8'), undefined); + assert.equal(clip(undefined), undefined); +}); + test('outlines keep valid rings, borders and named disputed areas', () => { const ring = [ [30, 45], @@ -488,6 +514,24 @@ test('feed cards read like Breaking: headline, meta parts, sides and chips', () const single = feedCardView(report('s', '2026-10-28T09:00:00Z'), NOW); assert.equal(single.sides, 'Reported by UA sources'); assert.equal(single.picture, null); + // A one-source event (a Telegram post) shows its picture and its clip in + // the panel, though the globe's picture cards need two sources. + const post = report('p', '2026-10-28T09:00:00Z', { + event: { + size: 1, + sides: [], + image: { + url: 'https://cdn4.telesco.pe/file/v.jpg', + publisher: 'Channel', + video: 'https://cdn4.telesco.pe/file/v.mp4?token=x', + }, + }, + }); + assert.deepEqual(feedCardView(post, NOW).picture, { + url: 'https://cdn4.telesco.pe/file/v.jpg', + credit: 'Channel', + video: 'https://cdn4.telesco.pe/file/v.mp4?token=x', + }); assert.equal(single.original, null); assert.equal(sidesLine({ side: 'unknown' }), ''); }); diff --git a/src/layers/news/records.js b/src/layers/news/records.js index a4ec22462..557216f22 100644 --- a/src/layers/news/records.js +++ b/src/layers/news/records.js @@ -162,11 +162,23 @@ function normalizeGeo(geo) { }; } -/** The event's picture: an HTTPS URL on the publisher's site, and whose it is. */ +/** + * The event's picture: an HTTPS URL on the publisher's site, and whose it is; + * with the post's own clip when it has one a browser plays itself (an HTTPS + * MP4 or WebM file), the picture then its poster. + */ function eventImage(image) { const url = httpUrl(image?.url); if (!url?.startsWith('https://')) return null; - return { url, publisher: text(image.publisher, 80) || '' }; + const video = httpUrl(image.video); + const playable = + video?.startsWith('https://') && + /\.(?:mp4|m4v|webm)$/i.test(new URL(video).pathname); + return { + url, + publisher: text(image.publisher, 80) || '', + ...(playable ? { video } : {}), + }; } /** An event summary from `/feed?events=1`: size and one report per side. */ diff --git a/src/ui/news/NewsCard.vue b/src/ui/news/NewsCard.vue index 1f2381760..1e424f787 100644 --- a/src/ui/news/NewsCard.vue +++ b/src/ui/news/NewsCard.vue @@ -7,8 +7,9 @@ import UiButton from '../kit/UiButton.vue'; // picture when the event has one, the headline, a mono meta line (severity // marker, category, place, sources, age), who reported it, and its // attestation and media chips. A click chooses it (the layer flies there and -// opens its card on the globe); open, it shows the summary, the other sides' -// reports and the link to the original article. The layer computes every +// opens its card on the globe); open, it plays the post's clip when it has +// one (on request, with sound and controls), and shows the summary, the other +// sides' reports and the link to the original article. The layer computes every // value shown (feedCardView in src/layers/news/model.js). const props = defineProps({ item: { type: Object, required: true }, @@ -22,9 +23,17 @@ const pictureFailed = ref(false); const dropPicture = () => { pictureFailed.value = true; }; +// So does a clip whose signed link has expired. +const clipFailed = ref(false); +const dropClip = () => { + clipFailed.value = true; +}; watch( () => props.item.picture?.url, - () => (pictureFailed.value = false), + () => { + pictureFailed.value = false; + clipFailed.value = false; + }, ); const meta = () => [ @@ -61,7 +70,13 @@ const meta = () => decoding="async" referrerpolicy="no-referrer" v-on="{ error: dropPicture }" - />{{ + />▶{{ item.picture.credit }} @@ -95,6 +110,17 @@ const meta = () => >
+

{{ item.original }}

diff --git a/src/ui/newsPanel.test.mjs b/src/ui/newsPanel.test.mjs index c4f584410..920e25c84 100644 --- a/src/ui/newsPanel.test.mjs +++ b/src/ui/newsPanel.test.mjs @@ -314,3 +314,49 @@ test('the panel names the window the server serves', () => { 'Last 12 h', ); }); + +test('a clip waits behind its picture and plays, on request, in the open card', () => { + const { news, panel } = fixture(); + const clipped = report('clip', { + event: { + size: 1, + sides: [], + image: { + url: 'https://cdn4.telesco.pe/file/v.jpg', + publisher: 'Channel', + video: 'https://cdn4.telesco.pe/file/v.mp4?token=x', + }, + }, + }); + news.update(view({ items: [feedCardView(clipped, NOW)] })); + const card = panel.querySelector('.news-card'); + assert.equal(card.querySelector('.news-card__play').textContent, '▶'); + assert.equal(card.querySelector('video'), null); + news.update( + view({ + items: [feedCardView(clipped, NOW)], + selected: feedCardView(clipped, NOW), + }), + ); + const video = panel.querySelector('.news-card.is-open video'); + assert.equal( + video.getAttribute('src'), + 'https://cdn4.telesco.pe/file/v.mp4?token=x', + ); + assert.equal( + video.getAttribute('poster'), + 'https://cdn4.telesco.pe/file/v.jpg', + ); + assert.equal(video.getAttribute('preload'), 'none'); + assert.ok(video.hasAttribute('controls')); + // An expired link leaves the card without the clip. + video.dispatchEvent(new Event('error')); + news.update( + view({ + items: [feedCardView(clipped, NOW)], + selected: feedCardView(clipped, NOW), + }), + ); + assert.equal(panel.querySelector('.news-card.is-open video'), null); + news.destroy(); +}); diff --git a/src/ui/styles/news.css b/src/ui/styles/news.css index 605ac2fcf..d25fe2a03 100644 --- a/src/ui/styles/news.css +++ b/src/ui/styles/news.css @@ -226,6 +226,25 @@ height: 100%; object-fit: cover; } +/* A clip waits behind its picture; the open card plays it. */ +.news-card__play { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--text-primary); + font-size: 14px; + text-shadow: 0 0 6px rgba(0, 0, 0, 0.8); + pointer-events: none; +} +.news-card__clip { + display: block; + width: 100%; + max-height: 220px; + margin-bottom: 6px; + border-radius: 4px; + background: #000; +} .news-card__credit { position: absolute; inset-inline-end: 0;