1
0
mirror of https://github.com/zedeus/nitter.git synced 2026-04-16 02:32:14 -04:00

Implement mixed-media tweet support

Fixes #697 #1101
This commit is contained in:
Zed
2026-03-13 05:47:37 +01:00
parent 4bf3df94f8
commit 35a929c415
15 changed files with 304 additions and 174 deletions

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,9 +10,47 @@
max-width: 533px; max-width: 533px;
pointer-events: all; pointer-events: all;
.still-image { &.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%; width: 100%;
align-self: center; 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); background-color: var(--bg_color);
align-items: center; align-items: center;
pointer-events: all; pointer-events: all;
.image-attachment {
width: 100%;
}
} }
.attachment { .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; max-height: 530px;
background-color: #101010; background-color: #101010;
} }
@@ -96,22 +137,6 @@
transition-property: max-height; 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 { .overlay-circle {
border-radius: 50%; border-radius: 50%;
background-color: var(--dark_grey); background-color: var(--dark_grey);
@@ -133,12 +158,6 @@
margin-left: 14px; margin-left: 14px;
} }
.media-gif {
display: table;
background-color: unset;
width: unset;
}
.media-body { .media-body {
flex: 1; flex: 1;
padding: 0; padding: 0;

View File

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

View File

@@ -9,14 +9,13 @@ video {
.gallery-video { .gallery-video {
display: flex; display: flex;
overflow: hidden; overflow: hidden;
}
.gallery-video.card-container { &.card-container {
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
} }
.video-container { > .attachment {
min-height: 80px; min-height: 80px;
min-width: 200px; min-width: 200px;
max-height: 530px; max-height: 530px;
@@ -26,6 +25,7 @@ video {
max-height: 100%; max-height: 100%;
max-width: 100%; max-width: 100%;
} }
}
} }
.video-overlay { .video-overlay {

View File

@@ -136,11 +136,28 @@ type
Gif* = object Gif* = object
url*: string url*: string
thumb*: string thumb*: string
altText*: string
Photo* = object Photo* = object
url*: string url*: string
altText*: 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 GalleryPhoto* = object
url*: string url*: string
tweetId*: string tweetId*: string
@@ -219,9 +236,7 @@ type
quote*: Option[Tweet] quote*: Option[Tweet]
card*: Option[Card] card*: Option[Card]
poll*: Option[Poll] poll*: Option[Poll]
gif*: Option[Gif] media*: MediaEntities
video*: Option[Video]
photos*: seq[Photo]
history*: seq[int64] history*: seq[int64]
note*: string note*: string
@@ -310,3 +325,24 @@ proc contains*(thread: Chain; tweet: Tweet): bool =
proc add*(timeline: var seq[Tweets]; tweet: Tweet) = proc add*(timeline: var seq[Tweets]; tweet: Tweet) =
timeline.add @[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

View File

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

View File

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

View File

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

View File

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