diff --git a/CHANGELOG.md b/CHANGELOG.md index 7001e4b..4a373f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ as the release notes. ## [Unreleased] +### Added + +- A video in a post shows its poster frame, decoded at the size it is drawn (400 px at most), with a line saying it cannot play on the 3DS. The poster and its shape come from Wolfram v0.37.0's `wf_post_embed`. ([#69](https://github.com/ewanc26/indigo/pull/69)) + ## [0.11.0] - 2026-10-07 What has and has not been run, plainly: the host tests pass (4029 checks) and the 3DS build links against Wolfram v0.36.2. The new tabs have not been run on a 3DS or in Azahar. diff --git a/docs/PARITY.md b/docs/PARITY.md index 275b46e..6ebf096 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -54,7 +54,7 @@ An unknown pairing code (a restarted node forgets its pairings) used to be polle | Attach an image when composing | yes | implemented: SELECT or the "Add an image" button in compose opens a picker over `sdmc:/3ds/indigo/images` (JPEG and PNG under 950 KB), then asks for alt text; works on posts, replies and quotes. The type and size filter, the folder scan and the upload are Wolfram's (`wolfram/attach.h`) | host only: the compose state, the picker and the layouts (snapshots `compose-image`, `attach-picker`). The upload and a real post have not been run on an emulator or a 3DS (#19 stays open for that). Whether the picker should also look in `sdmc:/DCIM`, where the camera saves, is undecided and needs a console | | On-card cache of timeline and profile data | not listed | issue: #13 | not started | | Settings screen | n/a | implemented: theme, text scale, alt text, diagnostics, startup feed; reduce motion and high contrast are stored and reserved | host | -| Video, GIFs and animated media | not planned | not planned: Wolfram's decoder (`wf_image_decode_rgba`) is for still images, and nothing in the 1.5MB image budget accounts for frames | no decoder evaluation has been done, so this is a decision and not a hardware limit | +| Video, GIFs and animated media | not planned | partial: a video shows its poster frame, decoded at the draw size (400 px cap), with a line saying it cannot play on the 3DS (#69). GIFs are not drawn. | Wolfram's decoder (`wf_image_decode_rgba`) is for still images, and nothing in the 1.5MB image budget accounts for frames. No decoder evaluation has been done, so playback is a decision and not a hardware limit. | | Auto-update | in progress, to the same wolfram#106 contract | implemented: More > "Check for updates" asks GitHub for the latest release, shows the version and asks before downloading. The download is checked (size and SHA-256), written, re-read from the card, and swapped in with the old build kept until the new one starts. Manifest signed (Ed25519, `update.json.sig`) and refused if unsigned | host only (the screen's states, the pinned URLs, and the swap and its recovery with power lost at every write; docs/UPDATE.md). The network, the card and the swap have not been run on an emulator or hardware (#25) | | Push notifications | not planned | not planned: no push service a homebrew application can register with; notifications are fetched when the screen is opened | | diff --git a/src/app/timeline.h b/src/app/timeline.h index 6459efe..4df841b 100644 --- a/src/app/timeline.h +++ b/src/app/timeline.h @@ -42,11 +42,12 @@ typedef enum { /* What a post carries, for drawing rather than for the summary line. A kind of * NONE with a non-empty embed_note is a quote or an attachment Indigo draws as - * text; IMAGE and LINK are drawn as themselves. */ + * text; IMAGE, LINK and VIDEO are drawn as themselves. */ typedef enum { INDIGO_EMBED_NONE = 0, INDIGO_EMBED_IMAGE, INDIGO_EMBED_LINK, + INDIGO_EMBED_VIDEO, /* the poster frame; Indigo cannot play the video */ } indigo_embed_kind; typedef struct { diff --git a/src/atproto/session_view.c b/src/atproto/session_view.c index 1e77aad..5bd2a4c 100644 --- a/src/atproto/session_view.c +++ b/src/atproto/session_view.c @@ -50,6 +50,26 @@ indigo_session_count_of(int v) return v > 0 ? (unsigned) v : 0; } +/* The declared aspect ratio, halved until each side fits a byte. */ +static void +set_embed_shape(indigo_post *out, int width, int height) +{ + unsigned w; + unsigned h; + + if (width <= 0 || height <= 0) { + return; + } + w = (unsigned) width; + h = (unsigned) height; + while (w > 255 || h > 255) { + w /= 2; + h /= 2; + } + out->embed_w = (unsigned char) (w ? w : 1); + out->embed_h = (unsigned char) (h ? h : 1); +} + /* Carry the embed onto the post, for the screens that draw it. The * display helper owns the safe text/facet view; the typed embed reader supplies * image metadata that the display summary deliberately keeps bounded to a count. */ @@ -77,16 +97,7 @@ fill_embed(const wf_post_display *d, const cJSON *raw_embed, indigo_post *out) indigo_media_cdn_url(out->embed_thumb, sizeof out->embed_thumb, img->thumb, INDIGO_CDN_THUMBNAIL); indigo_copy_utf8(out->embed_alt, sizeof out->embed_alt, img->alt ? img->alt : ""); - if (img->width > 0 && img->height > 0) { - unsigned w = (unsigned) img->width; - unsigned h = (unsigned) img->height; - while (w > 255 || h > 255) { - w /= 2; - h /= 2; - } - out->embed_w = (unsigned char) (w ? w : 1); - out->embed_h = (unsigned char) (h ? h : 1); - } + set_embed_shape(out, img->width, img->height); break; } } @@ -102,6 +113,16 @@ fill_embed(const wf_post_display *d, const cJSON *raw_embed, indigo_post *out) INDIGO_CDN_THUMBNAIL); } + /* A video's poster frame is a picture of the right shape; the video itself + * is not played, and the layout says so. */ + if (out->embed_kind == INDIGO_EMBED_NONE && embed.video_thumb && + embed.video_thumb[0]) { + out->embed_kind = INDIGO_EMBED_VIDEO; + indigo_media_cdn_url(out->embed_thumb, sizeof out->embed_thumb, embed.video_thumb, + INDIGO_CDN_THUMBNAIL); + set_embed_shape(out, embed.video_width, embed.video_height); + } + wf_post_embed_free(&embed); } diff --git a/src/ui/layout_post.c b/src/ui/layout_post.c index c5c9e05..446bd7f 100644 --- a/src/ui/layout_post.c +++ b/src/ui/layout_post.c @@ -87,12 +87,12 @@ draw_post_text(indigo_canvas *c, const indigo_post *p, unsigned max_lines, } /* True when the post has an embed the screen draws as itself rather than as a - * line of text. A quote, a video and an attachment Indigo cannot draw all keep - * the one-line note instead. */ + * line of text. A quote and an attachment Indigo cannot draw keep the one-line + * note instead; a video is drawn as its poster frame. */ static bool post_draws_embed(const indigo_post *p) { - if (p->embed_kind == INDIGO_EMBED_IMAGE) { + if (p->embed_kind == INDIGO_EMBED_IMAGE || p->embed_kind == INDIGO_EMBED_VIDEO) { return p->embed_thumb[0] != '\0'; } if (p->embed_kind == INDIGO_EMBED_LINK) { @@ -222,10 +222,17 @@ indigo_layout_draw_post_body(indigo_canvas *c, const indigo_post *p, bool show_a draw_post_text(c, p, embed ? POST_TEXT_LINES_EMBED : POST_TEXT_LINES, text_scale); if (embed) { - if (p->embed_kind != INDIGO_EMBED_IMAGE) { + if (p->embed_kind == INDIGO_EMBED_LINK) { draw_post_link(c, p); return; } + if (p->embed_kind == INDIGO_EMBED_VIDEO) { + /* The label takes its line off the band before the poster is + * fitted into what is left, so the two never overlap. */ + band_h = EMBED_H - draw_post_alt(c, "Video: it can't play on the 3DS") - 4.0f; + draw_post_image(c, p, band_h < EMBED_H_MIN ? EMBED_H_MIN : band_h); + return; + } if (show_alt && p->embed_alt[0]) { /* The alt text sits at the bottom of the band, so the picture is * fitted into what is above it. */ diff --git a/tests/tests.c b/tests/tests.c index 2eb7918..9817da4 100644 --- a/tests/tests.c +++ b/tests/tests.c @@ -2290,6 +2290,49 @@ test_layout_draws_post_images(void) } } +/* A video is drawn as its poster frame with a line saying it cannot play, and + * the line sits under the poster rather than on it. */ +static void +test_layout_draws_video_poster(void) +{ + static const char *const poster = "https://video.example/hls/did:plc:one/c/thumbnail.jpg"; + indigo_app app; + indigo_input in = {0}; + indigo_canvas top; + indigo_canvas bottom; + indigo_post *p; + const indigo_cmd *img; + + indigo_app_init(&app); + app.screen = INDIGO_SCREEN_HOME; + app.timeline.count = 1; + p = &app.timeline.posts[0]; + indigo_copy_utf8(p->text, sizeof p->text, "Watch this."); + indigo_copy_utf8(p->embed_note, sizeof p->embed_note, "[video]"); + indigo_copy_utf8(p->embed_thumb, sizeof p->embed_thumb, poster); + p->embed_kind = INDIGO_EMBED_VIDEO; + p->embed_w = 16; + p->embed_h = 9; + + indigo_layout_build(&app, &in, &top, &bottom); + CHECK(!top.overflow); + img = find_image(&top, poster); + CHECK(img != NULL); + CHECK(text_has(&top, "can't play")); + CHECK(!text_has(&top, "[video]")); + for (unsigned i = 0; i < top.count && img; i++) { + if (top.cmds[i].kind == INDIGO_CMD_TEXT && + strstr(top.text + top.cmds[i].text_offset, "can't play")) { + CHECK(top.cmds[i].y >= img->y + img->h); + } + } + + /* No poster URL, nothing to draw: the one-line note stands in for it. */ + p->embed_thumb[0] = '\0'; + indigo_layout_build(&app, &in, &top, &bottom); + CHECK(text_has(&top, "[video]")); +} + static void test_layout_draws_link_cards(void) { @@ -5394,6 +5437,7 @@ main(void) test_shapes_stay_on_screen(); test_settings_screen_shows_the_build(); test_layout_draws_post_images(); + test_layout_draws_video_poster(); test_layout_draws_link_cards(); test_layout_draws_alt_text(); test_layout_text_scale(); diff --git a/tools/snapshot.c b/tools/snapshot.c index 32b598e..79e2c86 100644 --- a/tools/snapshot.c +++ b/tools/snapshot.c @@ -416,6 +416,21 @@ fill_timeline(indigo_timeline *t, int kind, unsigned select) indigo_timeline_fail_fetch(t, "Could not reach the network."); return; } + if (kind == 4) { + /* One video post: its poster, shaped 16:9 the way the server declares + * it, and the line saying it cannot play. The host has no pixels, so + * the poster is the placeholder, which is the layout that matters. */ + indigo_post *p; + + add_post(t, "Video", "video.example", "A clip from the river walk.", "", 3, false, false); + p = &t->posts[0]; + p->embed_kind = INDIGO_EMBED_VIDEO; + snprintf(p->embed_thumb, sizeof p->embed_thumb, + "https://video.bsky.app/watch/did:plc:fake/bafyvideo/thumbnail.jpg"); + p->embed_w = 16; + p->embed_h = 9; + return; + } if (kind == 13) { /* Posts and a failure at the same time, which is the state the header * bar's status text and the viewer button have to share. */ @@ -795,6 +810,7 @@ main(int argc, char **argv) {"timeline-scrolled", INDIGO_SCREEN_HOME, false, 0, 0, 1, 4, false}, {"timeline-loading", INDIGO_SCREEN_HOME, false, 0, 0, 2, 0, false}, {"timeline-error", INDIGO_SCREEN_HOME, false, 0, 0, 3, 0, false}, + {"timeline-video", INDIGO_SCREEN_HOME, false, 0, 0, 4, 0, false}, {"thread", INDIGO_SCREEN_THREAD, false, 0, 0, 1, 1, false}, {"profile", INDIGO_SCREEN_PROFILE, false, 0, 0, 0, 0, false}, {"profile-follow", INDIGO_SCREEN_PROFILE, false, 0, 0, 1, 0, false}, diff --git a/wolfram.ref b/wolfram.ref index 5203324..945a6da 100644 --- a/wolfram.ref +++ b/wolfram.ref @@ -1 +1 @@ -v0.36.2 +v0.37.0