1
0
mirror of https://github.com/zedeus/nitter.git synced 2026-04-03 20:32:10 -04:00

Add support for broadcasts

Fixes #303
This commit is contained in:
Zed
2026-03-31 01:34:20 +02:00
parent 7d431781c3
commit 8114eefa19
18 changed files with 338 additions and 41 deletions

View File

@@ -3,6 +3,7 @@
function playVideo(overlay) { function playVideo(overlay) {
const video = overlay.parentElement.querySelector('video'); const video = overlay.parentElement.querySelector('video');
const url = video.getAttribute("data-url"); const url = video.getAttribute("data-url");
const startTime = parseFloat(video.getAttribute("data-start") || "0");
video.setAttribute("controls", ""); video.setAttribute("controls", "");
overlay.style.display = "none"; overlay.style.display = "none";
@@ -12,12 +13,13 @@ function playVideo(overlay) {
hls.attachMedia(video); hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function () { hls.on(Hls.Events.MANIFEST_PARSED, function () {
hls.loadLevel = hls.levels.length - 1; hls.loadLevel = hls.levels.length - 1;
hls.startLoad(); hls.startLoad(startTime);
video.play(); video.play();
}); });
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url; video.src = url;
video.addEventListener('canplay', function() { video.addEventListener('canplay', function() {
if (startTime > 0) video.currentTime = startTime;
video.play(); video.play();
}); });
} }

View File

@@ -73,6 +73,25 @@ proc getAboutAccount*(username: string): Future[AccountInfo] {.async.} =
js = await fetch(url) js = await fetch(url)
result = parseAboutAccount(js) result = parseAboutAccount(js)
proc restReq(endpoint: string; params: seq[(string, string)] = @[]): ApiReq =
let url = ApiUrl(endpoint: endpoint, params: params)
ApiReq(cookie: url, oauth: url)
proc getBroadcastInfo*(id: string): Future[Broadcast] {.async.} =
if id.len == 0: return
let
req = apiReq(graphBroadcast, """{"id":"$1"}""" % id)
js = await fetch(req)
result = parseBroadcastInfo(js)
proc fetchBroadcastStream*(mediaKey: string): Future[string] {.async.} =
if mediaKey.len == 0: return
let
streamReq = restReq(restLiveStream & mediaKey)
streamJs = await fetch(streamReq)
result = streamJs{"source", "noRedirectPlaybackUrl"}.getStr(
streamJs{"source", "location"}.getStr)
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
if id.len == 0: return if id.len == 0: return
let let

View File

@@ -34,13 +34,14 @@ proc setApiProxy*(url: string) =
apiProxy = "http://" & apiProxy apiProxy = "http://" & apiProxy
proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri = proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri =
case sessionKind let url = case sessionKind
of oauth: of oauth: req.oauth
let o = req.oauth of cookie: req.cookie
parseUri("https://api.x.com/graphql") / o.endpoint ? o.params let base = case sessionKind
of cookie: of oauth: "https://api.x.com"
let c = req.cookie of cookie: "https://x.com/i/api"
parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params let prefix = if url.endpoint.startsWith("1.1/"): "" else: "graphql/"
parseUri(base) / (prefix & url.endpoint) ? url.params
proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
let let
@@ -89,7 +90,7 @@ proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} =
result["sec-fetch-dest"] = "empty" result["sec-fetch-dest"] = "empty"
result["sec-fetch-mode"] = "cors" result["sec-fetch-mode"] = "cors"
result["sec-fetch-site"] = "same-site" result["sec-fetch-site"] = "same-site"
if disableTid: if disableTid or "/1.1/" in url.path:
result["authorization"] = bearerToken2 result["authorization"] = bearerToken2
else: else:
result["authorization"] = bearerToken result["authorization"] = bearerToken
@@ -116,7 +117,7 @@ template fetchImpl(result, fetchBody) {.dirty.} =
pool.use(await genHeaders(session, url)): pool.use(await genHeaders(session, url)):
template getContent = template getContent =
# TODO: this is a temporary simple implementation # TODO: this is a temporary simple implementation
if apiProxy.len > 0: if apiProxy.len > 0 and "/1.1/" notin url.path:
resp = await c.get(($url).replace("https://", apiProxy)) resp = await c.get(($url).replace("https://", apiProxy))
else: else:
resp = await c.get($url) resp = await c.get($url)

View File

@@ -27,6 +27,9 @@ const
graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline" graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
graphAboutAccount* = "zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery" graphAboutAccount* = "zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery"
graphBroadcast* = "0nMmbMh-_JwwRRFNXkyH3Q/BroadcastQuery"
restLiveStream* = "1.1/live_video_stream/status/"
gqlFeatures* = """{ gqlFeatures* = """{
"android_ad_formats_media_component_render_overlay_enabled": false, "android_ad_formats_media_component_render_overlay_enabled": false,
"android_graphql_skip_api_media_color_palette": false, "android_graphql_skip_api_media_color_palette": false,

View File

@@ -91,7 +91,17 @@ proc getM3u8Url*(content: string): string =
if re.find(content, m3u8Regex, matches) != -1: if re.find(content, m3u8Regex, matches) != -1:
result = matches[0] result = matches[0]
proc proxifyVideo*(manifest: string; proxy: bool): string = proc proxifyVideo*(manifest: string; proxy: bool; manifestUrl = ""): string =
let (baseUrl, basePath) =
if manifestUrl.len > 0:
let
u = parseUri(manifestUrl)
origin = u.scheme & "://" & u.hostname
idx = manifestUrl.rfind('/')
dirPath = if idx > 8: manifestUrl[0 .. idx] else: ""
(origin, dirPath)
else:
("https://video.twimg.com", "")
var replacements: seq[(string, string)] var replacements: seq[(string, string)]
for line in manifest.splitLines: for line in manifest.splitLines:
let url = let url =
@@ -99,9 +109,13 @@ proc proxifyVideo*(manifest: string; proxy: bool): string =
elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line: elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line:
line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))] line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))]
else: line else: line
if url.startsWith('/'): let resolved =
let path = "https://video.twimg.com" & url if url.startsWith('/'): baseUrl & url
replacements.add (url, if proxy: path.getVidUrl else: path) elif basePath.len > 0 and url.len > 0 and not url.startsWith('#') and
not url.startsWith("http") and ('.' in url): basePath & url
else: ""
if resolved.len > 0:
replacements.add (url, if proxy: resolved.getVidUrl else: resolved)
return manifest.multiReplace(replacements) return manifest.multiReplace(replacements)
proc getUserPic*(userPic: string; style=""): string = proc getUserPic*(userPic: string; style=""): string =
@@ -154,16 +168,18 @@ proc getShortTime*(tweet: Tweet): string =
else: else:
result = "now" result = "now"
proc getDuration*(video: Video): string = proc getDuration*(ms: int): string =
let let
ms = video.durationMs
sec = int(round(ms / 1000)) sec = int(round(ms / 1000))
min = floorDiv(sec, 60) min = floorDiv(sec, 60)
hour = floorDiv(min, 60) hour = floorDiv(min, 60)
if hour > 0: if hour > 0:
return &"{hour}:{min mod 60}:{sec mod 60:02}" &"{hour}:{min mod 60:02}:{sec mod 60:02}"
else: else:
return &"{min mod 60}:{sec mod 60:02}" &"{min mod 60}:{sec mod 60:02}"
proc getDuration*(video: Video): string =
getDuration(video.durationMs)
proc getLink*(id: int64; username="i"; focus=true): string = proc getLink*(id: int64; username="i"; focus=true): string =
var username = username var username = username

View File

@@ -10,7 +10,7 @@ import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
import views/[general, about] import views/[general, about]
import routes/[ import routes/[
preferences, timeline, status, media, search, rss, list, debug, preferences, timeline, status, media, search, rss, list, debug,
unsupported, embed, resolver, router_utils] unsupported, embed, resolver, broadcast, router_utils]
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
const issuesUrl = "https://github.com/zedeus/nitter/issues" const issuesUrl = "https://github.com/zedeus/nitter/issues"
@@ -58,6 +58,7 @@ createSearchRouter(cfg)
createMediaRouter(cfg) createMediaRouter(cfg)
createEmbedRouter(cfg) createEmbedRouter(cfg)
createRssRouter(cfg) createRssRouter(cfg)
createBroadcastRouter(cfg)
createDebugRouter(cfg) createDebugRouter(cfg)
settings: settings:
@@ -121,5 +122,6 @@ routes:
extend preferences, "" extend preferences, ""
extend resolver, "" extend resolver, ""
extend embed, "" extend embed, ""
extend broadcastRoute, ""
extend debug, "" extend debug, ""
extend unsupported, "" extend unsupported, ""

View File

@@ -110,6 +110,22 @@ proc parseAboutAccount*(js: JsonNode): AccountInfo =
with since, reason{"verified_since_msec"}: with since, reason{"verified_since_msec"}:
result.verifiedSince = since.getTimeFromMsStr result.verifiedSince = since.getTimeFromMsStr
proc parseBroadcastInfo*(js: JsonNode): Broadcast =
let bc = ? js{"data", "broadcast"}
result = Broadcast(
id: bc{"broadcast_id"}.getStr,
title: bc{"status"}.getStr,
state: bc{"state"}.getStr.toUpperAscii,
thumb: bc{"image_url"}.getStr,
mediaKey: bc{"media_key"}.getStr,
totalWatched: bc{"total_watched"}.getInt,
startTime: bc{"start_time"}.getTimeFromMs,
endTime: bc{"end_time"}.getTimeFromMs,
replayStart: bc{"edited_replay", "start_time"}.getInt,
availableForReplay: bc{"available_for_replay"}.getBool,
user: parseGraphUser(bc)
)
proc parseGraphList*(js: JsonNode): List = proc parseGraphList*(js: JsonNode): List =
if js.isNull: return if js.isNull: return
@@ -287,14 +303,23 @@ proc parsePromoVideo(js: JsonNode): Video =
result.variants.add variant result.variants.add variant
proc parseBroadcast(js: JsonNode): Card = proc parseBroadcast(js: JsonNode): Card =
let image = js{"broadcast_thumbnail_large"}.getImageVal let
image = js{"broadcast_thumbnail_large"}.getImageVal
broadcastUrl = js{"broadcast_url"}.getStrVal
broadcastId = broadcastUrl.rsplit('/', maxsplit=1)[^1]
streamUrl = "/i/broadcasts/" & broadcastId & "/stream"
result = Card( result = Card(
kind: broadcast, kind: broadcast,
url: js{"broadcast_url"}.getStrVal, url: "/i/broadcasts/" & broadcastId,
title: js{"broadcaster_display_name"}.getStrVal, title: js{"broadcaster_display_name"}.getStrVal,
text: js{"broadcast_title"}.getStrVal, text: js{"broadcast_title"}.getStrVal,
image: image, image: image,
video: some Video(thumb: image) video: some Video(
thumb: image,
available: true,
playbackType: m3u8,
variants: @[VideoVariant(contentType: m3u8, url: streamUrl)]
)
) )
proc parseCard(js: JsonNode; urls: JsonNode): Card = proc parseCard(js: JsonNode; urls: JsonNode): Card =

View File

@@ -158,6 +158,20 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
# if not result.isNil: # if not result.isNil:
# await cache(result) # await cache(result)
proc cache*(data: Broadcast) {.async.} =
if data.id.len == 0: return
await setEx("bc:" & data.id, baseCacheTime, compress(toFlatty(data)))
proc getCachedBroadcast*(id: string): Future[Broadcast] {.async.} =
if id.len == 0: return
let cached = await get("bc:" & id)
if cached != redisNil:
cached.deserialize(Broadcast)
else:
result = await getBroadcastInfo(id)
await cache(result)
result.m3u8Url = await fetchBroadcastStream(result.mediaKey)
proc cache*(data: AccountInfo; name: string) {.async.} = proc cache*(data: AccountInfo; name: string) {.async.} =
await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data))) await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data)))

44
src/routes/broadcast.nim Normal file
View File

@@ -0,0 +1,44 @@
# SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strutils
import jester
import router_utils
import ".."/[types, formatters, redis_cache]
import ../views/[general, broadcast]
import media
export broadcast
proc createBroadcastRouter*(cfg: Config) =
router broadcastRoute:
get "/i/broadcasts/@id":
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
var bc: Broadcast
try:
bc = await getCachedBroadcast(@"id")
except:
discard
if bc.id.len == 0:
resp Http404, showError("Broadcast not found", cfg)
let prefs = requestPrefs()
resp renderMain(renderBroadcast(bc, prefs, request.path), request, cfg, prefs,
bc.title, ogTitle=bc.title)
get "/i/broadcasts/@id/stream":
cond @"id".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9'})
var bc: Broadcast
try:
bc = await getCachedBroadcast(@"id")
except:
discard
if bc.m3u8Url.len == 0:
resp Http404
let manifest = await safeFetch(bc.m3u8Url)
if manifest.len == 0:
resp Http502
resp proxifyVideo(manifest, requestPrefs().proxyVideos, bc.m3u8Url), m3u8Mime

View File

@@ -86,6 +86,12 @@ proc decoded*(req: jester.Request; index: int): string =
if based: decode(encoded) if based: decode(encoded)
else: decodeUrl(encoded) else: decodeUrl(encoded)
proc normalizeImgUrl*(url: var string) =
if not url.startsWith("http"):
if "twimg.com" notin url:
url.insert(twimg)
url.insert(https)
proc createMediaRouter*(cfg: Config) = proc createMediaRouter*(cfg: Config) =
router media: router media:
get "/pic/?": get "/pic/?":
@@ -94,11 +100,7 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/pic\/orig\/(enc)?\/?(.+)": get re"^\/pic\/orig\/(enc)?\/?(.+)":
var url = decoded(request, 1) var url = decoded(request, 1)
cond "/amplify_video/" notin url cond "/amplify_video/" notin url
normalizeImgUrl(url)
if "twimg.com" notin url:
url.insert(twimg)
if not url.startsWith(https):
url.insert(https)
url.add("?name=orig") url.add("?name=orig")
let uri = parseUri(url) let uri = parseUri(url)
@@ -110,11 +112,7 @@ proc createMediaRouter*(cfg: Config) =
get re"^\/pic\/(enc)?\/?(.+)": get re"^\/pic\/(enc)?\/?(.+)":
var url = decoded(request, 1) var url = decoded(request, 1)
cond "/amplify_video/" notin url cond "/amplify_video/" notin url
normalizeImgUrl(url)
if "twimg.com" notin url:
url.insert(twimg)
if not url.startsWith(https):
url.insert(https)
let uri = parseUri(url) let uri = parseUri(url)
cond isTwitterUrl(uri) == true cond isTwitterUrl(uri) == true
@@ -143,6 +141,6 @@ proc createMediaRouter*(cfg: Config) =
if ".m3u8" in url: if ".m3u8" in url:
let vid = await safeFetch(url) let vid = await safeFetch(url)
content = proxifyVideo(vid, requestPrefs().proxyVideos) content = proxifyVideo(vid, requestPrefs().proxyVideos, url)
resp content, m3u8Mime resp content, m3u8Mime

75
src/sass/_broadcast.scss Normal file
View File

@@ -0,0 +1,75 @@
.broadcast-page {
max-width: 800px;
width: 100%;
margin: 20px auto 0;
}
.broadcast-panel {
background-color: var(--bg_panel);
border: 1px solid var(--border_grey);
border-radius: 8px;
overflow: hidden;
}
.broadcast-player {
position: relative;
background: black;
video,
img {
display: block;
width: 100%;
}
}
.broadcast-info {
padding: 14px 16px;
}
.broadcast-title {
font-size: 18px;
font-weight: bold;
margin: 0 0 12px;
}
.broadcast-user-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.broadcast-user {
display: flex;
align-items: center;
gap: 10px;
color: var(--fg_color);
img {
width: 40px;
height: 40px;
border-radius: 50%;
}
}
.broadcast-username {
color: var(--fg_dark);
}
.broadcast-meta {
color: var(--fg_faded);
font-size: 14px;
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
line-height: 1.5em;
}
.broadcast-live {
background: #e0245e;
color: white;
padding: 1px 6px;
border-radius: 3px;
font-weight: bold;
font-size: 12px;
}

View File

@@ -7,6 +7,7 @@
@import "inputs"; @import "inputs";
@import "timeline"; @import "timeline";
@import "search"; @import "search";
@import "broadcast";
body { body {
// colors // colors
@@ -160,7 +161,7 @@ body.fixed-nav .container {
display: inline-block; display: inline-block;
width: 14px; width: 14px;
height: 14px; height: 14px;
margin-left: 2px; margin-bottom: 2px;
.verified-icon-circle { .verified-icon-circle {
position: absolute; position: absolute;

View File

@@ -44,6 +44,10 @@
padding: 0; padding: 0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
.verified-icon {
margin-left: 2px;
}
} }
.fullname-and-username { .fullname-and-username {

View File

@@ -113,6 +113,20 @@ type
verifiedSince*: DateTime verifiedSince*: DateTime
overrideVerifiedYear*: int overrideVerifiedYear*: int
Broadcast* = object
id*: string
title*: string
state*: string
thumb*: string
mediaKey*: string
m3u8Url*: string
totalWatched*: int
startTime*: DateTime
endTime*: DateTime
replayStart*: int
availableForReplay*: bool
user*: User
VideoType* = enum VideoType* = enum
m3u8 = "application/x-mpegURL" m3u8 = "application/x-mpegURL"
mp4 = "video/mp4" mp4 = "video/mp4"

View File

@@ -17,7 +17,9 @@ const
"abs.twimg.com", "abs.twimg.com",
"pbs.twimg.com", "pbs.twimg.com",
"video.twimg.com", "video.twimg.com",
"x.com" "x.com",
"pscp.tv",
"video.pscp.tv"
] ]
proc setHmacKey*(key: string) = proc setHmacKey*(key: string) =
@@ -55,7 +57,8 @@ proc filterParams*(params: Table): seq[(string, string)] =
result.add p result.add p
proc isTwitterUrl*(uri: Uri): bool = proc isTwitterUrl*(uri: Uri): bool =
uri.hostname in twitterDomains uri.hostname in twitterDomains or
uri.hostname.endsWith(".video.pscp.tv")
proc isTwitterUrl*(url: string): bool = proc isTwitterUrl*(url: string): bool =
isTwitterUrl(parseUri(url)) isTwitterUrl(parseUri(url))

75
src/views/broadcast.nim Normal file
View File

@@ -0,0 +1,75 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, times
import karax/[karaxdsl, vdom]
import renderutils
import ".."/[types, utils, formatters]
proc renderBroadcast*(bc: Broadcast; prefs: Prefs; path: string): VNode =
let
isLive = bc.state == "RUNNING"
thumb = getPicUrl(bc.thumb)
source = if prefs.proxyVideos and bc.m3u8Url.startsWith("http"):
getVidUrl(bc.m3u8Url) else: bc.m3u8Url
stateText =
if isLive: "LIVE"
elif bc.endTime.year > 1: "Ended " & bc.endTime.format("MMM d, YYYY")
elif bc.state.len > 0: bc.state
else: "Ended"
durationMs =
if bc.startTime.year > 1 and bc.endTime.year > 1:
int((bc.endTime - bc.startTime).inMilliseconds) - bc.replayStart * 1000
else: 0
duration = if durationMs > 0: getDuration(durationMs) else: ""
buildHtml(tdiv(class="broadcast-page")):
tdiv(class="broadcast-panel"):
tdiv(class="broadcast-player"):
if bc.m3u8Url.len > 0 and prefs.hlsPlayback:
video(poster=thumb, data-url=source, data-autoload="false",
data-start=($bc.replayStart), muted=prefs.muteVideos)
verbatim "<div class=\"video-overlay\" onclick=\"playVideo(this)\">"
tdiv(class="overlay-circle"): span(class="overlay-triangle")
if isLive:
tdiv(class="broadcast-live"): text "LIVE"
elif duration.len > 0:
tdiv(class="overlay-duration"): text duration
verbatim "</div>"
elif bc.m3u8Url.len > 0:
img(src=thumb, alt=bc.title)
tdiv(class="video-overlay"):
buttonReferer "/enablehls", "Enable hls playback", path
if isLive:
tdiv(class="broadcast-live"): text "LIVE"
elif duration.len > 0:
tdiv(class="overlay-duration"): text duration
elif bc.thumb.len > 0:
img(src=thumb, alt=bc.title)
tdiv(class="video-overlay"):
if bc.availableForReplay:
p: text "Stream unavailable"
else:
p: text "Replay is not available"
else:
tdiv(class="video-overlay"):
p: text "Broadcast not found"
tdiv(class="broadcast-info"):
h2(class="broadcast-title"): text bc.title
tdiv(class="broadcast-user-row"):
a(class="broadcast-user", href=("/" & bc.user.username)):
genImg(getUserPic(bc.user.userPic, "_bigger"))
tdiv:
tdiv:
strong: text bc.user.fullname
verifiedIcon(bc.user)
span(class="broadcast-username"): text "@" & bc.user.username
tdiv(class="broadcast-meta"):
if bc.totalWatched > 0:
span: text insertSep($bc.totalWatched, ',') & " views"
if isLive:
span(class="broadcast-live"): text stateText
else:
span: text stateText

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=34") link(rel="stylesheet", type="text/css", href="/css/style.css?v=35")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5")
if theme.len > 0: if theme.len > 0:

View File

@@ -95,8 +95,8 @@ proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""; bigThumb=fal
let let
vars = videoData.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 and vidUrl.startsWith("http"):
else: vidUrl getVidUrl(vidUrl) else: vidUrl
case playbackType case playbackType
of mp4: of mp4:
video(poster=thumb, controls="", muted=prefs.muteVideos): video(poster=thumb, controls="", muted=prefs.muteVideos):
@@ -105,7 +105,8 @@ proc renderVideoAttachment(videoData: Video; prefs: Prefs; path=""; bigThumb=fal
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(videoData) if videoData.durationMs > 0:
tdiv(class="overlay-duration"): text getDuration(videoData)
verbatim "</div>" verbatim "</div>"
proc renderVideo*(video: Video; prefs: Prefs; path: string; bigThumb=false): VNode = proc renderVideo*(video: Video; prefs: Prefs; path: string; bigThumb=false): VNode =