mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +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
|
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
|
that window, coloured by severity only, with hotspot
|
||||||
beacons, outlines with disputed areas, picture cards for major events and a
|
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
|
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
|
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.
|
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) {
|
export function eventPicture(report) {
|
||||||
const event = report?.event;
|
const image = report?.event?.image;
|
||||||
if (!event?.image || event.size < NEWS_CARD_MIN_SOURCES) return null;
|
if (!image) return null;
|
||||||
return { url: event.image.url, credit: event.image.publisher || '' };
|
return {
|
||||||
|
url: image.url,
|
||||||
|
credit: image.publisher || '',
|
||||||
|
...(image.video ? { video: image.video } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What an event's picture card on the globe shows. */
|
/** What an event's picture card on the globe shows. */
|
||||||
export function cardView(report, now = Date.now()) {
|
export function cardView(report, now = Date.now()) {
|
||||||
const event = report.event;
|
const event = report.event;
|
||||||
const sides = eventSides(report, now).map(sideLabel);
|
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 {
|
return {
|
||||||
title: report.titleEn || report.title,
|
title: report.titleEn || report.title,
|
||||||
meta: [
|
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', () => {
|
test('outlines keep valid rings, borders and named disputed areas', () => {
|
||||||
const ring = [
|
const ring = [
|
||||||
[30, 45],
|
[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);
|
const single = feedCardView(report('s', '2026-10-28T09:00:00Z'), NOW);
|
||||||
assert.equal(single.sides, 'Reported by UA sources');
|
assert.equal(single.sides, 'Reported by UA sources');
|
||||||
assert.equal(single.picture, null);
|
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(single.original, null);
|
||||||
assert.equal(sidesLine({ side: 'unknown' }), '');
|
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) {
|
function eventImage(image) {
|
||||||
const url = httpUrl(image?.url);
|
const url = httpUrl(image?.url);
|
||||||
if (!url?.startsWith('https://')) return null;
|
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. */
|
/** 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
|
// picture when the event has one, the headline, a mono meta line (severity
|
||||||
// marker, category, place, sources, age), who reported it, and its
|
// marker, category, place, sources, age), who reported it, and its
|
||||||
// attestation and media chips. A click chooses it (the layer flies there and
|
// 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'
|
// opens its card on the globe); open, it plays the post's clip when it has
|
||||||
// reports and the link to the original article. The layer computes every
|
// 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).
|
// value shown (feedCardView in src/layers/news/model.js).
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
item: { type: Object, required: true },
|
item: { type: Object, required: true },
|
||||||
@@ -22,9 +23,17 @@ const pictureFailed = ref(false);
|
|||||||
const dropPicture = () => {
|
const dropPicture = () => {
|
||||||
pictureFailed.value = true;
|
pictureFailed.value = true;
|
||||||
};
|
};
|
||||||
|
// So does a clip whose signed link has expired.
|
||||||
|
const clipFailed = ref(false);
|
||||||
|
const dropClip = () => {
|
||||||
|
clipFailed.value = true;
|
||||||
|
};
|
||||||
watch(
|
watch(
|
||||||
() => props.item.picture?.url,
|
() => props.item.picture?.url,
|
||||||
() => (pictureFailed.value = false),
|
() => {
|
||||||
|
pictureFailed.value = false;
|
||||||
|
clipFailed.value = false;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
const meta = () =>
|
const meta = () =>
|
||||||
[
|
[
|
||||||
@@ -61,7 +70,13 @@ const meta = () =>
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
v-on="{ error: dropPicture }"
|
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
|
item.picture.credit
|
||||||
}}</span></span
|
}}</span></span
|
||||||
>
|
>
|
||||||
@@ -95,6 +110,17 @@ const meta = () =>
|
|||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="open" class="news-card__open">
|
<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">{{
|
<p v-if="item.original" class="news-card__original">{{
|
||||||
item.original
|
item.original
|
||||||
}}</p>
|
}}</p>
|
||||||
|
|||||||
@@ -314,3 +314,49 @@ test('the panel names the window the server serves', () => {
|
|||||||
'Last 12 h',
|
'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%;
|
height: 100%;
|
||||||
object-fit: cover;
|
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 {
|
.news-card__credit {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset-inline-end: 0;
|
inset-inline-end: 0;
|
||||||
|
|||||||
Reference in new issue
Block a user