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

+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;