Ver Fonte

Implement mixed-media tweet support

Fixes #697 #1101
Zed há 6 meses atrás
pai
commit
35a929c415

+ 2 - 0
.gitignore

@@ -13,3 +13,5 @@ nitter.conf
 guest_accounts.json*
 sessions.json*
 dump.rdb
+*.bak
+/tools/*.json*

+ 1 - 0
src/apiutils.nim

@@ -19,6 +19,7 @@ proc setDisableTid*(disable: bool) =
   disableTid = disable
 
 proc setApiProxy*(url: string) =
+  apiProxy = ""
   if url.len > 0:
     apiProxy = url.strip(chars={'/'}) & "/"
     if "http" notin apiProxy:

+ 32 - 16
src/parser.nim

@@ -140,27 +140,37 @@ proc parseVideo(js: JsonNode): Video =
 
   result.variants = parseVideoVariants(js{"video_info", "variants"})
 
+proc addMedia(media: var MediaEntities; photo: Photo) =
+  media.add Media(kind: photoMedia, photo: photo)
+
+proc addMedia(media: var MediaEntities; video: Video) =
+  media.add Media(kind: videoMedia, video: video)
+
+proc addMedia(media: var MediaEntities; gif: Gif) =
+  media.add Media(kind: gifMedia, gif: gif)
+
 proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
   with jsMedia, js{"extended_entities", "media"}:
     for m in jsMedia:
       case m.getTypeName:
       of "photo":
-        result.photos.add Photo(
+        result.media.addMedia(Photo(
           url: m{"media_url_https"}.getImageStr,
           altText: m{"ext_alt_text"}.getStr
-        )
+        ))
       of "video":
-        result.video = some(parseVideo(m))
+        result.media.addMedia(parseVideo(m))
         with user, m{"additional_media_info", "source_user"}:
           if user{"id"}.getInt > 0:
             result.attribution = some(parseUser(user))
           else:
             result.attribution = some(parseGraphUser(user))
       of "animated_gif":
-        result.gif = some Gif(
+        result.media.addMedia(Gif(
           url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
-          thumb: m{"media_url_https"}.getImageStr
-        )
+          thumb: m{"media_url_https"}.getImageStr,
+          altText: m{"ext_alt_text"}.getStr
+        ))
       else: discard
 
       with url, m{"url"}:
@@ -170,29 +180,35 @@ proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
 
 proc parseMediaEntities(js: JsonNode; result: var Tweet) =
   with mediaEntities, js{"media_entities"}:
+    var parsedMedia: MediaEntities
     for mediaEntity in mediaEntities:
       with mediaInfo, mediaEntity{"media_results", "result", "media_info"}:
         case mediaInfo.getTypeName
         of "ApiImage":
-          result.photos.add Photo(
+          parsedMedia.addMedia(Photo(
             url: mediaInfo{"original_img_url"}.getImageStr,
             altText: mediaInfo{"alt_text"}.getStr
-          )
+          ))
         of "ApiVideo":
           let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"}
-          result.video = some Video(
+          parsedMedia.addMedia(Video(
             available: status.getStr == "Available",
             thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
+            title: mediaInfo{"alt_text"}.getStr,
             durationMs: mediaInfo{"duration_millis"}.getInt,
             variants: parseVideoVariants(mediaInfo{"variants"})
-          )
+          ))
         of "ApiGif":
-          result.gif = some Gif(
+          parsedMedia.addMedia(Gif(
             url: mediaInfo{"variants"}[0]{"url"}.getImageStr,
-            thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr
-          )
+            thumb: mediaInfo{"preview_image", "original_img_url"}.getImageStr,
+            altText: mediaInfo{"alt_text"}.getStr
+          ))
         else: discard
 
+    if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len:
+      result.media = parsedMedia
+
   # Remove media URLs from text
   with mediaList, js{"legacy", "entities", "media"}:
     for url in mediaList:
@@ -348,13 +364,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull();
     let name = jsCard{"name"}.getStr
     if "poll" in name:
       if "image" in name:
-        result.photos.add Photo(
+        result.media.addMedia(Photo(
           url: jsCard{"binding_values", "image_large"}.getImageVal
-        )
+        ))
 
       result.poll = some parsePoll(jsCard)
     elif name == "amplify":
-      result.video = some parsePromoVideo(jsCard{"binding_values"})
+      result.media.addMedia(parsePromoVideo(jsCard{"binding_values"}))
     else:
       result.card = some parseCard(jsCard, js{"entities", "urls"})
 

+ 1 - 3
src/parserutils.nim

@@ -352,9 +352,7 @@ proc expandBirdwatchEntities*(text: string; entities: JsonNode): string =
 
 proc extractGalleryPhoto*(t: Tweet): GalleryPhoto =
   let url =
-    if t.photos.len > 0: t.photos[0].url
-    elif t.video.isSome: get(t.video).thumb
-    elif t.gif.isSome: get(t.gif).thumb
+    if t.media.len > 0: t.media[0].getThumb
     elif t.card.isSome: get(t.card).image
     else: ""
 

+ 1 - 1
src/routes/embed.nim

@@ -11,7 +11,7 @@ proc createEmbedRouter*(cfg: Config) =
   router embed:
     get "/i/videos/tweet/@id":
       let tweet = await getGraphTweetResult(@"id")
-      if tweet == nil or tweet.video.isNone:
+      if tweet == nil or not tweet.hasVideos:
         resp Http404
 
       resp renderVideoEmbed(tweet, cfg, request)

+ 10 - 6
src/routes/status.nim

@@ -44,15 +44,19 @@ proc createStatusRouter*(cfg: Config) =
         desc = conv.tweet.text
 
       var
-        images = conv.tweet.photos.mapIt(it.url)
+        images = conv.tweet.getPhotos.mapIt(it.url)
         video = ""
 
-      if conv.tweet.video.isSome():
-        images = @[get(conv.tweet.video).thumb]
+      let
+        firstMediaKind = if conv.tweet.media.len > 0: conv.tweet.media[0].kind
+                         else: photoMedia
+
+      if firstMediaKind == videoMedia:
+        images = @[conv.tweet.media[0].getThumb]
         video = getVideoEmbed(cfg, conv.tweet.id)
-      elif conv.tweet.gif.isSome():
-        images = @[get(conv.tweet.gif).thumb]
-        video = getPicUrl(get(conv.tweet.gif).url)
+      elif firstMediaKind == gifMedia:
+        images = @[conv.tweet.media[0].getThumb]
+        video = getPicUrl(conv.tweet.media[0].gif.url)
       elif conv.tweet.card.isSome():
         let card = conv.tweet.card.get()
         if card.image.len > 0:

+ 1 - 1
src/sass/tweet/embed.scss

@@ -11,7 +11,7 @@
     left: 0%;
   }
 
-  .video-container {
+  .gallery-video > .attachment {
     max-height: unset;
   }
 }

+ 49 - 30
src/sass/tweet/media.scss

@@ -10,9 +10,47 @@
   max-width: 533px;
   pointer-events: all;
 
-  .still-image {
-    width: 100%;
-    align-self: center;
+  &.mixed-row {
+    .attachment {
+      min-width: 0;
+      min-height: 0;
+      flex: 1 1 0;
+      max-height: 379.5px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      background-color: #101010;
+    }
+
+    .still-image,
+    .still-image img,
+    .attachment > video,
+    .attachment > img {
+      width: 100%;
+      height: 100%;
+      max-width: none;
+      max-height: none;
+    }
+
+    .still-image {
+      display: flex;
+      align-self: stretch;
+    }
+
+    .still-image img {
+      flex-basis: auto;
+      flex-grow: 0;
+      object-fit: cover;
+    }
+
+    .attachment > video,
+    .attachment > img {
+      object-fit: cover;
+    }
+
+    .attachment > video {
+      object-fit: contain;
+    }
   }
 }
 
@@ -28,10 +66,6 @@
   background-color: var(--bg_color);
   align-items: center;
   pointer-events: all;
-
-  .image-attachment {
-    width: 100%;
-  }
 }
 
 .attachment {
@@ -49,7 +83,14 @@
   }
 }
 
-.gallery-gif video {
+.media-gif {
+  display: table;
+  background-color: unset;
+  width: unset;
+  max-height: unset;
+}
+
+.media-gif video {
   max-height: 530px;
   background-color: #101010;
 }
@@ -96,22 +137,6 @@
   transition-property: max-height;
 }
 
-.image {
-  display: flex;
-}
-
-// .single-image {
-//     display: inline-block;
-//     width: 100%;
-//     max-height: 600px;
-
-//     .attachments {
-//         width: unset;
-//         max-height: unset;
-//         display: inherit;
-//     }
-// }
-
 .overlay-circle {
   border-radius: 50%;
   background-color: var(--dark_grey);
@@ -133,12 +158,6 @@
   margin-left: 14px;
 }
 
-.media-gif {
-  display: table;
-  background-color: unset;
-  width: unset;
-}
-
 .media-body {
   flex: 1;
   padding: 0;

+ 4 - 3
src/sass/tweet/quote.scss

@@ -95,7 +95,7 @@
     justify-content: center;
   }
 
-  .gallery-gif .attachment {
+  .media-gif > .attachment {
     display: flex;
     justify-content: center;
     background-color: var(--bg_color);
@@ -108,8 +108,9 @@
     }
   }
 
-  .gallery-video,
-  .gallery-gif {
+  .gallery-row .attachment,
+  .gallery-row .attachment > video,
+  .gallery-row .attachment > img {
     max-height: 300px;
   }
 

+ 14 - 14
src/sass/tweet/video.scss

@@ -9,22 +9,22 @@ video {
 .gallery-video {
   display: flex;
   overflow: hidden;
-}
-
-.gallery-video.card-container {
-  flex-direction: column;
-  width: 100%;
-}
+  
+  &.card-container {
+    flex-direction: column;
+    width: 100%;
+  }
 
-.video-container {
-  min-height: 80px;
-  min-width: 200px;
-  max-height: 530px;
-  margin: 0;
+  > .attachment {
+    min-height: 80px;
+    min-width: 200px;
+    max-height: 530px;
+    margin: 0;
 
-  img {
-    max-height: 100%;
-    max-width: 100%;
+    img {
+      max-height: 100%;
+      max-width: 100%;
+    }
   }
 }
 

+ 39 - 3
src/types.nim

@@ -136,11 +136,28 @@ type
   Gif* = object
     url*: string
     thumb*: string
+    altText*: string
 
   Photo* = object
     url*: string
     altText*: string
 
+  MediaKind* = enum
+    photoMedia
+    videoMedia
+    gifMedia
+
+  Media* = object
+    case kind*: MediaKind
+    of photoMedia:
+      photo*: Photo
+    of videoMedia:
+      video*: Video
+    of gifMedia:
+      gif*: Gif
+
+  MediaEntities* = seq[Media]
+
   GalleryPhoto* = object
     url*: string
     tweetId*: string
@@ -219,9 +236,7 @@ type
     quote*: Option[Tweet]
     card*: Option[Card]
     poll*: Option[Poll]
-    gif*: Option[Gif]
-    video*: Option[Video]
-    photos*: seq[Photo]
+    media*: MediaEntities
     history*: seq[int64]
     note*: string
 
@@ -310,3 +325,24 @@ proc contains*(thread: Chain; tweet: Tweet): bool =
 
 proc add*(timeline: var seq[Tweets]; tweet: Tweet) =
   timeline.add @[tweet]
+
+proc getPhotos*(tweet: Tweet): seq[Photo] =
+  tweet.media.filterIt(it.kind == photoMedia).mapIt(it.photo)
+
+proc getVideos*(tweet: Tweet): seq[Video] =
+  tweet.media.filterIt(it.kind == videoMedia).mapIt(it.video)
+
+proc hasPhotos*(tweet: Tweet): bool =
+  tweet.media.anyIt(it.kind == photoMedia)
+
+proc hasVideos*(tweet: Tweet): bool =
+  tweet.media.anyIt(it.kind == videoMedia)
+
+proc hasGifs*(tweet: Tweet): bool =
+  tweet.media.anyIt(it.kind == gifMedia)
+
+proc getThumb*(media: Media): string =
+  case media.kind
+  of photoMedia: media.photo.url
+  of videoMedia: media.video.thumb
+  of gifMedia: media.gif.thumb

+ 7 - 4
src/views/embed.nim

@@ -9,14 +9,17 @@ import general, tweet
 const doctype = "<!DOCTYPE html>\n"
 
 proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
-  let thumb = get(tweet.video).thumb
-  let vidUrl = getVideoEmbed(cfg, tweet.id)
-  let prefs = Prefs(hlsPlayback: true, mp4Playback: true)
+  let 
+    video = tweet.getVideos()[0]
+    thumb = video.thumb
+    vidUrl = getVideoEmbed(cfg, tweet.id)
+    prefs = Prefs(hlsPlayback: true, mp4Playback: true)
+
   let node = buildHtml(html(lang="en")):
     renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
 
     body:
       tdiv(class="embed-video"):
-        renderVideo(get(tweet.video), prefs, "")
+        renderVideo(video, prefs, "")
 
   result = doctype & $node

+ 1 - 1
src/views/general.nim

@@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
   let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
 
   buildHtml(head):
-    link(rel="stylesheet", type="text/css", href="/css/style.css?v=28")
+    link(rel="stylesheet", type="text/css", href="/css/style.css?v=29")
     link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4")
 
     if theme.len > 0:

+ 44 - 25
src/views/rss.nimf

@@ -1,29 +1,38 @@
 #? stdtmpl(subsChar = '$', metaChar = '#')
 ## SPDX-License-Identifier: AGPL-3.0-only
-#import strutils, xmltree, strformat, options, unicode
+#import strutils, sequtils, xmltree, strformat, options, unicode
 #import ../types, ../utils, ../formatters, ../prefs
 ## Snowflake ID cutoff for RSS GUID format transition
 ## Corresponds to approximately December 14, 2025 UTC
 #const guidCutoff = 2000000000000000000'i64
 #
 #proc getTitle(tweet: Tweet; retweet: string): string =
-#if tweet.pinned: result = "Pinned: "
-#elif retweet.len > 0: result = &"RT by @{retweet}: "
-#elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: "
+#var prefix = ""
+#if tweet.pinned: prefix = "Pinned: "
+#elif retweet.len > 0: prefix = &"RT by @{retweet}: "
+#elif tweet.reply.len > 0: prefix = &"R to @{tweet.reply[0]}: "
 #end if
 #var text = stripHtml(tweet.text)
 ##if unicode.runeLen(text) > 32:
 ##  text = unicode.runeSubStr(text, 0, 32) & "..."
 ##end if
-#result &= xmltree.escape(text)
-#if result.len > 0: return
+#text = xmltree.escape(text)
+#if text.len > 0:
+#  result = prefix & text
+#  return
 #end if
-#if tweet.photos.len > 0:
-#  result &= "Image"
-#elif tweet.video.isSome:
-#  result &= "Video"
-#elif tweet.gif.isSome:
-#  result &= "Gif"
+#if tweet.media.len > 0:
+#  result = prefix
+#  let firstKind = tweet.media[0].kind
+#  if tweet.media.anyIt(it.kind != firstKind):
+#    result &= "Media"
+#  else:
+#    case firstKind
+#    of photoMedia: result &= "Image"
+#    of videoMedia: result &= "Video"
+#    of gifMedia:   result &= "Gif"
+#    end case
+#  end if
 #end if
 #end proc
 #
@@ -31,6 +40,26 @@
 Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
 #end proc
 #
+#proc renderRssMedia(media: Media; tweet: Tweet; urlPrefix: string): string =
+#case media.kind
+#of photoMedia:
+#  let photo = media.photo
+<img src="${urlPrefix}${getPicUrl(photo.url)}" style="max-width:250px;" />
+#of videoMedia:
+#  let video = media.video
+<a href="${urlPrefix}${tweet.getLink}">
+<br>Video<br>
+  <img src="${urlPrefix}${getPicUrl(video.thumb)}" style="max-width:250px;" />
+</a>
+#of gifMedia:
+#  let gif = media.gif
+#  let thumb = &"{urlPrefix}{getPicUrl(gif.thumb)}"
+#  let url = &"{urlPrefix}{getPicUrl(gif.url)}"
+<video poster="${thumb}" autoplay muted loop style="max-width:250px;">
+  <source src="${url}" type="video/mp4"></video>
+#end case
+#end proc
+#
 #proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
 #result = profile.tweets.content
 #if profile.pinned.isSome and result.len > 0:
@@ -54,20 +83,10 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
 #let urlPrefix = getUrlPrefix(cfg)
 #let text = replaceUrls(tweet.text, prefs, absolute=urlPrefix)
 <p>${text.replace("\n", "<br>\n")}</p>
-#if tweet.photos.len > 0:
-#  for photo in tweet.photos:
-<img src="${urlPrefix}${getPicUrl(photo.url)}" style="max-width:250px;" />
+#if tweet.media.len > 0:
+#  for media in tweet.media:
+${renderRssMedia(media, tweet, urlPrefix)}
 #  end for
-#elif tweet.video.isSome:
-<a href="${urlPrefix}${tweet.getLink}">
-<br>Video<br>
-  <img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
-</a>
-#elif tweet.gif.isSome:
-#  let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
-#  let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
-<video poster="${thumb}" autoplay muted loop style="max-width:250px;">
-  <source src="${url}" type="video/mp4"></video>
 #elif tweet.card.isSome:
 #  let card = tweet.card.get()
 #  if card.image.len > 0:

+ 99 - 68
src/views/tweet.nim

@@ -38,24 +38,19 @@ proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VN
           a(href=getLink(tweet), title=tweet.getTime):
             text tweet.getShortTime
 
-proc renderAlbum(tweet: Tweet): VNode =
-  let
-    groups = if tweet.photos.len < 3: @[tweet.photos]
-             else: tweet.photos.distribute(2)
-
-  buildHtml(tdiv(class="attachments")):
-    for i, photos in groups:
-      let margin = if i > 0: ".25em" else: ""
-      tdiv(class="gallery-row", style={marginTop: margin}):
-        for photo in photos:
-          tdiv(class="attachment image"):
-            let
-              named = "name=" in photo.url
-              small = if named: photo.url else: photo.url & smallWebp
-            a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"):
-              genImg(small, alt=photo.altText)
-            if photo.altText.len > 0:
-              p(class="alt-text"): text "ALT  " & photo.altText
+proc renderAltText(altText: string): VNode =
+  buildHtml(p(class="alt-text")):
+    text "ALT  " & altText
+
+proc renderPhotoAttachment(photo: Photo): VNode =
+  buildHtml(tdiv(class="attachment")):
+    let
+      named = "name=" in photo.url
+      small = if named: photo.url else: photo.url & smallWebp
+    a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"):
+      genImg(small, alt=photo.altText)
+    if photo.altText.len > 0:
+      renderAltText(photo.altText)
 
 proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
   case playbackType
@@ -65,7 +60,7 @@ proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
 proc hasMp4Url(video: Video): bool =
   video.variants.anyIt(it.contentType == mp4)
 
-proc renderVideoDisabled(playbackType: VideoType; path: string): VNode =
+proc renderVideoDisabled(playbackType: VideoType; path=""): VNode =
   buildHtml(tdiv(class="video-overlay")):
     case playbackType
     of mp4:
@@ -81,52 +76,97 @@ proc renderVideoUnavailable(video: Video): VNode =
     else:
       p: text "This media is unavailable"
 
-proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode =
+proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""): VNode =
   let
-    container = if video.description.len == 0 and video.title.len == 0: ""
-                else: " card-container"
-    playbackType = if not prefs.proxyVideos and video.hasMp4Url: mp4
-                   else: video.playbackType
+    playbackType = if not prefs.proxyVideos and videoData.hasMp4Url: mp4
+                   else: videoData.playbackType
+    thumb = getSmallPic(videoData.thumb)
+
+  buildHtml(tdiv(class="attachment")):
+    if not videoData.available:
+      img(src=thumb, loading="lazy")
+      renderVideoUnavailable(videoData)
+    elif not prefs.isPlaybackEnabled(playbackType):
+      img(src=thumb, loading="lazy")
+      renderVideoDisabled(playbackType, path)
+    else:
+      let
+        vars = videoData.variants.filterIt(it.contentType == playbackType)
+        vidUrl = vars.sortedByIt(it.resolution)[^1].url
+        source = if prefs.proxyVideos: getVidUrl(vidUrl)
+                 else: vidUrl
+      case playbackType
+      of mp4:
+        video(poster=thumb, controls="", muted=prefs.muteVideos):
+          source(src=source, `type`="video/mp4")
+      of m3u8, vmap:
+        video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
+        verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
+        tdiv(class="overlay-circle"): span(class="overlay-triangle")
+        tdiv(class="overlay-duration"): text getDuration(videoData)
+        verbatim "</div>"
+
+proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode =
+  let hasCardContent = video.description.len > 0 or video.title.len > 0
 
   buildHtml(tdiv(class="attachments card")):
-    tdiv(class="gallery-video" & container):
-      tdiv(class="attachment video-container"):
-        let thumb = getSmallPic(video.thumb)
-        if not video.available:
-          img(src=thumb, loading="lazy")
-          renderVideoUnavailable(video)
-        elif not prefs.isPlaybackEnabled(playbackType):
-          img(src=thumb, loading="lazy")
-          renderVideoDisabled(playbackType, path)
-        else:
-          let
-            vars = video.variants.filterIt(it.contentType == playbackType)
-            vidUrl = vars.sortedByIt(it.resolution)[^1].url
-            source = if prefs.proxyVideos: getVidUrl(vidUrl)
-                     else: vidUrl
-          case playbackType
-          of mp4:
-            video(poster=thumb, controls="", muted=prefs.muteVideos):
-              source(src=source, `type`="video/mp4")
-          of m3u8, vmap:
-            video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos)
-            verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
-            tdiv(class="overlay-circle"): span(class="overlay-triangle")
-            tdiv(class="overlay-duration"): text getDuration(video)
-            verbatim "</div>"
-      if container.len > 0:
+    tdiv(class=("gallery-video" & (if hasCardContent: " card-container" else: ""))):
+      renderVideoAttachment(video, prefs, path)
+      if hasCardContent:
         tdiv(class="card-content"):
           h2(class="card-title"): text video.title
           if video.description.len > 0:
             p(class="card-description"): text video.description
 
+proc renderGifAttachment(gif: Gif; prefs: Prefs): VNode =
+  let thumb = getSmallPic(gif.thumb)
+
+  buildHtml(tdiv(class="attachment")):
+    if not prefs.mp4Playback:
+      img(src=thumb, loading="lazy")
+      renderVideoDisabled(mp4)
+    elif prefs.autoplayGifs:
+      video(class="gif", poster=thumb, autoplay="", muted="", loop=""):
+        source(src=getPicUrl(gif.url), `type`="video/mp4")
+    else:
+      video(class="gif", poster=thumb, controls="", muted="", loop=""):
+        source(src=getPicUrl(gif.url), `type`="video/mp4")
+    if gif.altText.len > 0:
+      renderAltText(gif.altText)
+
 proc renderGif(gif: Gif; prefs: Prefs): VNode =
   buildHtml(tdiv(class="attachments media-gif")):
-    tdiv(class="gallery-gif", style={maxHeight: "unset"}):
-      tdiv(class="attachment"):
-        video(class="gif", poster=getSmallPic(gif.thumb), autoplay=prefs.autoplayGifs,
-              controls="", muted="", loop=""):
-          source(src=getPicUrl(gif.url), `type`="video/mp4")
+    renderGifAttachment(gif, prefs)
+
+proc renderMedia(media: seq[Media]; prefs: Prefs; path: string): VNode =
+  if media.len == 0:
+    return nil
+
+  if media.len == 1:
+    let item = media[0]
+    if item.kind == videoMedia:
+      return renderVideo(item.video, prefs, path)
+    if item.kind == gifMedia:
+      return renderGif(item.gif, prefs)
+
+  let
+    groups = if media.len < 3: @[media]
+             else: media.distribute(2)
+
+  buildHtml(tdiv(class="attachments")):
+    for i, mediaGroup in groups:
+      let margin = if i > 0: ".25em" else: ""
+      let rowClass = "gallery-row" &
+                     (if mediaGroup.allIt(it.kind == photoMedia): "" else: " mixed-row")
+      tdiv(class=rowClass, style={marginTop: margin}):
+        for mediaItem in mediaGroup:
+          case mediaItem.kind
+          of photoMedia:
+            renderPhotoAttachment(mediaItem.photo)
+          of videoMedia:
+            renderVideoAttachment(mediaItem.video, prefs, path)
+          of gifMedia:
+            renderGifAttachment(mediaItem.gif, prefs)
 
 proc renderPoll(poll: Poll): VNode =
   buildHtml(tdiv(class="poll")):
@@ -219,12 +259,7 @@ proc renderLatestPost(username: string; id: int64): VNode =
 
 proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
   buildHtml(tdiv(class="quote-media-container")):
-    if quote.photos.len > 0:
-      renderAlbum(quote)
-    elif quote.video.isSome:
-      renderVideo(quote.video.get(), prefs, path)
-    elif quote.gif.isSome:
-      renderGif(quote.gif.get(), prefs)
+    renderMedia(quote.media, prefs, path)
 
 proc renderCommunityNote(note: string; prefs: Prefs): VNode =
   buildHtml(tdiv(class="community-note")):
@@ -266,7 +301,7 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
       tdiv(class="quote-text", dir="auto"):
         verbatim replaceUrls(quote.text, prefs)
 
-    if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome:
+    if quote.media.len > 0:
       renderQuoteMedia(quote, prefs, path)
 
     if quote.note.len > 0 and not prefs.hideCommunityNotes:
@@ -344,12 +379,8 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
       if tweet.card.isSome and tweet.card.get().kind != hidden:
         renderCard(tweet.card.get(), prefs, path)
 
-      if tweet.photos.len > 0:
-        renderAlbum(tweet)
-      elif tweet.video.isSome:
-        renderVideo(tweet.video.get(), prefs, path)
-      elif tweet.gif.isSome:
-        renderGif(tweet.gif.get(), prefs)
+      if tweet.media.len > 0:
+        renderMedia(tweet.media, prefs, path)
 
       if tweet.poll.isSome:
         renderPoll(tweet.poll.get())