Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ Go to [chrome://extensions/?options=knnaflplggjdedobhbidojmmnocfbopf](chrome://e

### Manual Installation

#### Chrome

1. Clone this repository
2. Install dependencies:
```sh
Expand All @@ -49,6 +51,23 @@ Go to [chrome://extensions/?options=knnaflplggjdedobhbidojmmnocfbopf](chrome://e
- Enable "Developer mode"
- Click "Load unpacked" and select the `.output/chrome-mv3` directory

#### Firefox

1. Clone this repository
2. Install dependencies:
```sh
bun i
```
3. Build the extension:
```sh
bun run build:firefox
```
4. Load the temporary extension:
- Open Firefox and navigate to `about:debugging`
- Click "This Firefox"
- Click "Load Temporary Add-on..."
- Select the `manifest.json` file in the `.output/firefox-mv2` directory

## Usage

1. Navigate to any webpage you want to copy
Expand Down
66 changes: 66 additions & 0 deletions components/SubtitleSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React, { useEffect } from "react"
import { showNotification } from "@/lib/showNotification"

export interface Track {
baseUrl: string
name: { simpleText: string }
languageCode: string
kind?: string
}

interface SubtitleSelectorProps {
tracks: Track[]
onSelect: (track: Track) => void
onClose: () => void
}

export const SubtitleSelector: React.FC<SubtitleSelectorProps> = ({
tracks,
onSelect,
onClose,
}) => {
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [onClose])

return (
<div className="fixed top-4 right-4 z-50 bg-white dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 rounded-lg shadow-lg p-4 w-64 max-h-[80vh] overflow-y-auto flex flex-col gap-2 font-sans text-sm animate-in fade-in slide-in-from-top-2">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-zinc-900 dark:text-zinc-50">
Select Subtitle
</h3>
<button
onClick={onClose}
className="text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
type="button"
>
</button>
</div>
<div className="flex flex-col gap-1">
{tracks.map((track, index) => (
<button
key={`${track.languageCode}-${index}`}
onClick={() => onSelect(track)}
className="text-left px-3 py-2 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors text-zinc-700 dark:text-zinc-300 truncate"
type="button"
>
{track.name.simpleText}
{track.kind === "asr" && (
<span className="ml-2 text-xs text-zinc-400 italic">
(Auto)
</span>
)}
</button>
))}
</div>
</div>
)
}
87 changes: 73 additions & 14 deletions entrypoints/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { getOptions } from "@/lib/storage"
import { defaultTagsToRemove } from "@/lib/tagsToRemove"
import { convertSrtToText } from "@/lib/yt/convertSrtToText"
import { getVideoInfo } from "@/lib/yt/getVideoInfo"
import { getVideoSubtitle } from "@/lib/yt/getVideoSubtitle"
import {
downloadSubtitle,
getVideoSubtitlesList,
} from "@/lib/yt/getVideoSubtitle"
import { SubtitleSelector, type Track } from "@/components/SubtitleSelector"

const tiktoken = new Tiktoken(o200k_base)

Expand Down Expand Up @@ -167,24 +171,79 @@ export default defineContentScript({
document.querySelector("#title")?.textContent?.trim() ||
"Untitle Video"

const subtitle = await getVideoSubtitle(videoId)
const subtitleData = await getVideoSubtitlesList(videoId)

if (!subtitle) {
throw new Error("No subtitle found")
if (!subtitleData || !subtitleData.tracks || subtitleData.tracks.length === 0) {
showNotification("No subtitle found", "error")
return
}

let markdown = await convertSrtToText(videoId, subtitle)
const { tracks, pot } = subtitleData

markdown = `# ${title}\n\n\n${markdown}`
// Helper function to handle subtitle processing and notification
const processSubtitle = async (track: Track) => {
try {
const subtitle = await downloadSubtitle(track.baseUrl, pot)

if (!subtitle) {
throw new Error("Failed to download subtitle")
}

let markdown = await convertSrtToText(videoId, subtitle)
markdown = `# ${title}\n\n\n${markdown}`

await copyAndNotify({
markdown,
wrapInTripleBackticks,
showSuccessToast,
showConfetti,
sendResponse,
successMessagePrefix: "Subtitle copied to clipboard",
})
} catch (error) {
console.error("Error processing subtitle:", error)
showNotification("Failed to process subtitle", "error")
}
}

await copyAndNotify({
markdown,
wrapInTripleBackticks,
showSuccessToast,
showConfetti,
sendResponse,
successMessagePrefix: "Subtitle copied to clipboard",
})
if (tracks.length === 1) {
await processSubtitle(tracks[0])
} else {
// Render selection UI
const root = getRoot()
const container = document.createElement("div")
// Ensure container is above everything
container.style.position = "absolute"
container.style.top = "0"
container.style.left = "0"
container.style.width = "100%"
container.style.height = "0" // Don't block interaction with rest of page unless necessary?
// Actually, the SubtitleSelector is fixed, so the container just needs to be in DOM

root.appendChild(container)
const reactRoot = createRoot(container)

const handleSelect = async (track: Track) => {
// Unmount first
reactRoot.unmount()
container.remove()

await processSubtitle(track)
}

const handleClose = () => {
reactRoot.unmount()
container.remove()
}

reactRoot.render(
<SubtitleSelector
tracks={tracks}
onSelect={handleSelect}
onClose={handleClose}
/>
)
}

return true
}
Expand Down
39 changes: 39 additions & 0 deletions entrypoints/options/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { useEffect, useState } from "react"
import { ToggleOption } from "@/components/ToggleOption"
import { getOptions, type OptionsState, saveOptions } from "@/lib/storage"
import packageJson from "../../package.json"
import { SubtitleSelector, type Track } from "@/components/SubtitleSelector"

export const App = () => {
const [options, setOptions] = useState<OptionsState | null>(null)
const [showSelector, setShowSelector] = useState(false)

useEffect(() => {
const loadOptions = async () => {
Expand Down Expand Up @@ -33,12 +35,49 @@ export const App = () => {
await saveOptions(newOptions)
}

const mockTracks: Track[] = [
{
baseUrl: "http://example.com/en",
name: { simpleText: "English" },
languageCode: "en",
},
{
baseUrl: "http://example.com/es",
name: { simpleText: "Spanish" },
languageCode: "es",
},
{
baseUrl: "http://example.com/ja",
name: { simpleText: "Japanese" },
languageCode: "ja",
kind: "asr",
},
]

return (
<div className="mx-auto flex min-h-screen max-w-xl flex-col bg-background p-4 text-foreground">
<header className="mb-4">
<h1 className="font-bold text-xl">Settings</h1>
</header>

<button
onClick={() => setShowSelector(true)}
className="mb-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Test Subtitle Selector
</button>

{showSelector && (
<div className="relative border p-4 mb-4 h-64 bg-gray-100">
{/* Render it inside this relative container to simulate how it looks */}
<SubtitleSelector
tracks={mockTracks}
onSelect={(track) => alert(`Selected ${track.name.simpleText}`)}
onClose={() => setShowSelector(false)}
/>
</div>
)}

<div className="space-y-1 rounded-lg border border-border bg-card p-6">
{options && (
<>
Expand Down
48 changes: 39 additions & 9 deletions lib/yt/getVideoSubtitle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,55 @@ import { memoize } from "@fxts/core"
import { ofetch } from "ofetch"
import { getVideoInfo } from "@/lib/yt/getVideoInfo"

export const getVideoSubtitle = memoize(async (videoId: string) => {
const { r, pot } = await getVideoInfo(videoId)

const url =
r.captions.playerCaptionsTracklistRenderer.captionTracks.at(0)?.baseUrl

if (!url) {
// New function to download subtitle content
export const downloadSubtitle = async (
baseUrl: string,
pot?: string,
): Promise<string | null> => {
if (!baseUrl) {
return null
}

const { origin, pathname, searchParams } = new URL(url)
const { origin, pathname, searchParams } = new URL(baseUrl)

searchParams.set("fmt", "srt")
searchParams.set("c", "WEB")
searchParams.set("pot", pot)
if (pot) {
searchParams.set("pot", pot)
}

const srt = await ofetch<string>(`${origin}${pathname}?${searchParams}`, {
parseResponse: (txt) => txt,
})

return srt
}

// Renamed and refactored to return the list of tracks and pot, or null
export const getVideoSubtitlesList = memoize(async (videoId: string) => {
const { r, pot } = await getVideoInfo(videoId)

const tracks = r.captions?.playerCaptionsTracklistRenderer?.captionTracks

if (!tracks || tracks.length === 0) {
return null
}

return { tracks, pot }
})

// Kept for backward compatibility if needed, but we will move away from it
export const getVideoSubtitle = memoize(async (videoId: string) => {
const result = await getVideoSubtitlesList(videoId)

if (!result) return null

const { tracks, pot } = result
const firstTrack = tracks.at(0)

if (!firstTrack?.baseUrl) {
return null
}

return downloadSubtitle(firstTrack.baseUrl, pot)
})
Loading
Loading