diff --git a/packages/api/package.json b/packages/api/package.json index b387616f4..3de277a1c 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -46,6 +46,7 @@ "@sentry/integrations": "^7.10.0", "@sentry/node": "^5.26.0", "@sentry/tracing": "^7.9.0", + "@types/showdown": "^2.0.6", "addressparser": "^1.0.1", "apollo-datasource": "^3.3.1", "apollo-server-express": "^3.6.3", @@ -97,6 +98,7 @@ "sanitize-html": "^2.3.2", "sax": "^1.3.0", "search-query-parser": "^1.6.0", + "showdown": "^2.1.0", "snake-case": "^3.0.3", "supertest": "^6.2.2", "ts-loader": "^9.3.0", @@ -107,7 +109,9 @@ "uuid": "^8.3.1", "voca": "^1.4.0", "winston": "^3.3.3", - "word-counting": "^1.1.4" + "word-counting": "^1.1.4", + "youtubei": "^1.3.4", + "youtubei.js": "^9.1.0" }, "devDependencies": { "@babel/register": "^7.14.5", @@ -136,6 +140,7 @@ "@types/private-ip": "^1.0.0", "@types/sanitize-html": "^1.27.1", "@types/sax": "^1.2.7", + "@types/showdown": "^2.0.6", "@types/sinon": "^10.0.13", "@types/sinon-chai": "^3.2.8", "@types/supertest": "^2.0.11", diff --git a/packages/api/src/jobs/process-youtube-video.ts b/packages/api/src/jobs/process-youtube-video.ts new file mode 100644 index 000000000..fa4edcc76 --- /dev/null +++ b/packages/api/src/jobs/process-youtube-video.ts @@ -0,0 +1,521 @@ +import { logger } from '../utils/logger' +import { authTrx } from '../repository' +import { libraryItemRepository } from '../repository/library_item' +import { LibraryItem, LibraryItemState } from '../entity/library_item' + +import { Chapter, Client as YouTubeClient } from 'youtubei' +import showdown from 'showdown' +import { parseHTML } from 'linkedom' +import { parsePreparedContent } from '../utils/parser' +import { OpenAI } from '@langchain/openai' +import { PromptTemplate } from '@langchain/core/prompts' +import { enqueueProcessYouTubeTranscript } from '../utils/createTask' +import { env } from '../env' +import * as stream from 'stream' + +import { Storage } from '@google-cloud/storage' +import { stringToHash } from '../utils/helpers' +import { FeatureName, findFeatureByName } from '../services/features' + +export interface ProcessYouTubeVideoJobData { + userId: string + libraryItemId: string +} + +export const PROCESS_YOUTUBE_VIDEO_JOB_NAME = 'process-youtube-video' +export const PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME = 'process-youtube-transcript' + +const TRANSCRIPT_PLACEHOLDER_TEXT = + '* Omnivore is preparing a transcript for this video' + +const calculateWordCount = (durationInSeconds: number): number => { + // Calculate word count using the formula: word count = read time (in seconds) * words per second + // Assuming average reading speed is 235 words per minute (or about 3.92 words per second) + const wordsPerSecond = 3.92 + const wordCount = Math.round(durationInSeconds * wordsPerSecond) + return wordCount +} + +interface ChapterProperties { + title: string + start: number +} + +interface TranscriptProperties { + text: string + start: number + duration: number +} + +export const addTranscriptChapters = ( + chapters: ChapterProperties[], + transcript: TranscriptProperties[] +): TranscriptProperties[] => { + chapters.sort((a, b) => a.start - b.start) + + for (const chapter of chapters) { + const startOffset = chapter.start + const title = '\n\n## ' + chapter.title + '\n\n' + + const index = transcript.findIndex( + (textItem) => textItem.start > startOffset + ) + + if (index !== -1) { + transcript.splice(index, 0, { + text: title, + duration: 1, + start: startOffset, + }) + } else { + transcript.push({ text: title, duration: 0, start: startOffset }) + } + } + return transcript +} + +const createTranscriptHash = (transcript: TranscriptProperties[]): string => { + const rawTranscript = transcript.map((item) => item.text).join(' ') + return stringToHash(rawTranscript) +} + +export const createTranscriptHTML = async ( + videoId: string, + transcript: TranscriptProperties[] +): Promise => { + let transcriptMarkdown = '' + const transcriptHash = createTranscriptHash(transcript) + const promptHash = stringToHash(process.env.YOUTUBE_TRANSCRIPT_PROMPT ?? '') + + if (process.env.YOUTUBE_TRANSCRIPT_PROMPT && process.env.OPENAI_API_KEY) { + const cachedTranscriptHTML = await fetchCachedYouTubeTranscript( + videoId, + transcriptHash, + promptHash + ) + if (cachedTranscriptHTML) { + return cachedTranscriptHTML + } + + const llm = new OpenAI({ + modelName: 'gpt-4', + configuration: { + apiKey: process.env.OPENAI_API_KEY, + }, + }) + const promptTemplate = PromptTemplate.fromTemplate( + `${process.env.YOUTUBE_TRANSCRIPT_PROMPT} + + {transcriptData}` + ) + const chain = promptTemplate.pipe(llm) + + let transcriptChunkLength = 0 + let transcriptChunk: TranscriptProperties[] = [] + for (const item of transcript) { + if (transcriptChunkLength + item.text.length > 8000) { + const result = await chain.invoke({ + transcriptData: transcriptChunk.map((item) => item.text).join(' '), + }) + + transcriptMarkdown += result + + transcriptChunk = [] + transcriptChunkLength = 0 + } + + transcriptChunk.push(item) + transcriptChunkLength += item.text.length + } + + if (transcriptChunk.length > 0) { + const result = await chain.invoke({ + transcriptData: transcriptChunk.map((item) => item.text).join(' '), + }) + + transcriptMarkdown += result + } + } + + // If the LLM didn't give us enough data fallback to the raw template + if (transcriptMarkdown.length < 1) { + transcriptMarkdown = transcript.map((item) => item.text).join(' ') + } + + const converter = new showdown.Converter({ + backslashEscapesHTMLTags: true, + }) + const transcriptHTML = converter.makeHtml(transcriptMarkdown) + + if (process.env.YOUTUBE_TRANSCRIPT_PROMPT && process.env.OPENAI_API_KEY) { + await cacheYouTubeTranscript( + videoId, + transcriptHash, + promptHash, + transcriptHTML + ) + } + + return transcriptHTML +} + +export const addTranscriptToReadableContent = async ( + originalUrl: string, + originalHTML: string, + transcriptHTML: string +): Promise => { + const html = parseHTML(originalHTML) + + const transcriptNode = html.document.querySelector( + '#_omnivore_youtube_transcript' + ) + + if (transcriptNode) { + transcriptNode.innerHTML = transcriptHTML + } else { + const div = html.document.createElement('div') + div.innerHTML = transcriptHTML + html.document.body.appendChild(div) + } + + const preparedDocument = { + document: html.document.toString(), + pageInfo: {}, + } + const updatedContent = await parsePreparedContent( + originalUrl, + preparedDocument, + true + ) + return updatedContent.parsedContent?.content +} + +export const addTranscriptPlaceholdReadableContent = async ( + originalUrl: string, + originalHTML: string +): Promise => { + const html = parseHTML(originalHTML) + + const transcriptNode = html.document.querySelector( + '#_omnivore_youtube_transcript' + ) + + if (transcriptNode) { + transcriptNode.innerHTML = TRANSCRIPT_PLACEHOLDER_TEXT + } else { + const div = html.document.createElement('div') + div.innerHTML = TRANSCRIPT_PLACEHOLDER_TEXT + html.document.body.appendChild(div) + } + + const preparedDocument = { + document: html.document.toString(), + pageInfo: {}, + } + const updatedContent = await parsePreparedContent( + originalUrl, + preparedDocument, + true + ) + return updatedContent.parsedContent?.content +} + +async function readStringFromStorage( + bucketName: string, + fileName: string +): Promise { + try { + const storage = env.fileUpload?.gcsUploadSAKeyFilePath + ? new Storage({ keyFilename: env.fileUpload.gcsUploadSAKeyFilePath }) + : new Storage() + + const existsResponse = await storage + .bucket(bucketName) + .file(fileName) + .exists() + const exists = existsResponse[0] + + if (!exists) { + throw new Error( + `File '${fileName}' does not exist in bucket '${bucketName}'.` + ) + } + + // Download the file contents as a string + const fileContentResponse = await storage + .bucket(bucketName) + .file(fileName) + .download() + const fileContent = fileContentResponse[0].toString() + + console.log(`File '${fileName}' downloaded successfully as string.`) + return fileContent + } catch (error) { + console.error('Error downloading file:', error) + throw error + } +} + +const writeStringToStorage = async ( + bucketName: string, + fileName: string, + content: string +): Promise => { + try { + const storage = env.fileUpload?.gcsUploadSAKeyFilePath + ? new Storage({ keyFilename: env.fileUpload.gcsUploadSAKeyFilePath }) + : new Storage() + + const writableStream = storage + .bucket(bucketName) + .file(fileName) + .createWriteStream() + + // Convert the string content to a readable stream + const readableStream = new stream.Readable() + readableStream.push(content) + readableStream.push(null) // Signal the end of the stream + + // Pipe the readable stream to the writable stream to upload the file content + await new Promise((resolve, reject) => { + readableStream + .pipe(writableStream) + .on('finish', resolve) + .on('error', reject) + }) + + console.log( + `File '${fileName}' uploaded successfully to bucket '${bucketName}'.` + ) + } catch (error) { + console.error('Error uploading file:', error) + throw error + } +} + +const fetchCachedYouTubeTranscript = async ( + videoId: string, + transcriptHash: string, + promptHash: string +): Promise => { + const bucketName = env.fileUpload.gcsUploadBucket + + try { + return await readStringFromStorage( + bucketName, + `youtube-transcripts/${videoId}/${transcriptHash}.${promptHash}.html` + ) + } catch (err) { + logger.info(`unable to fetch cached transcript`, { error: err }) + } + + return undefined +} + +const cacheYouTubeTranscript = async ( + videoId: string, + transcriptHash: string, + promptHash: string, + transcript: string +): Promise => { + const bucketName = env.fileUpload.gcsUploadBucket + + try { + await writeStringToStorage( + bucketName, + `youtube-transcripts/${videoId}/${transcriptHash}.${promptHash}.html`, + transcript + ) + } catch (err) { + logger.info(`unable to cache transcript`, { error: err }) + } +} + +export const processYouTubeVideo = async ( + jobData: ProcessYouTubeVideoJobData +) => { + try { + const libraryItem = await authTrx( + async (tx) => + tx + .withRepository(libraryItemRepository) + .findById(jobData.libraryItemId), + undefined, + jobData.userId + ) + if ( + !libraryItem || + libraryItem.state !== LibraryItemState.Succeeded || + !libraryItem.originalContent + ) { + logger.info( + `Not ready to get YouTube metadata job state: ${ + libraryItem?.state ?? 'null' + }` + ) + return + } + + const u = new URL(libraryItem.originalUrl) + const videoId = u.searchParams.get('v') + + if (!videoId) { + console.warn('no video id for supplied youtube url', { + url: libraryItem.originalUrl, + }) + return + } + + let needsUpdate = false + const youtube = new YouTubeClient() + const video = await youtube.getVideo(videoId) + if (!video) { + console.warn('no video found for youtube url', { + url: libraryItem.originalUrl, + }) + return + } + + if (video.description && libraryItem.description !== video.description) { + needsUpdate = true + libraryItem.description = video.description + } + + let duration = -1 + if ('duration' in video && video.duration > 0) { + needsUpdate = true + libraryItem.wordCount = calculateWordCount(video.duration) + duration = video.duration + } + + if ( + await findFeatureByName(FeatureName.YouTubeTranscripts, jobData.userId) + ) { + if ('getTranscript' in video && duration > 0 && duration < 1801) { + // If the video has a transcript available, put a placehold in and + // enqueue a job to process the full transcript + const updatedContent = await addTranscriptPlaceholdReadableContent( + libraryItem.originalUrl, + libraryItem.originalContent + ) + + if (updatedContent) { + needsUpdate = true + libraryItem.readableContent = updatedContent + } + + await enqueueProcessYouTubeTranscript({ + videoId, + ...jobData, + }) + } + } + + if (needsUpdate) { + const updated = await authTrx( + async (t) => { + return t + .getRepository(LibraryItem) + .update(jobData.libraryItemId, libraryItem) + }, + undefined, + jobData.userId + ) + if (!updated) { + console.warn('could not updated library item') + } + } + } catch (err) { + console.warn('error creating summary: ', err) + } +} + +export interface ProcessYouTubeTranscriptJobData { + userId: string + videoId: string + libraryItemId: string +} + +export const processYouTubeTranscript = async ( + jobData: ProcessYouTubeTranscriptJobData +) => { + try { + const libraryItem = await authTrx( + async (tx) => + tx + .withRepository(libraryItemRepository) + .findById(jobData.libraryItemId), + undefined, + jobData.userId + ) + if ( + !libraryItem || + libraryItem.state !== LibraryItemState.Succeeded || + !libraryItem.originalContent + ) { + logger.info( + `Not ready to get YouTube metadata job state: ${ + libraryItem?.state ?? 'null' + }` + ) + return + } + + let needsUpdate = false + const youtube = new YouTubeClient() + const video = await youtube.getVideo(jobData.videoId) + if (!video) { + logger.warn('no video found for youtube url', { + url: libraryItem.originalUrl, + }) + return + } + + let chapters: Chapter[] = [] + if ('chapters' in video) { + chapters = video.chapters + } + + let transcript: TranscriptProperties[] | undefined = undefined + if ('getTranscript' in video) { + transcript = await video.getTranscript() + } + + if (transcript) { + if (chapters) { + transcript = addTranscriptChapters(chapters, transcript) + } + const transcriptHTML = await createTranscriptHTML( + jobData.videoId, + transcript + ) + const updatedContent = await addTranscriptToReadableContent( + libraryItem.originalUrl, + libraryItem.originalContent, + transcriptHTML + ) + + if (updatedContent) { + needsUpdate = true + libraryItem.readableContent = updatedContent + } + } + + if (needsUpdate) { + const updated = await authTrx( + async (t) => { + return t + .getRepository(LibraryItem) + .update(jobData.libraryItemId, libraryItem) + }, + undefined, + jobData.userId + ) + if (!updated) { + console.warn('could not updated library item') + } + } + } catch (err) { + console.warn('error creating summary: ', err) + } +} diff --git a/packages/api/src/pubsub.ts b/packages/api/src/pubsub.ts index 7bf25d921..9bc4858b1 100644 --- a/packages/api/src/pubsub.ts +++ b/packages/api/src/pubsub.ts @@ -7,6 +7,7 @@ import { Merge } from './util' import { enqueueAISummarizeJob, enqueueExportItem, + enqueueProcessYouTubeVideo, enqueueTriggerRuleJob, enqueueWebhookJob, } from './utils/createTask' @@ -17,6 +18,7 @@ import { findFeatureByName, getFeatureName, } from './services/features' +import { processYouTubeVideo } from './jobs/process-youtube-video' const logger = buildLogger('pubsub') @@ -24,6 +26,18 @@ const client = new PubSub() type EntityData = Merge +const isYouTubeVideoURL = (url: string | undefined): boolean => { + if (!url) { + return false + } + const u = new URL(url) + if (!u.host.endsWith('youtube.com') && !u.host.endsWith('youtu.be')) { + return false + } + const videoId = u.searchParams.get('v') + return videoId != null +} + export const createPubSubClient = (): PubsubClient => { const fieldsToDelete = ['user'] as const @@ -89,7 +103,17 @@ export const createPubSubClient = (): PubsubClient => { }) if (await findFeatureByName(FeatureName.AISummaries, userId)) { - await enqueueAISummarizeJob({ + // await enqueueAISummarizeJob({ + // userId, + // libraryItemId, + // }) + } + + if ( + 'originalUrl' in data && + isYouTubeVideoURL(data['originalUrl'] as string | undefined) + ) { + await enqueueProcessYouTubeVideo({ userId, libraryItemId, }) diff --git a/packages/api/src/queue-processor.ts b/packages/api/src/queue-processor.ts index 959c866fc..36bea979b 100644 --- a/packages/api/src/queue-processor.ts +++ b/packages/api/src/queue-processor.ts @@ -44,6 +44,12 @@ import { redisDataSource } from './redis_data_source' import { CACHED_READING_POSITION_PREFIX } from './services/cached_reading_position' import { getJobPriority } from './utils/createTask' import { logger } from './utils/logger' +import { + PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME, + PROCESS_YOUTUBE_VIDEO_JOB_NAME, + processYouTubeTranscript, + processYouTubeVideo, +} from './jobs/process-youtube-video' export const QUEUE_NAME = 'omnivore-backend-queue' export const JOB_VERSION = 'v001' @@ -116,8 +122,14 @@ export const createWorker = (connection: ConnectionOptions) => return exportItem(job.data) case AI_SUMMARIZE_JOB_NAME: return aiSummarize(job.data) + case PROCESS_YOUTUBE_VIDEO_JOB_NAME: + return processYouTubeVideo(job.data) + case PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME: + return processYouTubeTranscript(job.data) case EXPORT_ALL_ITEMS_JOB_NAME: return exportAllItems(job.data) + default: + logger.warn(`[queue-processor] unhandled job: ${job.name}`) } }, { diff --git a/packages/api/src/services/features.ts b/packages/api/src/services/features.ts index 699c0f66e..f362d6b56 100644 --- a/packages/api/src/services/features.ts +++ b/packages/api/src/services/features.ts @@ -8,6 +8,7 @@ import { logger } from '../utils/logger' export enum FeatureName { AISummaries = 'ai-summaries', + YouTubeTranscripts = 'youtube-transcripts', UltraRealisticVoice = 'ultra-realistic-voice', } diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 56e209091..6d6c30baf 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -45,6 +45,12 @@ import { stringToHash } from './helpers' import { logger } from './logger' import View = google.cloud.tasks.v2.Task.View import { AISummarizeJobData, AI_SUMMARIZE_JOB_NAME } from '../jobs/ai-summarize' +import { + PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME, + PROCESS_YOUTUBE_VIDEO_JOB_NAME, + ProcessYouTubeTranscriptJobData, + ProcessYouTubeVideoJobData, +} from '../jobs/process-youtube-video' // Instantiates a client. const client = new CloudTasksClient() @@ -67,10 +73,13 @@ export const getJobPriority = (jobName: string): number => { case TRIGGER_RULE_JOB_NAME: case CALL_WEBHOOK_JOB_NAME: case AI_SUMMARIZE_JOB_NAME: + case PROCESS_YOUTUBE_VIDEO_JOB_NAME: return 5 case BULK_ACTION_JOB_NAME: case `${REFRESH_FEED_JOB_NAME}_high`: return 10 + case PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME: + return 20 case `${REFRESH_FEED_JOB_NAME}_low`: case EXPORT_ITEM_JOB_NAME: return 50 @@ -78,6 +87,7 @@ export const getJobPriority = (jobName: string): number => { case REFRESH_ALL_FEEDS_JOB_NAME: case THUMBNAIL_JOB: return 100 + default: logger.error(`unknown job name: ${jobName}`) return 1 @@ -708,6 +718,36 @@ export const enqueueAISummarizeJob = async (data: AISummarizeJobData) => { }) } +export const enqueueProcessYouTubeVideo = async ( + data: ProcessYouTubeVideoJobData +) => { + const queue = await getBackendQueue() + if (!queue) { + return undefined + } + + return queue.add(PROCESS_YOUTUBE_VIDEO_JOB_NAME, data, { + priority: getJobPriority(PROCESS_YOUTUBE_VIDEO_JOB_NAME), + attempts: 3, + delay: 2000, + }) +} + +export const enqueueProcessYouTubeTranscript = async ( + data: ProcessYouTubeTranscriptJobData +) => { + const queue = await getBackendQueue() + if (!queue) { + return undefined + } + + return queue.add(PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME, data, { + priority: getJobPriority(PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME), + attempts: 3, + delay: 2000, + }) +} + export const bulkEnqueueUpdateLabels = async (data: UpdateLabelsData[]) => { const queue = await getBackendQueue() if (!queue) { diff --git a/packages/api/test/jobs/process-youtube-job.test.ts b/packages/api/test/jobs/process-youtube-job.test.ts new file mode 100644 index 000000000..849aca41c --- /dev/null +++ b/packages/api/test/jobs/process-youtube-job.test.ts @@ -0,0 +1,101 @@ +import { expect } from 'chai' +import 'mocha' +import { addTranscriptChapters } from '../../src/jobs/process-youtube-video' + +describe('create transcript', () => { + describe('build items', () => { + it('properly adds chapter headers to transcript', async () => { + const chapters = [ + { + title: 'Intro', + start: 0, + }, + { + title: "Joe Biden's re-election effort", + start: 22000, + }, + { + title: 'Ad break', + start: 909000, + }, + { + title: "Trump's crazy speech & Orbán relationship", + start: 1060000, + }, + ] + const transcript = [ + { + text: "welcome to pod save America I'm John", + duration: 3280, + start: 80, + }, + { + text: "favro I'm John L I'm Tommy VOR on", + duration: 3480, + start: 1480, + }, + { + text: "today's show Donald Trump kicks off the", + duration: 3320, + start: 3360, + }, + { + text: 'general election by mocking Joe Biden', + duration: 3400, + start: 4960, + }, + { + text: 'stutter hosting a concert for Victor', + duration: 3680, + start: 6680, + }, + { + text: 'Orban and floating cuts to Medicare and', + duration: 4239, + start: 8360, + }, + { + text: 'Social Security Alabama Senator Katie', + duration: 3840, + start: 10360, + }, + { + text: 'Brit and Republicans are still dealing', + duration: 3401, + start: 12599, + }, + { + text: 'with the Fallout from what may have been', + duration: 3320, + start: 14200, + }, + { + text: 'the worst ever State of the Union', + duration: 4600, + start: 16000, + }, + { + text: 'response and later take appreciator is', + duration: 6640, + start: 17520, + }, + { + text: 'back so is Elijah uh but first the man', + duration: 6519, + start: 20600, + }, + { + text: 'Sean Hannity now calls jacked up Joe has', + duration: 4680, + start: 24160, + }, + ] + + const res = addTranscriptChapters(chapters, transcript) + console.log('res: ', res) + + expect(res.length).to.eq(17) + expect(res[13].text).to.eq("\n\n## Joe Biden's re-election effort\n\n") + }) + }) +}) diff --git a/packages/api/test/resolvers/article.test.ts b/packages/api/test/resolvers/article.test.ts index b7b638d22..e5d05e6ae 100644 --- a/packages/api/test/resolvers/article.test.ts +++ b/packages/api/test/resolvers/article.test.ts @@ -640,6 +640,7 @@ describe('Article API', () => { context('when the source is rss-feeder and url is from youtube.com', () => { const source = 'rss-feeder' const stub = sinon.stub(createTask, 'enqueueParseRequest') + const stub2 = sinon.stub(createTask, 'enqueueProcessYouTubeVideo') before(() => { url = 'https://www.youtube.com/watch?v=123' diff --git a/packages/content-handler/src/websites/youtube-handler.ts b/packages/content-handler/src/websites/youtube-handler.ts index e86eda113..cd7f87c95 100644 --- a/packages/content-handler/src/websites/youtube-handler.ts +++ b/packages/content-handler/src/websites/youtube-handler.ts @@ -86,9 +86,14 @@ export class YoutubeHandler extends ContentHandler { - -

${escapedTitle}

- +
+ +
` diff --git a/packages/web/components/templates/article/Article.tsx b/packages/web/components/templates/article/Article.tsx index 5f7d48ba4..91ef82161 100644 --- a/packages/web/components/templates/article/Article.tsx +++ b/packages/web/components/templates/article/Article.tsx @@ -115,6 +115,28 @@ export function Article(props: ArticleProps): JSX.Element { } }, 2500) + useEffect(() => { + const youtubePlayer = document.getElementById('_omnivore_youtube_video') + + const updateScroll = () => { + console.log('scroll y: ', window.scrollY, youtubePlayer) + + if (youtubePlayer) { + if (window.scrollY > 200) { + youtubePlayer.classList.add('is-sticky') + } else { + youtubePlayer.classList.remove('is-sticky') + } + } + } + if (youtubePlayer) { + window.addEventListener('scroll', updateScroll) + } + return () => { + window.removeEventListener('scroll', updateScroll) // clean up + } + }, [props]) + // Scroll to initial anchor position useEffect(() => { if (typeof window === 'undefined') { diff --git a/packages/web/styles/articleInnerStyling.css b/packages/web/styles/articleInnerStyling.css index c38f290a7..251da4bb7 100644 --- a/packages/web/styles/articleInnerStyling.css +++ b/packages/web/styles/articleInnerStyling.css @@ -610,3 +610,58 @@ white-space: pre-wrap; overflow-wrap: break-word; } + +.is-sticky { + position: fixed; + right: 5px; + bottom: 5px; + top: auto; + left: auto; + z-index: 10; + max-width: 400px; + max-height: 222px; + width: 400px; + height: 222px; + animation-name: fadeInUp; + animation-duration: 0.5s; + animation-fill-mode: both; + -webkit-animation-name: fadeInUp; + -webkit-animation-duration: 0.5s; + -webkit-animation-fill-mode: both; + overflow: hidden; + box-shadow: 0px 4px 4px rgba(33, 33, 33, 0.1) !important; +} + +@media (max-width: 600px) { + .is-sticky { + max-width: 200px; + max-height: 110px; + } +} + + +@keyframes fadeInUp { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + } + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} + +@-webkit-keyframes fadeInUp { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + } + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index cf307822f..192ec8f0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2428,6 +2428,11 @@ dependencies: text-decoding "^1.0.0" +"@fastify/busboy@^2.0.0": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d" + integrity sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA== + "@ffmpeg-installer/darwin-arm64@4.1.5": version "4.1.5" resolved "https://registry.yarnpkg.com/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz#b7b5c262dd96d1aea4807514e1cdcf6e11f82743" @@ -8173,6 +8178,11 @@ resolved "https://registry.yarnpkg.com/@types/showdown/-/showdown-2.0.1.tgz#24134738ba3107237d6a783e054a54773e739f81" integrity sha512-xdnAw2nFqomkaL0QdtEk0t7yz26UkaVPl4v1pYJvtE1T0fmfQEH3JaxErEhGByEAl3zUZrkNBlneuJp0WJGqEA== +"@types/showdown@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@types/showdown/-/showdown-2.0.6.tgz#3d7affd5f971b4a17783ec2b23b4ad3b97477b7e" + integrity sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ== + "@types/sinon-chai@^3.2.8": version "3.2.8" resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.8.tgz#5871d09ab50d671d8e6dd72e9073f8e738ac61dc" @@ -19286,6 +19296,13 @@ jest@^27.4.5: import-local "^3.0.2" jest-cli "^27.5.1" +jintr@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/jintr/-/jintr-1.1.0.tgz#223a3b07f5e03d410cec6e715c537c8ad1e714c3" + integrity sha512-Tu9wk3BpN2v+kb8yT6YBtue+/nbjeLFv4vvVC4PJ7oCidHKbifWhvORrAbQfxVIQZG+67am/mDagpiGSVtvrZg== + dependencies: + acorn "^8.8.0" + jose@^2.0.5: version "2.0.7" resolved "https://registry.yarnpkg.com/jose/-/jose-2.0.7.tgz#3aabbaec70bff313c108b9406498a163737b16ba" @@ -29876,6 +29893,13 @@ undici@^4.9.3: resolved "https://registry.yarnpkg.com/undici/-/undici-4.14.1.tgz#7633b143a8a10d6d63335e00511d071e8d52a1d9" integrity sha512-WJ+g+XqiZcATcBaUeluCajqy4pEDcQfK1vy+Fo+bC4/mqXI9IIQD/XWHLS70fkGUT6P52Drm7IFslO651OdLPQ== +undici@^5.19.1: + version "5.28.3" + resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.3.tgz#a731e0eff2c3fcfd41c1169a869062be222d1e5b" + integrity sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA== + dependencies: + "@fastify/busboy" "^2.0.0" + unfetch@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-4.2.0.tgz#7e21b0ef7d363d8d9af0fb929a5555f6ef97a3be" @@ -31536,6 +31560,23 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== +youtubei.js@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-9.1.0.tgz#bcf154c9fa21d3c8c1d00a5e10360d0a065c660e" + integrity sha512-C5GBJ4LgnS6vGAUkdIdQNOFFb5EZ1p3xBvUELNXmIG3Idr6vxWrKNBNy8ClZT3SuDVXaAJqDgF9b5jvY8lNKcg== + dependencies: + jintr "^1.1.0" + tslib "^2.5.0" + undici "^5.19.1" + +youtubei@^1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/youtubei/-/youtubei-1.3.4.tgz#b9761e33dcc6e0a9569e6628ba1fc48c729636f0" + integrity sha512-xN6p2oddcTpreF/ojU2mChwdiUlV+TwwUL6xgP6lXRuxeGS5MokM1tzRdXCgIpxkzYYNNAWpt7xvPuAUQM0PCg== + dependencies: + node-fetch "2.6.7" + protobufjs "7.2.4" + yup@^0.31.0: version "0.31.1" resolved "https://registry.yarnpkg.com/yup/-/yup-0.31.1.tgz#0954cb181161f397b804346037a04f8a4b31599e"