diff --git a/app/surprise-me/page.tsx b/app/surprise-me/page.tsx
index 11892276..958a040c 100644
--- a/app/surprise-me/page.tsx
+++ b/app/surprise-me/page.tsx
@@ -1,5 +1,5 @@
'use client'
-import { useEffect, useState } from 'react'
+import { useState } from 'react'
import { useInterval, useTitle } from 'react-use'
import dynamic from 'next/dynamic'
diff --git a/components/common/posts/PostsFeed.tsx b/components/common/posts/PostsFeed.tsx
index e0c26d74..c1eeeaa7 100644
--- a/components/common/posts/PostsFeed.tsx
+++ b/components/common/posts/PostsFeed.tsx
@@ -1,13 +1,14 @@
'use client'
import { useEffect, useState } from 'react'
+import dynamic from 'next/dynamic'
import {
AppBskyFeedDefs,
AppBskyEmbedImages,
AppBskyEmbedExternal,
AppBskyEmbedRecordWithMedia,
+ AppBskyEmbedVideo,
AppBskyFeedPost,
AppBskyEmbedRecord,
- AppBskyGraphList,
AppBskyGraphDefs,
} from '@atproto/api'
import Link from 'next/link'
@@ -15,7 +16,6 @@ import {
DocumentMagnifyingGlassIcon,
ExclamationCircleIcon,
LanguageIcon,
- InformationCircleIcon,
FolderMinusIcon,
FolderPlusIcon,
} from '@heroicons/react/24/outline'
@@ -37,6 +37,9 @@ import {
useWorkspaceRemoveItemsMutation,
} from '@/workspace/hooks'
import { ImageList } from './ImageList'
+const VideoPlayer = dynamic(() => import('@/common/video/player'), {
+ ssr: false,
+})
export function PostsFeed({
items,
@@ -229,6 +232,18 @@ export function PostEmbeds({ item }: { item: AppBskyFeedDefs.FeedViewPost }) {
imageRequiresBlur ? 'blur-sm hover:blur-none' : '',
)
+ if (AppBskyEmbedVideo.isView(embed)) {
+ return (
+
+
+
+ )
+ }
+
// render image embeds
if (AppBskyEmbedImages.isView(embed)) {
const embeddedImageClassName = classNames(
diff --git a/components/common/video/player.tsx b/components/common/video/player.tsx
new file mode 100644
index 00000000..00e124ca
--- /dev/null
+++ b/components/common/video/player.tsx
@@ -0,0 +1,105 @@
+import Hls from 'hls.js/dist/hls.light' // Use light build of hls.
+import { useEffect, useId, useRef, useState } from 'react'
+import { ActionButton } from '../buttons'
+import { useSubtitle } from './useSubtitle'
+
+export default function VideoPlayer({
+ source,
+ alt,
+ thumbnail,
+}: {
+ source: string
+ alt?: string
+ thumbnail?: string
+}) {
+ const [hls] = useState(() => new Hls())
+ const [isUnsupported, setIsUnsupported] = useState(false)
+ const { subtitle, loadSubtitle } = useSubtitle(source)
+ const figId = useId()
+ const ref = useRef(null)
+
+ useEffect(() => {
+ if (ref.current && Hls.isSupported()) {
+ hls.attachMedia(ref.current)
+
+ return () => {
+ hls.detachMedia()
+ }
+ }
+ }, [hls])
+
+ useEffect(() => {
+ if (ref.current) {
+ if (Hls.isSupported()) {
+ setIsUnsupported(false)
+ hls.loadSource(source)
+ } else {
+ setIsUnsupported(true)
+ }
+ }
+ }, [source, hls])
+
+ return (
+
+
+
+ loadSubtitle()}
+ size="sm"
+ >
+ {subtitle.isLoading
+ ? 'Loading Subtitles...'
+ : subtitle.url
+ ? 'Hide Subtitles'
+ : 'Show Subtitles'}
+
+
+ {alt && (
+
+ {alt}
+
+ )}
+
+ )
+}
diff --git a/components/common/video/useSubtitle.tsx b/components/common/video/useSubtitle.tsx
new file mode 100644
index 00000000..bec78eeb
--- /dev/null
+++ b/components/common/video/useSubtitle.tsx
@@ -0,0 +1,66 @@
+import client from '@/lib/client'
+import { useState } from 'react'
+import { toast } from 'react-toastify'
+
+export const useSubtitle = (source: string) => {
+ const [subtitle, setSubtitle] = useState<{
+ isLoading: boolean
+ error?: string
+ url: string
+ }>({
+ isLoading: false,
+ error: '',
+ url: '',
+ })
+
+ const loadSubtitle = async () => {
+ setSubtitle({
+ isLoading: true,
+ error: '',
+ url: '',
+ })
+ try {
+ const videoUrl = new URL(source)
+ const sourceFragments = source.split('/')
+ const videoCid = sourceFragments[sourceFragments.length - 2]
+ const authorDid = sourceFragments[sourceFragments.length - 3]
+
+ const { data: serviceAuth } =
+ await client.api.com.atproto.server.getServiceAuth({
+ aud: videoUrl.host,
+ lxm: 'app.bsky.video.getTranscript',
+ })
+
+ if (!serviceAuth?.token) {
+ toast.error('Failed to get auth token')
+ setSubtitle({
+ isLoading: false,
+ error: 'Failed to get auth token',
+ url: '',
+ })
+ return
+ }
+
+ setSubtitle({
+ isLoading: false,
+ error: '',
+ // TODO: does the endpoint expect the token only in the header of the req? or does query param work?
+ url: `${videoUrl.origin}/video/${authorDid}/${videoCid}/transcription_eng.vtt?token=${serviceAuth.token}`,
+ })
+ } catch (error) {
+ console.error(error)
+ const err = `Failed to get subtitle: ${error?.['message']}`
+ setSubtitle({
+ isLoading: false,
+ error: err,
+ url: '',
+ })
+ toast.error(err)
+ }
+ }
+
+ return {
+ subtitle,
+ loadSubtitle,
+ }
+}
diff --git a/lib/client.ts b/lib/client.ts
index 2c9e0b3e..15b52110 100644
--- a/lib/client.ts
+++ b/lib/client.ts
@@ -1,4 +1,4 @@
-import { AtpAgent, AtpServiceClient, AtpSessionData } from '@atproto/api'
+import { AtpAgent, Agent, AtpSessionData } from '@atproto/api'
import { AuthState } from './types'
import { OzoneConfig, getConfig } from './client-config'
import { OZONE_SERVICE_DID } from './constants'
@@ -36,7 +36,7 @@ class ClientManager extends EventTarget {
return AuthState.LoggedIn
}
- get api(): AtpServiceClient {
+ get api(): Agent {
if (this._agent) {
return this._agent.api
}
diff --git a/package.json b/package.json
index d8a8f1d4..f2696fe2 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
"e2e:run": "$(yarn bin)/cypress run --browser chrome"
},
"dependencies": {
- "@atproto/api": "0.12.22",
+ "@atproto/api": "0.13.5",
"@headlessui/react": "^1.7.7",
"@heroicons/react": "^2.0.13",
"@tanstack/react-query": "^4.22.0",
@@ -29,6 +29,7 @@
"date-fns": "^2.29.3",
"eslint": "8.30.0",
"eslint-config-next": "13.4.8",
+ "hls.js": "^1.5.13",
"kbar": "^0.1.0-beta.45",
"lande": "^1.0.10",
"next": "14.2.5",
diff --git a/yarn.lock b/yarn.lock
index 8aa72194..2d3d9a2b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,15 +2,16 @@
# yarn lockfile v1
-"@atproto/api@0.12.22":
- version "0.12.22"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.22.tgz#1880a93a0caa4485cd8463bd1e10bf2424b9826c"
- integrity sha512-TIXSnf3qqyX40Ei/FkK4H24w+7s5rOc63TPwrGakRBOqIgSNBKOggei8I600fJ/AXB7HO6Vp9tBmDVOt2+021A==
+"@atproto/api@0.13.5":
+ version "0.13.5"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.5.tgz#04305cdb0a467ba366305c5e95cebb7ce0d39735"
+ integrity sha512-yT/YimcKYkrI0d282Zxo7O30OSYR+KDW89f81C6oYZfDRBcShC1aniVV8kluP5LrEAg8O27yrOSnBgx2v7XPew==
dependencies:
"@atproto/common-web" "^0.3.0"
- "@atproto/lexicon" "^0.4.0"
+ "@atproto/lexicon" "^0.4.1"
"@atproto/syntax" "^0.3.0"
- "@atproto/xrpc" "^0.5.0"
+ "@atproto/xrpc" "^0.6.1"
+ await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
@@ -24,29 +25,29 @@
uint8arrays "3.0.0"
zod "^3.21.4"
-"@atproto/lexicon@^0.4.0":
- version "0.4.0"
- resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.0.tgz#63e8829945d80c25524882caa8ed27b1151cc576"
- integrity sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ==
+"@atproto/lexicon@^0.4.1":
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.1.tgz#19155210570a2fafbcc7d4f655d9b813948e72a0"
+ integrity sha512-bzyr+/VHXLQWbumViX5L7h1NKQObfs8Z+XZJl43OUK8nYFUI4e/sW1IZKRNfw7Wvi5YVNK+J+yP3DWIBZhkCYA==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/syntax" "^0.3.0"
iso-datestring-validator "^2.2.2"
multiformats "^9.9.0"
- zod "^3.21.4"
+ zod "^3.23.8"
"@atproto/syntax@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
-"@atproto/xrpc@^0.5.0":
- version "0.5.0"
- resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.5.0.tgz#dacbfd8f7b13f0ab5bd56f8fdd4b460e132a6032"
- integrity sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog==
+"@atproto/xrpc@^0.6.1":
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5"
+ integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A==
dependencies:
- "@atproto/lexicon" "^0.4.0"
- zod "^3.21.4"
+ "@atproto/lexicon" "^0.4.1"
+ zod "^3.23.8"
"@babel/runtime-corejs3@^7.10.2":
version "7.20.6"
@@ -742,6 +743,11 @@ autoprefixer@^10.4.13:
picocolors "^1.0.0"
postcss-value-parser "^4.2.0"
+await-lock@^2.2.2:
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef"
+ integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==
+
aws-sign2@~0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
@@ -2196,6 +2202,11 @@ hastscript@^7.0.0:
property-information "^6.0.0"
space-separated-tokens "^2.0.0"
+hls.js@^1.5.13:
+ version "1.5.13"
+ resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.5.13.tgz#27bf1c9b91c433e25e7a84635fe7491bb5988d93"
+ integrity sha512-xRgKo84nsC7clEvSfIdgn/Tc0NOT+d7vdiL/wvkLO+0k0juc26NRBPPG1SfB8pd5bHXIjMW/F5VM8VYYkOYYdw==
+
html-void-elements@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-2.0.1.tgz#29459b8b05c200b6c5ee98743c41b979d577549f"
@@ -4896,7 +4907,7 @@ yocto-queue@^0.1.0:
resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
-zod@^3.21.4:
+zod@^3.21.4, zod@^3.23.8:
version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==