=> {
+ return new Promise((resolve, reject) => {
+ const listener = (event: Event) => {
+ const data = (event as CustomEvent).detail as VideoData;
+ resolve(data);
+ document.removeEventListener('asbplayer-synced-language-data', listener, false);
+ };
+ document.addEventListener('asbplayer-synced-language-data', listener, false);
+ document.dispatchEvent(new CustomEvent('asbplayer-get-synced-language-data', { detail: language }));
+ });
+};
+
export default class VideoDataSyncController {
private readonly _context: Binding;
private readonly _domain: string;
@@ -72,7 +84,8 @@ export default class VideoDataSyncController {
this._autoSync = false;
this._lastLanguagesSynced = {};
this._emptySubtitle = {
- language: '',
+ id: '-',
+ language: '-',
url: '-',
label: i18n.t('extension.videoDataSync.emptySubtitleTrack'),
extension: 'srt',
@@ -209,7 +222,7 @@ export default class VideoDataSyncController {
openedFromMiningCommand,
defaultCheckboxState: defaultCheckboxState,
};
- state.selectedSubtitle = selectedSub.map((subtitle) => subtitle.url || '-');
+ state.selectedSubtitle = selectedSub.map((subtitle) => subtitle.id || '-');
const client = await this._client();
this._prepareShow();
client.updateState(state);
@@ -223,14 +236,16 @@ export default class VideoDataSyncController {
completeMatch: false,
};
- if (!subtitleTrackChoices.length && this.lastLanguageSynced.join('') === '') {
+ const emptyChoice = this.lastLanguageSynced.some((lang) => lang !== '-') === undefined;
+
+ if (!subtitleTrackChoices.length && emptyChoice) {
tracks.completeMatch = true;
} else {
let matches: number = 0;
for (let i = 0; i < this.lastLanguageSynced.length; i++) {
const language = this.lastLanguageSynced[i];
for (let j = 0; j < subtitleTrackChoices.length; j++) {
- if (language === '') {
+ if (language === '-') {
matches++;
break;
} else if (language === subtitleTrackChoices[j].language) {
@@ -372,9 +387,10 @@ export default class VideoDataSyncController {
let subtitles: SerializedSubtitleFile[] = [];
for (let i = 0; i < data.length; i++) {
- const { extension, url, m3U8BaseUrl } = data[i];
+ const { extension, url, m3U8BaseUrl, language } = data[i];
const subtitleFiles = await this._subtitlesForUrl(
this._defaultVideoName(this._syncedData?.basename, data[i]),
+ language,
extension,
url,
m3U8BaseUrl
@@ -403,8 +419,8 @@ export default class VideoDataSyncController {
let subtitles: SerializedSubtitleFile[] = [];
for (let i = 0; i < data.length; i++) {
- const { name, extension, subtitleUrl, m3U8BaseUrl } = data[i];
- const subtitleFiles = await this._subtitlesForUrl(name, extension, subtitleUrl, m3U8BaseUrl);
+ const { name, language, extension, url, m3U8BaseUrl } = data[i];
+ const subtitleFiles = await this._subtitlesForUrl(name, language, extension, url, m3U8BaseUrl);
if (subtitleFiles !== undefined) {
subtitles.push(...subtitleFiles);
}
@@ -448,6 +464,7 @@ export default class VideoDataSyncController {
private async _subtitlesForUrl(
name: string,
+ language: string,
extension: string,
url: string,
m3U8BaseUrl?: string
@@ -461,6 +478,24 @@ export default class VideoDataSyncController {
];
}
+ if (url === 'lazy') {
+ const data = await fetchDataForLanguageOnDemand(language);
+
+ if (data.error) {
+ this._reportError(data.error);
+ return undefined;
+ }
+
+ const lazilyFetchedUrl = data.subtitles?.find((t) => t.language === language)?.url;
+
+ if (lazilyFetchedUrl === undefined) {
+ this._reportError('Failed to fetch subtitles for specified language');
+ return undefined;
+ }
+
+ url = lazilyFetchedUrl;
+ }
+
const response = await fetch(url).catch((error) => {
this._reportError(error.message);
});
@@ -522,24 +557,10 @@ export default class VideoDataSyncController {
this._prepareShow();
- const subtitleTrackChoices = this._syncedData?.subtitles ?? [];
- let selectedSub: VideoDataSubtitleTrack[] = [this._emptySubtitle, this._emptySubtitle, this._emptySubtitle];
- for (let i = 0; i < this.lastLanguageSynced.length; i++) {
- const language = this.lastLanguageSynced[i];
- for (let j = 0; j < subtitleTrackChoices.length; j++) {
- if (language === subtitleTrackChoices[j].language) {
- selectedSub[i] = subtitleTrackChoices[j];
- break;
- }
- }
- }
-
return client.updateState({
open: true,
isLoading: false,
showSubSelect: true,
- subtitles: this._syncedData?.subtitles || [],
- selectedSubtitle: selectedSub.map((subtitle) => subtitle.url) || '-',
error,
themeType: themeType,
});
diff --git a/extension/src/handlers/video/stop-recording-media-handler.ts b/extension/src/handlers/video/stop-recording-media-handler.ts
index 60622a8f..2f66a149 100755
--- a/extension/src/handlers/video/stop-recording-media-handler.ts
+++ b/extension/src/handlers/video/stop-recording-media-handler.ts
@@ -2,10 +2,8 @@ import ImageCapturer from '../../services/image-capturer';
import {
AudioModel,
Command,
- ExtensionToVideoCommand,
ImageModel,
Message,
- RecordingFinishedMessage,
StopRecordingMediaMessage,
SubtitleModel,
VideoToExtensionCommand,
@@ -14,6 +12,7 @@ import { SettingsProvider } from '@project/common/settings';
import { mockSurroundingSubtitles } from '@project/common/util';
import { CardPublisher } from '../../services/card-publisher';
import AudioRecorderService from '../../services/audio-recorder-service';
+import { AutoRecordingInProgressError } from '../../services/audio-recorder-delegate';
export default class StopRecordingMediaHandler {
private readonly _audioRecorder: AudioRecorderService;
@@ -77,33 +76,44 @@ export default class StopRecordingMediaHandler {
}
const preferMp3 = await this._settingsProvider.getSingle('preferMp3');
- const audioBase64 = await this._audioRecorder.stop(preferMp3, {
- tabId: sender.tab!.id!,
- src: stopRecordingCommand.src,
- });
- const audioModel: AudioModel = {
- base64: audioBase64,
- extension: preferMp3 ? 'mp3' : 'webm',
- paddingStart: 0,
- paddingEnd: 0,
- start: stopRecordingCommand.message.startTimestamp,
- end: stopRecordingCommand.message.endTimestamp,
- playbackRate: stopRecordingCommand.message.playbackRate,
- };
+ try {
+ const audioBase64 = await this._audioRecorder.stop(preferMp3, {
+ tabId: sender.tab!.id!,
+ src: stopRecordingCommand.src,
+ });
+ const audioModel: AudioModel = {
+ base64: audioBase64,
+ extension: preferMp3 ? 'mp3' : 'webm',
+ paddingStart: 0,
+ paddingEnd: 0,
+ start: stopRecordingCommand.message.startTimestamp,
+ end: stopRecordingCommand.message.endTimestamp,
+ playbackRate: stopRecordingCommand.message.playbackRate,
+ };
- this._cardPublisher.publish(
- {
- subtitle: subtitle,
- surroundingSubtitles: surroundingSubtitles,
- image: imageModel,
- audio: audioModel,
- url: stopRecordingCommand.message.url,
- subtitleFileName: stopRecordingCommand.message.subtitleFileName,
- mediaTimestamp: stopRecordingCommand.message.startTimestamp,
- },
- stopRecordingCommand.message.postMineAction,
- sender.tab!.id!,
- stopRecordingCommand.src
- );
+ this._cardPublisher.publish(
+ {
+ subtitle: subtitle,
+ surroundingSubtitles: surroundingSubtitles,
+ image: imageModel,
+ audio: audioModel,
+ url: stopRecordingCommand.message.url,
+ subtitleFileName: stopRecordingCommand.message.subtitleFileName,
+ mediaTimestamp: stopRecordingCommand.message.startTimestamp,
+ },
+ stopRecordingCommand.message.postMineAction,
+ sender.tab!.id!,
+ stopRecordingCommand.src
+ );
+ } catch (e) {
+ if (!(e instanceof AutoRecordingInProgressError)) {
+ throw e;
+ }
+
+ // Else a recording scheduled from record-media-handler (or rerecord-media-handler) was in-progress
+ // and the call to stop() just above force-stopped it.
+ // We should do nothing else because execution in record-media-handler will continue
+ // and publish the card etc.
+ }
}
}
diff --git a/extension/src/manifest.json b/extension/src/manifest.json
index 5012bd5d..46ffe0fb 100755
--- a/extension/src/manifest.json
+++ b/extension/src/manifest.json
@@ -1,7 +1,7 @@
{
"name": "asbplayer: Language-learning with subtitles",
"description": "__MSG_extensionDescription__",
- "version": "1.3.1",
+ "version": "1.4.2",
"manifest_version": 3,
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxmdAa3ymqAjLms43ympXqtyuJnC2bSYh70+5ZZmtyx/MsnGhTEdfbqtsp3BKxHbv0rPd49+Joacm1Shik5/mCppZ0h4I4ISMm983X01H6p/hfAzQYAcnvw/ZQNHAv1QgY9JiuyTBirCDoYB50Fxol/kI/0EviYXuX83KoYpjB0VGP/ssY9ocT//fQUbRmeLDJnciry8y6MduWXHzseOP99axQIjeVsNTE30L4fRN+ppX3aOkG/RFJNx0eI02qbLul3qw5dUuBK5GgMbYftwjHnDoOegnZYFr1sxRO1zsgmxdp/6du75RiDPRJOkPCz2GTrw4CX2FCywbDZlqaIpwqQIDAQAB",
"default_locale": "en",
@@ -110,6 +110,7 @@
"pages/emby-page.js",
"pages/osnplus-page.js",
"pages/bilibili-page.js",
+ "pages/nrk-tv-page.js",
"anki-ui.js",
"mp3-encoder-worker.js",
"pgs-parser-worker.js",
diff --git a/extension/src/offscreen-audio-recorder.ts b/extension/src/offscreen-audio-recorder.ts
index 17e453bb..77397275 100644
--- a/extension/src/offscreen-audio-recorder.ts
+++ b/extension/src/offscreen-audio-recorder.ts
@@ -5,7 +5,7 @@ import {
StartRecordingAudioMessage,
OffscreenDocumentToExtensionCommand,
} from '@project/common';
-import AudioRecorder from './services/audio-recorder';
+import AudioRecorder, { AutoRecordingInProgressError } from './services/audio-recorder';
import { bufferToBase64 } from './services/base64';
import { Mp3Encoder } from '@project/common/audio-clip';
@@ -81,8 +81,18 @@ window.onload = async () => {
const stopRecordingAudioMessage = request.message as StopRecordingAudioMessage;
audioRecorder
.stop()
- .then((audioBase64) => _sendAudioBase64(audioBase64, stopRecordingAudioMessage.preferMp3));
- break;
+ .then((audioBase64) => {
+ sendResponse(true);
+ _sendAudioBase64(audioBase64, stopRecordingAudioMessage.preferMp3);
+ })
+ .catch((e) => {
+ if (e instanceof AutoRecordingInProgressError) {
+ sendResponse(false);
+ } else {
+ console.error(e);
+ }
+ });
+ return true;
}
}
};
diff --git a/extension/src/pages.json b/extension/src/pages.json
index 4a84bae8..e8148a0b 100644
--- a/extension/src/pages.json
+++ b/extension/src/pages.json
@@ -117,6 +117,14 @@
"autoSync": {
"enabled": true
}
+ },
+ {
+ "host": "tv.nrk.no",
+ "script": "nrk-tv-page.js",
+ "path": ".*",
+ "autoSync": {
+ "enabled": true
+ }
}
]
}
diff --git a/extension/src/pages/bandai-channel-page.ts b/extension/src/pages/bandai-channel-page.ts
index a62872ba..07b69808 100644
--- a/extension/src/pages/bandai-channel-page.ts
+++ b/extension/src/pages/bandai-channel-page.ts
@@ -56,8 +56,8 @@ inferTracks({
setBasename(value.bch.episode_title);
}
},
- onRequest: (addTrack, setBasename) => {
- const succeeded = poll(() => {
+ onRequest: async (addTrack, setBasename) => {
+ const succeeded = await poll(() => {
const basename = basenameFromDOM();
if (basename) {
diff --git a/extension/src/pages/disney-plus-page.ts b/extension/src/pages/disney-plus-page.ts
index 2cfd2eba..28f8d385 100644
--- a/extension/src/pages/disney-plus-page.ts
+++ b/extension/src/pages/disney-plus-page.ts
@@ -1,5 +1,6 @@
import { VideoDataSubtitleTrack } from '@project/common';
import { Parser } from 'm3u8-parser';
+import { trackFromDef, trackId } from './util';
setTimeout(() => {
function basenameFromDOM(): string {
@@ -83,13 +84,14 @@ setTimeout(() => {
if (track && typeof track.language === 'string' && typeof track.uri === 'string') {
const baseUrl = baseUrlForUrl(url);
const subtitleM3U8Url = `${baseUrl}/${track.uri}`;
- subtitles.push({
+ const def = {
label: label,
language: track.language,
url: subtitleM3U8Url,
m3U8BaseUrl: baseUrlForUrl(subtitleM3U8Url),
extension: 'm3u8',
- });
+ };
+ subtitles.push(trackFromDef(def));
}
}
diff --git a/extension/src/pages/emby-page.ts b/extension/src/pages/emby-page.ts
index 8e6bc0c1..2a9ce2e7 100644
--- a/extension/src/pages/emby-page.ts
+++ b/extension/src/pages/emby-page.ts
@@ -1,5 +1,6 @@
import { VideoDataSubtitleTrack } from '@project/common';
import { VideoData } from '@project/common';
+import { trackFromDef } from './util';
declare const ApiClient: any | undefined;
@@ -41,12 +42,14 @@ document.addEventListener(
sub.Codec +
'?api_key=' +
apikey;
- subtitles.push({
- label: sub.DisplayTitle,
- language: sub.Language,
- url: url,
- extension: sub.Codec,
- });
+ subtitles.push(
+ trackFromDef({
+ label: sub.DisplayTitle,
+ language: sub.Language,
+ url: url,
+ extension: sub.Codec,
+ })
+ );
});
response.subtitles = subtitles;
diff --git a/extension/src/pages/hulu-page.ts b/extension/src/pages/hulu-page.ts
index bc520ab0..877923c8 100644
--- a/extension/src/pages/hulu-page.ts
+++ b/extension/src/pages/hulu-page.ts
@@ -1,5 +1,5 @@
import { VideoDataSubtitleTrack } from '@project/common';
-import { extractExtension } from './util';
+import { extractExtension, trackFromDef } from './util';
setTimeout(() => {
function isObject(val: any) {
@@ -16,12 +16,14 @@ setTimeout(() => {
if (typeof url === 'string') {
if (subtitles.find((s) => s.label === s.language) === undefined) {
- subtitles.push({
- label: language,
- language: language.toLowerCase(),
- url: url,
- extension: extractExtension(url, 'vtt'),
- });
+ subtitles.push(
+ trackFromDef({
+ label: language,
+ language: language.toLowerCase(),
+ url: url,
+ extension: extractExtension(url, 'vtt'),
+ })
+ );
}
}
}
diff --git a/extension/src/pages/mpd-util.ts b/extension/src/pages/mpd-util.ts
index ec2ed2bd..fb8c9e48 100644
--- a/extension/src/pages/mpd-util.ts
+++ b/extension/src/pages/mpd-util.ts
@@ -1,5 +1,5 @@
-import { VideoDataSubtitleTrack } from '@project/common';
-import { extractExtension, inferTracks } from './util';
+import { VideoDataSubtitleTrack, VideoDataSubtitleTrackDef } from '@project/common';
+import { extractExtension, inferTracks, trackId } from './util';
import { parse } from 'mpd-parser';
export interface Playlist {
@@ -9,7 +9,7 @@ export interface Playlist {
export const inferTracksFromInterceptedMpd = (
mpdUrlRegex: RegExp,
- trackExtractor: (playlist: Playlist, language: string) => VideoDataSubtitleTrack | undefined
+ trackExtractor: (playlist: Playlist, language: string) => VideoDataSubtitleTrackDef | undefined
) => {
const originalFetch = window.fetch;
@@ -42,7 +42,8 @@ export const inferTracksFromInterceptedMpd = (
const track = trackExtractor(playlist, language);
if (track !== undefined) {
- tracks.push(track);
+ const id = trackId(track);
+ tracks.push({ id, ...track });
}
}
}
diff --git a/extension/src/pages/netflix-page.ts b/extension/src/pages/netflix-page.ts
index b1a84727..e1711d28 100644
--- a/extension/src/pages/netflix-page.ts
+++ b/extension/src/pages/netflix-page.ts
@@ -1,4 +1,5 @@
-import { VideoData } from '@project/common';
+import { VideoData, VideoDataSubtitleTrack } from '@project/common';
+import { poll, trackFromDef } from './util';
declare const netflix: any | undefined;
@@ -60,7 +61,7 @@ setTimeout(() => {
const webvttDL = track.ttDownloadables[webvtt];
if (!webvttDL?.urls || webvttDL.urls.length === 0) {
- return undefined;
+ return 'lazy';
}
return webvttDL.urls[0].url;
@@ -72,7 +73,7 @@ setTimeout(() => {
for (const track of timedTextracks) {
const url = extractUrlLegacy(track) ?? extractUrl(track);
- if (!url) {
+ if (url === undefined) {
continue;
}
@@ -131,43 +132,148 @@ setTimeout(() => {
return basename;
}
+ const dataForTrack = (track: any, storedTracks: Map
): VideoDataSubtitleTrack => {
+ const isClosedCaptions = 'CLOSEDCAPTIONS' === track.rawTrackType;
+ const language = isClosedCaptions ? `${track.bcp47.toLowerCase()}-CC` : track.bcp47.toLowerCase();
+ const label = `${track.bcp47} - ${track.displayName}${isClosedCaptions ? ' [CC]' : ''}`;
+
+ return trackFromDef({
+ label,
+ language,
+ // 'lazy' is a sentinel value indicating to the content script that it should
+ // make a lazy language-specific request to get the URL
+ url: storedTracks.get(track.trackId) ?? 'lazy',
+ extension: 'nfvtt',
+ });
+ };
+
+ const buildResponse = async () => {
+ const response: VideoData = { error: '', basename: '', subtitles: [] };
+ const np = player();
+ const titleId = np?.getMovieId();
+
+ if (!np || !titleId) {
+ response.error = 'Netflix Player or Title Id not found...';
+ return response;
+ }
+
+ response.basename = await determineBasenameWithRetries(titleId, 5);
+ const storedTracks = subTracks.get(titleId) || new Map();
+ response.subtitles = np
+ .getTimedTextTrackList()
+ .filter((track: any) => storedTracks.has(track.trackId))
+ .map((track: any) => {
+ return dataForTrack(track, storedTracks);
+ });
+ return response;
+ };
+
document.addEventListener(
'asbplayer-get-synced-data',
async () => {
- const response: VideoData = { error: '', basename: '', subtitles: [] };
- const np = player();
- const titleId = np?.getMovieId();
-
- if (!np || !titleId) {
- response.error = 'Netflix Player or Title Id not found...';
- return document.dispatchEvent(
- new CustomEvent('asbplayer-synced-data', {
- detail: response,
+ const response: VideoData = await buildResponse();
+
+ document.dispatchEvent(
+ new CustomEvent('asbplayer-synced-data', {
+ detail: response,
+ })
+ );
+ },
+ false
+ );
+
+ const fetchDataForLanguage = async (e: Event) => {
+ const fail = (message?: string) => {
+ document.dispatchEvent(
+ new CustomEvent('asbplayer-synced-language-data', {
+ detail: {
+ error: message ?? 'Failed to fetch subtitles for requested language',
+ basename: '',
+ subtitles: [],
+ },
+ })
+ );
+ };
+
+ const np = player();
+
+ if (np === undefined) {
+ fail();
+ return;
+ }
+
+ const previousTrack = np.getTimedTextTrack();
+ let shouldRevert = false;
+
+ try {
+ const event = e as CustomEvent;
+ const language = event.detail as string;
+ const storedTracks = subTracks.get(np.getMovieId()) || new Map();
+ const track = np
+ .getTimedTextTrackList()
+ ?.find((track: any) => dataForTrack(track, storedTracks).language === language);
+
+ if (track === undefined) {
+ fail();
+ return;
+ }
+
+ const alreadyStoredTrack = storedTracks.get(track.trackId);
+
+ if (alreadyStoredTrack !== undefined && alreadyStoredTrack !== 'lazy') {
+ // If track is already stored (e.g. from previous request) then
+ // send response now and early-out
+ document.dispatchEvent(
+ new CustomEvent('asbplayer-synced-language-data', {
+ detail: await buildResponse(),
})
);
+ return;
}
- response.basename = await determineBasenameWithRetries(titleId, 5);
- const storedTracks = subTracks.get(titleId) || new Map();
- response.subtitles = np
- .getTimedTextTrackList()
- .filter((track: any) => storedTracks.has(track.trackId))
- .map((track: any) => {
- return {
- label: `${track.bcp47} - ${track.displayName}${
- 'CLOSEDCAPTIONS' === track.rawTrackType ? ' [CC]' : ''
- }`,
- language: track.bcp47.toLowerCase(),
- url: storedTracks.get(track.trackId),
- extension: 'nfvtt',
- };
- });
+ // Trigger tracks to be refetched by temporarily setting the text track to the desired language
+ await np.setTimedTextTrack(track);
+ shouldRevert = true;
+
+ // Wait for the track to appear
+ const succeeded = await poll(() => {
+ const t = storedTracks.get(track.trackId);
+ return t !== undefined && t !== 'lazy';
+ });
+
+ if (!succeeded) {
+ fail();
+ return;
+ }
document.dispatchEvent(
- new CustomEvent('asbplayer-synced-data', {
- detail: response,
+ new CustomEvent('asbplayer-synced-language-data', {
+ detail: await buildResponse(),
})
);
+ } catch (e) {
+ fail(e instanceof Error ? e.message : String(e));
+ } finally {
+ if (shouldRevert && previousTrack !== undefined) {
+ await np.setTimedTextTrack(previousTrack);
+ }
+ }
+ };
+
+ let currentFetchForLanguagePromise: Promise | undefined;
+
+ document.addEventListener(
+ 'asbplayer-get-synced-language-data',
+ // Fetch data for specific language, since Netflix does not provide all URLs in the initial data sync
+ async (e) => {
+ if (currentFetchForLanguagePromise === undefined) {
+ currentFetchForLanguagePromise = fetchDataForLanguage(e);
+ } else {
+ currentFetchForLanguagePromise.then(() => fetchDataForLanguage(e));
+ }
+
+ await currentFetchForLanguagePromise;
+ currentFetchForLanguagePromise = undefined;
},
false
);
diff --git a/extension/src/pages/nrk-tv-page.ts b/extension/src/pages/nrk-tv-page.ts
new file mode 100644
index 00000000..6a1f3f57
--- /dev/null
+++ b/extension/src/pages/nrk-tv-page.ts
@@ -0,0 +1,85 @@
+import { inferTracks } from './util';
+
+const originalFetch = window.fetch;
+let lastMetadataUrl: string | undefined;
+
+window.fetch = (...args) => {
+ let metadataUrl = undefined;
+
+ for (const arg of args) {
+ if (typeof arg === 'string' && arg.includes('metadata')) {
+ metadataUrl = arg;
+ }
+ if (arg instanceof Request && arg.url.includes('metadata')) {
+ metadataUrl = arg.url;
+ }
+ }
+
+ if (metadataUrl !== undefined) {
+ lastMetadataUrl = metadataUrl;
+ }
+
+ return originalFetch(...args);
+};
+
+const requestTracks = async (url: string) => {
+ const tracks = [];
+
+ try {
+ const value = await (await fetch(url)).json();
+
+ if (typeof value?.playable?.subtitles === 'object' && Array.isArray(value.playable.subtitles)) {
+ for (const track of value.playable.subtitles) {
+ if (
+ typeof track.label === 'string' &&
+ typeof track.language === 'string' &&
+ typeof track.webVtt === 'string'
+ ) {
+ tracks.push({
+ label: track.label as string,
+ language: track.language as string,
+ url: track.webVtt as string,
+ extension: 'vtt',
+ });
+ }
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ return tracks;
+};
+
+inferTracks({
+ onRequest: async (addTrack, setBasename) => {
+ if (lastMetadataUrl === undefined) {
+ return;
+ }
+
+ const manifestUrl = new URL(lastMetadataUrl);
+ const value = await (await fetch(lastMetadataUrl)).json();
+
+ if (typeof value?.preplay?.titles?.title === 'string') {
+ if (typeof value?.preplay?.titles?.subtitle === 'string') {
+ setBasename(`${value.preplay.titles.title} ${value.preplay.titles.subtitle}`);
+ } else {
+ setBasename(value.preplay.titles.title);
+ }
+ }
+
+ if (typeof value?._links?.manifests === 'object' && Array.isArray(value._links.manifests)) {
+ for (const manifest of value._links.manifests) {
+ if (typeof manifest.href === 'string') {
+ const tracks = await requestTracks(`${manifestUrl.origin}${manifest.href}`);
+
+ for (const track of tracks) {
+ addTrack(track);
+ }
+ }
+ }
+ }
+ },
+
+ waitForBasename: true,
+});
diff --git a/extension/src/pages/unext-page.ts b/extension/src/pages/unext-page.ts
index 1c36aea1..887b4e48 100644
--- a/extension/src/pages/unext-page.ts
+++ b/extension/src/pages/unext-page.ts
@@ -1,55 +1,68 @@
-import { poll } from './util';
+setTimeout(() => {
+ let baseName: string | undefined;
-document.addEventListener('asbplayer-get-synced-data', async () => {
- let basename = '';
+ const originalParse = JSON.parse;
+ JSON.parse = function () {
+ // @ts-ignore
+ const value = originalParse.apply(this, arguments);
- await poll(() => {
- const titleNodes = document.querySelectorAll('div[class^="Header__TitleContainer"]');
- const segments: string[] = [];
- const nodes: Node[] = [];
+ if (typeof value?.data?.webfront_title_stage?.titleName === 'string') {
+ baseName = value.data.webfront_title_stage.titleName;
- for (let i = 0; i < titleNodes.length; ++i) {
- nodes.push(titleNodes[i]);
- }
-
- while (nodes.length > 0) {
- const node = nodes.shift();
+ if (typeof value.data.webfront_title_stage.episode?.id === 'string') {
+ const episodeId = value.data.webfront_title_stage.episode.id;
- if (node === undefined) {
- break;
- }
+ if (
+ typeof value.data.webfront_title_titleEpisodes?.episodes === 'object' &&
+ Array.isArray(value.data.webfront_title_titleEpisodes.episodes)
+ ) {
+ for (const obj of value.data.webfront_title_titleEpisodes.episodes) {
+ if (obj.id === episodeId) {
+ if (typeof obj.displayNo === 'string') {
+ baseName = `${baseName} ${obj.displayNo}`;
+ }
- if (node.childNodes.length === 0) {
- const textContent = node.textContent;
+ if (typeof obj.episodeName === 'string') {
+ baseName = `${baseName} ${obj.episodeName}`;
+ }
- if (textContent !== null) {
- segments.push(textContent);
+ break;
+ }
+ }
}
-
- continue;
- }
-
- for (let i = 0; i < node.childNodes.length; ++i) {
- const childNode = node.childNodes[i];
- nodes.push(childNode);
}
}
- if (segments.length > 0) {
- basename = segments.join(' ');
- return true;
- }
+ return value;
+ };
+
+ document.addEventListener(
+ 'asbplayer-get-synced-data',
+ () => {
+ if (!baseName) {
+ document.dispatchEvent(
+ new CustomEvent('asbplayer-synced-data', {
+ detail: {
+ error: '',
+ basename: '',
+ subtitles: [],
+ },
+ })
+ );
+ return;
+ }
- return false;
- });
-
- document.dispatchEvent(
- new CustomEvent('asbplayer-synced-data', {
- detail: {
- error: '',
- basename: basename,
- subtitles: [],
- },
- })
+ document.dispatchEvent(
+ new CustomEvent('asbplayer-synced-data', {
+ detail: {
+ error: '',
+ basename: baseName,
+ subtitles: [],
+ },
+ })
+ );
+ baseName = undefined;
+ },
+ false
);
-});
+}, 0);
diff --git a/extension/src/pages/util.ts b/extension/src/pages/util.ts
index 064d9a48..362caa7f 100644
--- a/extension/src/pages/util.ts
+++ b/extension/src/pages/util.ts
@@ -1,4 +1,4 @@
-import { VideoDataSubtitleTrack } from '@project/common';
+import { VideoDataSubtitleTrack, VideoDataSubtitleTrackDef } from '@project/common';
export function extractExtension(url: string, fallback: string) {
const dotIndex = url.lastIndexOf('.');
@@ -45,13 +45,21 @@ type SubtitlesByPath = { [key: string]: VideoDataSubtitleTrack[] };
export interface InferHooks {
onJson?: (
value: any,
- addTrack: (track: VideoDataSubtitleTrack) => void,
+ addTrack: (track: VideoDataSubtitleTrackDef) => void,
setBasename: (basename: string) => void
) => void;
- onRequest?: (addTrack: (track: VideoDataSubtitleTrack) => void, setBasename: (basename: string) => void) => void;
+ onRequest?: (addTrack: (track: VideoDataSubtitleTrackDef) => void, setBasename: (basename: string) => void) => void;
waitForBasename: boolean;
}
+export const trackFromDef = (def: VideoDataSubtitleTrackDef) => {
+ return { id: trackId(def), ...def };
+};
+
+export const trackId = (def: VideoDataSubtitleTrackDef) => {
+ return `${def.language}:${def.label}:${def.url}:${def.m3U8BaseUrl ?? ''}`;
+};
+
export function inferTracks({ onJson, onRequest, waitForBasename }: InferHooks) {
setTimeout(() => {
const subtitlesByPath: SubtitlesByPath = {};
@@ -76,12 +84,10 @@ export function inferTracks({ onJson, onRequest, waitForBasename }: InferHooks)
subtitlesByPath[path] = [];
}
- if (
- subtitlesByPath[path].find(
- (s) => s.label === track.label && s.language === track.language
- ) === undefined
- ) {
- subtitlesByPath[path].push(track);
+ const newId = trackId(track);
+
+ if (subtitlesByPath[path].find((s) => s.id === newId) === undefined) {
+ subtitlesByPath[path].push({ id: newId, ...track });
tracksFound = true;
}
},
@@ -127,12 +133,10 @@ export function inferTracks({ onJson, onRequest, waitForBasename }: InferHooks)
subtitlesByPath[path] = [];
}
- if (
- subtitlesByPath[path].find(
- (s) => s.label === track.label && s.language === track.language
- ) === undefined
- ) {
- subtitlesByPath[path].push(track);
+ const newId = trackId(track);
+
+ if (subtitlesByPath[path].find((s) => s.id === newId) === undefined) {
+ subtitlesByPath[path].push({ id: newId, ...track });
}
},
(theBasename) => {
diff --git a/extension/src/pages/youtube-page.ts b/extension/src/pages/youtube-page.ts
index f5bfb192..5b2c5d86 100644
--- a/extension/src/pages/youtube-page.ts
+++ b/extension/src/pages/youtube-page.ts
@@ -1,4 +1,32 @@
import { VideoData } from '@project/common';
+import { trackFromDef } from './util';
+
+declare global {
+ interface Window {
+ trustedTypes?: any;
+ }
+}
+
+let trustedPolicy: any = undefined;
+
+if (window.trustedTypes !== undefined) {
+ // YouTube doesn't define a default policy
+ // we create a default policy to avoid errors that seem to be caused by chrome not supporting trustedScripts in Function sinks
+ // If YT enforce a strict default policy in the future, we may need to revisit this
+ // hopefully by then chrome will have fixed the issue: https://wpt.fyi/results/trusted-types/eval-function-constructor.html
+ // (in chrome 127 the final test was failing)
+ if (window.trustedTypes.defaultPolicy === null) {
+ window.trustedTypes.createPolicy('default', {
+ createHTML: (s: string) => s,
+ createScript: (s: string) => s,
+ createScriptURL: (s: string) => s,
+ });
+ }
+ trustedPolicy = window.trustedTypes.createPolicy('passThrough', {
+ createHTML: (s: string) => s,
+ createScript: (s: string) => s,
+ });
+}
document.addEventListener(
'asbplayer-get-synced-data',
@@ -15,7 +43,13 @@ document.addEventListener(
}
return webResponse.text();
})
- .then((pageString) => new window.DOMParser().parseFromString(pageString, 'text/html'))
+ .then((pageString) => {
+ if (trustedPolicy !== undefined) {
+ pageString = trustedPolicy.createHTML(pageString);
+ }
+
+ return new window.DOMParser().parseFromString(pageString, 'text/html');
+ })
.then((page) => {
const scriptElements = page.body.querySelectorAll('script');
@@ -23,7 +57,13 @@ document.addEventListener(
const elm = scriptElements[i];
if (elm.textContent?.includes('ytInitialPlayerResponse')) {
- const context = new Function(`${elm.textContent}; return ytInitialPlayerResponse;`)();
+ let scriptString = `${elm.textContent}; return ytInitialPlayerResponse;`;
+
+ if (trustedPolicy !== undefined) {
+ scriptString = trustedPolicy.createScript(scriptString);
+ }
+
+ const context = new Function(scriptString)();
if (context) {
return context;
@@ -41,12 +81,12 @@ document.addEventListener(
response.basename = playerContext.videoDetails?.title || document.title;
response.subtitles = (playerContext?.captions?.playerCaptionsTracklistRenderer?.captionTracks || []).map(
(track: any) => {
- return {
+ return trackFromDef({
label: `${track.languageCode} - ${track.name?.simpleText ?? track.name?.runs?.[0]?.text}`,
language: track.languageCode.toLowerCase(),
url: track.baseUrl,
extension: 'ytxml',
- };
+ });
}
);
} catch (error) {
diff --git a/extension/src/services/audio-recorder-delegate.ts b/extension/src/services/audio-recorder-delegate.ts
index 81783b94..745ffeef 100644
--- a/extension/src/services/audio-recorder-delegate.ts
+++ b/extension/src/services/audio-recorder-delegate.ts
@@ -23,6 +23,8 @@ export interface AudioRecorderDelegate {
export class DrmProtectedStreamError extends Error {}
+export class AutoRecordingInProgressError extends Error {}
+
export class OffscreenAudioRecorder implements AudioRecorderDelegate {
private audioBase64Resolve?: (value: string) => void;
@@ -131,7 +133,12 @@ export class OffscreenAudioRecorder implements AudioRecorderDelegate {
preferMp3,
},
};
- chrome.runtime.sendMessage(command);
+ const stopped = (await chrome.runtime.sendMessage(command)) as boolean;
+
+ if (stopped === false) {
+ throw new AutoRecordingInProgressError();
+ }
+
return await this._audioBase64();
}
@@ -202,7 +209,12 @@ export class CaptureStreamAudioRecorder implements AudioRecorderDelegate {
},
src,
};
- chrome.tabs.sendMessage(tabId, command);
+ const stopped = (await chrome.tabs.sendMessage(tabId, command)) as boolean;
+
+ if (stopped === false) {
+ throw new AutoRecordingInProgressError();
+ }
+
return await this._audioBase64();
}
diff --git a/extension/src/services/audio-recorder.ts b/extension/src/services/audio-recorder.ts
index a695a587..70a91cc3 100755
--- a/extension/src/services/audio-recorder.ts
+++ b/extension/src/services/audio-recorder.ts
@@ -1,10 +1,14 @@
import { bufferToBase64 } from './base64';
+export class AutoRecordingInProgressError extends Error {}
+
export default class AudioRecorder {
private recording: boolean;
private recorder: MediaRecorder | null;
private stream: MediaStream | null;
private blobPromise: Promise | null;
+ private timeoutId?: NodeJS.Timeout;
+ private timeoutResolve?: (base64: string) => void;
constructor() {
this.recording = false;
@@ -24,7 +28,9 @@ export default class AudioRecorder {
await this.start(stream);
onStartedCallback();
- setTimeout(async () => {
+ this.timeoutResolve = resolve;
+ this.timeoutId = setTimeout(async () => {
+ this.timeoutId = undefined;
resolve(await this.stop());
}, time);
} catch (e) {
@@ -78,6 +84,16 @@ export default class AudioRecorder {
this.stream = null;
const blob = await this.blobPromise;
this.blobPromise = null;
- return await bufferToBase64(await blob!.arrayBuffer());
+ const base64 = await bufferToBase64(await blob!.arrayBuffer());
+
+ if (this.timeoutId !== undefined) {
+ clearTimeout(this.timeoutId);
+ this.timeoutId = undefined;
+ this.timeoutResolve?.(base64);
+ this.timeoutResolve = undefined;
+ throw new AutoRecordingInProgressError();
+ }
+
+ return base64;
}
}
diff --git a/extension/src/services/binding.ts b/extension/src/services/binding.ts
index 56f7d6af..576643f8 100755
--- a/extension/src/services/binding.ts
+++ b/extension/src/services/binding.ts
@@ -43,7 +43,12 @@ import {
} from '@project/common';
import Mp3Encoder from '@project/common/audio-clip/mp3-encoder';
import { adjacentSubtitle } from '@project/common/key-binder';
-import { extractAnkiSettings, SettingsProvider, SubtitleListPreference } from '@project/common/settings';
+import {
+ extractAnkiSettings,
+ PauseOnHoverMode,
+ SettingsProvider,
+ SubtitleListPreference,
+} from '@project/common/settings';
import { SubtitleSlice } from '@project/common/subtitle-collection';
import { SubtitleReader } from '@project/common/subtitle-reader';
import { extractText, seekWithNudge, sourceString, surroundingSubtitlesAroundInterval } from '@project/common/util';
@@ -55,7 +60,7 @@ import { MobileVideoOverlayController } from '../controllers/mobile-video-overla
import NotificationController from '../controllers/notification-controller';
import SubtitleController, { SubtitleModelWithIndex } from '../controllers/subtitle-controller';
import VideoDataSyncController from '../controllers/video-data-sync-controller';
-import AudioRecorder from './audio-recorder';
+import AudioRecorder, { AutoRecordingInProgressError } from './audio-recorder';
import { bufferToBase64 } from './base64';
import { isMobile } from './device-detection';
import { OffsetAnchor } from './element-overlay';
@@ -70,6 +75,8 @@ document.addEventListener('asbplayer-netflix-enabled', (e) => {
});
document.dispatchEvent(new CustomEvent('asbplayer-query-netflix'));
+const youtube = /(m|www)\.youtube\.com/.test(window.location.host);
+
enum RecordingState {
requested,
started,
@@ -86,11 +93,12 @@ export default class Binding {
private _syncedTimestamp?: number;
recordingState: RecordingState = RecordingState.notRecording;
+ recordingPostMineAction?: PostMineAction;
wasPlayingBeforeRecordingMedia?: boolean;
postMinePlayback: PostMinePlayback = PostMinePlayback.remember;
private recordingMediaStartedTimestamp?: number;
private recordingMediaWithScreenshot: boolean;
-
+ private pausedDueToHover = false;
private _playMode: PlayMode = PlayMode.normal;
private _speedChangeStep = 0.1;
@@ -120,6 +128,7 @@ export default class Binding {
private fastForwardPlaybackMinimumGapMs = 600;
private fastForwardModePlaybackRate = 2.7;
private imageDelay = 0;
+ private pauseOnHoverMode: PauseOnHoverMode = PauseOnHoverMode.disabled;
recordMedia: boolean;
private playListener?: EventListener;
@@ -127,6 +136,7 @@ export default class Binding {
private seekedListener?: EventListener;
private playbackRateListener?: EventListener;
private videoChangeListener?: EventListener;
+ private mouseOverListener?: EventListener;
private listener?: (
message: any,
sender: chrome.runtime.MessageSender,
@@ -174,10 +184,6 @@ export default class Binding {
return this._synced;
}
- get url() {
- return window.location !== window.parent.location ? document.referrer : document.location.href;
- }
-
get speedChangeStep() {
return this._speedChangeStep;
}
@@ -424,6 +430,10 @@ export default class Binding {
};
chrome.runtime.sendMessage(command);
+
+ if (this.recordingMedia && this.recordingPostMineAction !== undefined) {
+ this._toggleRecordingMedia(this.recordingPostMineAction);
+ }
};
this.seekedListener = (event) => {
@@ -471,10 +481,25 @@ export default class Binding {
}
};
+ this.mouseOverListener = () => {
+ if (this.pauseOnHoverMode === PauseOnHoverMode.inAndOut && this.pausedDueToHover && this.video.paused) {
+ this.play();
+ }
+ this.pausedDueToHover = false;
+ };
+
this.video.addEventListener('play', this.playListener);
this.video.addEventListener('pause', this.pauseListener);
this.video.addEventListener('seeked', this.seekedListener);
this.video.addEventListener('ratechange', this.playbackRateListener);
+ this.video.addEventListener('mouseover', this.mouseOverListener);
+
+ this.subtitleController.onMouseOver = () => {
+ if (this.pauseOnHoverMode !== PauseOnHoverMode.disabled && !this.video.paused) {
+ this.video.pause();
+ this.pausedDueToHover = true;
+ }
+ };
if (this.subSyncAvailable) {
this.videoChangeListener = () => {
@@ -741,10 +766,18 @@ export default class Binding {
const stopRecordingAudioMessage = request.message as StopRecordingAudioMessage;
this._audioRecorder
.stop()
- .then((audioBase64) =>
- this._sendAudioBase64(audioBase64, stopRecordingAudioMessage.preferMp3)
- );
- break;
+ .then((audioBase64) => {
+ sendResponse(true);
+ this._sendAudioBase64(audioBase64, stopRecordingAudioMessage.preferMp3);
+ })
+ .catch((e) => {
+ if (e instanceof AutoRecordingInProgressError) {
+ sendResponse(false);
+ } else {
+ console.error(e);
+ }
+ });
+ return true;
case 'notification-dialog':
const notificationDialogMessage = request.message as NotificationDialogMessage;
this.notificationController.show(
@@ -788,9 +821,11 @@ export default class Binding {
this.copyToClipboardOnMine = currentSettings.copyToClipboardOnMine;
this.autoPausePreference = currentSettings.autoPausePreference;
this.alwaysPlayOnSubtitleRepeat = currentSettings.alwaysPlayOnSubtitleRepeat;
+ this.pauseOnHoverMode = currentSettings.pauseOnHoverMode;
this.subtitleController.displaySubtitles = currentSettings.streamingDisplaySubtitles;
this.subtitleController.subtitlePositionOffset = currentSettings.subtitlePositionOffset;
+ this.subtitleController.subtitlesWidth = currentSettings.subtitlesWidth;
this.subtitleController.subtitleAlignment = currentSettings.subtitleAlignment;
this.subtitleController.surroundingSubtitlesCountRadius = currentSettings.surroundingSubtitlesCountRadius;
this.subtitleController.surroundingSubtitlesTimeRadius = currentSettings.surroundingSubtitlesTimeRadius;
@@ -848,6 +883,11 @@ export default class Binding {
this.videoChangeListener = undefined;
}
+ if (this.mouseOverListener) {
+ this.video.removeEventListener('mouseover', this.mouseOverListener);
+ this.mouseOverListener = undefined;
+ }
+
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = undefined;
@@ -927,6 +967,7 @@ export default class Binding {
if (this.recordMedia) {
this.recordingState = RecordingState.requested;
+ this.recordingPostMineAction = postMineAction;
this.wasPlayingBeforeRecordingMedia = !this.video.paused;
this.recordingMediaStartedTimestamp = this.video.currentTime * 1000;
this.recordingMediaWithScreenshot = this.takeScreenshot;
@@ -947,7 +988,7 @@ export default class Binding {
surroundingSubtitles: surroundingSubtitles,
record: this.recordMedia,
screenshot: this.takeScreenshot,
- url: this.url,
+ url: this.url(subtitle.start, subtitle.end),
mediaTimestamp: this.video.currentTime * 1000,
subtitleFileName: this.subtitleFileName(subtitle.track),
postMineAction: postMineAction,
@@ -984,7 +1025,7 @@ export default class Binding {
playbackRate: this.video.playbackRate,
screenshot: this.recordingMediaWithScreenshot,
videoDuration: this.video.duration * 1000,
- url: this.url,
+ url: this.url(this.recordingMediaStartedTimestamp!, currentTimestamp),
subtitleFileName: this.subtitleFileName(),
...this._imageCaptureParams,
...this._surroundingSubtitlesAroundInterval(this.recordingMediaStartedTimestamp!, currentTimestamp),
@@ -1007,6 +1048,7 @@ export default class Binding {
this.wasPlayingBeforeRecordingMedia = !this.video.paused;
this.recordingMediaStartedTimestamp = timestamp;
this.recordingMediaWithScreenshot = this.takeScreenshot;
+ this.recordingPostMineAction = postMineAction;
if (this.video.paused) {
await this.play();
@@ -1021,7 +1063,7 @@ export default class Binding {
record: this.recordMedia,
postMineAction: postMineAction,
screenshot: this.takeScreenshot,
- url: this.url,
+ url: this.url(timestamp),
subtitleFileName: this.subtitleFileName(),
imageDelay: this.imageDelay,
...this._imageCaptureParams,
@@ -1364,4 +1406,18 @@ export default class Binding {
chrome.runtime.sendMessage(command);
}
+
+ url(start: number, end?: number) {
+ if (youtube) {
+ const toSeconds = (ms: number) => Math.floor(ms / 1000);
+ const videoId = new URLSearchParams(window.location.search).get('v');
+
+ if (videoId !== null) {
+ const embedUrl = `https://www.youtube.com/embed/${videoId}?start=${toSeconds(start)}&autoplay=1`;
+ return end === undefined ? embedUrl : `${embedUrl}&end=${toSeconds(end)}`;
+ }
+ }
+
+ return window.location !== window.parent.location ? document.referrer : document.location.href;
+ }
}
diff --git a/extension/src/services/element-overlay.ts b/extension/src/services/element-overlay.ts
index abb7cac0..f1e8f82a 100755
--- a/extension/src/services/element-overlay.ts
+++ b/extension/src/services/element-overlay.ts
@@ -1,4 +1,5 @@
import { OffscreenDomCache } from '@project/common';
+import { MouseEventHandler } from 'react';
export enum OffsetAnchor {
bottom,
@@ -18,6 +19,8 @@ export interface ElementOverlayParams {
fullscreenContentClassName: string;
offsetAnchor: OffsetAnchor;
contentPositionOffset?: number;
+ contentWidthPercentage: number;
+ onMouseOver: (event: MouseEvent) => void;
}
export interface ElementOverlay {
@@ -32,6 +35,7 @@ export interface ElementOverlay {
fullscreenContentClassName: string;
offsetAnchor: OffsetAnchor;
contentPositionOffset: number;
+ contentWidthPercentage: number;
}
export class CachingElementOverlay implements ElementOverlay {
@@ -48,6 +52,7 @@ export class CachingElementOverlay implements ElementOverlay {
private fullscreenElementFullscreenChangeListener?: (this: any, event: Event) => any;
private fullscreenElementFullscreenPollingInterval?: NodeJS.Timer;
private fullscreenStylesInterval?: NodeJS.Timer;
+ private onMouseOver: (event: MouseEvent) => void;
nonFullscreenContainerClassName: string;
nonFullscreenContentClassName: string;
@@ -55,6 +60,7 @@ export class CachingElementOverlay implements ElementOverlay {
fullscreenContentClassName: string;
offsetAnchor: OffsetAnchor = OffsetAnchor.bottom;
contentPositionOffset: number;
+ contentWidthPercentage: number;
constructor({
targetElement,
@@ -64,6 +70,8 @@ export class CachingElementOverlay implements ElementOverlay {
fullscreenContentClassName,
offsetAnchor,
contentPositionOffset,
+ contentWidthPercentage,
+ onMouseOver,
}: ElementOverlayParams) {
this.targetElement = targetElement;
this.nonFullscreenContainerClassName = nonFullscreenContainerClassName;
@@ -72,6 +80,8 @@ export class CachingElementOverlay implements ElementOverlay {
this.fullscreenContentClassName = fullscreenContentClassName;
this.offsetAnchor = offsetAnchor;
this.contentPositionOffset = contentPositionOffset ?? 75;
+ this.contentWidthPercentage = contentWidthPercentage;
+ this.onMouseOver = onMouseOver;
}
uncacheHtml() {
@@ -121,6 +131,7 @@ export class CachingElementOverlay implements ElementOverlay {
const container = document.createElement('div');
container.className = this.nonFullscreenContainerClassName;
+ container.onmouseover = this.onMouseOver;
this._applyContainerStyles(container);
document.body.appendChild(container);
@@ -152,6 +163,7 @@ export class CachingElementOverlay implements ElementOverlay {
const container = document.createElement('div');
container.className = this.fullscreenContainerClassName;
+ container.onmouseover = this.onMouseOver;
this._applyContainerStyles(container);
this._findFullscreenParentElement(container).appendChild(container);
container.style.display = 'none';
@@ -313,11 +325,20 @@ export class CachingElementOverlay implements ElementOverlay {
private _applyContainerStyles(container: HTMLElement) {
const rect = this.targetElement.getBoundingClientRect();
container.style.left = rect.left + rect.width / 2 + 'px';
- container.style.maxWidth = rect.width + 'px';
+
+ if (this.contentWidthPercentage === -1) {
+ container.style.maxWidth = rect.width + 'px';
+ container.style.width = '';
+ } else {
+ container.style.maxWidth = '';
+ container.style.width =
+ Math.min(window.innerWidth, (rect.width * this.contentWidthPercentage) / 100) + 'px';
+ }
+
const clampedY = Math.max(rect.top + window.scrollY, 0);
if (this.offsetAnchor === OffsetAnchor.bottom) {
- const clampedHeight = Math.min(clampedY + rect.height, window.innerHeight);
+ const clampedHeight = Math.min(clampedY + rect.height, window.innerHeight + window.scrollY);
container.style.top = clampedHeight - this.contentPositionOffset + 'px';
container.style.bottom = '';
} else {
@@ -354,6 +375,7 @@ export class DefaultElementOverlay implements ElementOverlay {
private fullscreenElementFullscreenChangeListener?: (this: any, event: Event) => any;
private fullscreenElementFullscreenPollingInterval?: NodeJS.Timer;
private fullscreenStylesInterval?: NodeJS.Timer;
+ private onMouseOver: (event: MouseEvent) => void;
nonFullscreenContainerClassName: string;
nonFullscreenContentClassName: string;
@@ -361,6 +383,7 @@ export class DefaultElementOverlay implements ElementOverlay {
fullscreenContentClassName: string;
contentPositionOffset: number;
offsetAnchor: OffsetAnchor = OffsetAnchor.bottom;
+ contentWidthPercentage: number;
constructor({
targetElement,
@@ -370,6 +393,8 @@ export class DefaultElementOverlay implements ElementOverlay {
fullscreenContentClassName,
offsetAnchor,
contentPositionOffset,
+ contentWidthPercentage,
+ onMouseOver,
}: ElementOverlayParams) {
this.targetElement = targetElement;
this.nonFullscreenContainerClassName = nonFullscreenContainerClassName;
@@ -378,6 +403,8 @@ export class DefaultElementOverlay implements ElementOverlay {
this.fullscreenContentClassName = fullscreenContentClassName;
this.offsetAnchor = offsetAnchor;
this.contentPositionOffset = contentPositionOffset ?? 75;
+ this.contentWidthPercentage = contentWidthPercentage;
+ this.onMouseOver = onMouseOver;
}
uncacheHtml(): void {}
@@ -456,6 +483,7 @@ export class DefaultElementOverlay implements ElementOverlay {
const container = document.createElement('div');
container.appendChild(div);
container.className = this.nonFullscreenContainerClassName;
+ container.onmouseover = this.onMouseOver;
div.className = this.nonFullscreenContentClassName;
this._applyContainerStyles(container);
document.body.appendChild(container);
@@ -482,11 +510,20 @@ export class DefaultElementOverlay implements ElementOverlay {
private _applyContainerStyles(container: HTMLElement) {
const rect = this.targetElement.getBoundingClientRect();
container.style.left = rect.left + rect.width / 2 + 'px';
- container.style.maxWidth = rect.width + 'px';
+
+ if (this.contentWidthPercentage === -1) {
+ container.style.maxWidth = rect.width + 'px';
+ container.style.width = '';
+ } else {
+ container.style.maxWidth = '';
+ container.style.width =
+ Math.min(window.innerWidth, (rect.width * this.contentWidthPercentage) / 100) + 'px';
+ }
+
const clampedY = Math.max(rect.top + window.scrollY, 0);
if (this.offsetAnchor === OffsetAnchor.bottom) {
- const clampedHeight = Math.min(clampedY + rect.height, window.innerHeight);
+ const clampedHeight = Math.min(clampedY + rect.height, window.innerHeight + window.scrollY);
container.style.top = clampedHeight - this.contentPositionOffset + 'px';
container.style.bottom = '';
} else {
@@ -504,6 +541,7 @@ export class DefaultElementOverlay implements ElementOverlay {
const container = document.createElement('div');
container.appendChild(div);
container.className = this.fullscreenContainerClassName;
+ container.onmouseover = this.onMouseOver;
div.className = this.fullscreenContentClassName;
this._applyContainerStyles(container);
this._findFullscreenParentElement(container).appendChild(container);
diff --git a/extension/src/services/extension-config.ts b/extension/src/services/extension-config.ts
index d7e48dac..eb9ef6f9 100644
--- a/extension/src/services/extension-config.ts
+++ b/extension/src/services/extension-config.ts
@@ -20,7 +20,7 @@ const settings = new SettingsProvider(new ExtensionSettingsStorage());
// As of this writing, session storage is not accessible to content scripts on Firefox so we use local storage instead.
// Since unlike session storage, local storage is not automatically cleared, we clear it manually once a day.
-const storage = isFirefoxBuild ? chrome.storage.session : chrome.storage.local;
+const storage = isFirefoxBuild ? chrome.storage.local : chrome.storage.session;
const firefoxTtl = 3600 * 24 * 1000; // 1 day
export const fetchExtensionConfig = async (noCache = false): Promise => {
diff --git a/extension/src/services/pages.ts b/extension/src/services/pages.ts
index 5ed2fe47..0838f54c 100644
--- a/extension/src/services/pages.ts
+++ b/extension/src/services/pages.ts
@@ -1,19 +1,39 @@
import pagesConfig from '../pages.json';
interface PageConfig {
+ // Regex for URLs where script should be loaded
host: string;
+
+ // Script to load
script?: string;
+
+ // URL relative path regex where subtitle track data syncing is allowed
path?: string;
+
+ // URL hash segment regex where subtitle track data syncing is allowed
hash?: string;
+
+ // Whether shadow roots should be searched for video elements on this page
searchShadowRoots?: boolean;
+
+ // Whether video elements with blank src should be bindable on this page
allowBlankSrc?: boolean;
+
autoSync?: {
+ // Whether to attempt to load detected subtitles automatically
enabled: boolean;
+
+ // Video src string regex for video elemennts that should be considered for auto-syync
videoSrc?: string;
+
+ // Video element ID regex for video elements that should be considered for auto-sync
elementId?: string;
};
+
ignore?: {
+ // CSS classes that should cause video elements to be ignored for binding
class?: string;
+ // Styles that should cause video elements to be ignored for binding
style?: { [key: string]: string };
};
}
diff --git a/extension/src/ui/components/HoldableIconButton.tsx b/extension/src/ui/components/HoldableIconButton.tsx
index ed738c8e..0bdb5cc2 100644
--- a/extension/src/ui/components/HoldableIconButton.tsx
+++ b/extension/src/ui/components/HoldableIconButton.tsx
@@ -1,4 +1,4 @@
-import { IconButton, IconButtonProps } from '@material-ui/core';
+import IconButton, { IconButtonProps } from '@material-ui/core/IconButton';
import React, { useEffect, useCallback, useState } from 'react';
import { isMobile } from 'react-device-detect';
diff --git a/extension/src/ui/components/MobileVideoOverlay.tsx b/extension/src/ui/components/MobileVideoOverlay.tsx
index 5c7472ad..ba47fcd9 100644
--- a/extension/src/ui/components/MobileVideoOverlay.tsx
+++ b/extension/src/ui/components/MobileVideoOverlay.tsx
@@ -7,7 +7,7 @@ import NavigateBeforeIcon from '@material-ui/icons/NavigateBefore';
import NavigateNextIcon from '@material-ui/icons/NavigateNext';
import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord';
import SubtitlesIcon from '@material-ui/icons/Subtitles';
-import { useCallback, useRef } from 'react';
+import React, { useCallback, useRef } from 'react';
import {
AsbPlayerToVideoCommandV2,
CopySubtitleMessage,
@@ -27,7 +27,7 @@ import makeStyles from '@material-ui/core/styles/makeStyles';
import withStyles from '@material-ui/core/styles/withStyles';
import { useI18n } from '../hooks/use-i18n';
import { useTranslation } from 'react-i18next';
-import MuiTooltip from '@material-ui/core/Tooltip';
+import MuiTooltip, { TooltipProps } from '@material-ui/core/Tooltip';
import LogoIcon from '@project/common/components/LogoIcon';
import CloseIcon from '@material-ui/icons/Close';
import HoldableIconButton from './HoldableIconButton';
@@ -49,13 +49,23 @@ const useStyles = makeStyles({
borderRadius: 16,
},
});
+const params = new URLSearchParams(location.search);
+const anchor = params.get('anchor') as 'top' | 'bottom';
+const tooltipsEnabled = params.get('tooltips') === 'true';
-const anchor = new URLSearchParams(location.search).get('anchor') as 'top' | 'bottom';
-const Tooltip =
+const DisabledTooltip = ({ children }: { children: React.ReactNode } & TooltipProps) => {
+ return children;
+};
+
+let Tooltip =
anchor === 'bottom'
? withStyles({ tooltipPlacementBottom: { marginTop: 0, marginBottom: 16 } })(MuiTooltip)
: withStyles({ tooltipPlacementTop: { marginTop: 16, marginBottom: 0 } })(MuiTooltip);
+if (!tooltipsEnabled) {
+ Tooltip = DisabledTooltip;
+}
+
const GridContainer = ({ children, ...props }: { children: React.ReactNode } & GridProps) => {
return (
diff --git a/extension/src/ui/components/Popup.tsx b/extension/src/ui/components/Popup.tsx
index 62a094f8..edbb8736 100644
--- a/extension/src/ui/components/Popup.tsx
+++ b/extension/src/ui/components/Popup.tsx
@@ -116,6 +116,8 @@ const Popup = ({
extensionSupportsSidePanel={!isFirefoxBuild}
extensionSupportsOrderableAnkiFields
extensionSupportsTrackSpecificSettings
+ extensionSupportsSubtitlesWidthSetting
+ extensionSupportsPauseOnHover
forceVerticalTabs={false}
anki={anki}
chromeKeyBinds={chromeCommandBindsToKeyBinds(commands)}
diff --git a/extension/src/ui/components/SettingsUi.tsx b/extension/src/ui/components/SettingsUi.tsx
index bbabeb0b..7d14d8f6 100644
--- a/extension/src/ui/components/SettingsUi.tsx
+++ b/extension/src/ui/components/SettingsUi.tsx
@@ -92,6 +92,8 @@ const SettingsUi = () => {
extensionSupportsSidePanel={!isFirefoxBuild}
extensionSupportsOrderableAnkiFields
extensionSupportsTrackSpecificSettings
+ extensionSupportsSubtitlesWidthSetting
+ extensionSupportsPauseOnHover
chromeKeyBinds={commands}
onOpenChromeExtensionShortcuts={handleOpenExtensionShortcuts}
onSettingsChanged={onSettingsChanged}
diff --git a/extension/src/ui/components/SidePanel.tsx b/extension/src/ui/components/SidePanel.tsx
index 62dde95d..0f5d1ea8 100644
--- a/extension/src/ui/components/SidePanel.tsx
+++ b/extension/src/ui/components/SidePanel.tsx
@@ -198,6 +198,13 @@ export default function SidePanel({ settings, extension }: Props) {
});
}, [extension]);
+ useEffect(() => {
+ return keyBinder.bindToggleSidePanel(
+ () => window.close(),
+ () => false
+ );
+ }, [keyBinder]);
+
const handleError = useCallback(
(message: any) => {
console.error(message);
@@ -286,7 +293,7 @@ export default function SidePanel({ settings, extension }: Props) {
[showTopControls]
);
- const { copyHistoryItems, refreshCopyHistory, deleteCopyHistoryItem } = useCopyHistory(
+ const { copyHistoryItems, refreshCopyHistory, deleteCopyHistoryItem, deleteAllCopyHistoryItems } = useCopyHistory(
settings.miningHistoryStorageLimit
);
useEffect(() => {
@@ -450,6 +457,7 @@ export default function SidePanel({ settings, extension }: Props) {
forceShowDownloadOptions={true}
onClose={handleCloseCopyHistory}
onDelete={deleteCopyHistoryItem}
+ onDeleteAll={deleteAllCopyHistoryItems}
onAnki={handleAnki}
onClipAudio={handleClipAudio}
onDownloadImage={handleDownloadImage}
@@ -462,6 +470,7 @@ export default function SidePanel({ settings, extension }: Props) {
items={copyHistoryItems}
onClose={handleCloseCopyHistory}
onDelete={deleteCopyHistoryItem}
+ onDeleteAll={deleteAllCopyHistoryItems}
onAnki={handleAnki}
onClipAudio={handleClipAudio}
onDownloadImage={handleDownloadImage}
diff --git a/extension/src/ui/components/VideoDataSyncDialog.tsx b/extension/src/ui/components/VideoDataSyncDialog.tsx
index a6dfed84..4d1ec092 100644
--- a/extension/src/ui/components/VideoDataSyncDialog.tsx
+++ b/extension/src/ui/components/VideoDataSyncDialog.tsx
@@ -15,7 +15,7 @@ import makeStyles from '@material-ui/styles/makeStyles';
import Switch from '@material-ui/core/Switch';
import LabelWithHoverEffect from '@project/common/components/LabelWithHoverEffect';
import { ConfirmedVideoDataSubtitleTrack, VideoDataSubtitleTrack } from '@project/common';
-import React, { useEffect, useState } from 'react';
+import React, { useRef, useEffect, useState, FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
const createClasses = makeStyles((theme) => ({
@@ -51,8 +51,8 @@ interface Props {
isLoading: boolean;
suggestedName: string;
showSubSelect: boolean;
- subtitles: VideoDataSubtitleTrack[];
- selectedSubtitle: string[];
+ subtitleTracks: VideoDataSubtitleTrack[];
+ selectedSubtitleTrackIds: string[];
defaultCheckboxState: boolean;
error: string;
openedFromMiningCommand: boolean;
@@ -67,8 +67,8 @@ export default function VideoDataSyncDialog({
isLoading,
suggestedName,
showSubSelect,
- subtitles,
- selectedSubtitle,
+ subtitleTracks,
+ selectedSubtitleTrackIds,
defaultCheckboxState,
error,
openedFromMiningCommand,
@@ -82,18 +82,20 @@ export default function VideoDataSyncDialog({
const [shouldRememberTrackChoices, setShouldRememberTrackChoices] = React.useState(false);
const trimmedName = name.trim();
const classes = createClasses();
+ const [isDropdownActive, setIsDropdownActive] = useState(false);
+ const okButtonRef = useRef(null);
useEffect(() => {
if (open) {
setSelectedSubtitles(
- selectedSubtitle.map((url) => {
- return url !== undefined ? url : '-';
+ selectedSubtitleTrackIds.map((id) => {
+ return id !== undefined ? id : '-';
})
);
} else if (!open) {
setName('');
}
- }, [open, selectedSubtitle]);
+ }, [open, selectedSubtitleTrackIds]);
useEffect(() => {
if (open) {
@@ -103,7 +105,7 @@ export default function VideoDataSyncDialog({
useEffect(() => {
setName((name) => {
- if (!subtitles) {
+ if (!subtitleTracks) {
// Unable to calculate the video name
return name;
}
@@ -114,9 +116,9 @@ export default function VideoDataSyncDialog({
if (
!name ||
name === suggestedName ||
- subtitles.find((track) => track.url !== '-' && name === calculateName(suggestedName, track.label))
+ subtitleTracks.find((track) => track.url !== '-' && name === calculateName(suggestedName, track.label))
) {
- const selectedTrack = subtitles.find((track) => track.url === selectedSubtitles[0])!;
+ const selectedTrack = subtitleTracks.find((track) => track.id === selectedSubtitles[0])!;
if (selectedTrack.url === '-') {
return suggestedName;
@@ -128,9 +130,25 @@ export default function VideoDataSyncDialog({
// Otherwise, let the name be whatever the user set it to
return name;
});
- }, [suggestedName, selectedSubtitles, subtitles]);
+ }, [suggestedName, selectedSubtitles, subtitleTracks]);
- function handleOkButtonClick() {
+ useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ if (!isDropdownActive && okButtonRef.current && (event.key === 'Enter' || event.key === 'NumpadEnter')) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ okButtonRef.current.click();
+ }
+ }
+ if (open) {
+ document.addEventListener('keydown', handleKeyDown);
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }
+ }, [open, isDropdownActive]);
+
+ function handleSubmit() {
const selectedSubtitleTracks: ConfirmedVideoDataSubtitleTrack[] = allSelectedSubtitleTracks();
onConfirm(selectedSubtitleTracks, shouldRememberTrackChoices);
}
@@ -142,15 +160,12 @@ export default function VideoDataSyncDialog({
function allSelectedSubtitleTracks() {
const selectedSubtitleTracks: ConfirmedVideoDataSubtitleTrack[] = selectedSubtitles
.map((selected): ConfirmedVideoDataSubtitleTrack | undefined => {
- const subtitle = subtitles.find((subtitle) => subtitle.url === selected);
+ const subtitle = subtitleTracks.find((subtitle) => subtitle.id === selected);
if (subtitle) {
- const { language, extension, m3U8BaseUrl } = subtitle;
+ const { language } = subtitle;
return {
name: suggestedName.trim() + language.trim(),
- extension: extension,
- subtitleUrl: selected,
- language: language,
- m3U8BaseUrl: m3U8BaseUrl,
+ ...subtitle,
};
}
})
@@ -186,9 +201,11 @@ export default function VideoDataSyncDialog({
return newSelectedSubtitles;
})
}
+ onFocus={() => setIsDropdownActive(true)}
+ onBlur={() => setIsDropdownActive(false)}
>
- {subtitles.map((subtitle) => (
-
+ {subtitleTracks.map((subtitle) => (
+
{subtitle.label}
))}
@@ -264,7 +281,7 @@ export default function VideoDataSyncDialog({
onOpenFile()}>
{t('action.openFiles')}
-
+
{t('action.ok')}
diff --git a/extension/src/ui/components/VideoDataSyncUi.tsx b/extension/src/ui/components/VideoDataSyncUi.tsx
index 4771f971..af98b732 100644
--- a/extension/src/ui/components/VideoDataSyncUi.tsx
+++ b/extension/src/ui/components/VideoDataSyncUi.tsx
@@ -30,9 +30,9 @@ export default function VideoDataSyncUi({ bridge }: Props) {
const [suggestedName, setSuggestedName] = useState('');
const [showSubSelect, setShowSubSelect] = useState(true);
const [subtitles, setSubtitles] = useState([
- { language: '', url: '-', label: t('extension.videoDataSync.emptySubtitleTrack'), extension: 'srt' },
+ { id: '-', language: '-', url: '-', label: t('extension.videoDataSync.emptySubtitleTrack'), extension: 'srt' },
]);
- const [selectedSubtitle, setSelectedSubtitle] = useState(['-', '-', '-']);
+ const [selectedSubtitleTrackIds, setSelectedSubtitleTrackIds] = useState(['-', '-', '-']);
const [defaultCheckboxState, setDefaultCheckboxState] = useState(false);
const [openedFromMiningCommand, setOpenedFromMiningCommand] = useState(false);
const [error, setError] = useState('');
@@ -80,7 +80,8 @@ export default function VideoDataSyncUi({ bridge }: Props) {
if (Object.prototype.hasOwnProperty.call(state, 'subtitles')) {
setSubtitles([
{
- language: '',
+ id: '-',
+ language: '-',
url: '-',
label: t('extension.videoDataSync.emptySubtitleTrack'),
extension: 'srt',
@@ -90,7 +91,7 @@ export default function VideoDataSyncUi({ bridge }: Props) {
}
if (Object.prototype.hasOwnProperty.call(state, 'selectedSubtitle')) {
- setSelectedSubtitle(state.selectedSubtitle);
+ setSelectedSubtitleTrackIds(state.selectedSubtitle);
}
if (Object.prototype.hasOwnProperty.call(state, 'defaultCheckboxState')) {
@@ -151,8 +152,8 @@ export default function VideoDataSyncUi({ bridge }: Props) {
isLoading={isLoading}
suggestedName={suggestedName}
showSubSelect={showSubSelect}
- subtitles={subtitles}
- selectedSubtitle={selectedSubtitle}
+ subtitleTracks={subtitles}
+ selectedSubtitleTrackIds={selectedSubtitleTrackIds}
defaultCheckboxState={defaultCheckboxState}
openedFromMiningCommand={openedFromMiningCommand}
error={error}
diff --git a/extension/src/video.css b/extension/src/video.css
index 2d8e2559..1d2becb2 100755
--- a/extension/src/video.css
+++ b/extension/src/video.css
@@ -3,7 +3,7 @@
left: 50%;
transform: translate(-50%, -100%);
display: inline-block;
- z-index: 2147483647;
+ z-index: 2147483646;
}
.asbplayer-subtitles-container-top {
@@ -11,7 +11,7 @@
left: 50%;
transform: translate(-50%);
display: inline-block;
- z-index: 2147483647;
+ z-index: 2147483646;
}
.asbplayer-subtitles {
@@ -39,7 +39,7 @@
left: 50%;
transform: translate(-50%, -100%);
display: inline-block;
- z-index: 2147483647 !important;
+ z-index: 2147483646 !important;
}
.asbplayer-notification-container-top {
@@ -47,7 +47,7 @@
left: 50%;
transform: translate(-50%);
display: inline-block;
- z-index: 2147483647 !important;
+ z-index: 2147483646 !important;
}
.asbplayer-notification {