mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 05:42:22 +00:00
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:
1 parent
f72f79f48b
commit
74d40c4b1a
7 files changed
+171
-12
No files matched your search
@@ -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.
|
||||
@@ -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: [
|
||||
|
||||
@@ -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' }), '');
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user