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.
This commit is contained in:
Denis Groshev committed 2026-11-06 00:45:30 +01:00
1 parent f72f79f48b
commit 74d40c4b1a
7 files changed
+171 -12

No files matched your search

+3 -1
View File
@@ -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.
+15 -5
View File
@@ -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: [
+44
View File
@@ -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' }), '');
});
+14 -2
View File
@@ -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. */
+30 -4
View File
@@ -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 }"
/><span v-if="item.picture.credit" class="news-card__credit">{{
/><span
v-if="item.picture.video && !clipFailed"
class="news-card__play"
role="img"
:aria-label="t('Video')"
>▶</span
><span v-if="item.picture.credit" class="news-card__credit">{{
item.picture.credit
}}</span></span
>
@@ -95,6 +110,17 @@ const meta = () =>
>
</button>
<div v-if="open" class="news-card__open">
<video
v-if="item.picture?.video && !clipFailed"
class="news-card__clip"
:src="item.picture.video"
:poster="pictureFailed ? undefined : item.picture.url"
controls
playsinline
preload="none"
referrerpolicy="no-referrer"
v-on="{ error: dropClip }"
></video>
<p v-if="item.original" class="news-card__original">{{
item.original
}}</p>
+46
View File
@@ -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();
});
+19
View File
@@ -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;