Skip to content

Commit 5ac55f6

Browse files
authored
Merge pull request #789 from Stremio/feat/player-local-subtitles
Player: Allow to drop local subtitles
2 parents 8a1b040 + 02d3c42 commit 5ac55f6

File tree

14 files changed

+240
-26
lines changed

14 files changed

+240
-26
lines changed

package-lock.json

+4-5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"@stremio/stremio-colors": "5.2.0",
1919
"@stremio/stremio-core-web": "0.48.5",
2020
"@stremio/stremio-icons": "5.4.1",
21-
"@stremio/stremio-video": "0.0.52",
21+
"@stremio/stremio-video": "0.0.53",
2222
"a-color-picker": "1.2.1",
2323
"bowser": "2.11.0",
2424
"buffer": "6.0.3",

src/App/App.js

+12-10
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const { useTranslation } = require('react-i18next');
66
const { Router } = require('stremio-router');
77
const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services');
88
const { NotFound } = require('stremio/routes');
9-
const { PlatformProvider, ToastProvider, TooltipProvider, CONSTANTS, withCoreSuspender } = require('stremio/common');
9+
const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, CONSTANTS, withCoreSuspender } = require('stremio/common');
1010
const ServicesToaster = require('./ServicesToaster');
1111
const DeepLinkHandler = require('./DeepLinkHandler');
1212
const SearchParamsHandler = require('./SearchParamsHandler');
@@ -166,15 +166,17 @@ const App = () => {
166166
<PlatformProvider>
167167
<ToastProvider className={styles['toasts-container']}>
168168
<TooltipProvider className={styles['tooltip-container']}>
169-
<ServicesToaster />
170-
<DeepLinkHandler />
171-
<SearchParamsHandler />
172-
<UpdaterBanner className={styles['updater-banner-container']} />
173-
<RouterWithProtectedRoutes
174-
className={styles['router']}
175-
viewsConfig={routerViewsConfig}
176-
onPathNotMatch={onPathNotMatch}
177-
/>
169+
<FileDropProvider className={styles['file-drop-container']}>
170+
<ServicesToaster />
171+
<DeepLinkHandler />
172+
<SearchParamsHandler />
173+
<UpdaterBanner className={styles['updater-banner-container']} />
174+
<RouterWithProtectedRoutes
175+
className={styles['router']}
176+
viewsConfig={routerViewsConfig}
177+
onPathNotMatch={onPathNotMatch}
178+
/>
179+
</FileDropProvider>
178180
</TooltipProvider>
179181
</ToastProvider>
180182
</PlatformProvider>

src/App/styles.less

+13-1
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,21 @@ html {
202202
background-color: var(--modal-background-color);
203203
box-shadow: var(--outer-glow);
204204
transition: opacity 0.1s ease-out;
205+
}
206+
207+
.file-drop-container {
208+
position: fixed;
209+
top: 0;
210+
bottom: 0;
211+
left: 0;
212+
right: 0;
213+
border-radius: 1rem;
214+
border: 0.5rem dashed transparent;
215+
pointer-events: none;
216+
transition: border-color 0.25s ease-out;
205217

206218
&:global(.active) {
207-
transition-delay: 0.25s;
219+
border-color: var(--primary-accent-color);
208220
}
209221
}
210222

src/common/CONSTANTS.js

+12
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ const ICON_FOR_TYPE = new Map([
4141
['other', 'movies'],
4242
]);
4343

44+
const MIME_SIGNATURES = {
45+
'application/x-subrip': ['310D0A', '310A'],
46+
'text/vtt': ['574542565454'],
47+
};
48+
49+
const SUPPORTED_LOCAL_SUBTITLES = [
50+
'application/x-subrip',
51+
'text/vtt',
52+
];
53+
4454
const EXTERNAL_PLAYERS = [
4555
{
4656
label: 'EXTERNAL_PLAYER_DISABLED',
@@ -113,6 +123,8 @@ module.exports = {
113123
WRITERS_LINK_CATEGORY,
114124
TYPE_PRIORITIES,
115125
ICON_FOR_TYPE,
126+
MIME_SIGNATURES,
127+
SUPPORTED_LOCAL_SUBTITLES,
116128
EXTERNAL_PLAYERS,
117129
WHITELISTED_HOSTS,
118130
};

src/common/FileDrop/FileDrop.tsx

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
2+
import classNames from 'classnames';
3+
import { isFileType } from './utils';
4+
5+
export type FileType = string;
6+
export type FileDropListener = (filename: string, buffer: ArrayBuffer) => void;
7+
8+
type FileDropContext = {
9+
on: (type: FileType, listener: FileDropListener) => void,
10+
off: (type: FileType, listener: FileDropListener) => void,
11+
};
12+
13+
const FileDropContext = createContext({} as FileDropContext);
14+
15+
type Props = {
16+
className: string,
17+
children: JSX.Element,
18+
};
19+
20+
const FileDropProvider = ({ className, children }: Props) => {
21+
const [listeners, setListeners] = useState<[FileType, FileDropListener][]>([]);
22+
const [active, setActive] = useState(false);
23+
24+
const onDragOver = (event: DragEvent) => {
25+
event.preventDefault();
26+
setActive(true);
27+
};
28+
29+
const onDragLeave = () => {
30+
setActive(false);
31+
};
32+
33+
const onDrop = useCallback((event: DragEvent) => {
34+
event.preventDefault();
35+
const { dataTransfer } = event;
36+
37+
if (dataTransfer && dataTransfer?.files.length > 0) {
38+
const file = dataTransfer.files[0];
39+
40+
file
41+
.arrayBuffer()
42+
.then((buffer) => {
43+
listeners
44+
.filter(([type]) => file.type ? type === file.type : isFileType(buffer, type))
45+
.forEach(([, listerner]) => listerner(file.name, buffer));
46+
});
47+
}
48+
49+
setActive(false);
50+
}, [listeners]);
51+
52+
const on = (type: FileType, listener: FileDropListener) => {
53+
setListeners((listeners) => {
54+
return [...listeners, [type, listener]];
55+
});
56+
};
57+
58+
const off = (type: FileType, listener: FileDropListener) => {
59+
setListeners((listeners) => {
60+
return listeners.filter(([key, value]) => key !== type && value !== listener);
61+
});
62+
};
63+
64+
useEffect(() => {
65+
window.addEventListener('dragover', onDragOver);
66+
window.addEventListener('dragleave', onDragLeave);
67+
window.addEventListener('drop', onDrop);
68+
69+
return () => {
70+
window.removeEventListener('dragover', onDragOver);
71+
window.removeEventListener('dragleave', onDragLeave);
72+
window.removeEventListener('drop', onDrop);
73+
};
74+
}, [onDrop]);
75+
76+
return (
77+
<FileDropContext.Provider value={{ on, off }}>
78+
{ children }
79+
<div className={classNames(className, { 'active': active })} />
80+
</FileDropContext.Provider>
81+
);
82+
};
83+
84+
const useFileDrop = () => {
85+
return useContext(FileDropContext);
86+
};
87+
88+
export {
89+
FileDropProvider,
90+
useFileDrop,
91+
};

src/common/FileDrop/index.ts

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { FileDropProvider, useFileDrop } from './FileDrop';
2+
import onFileDrop from './onFileDrop';
3+
4+
export {
5+
FileDropProvider,
6+
useFileDrop,
7+
onFileDrop,
8+
};

src/common/FileDrop/onFileDrop.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { useEffect } from 'react';
2+
import { type FileType, type FileDropListener, useFileDrop } from './FileDrop';
3+
4+
const onFileDrop = (types: FileType[], listener: FileDropListener) => {
5+
const { on, off } = useFileDrop();
6+
7+
useEffect(() => {
8+
types.forEach((type) => on(type, listener));
9+
10+
return () => types.forEach((type) => off(type, listener));
11+
}, []);
12+
};
13+
14+
export default onFileDrop;

src/common/FileDrop/utils.ts

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { MIME_SIGNATURES } from 'stremio/common/CONSTANTS';
2+
3+
const SIGNATURES = MIME_SIGNATURES as Record<string, string[]>;
4+
5+
const isFileType = (buffer: ArrayBuffer, type: string) => {
6+
const signatures = SIGNATURES[type];
7+
8+
return signatures.some((signature) => {
9+
const array = new Uint8Array(buffer);
10+
const signatureBuffer = Buffer.from(signature, 'hex');
11+
const bufferToCompare = array.subarray(0, signatureBuffer.length);
12+
13+
return Buffer.compare(signatureBuffer, bufferToCompare) === 0;
14+
});
15+
};
16+
17+
export {
18+
isFileType,
19+
};

src/common/index.js

+3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright (C) 2017-2023 Smart code 203358507
22

3+
const { FileDropProvider, onFileDrop } = require('./FileDrop');
34
const { PlatformProvider, usePlatform } = require('./Platform');
45
const { ToastProvider, useToast } = require('./Toast');
56
const { TooltipProvider, Tooltip } = require('./Tooltips');
@@ -25,6 +26,8 @@ const useTorrent = require('./useTorrent');
2526
const useTranslate = require('./useTranslate');
2627

2728
module.exports = {
29+
FileDropProvider,
30+
onFileDrop,
2831
PlatformProvider,
2932
usePlatform,
3033
ToastProvider,

src/routes/Player/Player.js

+19-6
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const langs = require('langs');
88
const { useTranslation } = require('react-i18next');
99
const { useRouteFocused } = require('stremio-router');
1010
const { useServices } = require('stremio/services');
11-
const { useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender } = require('stremio/common');
11+
const { onFileDrop, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS } = require('stremio/common');
1212
const { HorizontalNavBar, Transition } = require('stremio/components');
1313
const BufferingLoader = require('./BufferingLoader');
1414
const VolumeChangeIndicator = require('./VolumeChangeIndicator');
@@ -133,11 +133,20 @@ const Player = ({ urlParams, queryParams }) => {
133133
toast.show({
134134
type: 'success',
135135
title: t('PLAYER_SUBTITLES_LOADED'),
136-
message: track.exclusive ? t('PLAYER_SUBTITLES_LOADED_EXCLUSIVE') : t('PLAYER_SUBTITLES_LOADED_ORIGIN', { origin: track.origin }),
136+
message:
137+
track.exclusive ? t('PLAYER_SUBTITLES_LOADED_EXCLUSIVE') :
138+
track.local ? t('PLAYER_SUBTITLES_LOADED_LOCAL') :
139+
t('PLAYER_SUBTITLES_LOADED_ORIGIN', { origin: track.origin }),
137140
timeout: 3000
138141
});
139142
}, []);
140143

144+
const onExtraSubtitlesTrackAdded = React.useCallback((track) => {
145+
if (track.local) {
146+
video.setExtraSubtitlesTrack(track.id);
147+
}
148+
}, []);
149+
141150
const onPlayRequested = React.useCallback(() => {
142151
video.setProp('paused', false);
143152
setSeeking(false);
@@ -172,13 +181,11 @@ const Player = ({ urlParams, queryParams }) => {
172181
}, []);
173182

174183
const onSubtitlesTrackSelected = React.useCallback((id) => {
175-
video.setProp('selectedSubtitlesTrackId', id);
176-
video.setProp('selectedExtraSubtitlesTrackId', null);
184+
video.setSubtitlesTrack(id);
177185
}, []);
178186

179187
const onExtraSubtitlesTrackSelected = React.useCallback((id) => {
180-
video.setProp('selectedSubtitlesTrackId', null);
181-
video.setProp('selectedExtraSubtitlesTrackId', id);
188+
video.setExtraSubtitlesTrack(id);
182189
}, []);
183190

184191
const onAudioTrackSelected = React.useCallback((id) => {
@@ -270,6 +277,10 @@ const Player = ({ urlParams, queryParams }) => {
270277
event.nativeEvent.immersePrevented = true;
271278
}, []);
272279

280+
onFileDrop(CONSTANTS.SUPPORTED_LOCAL_SUBTITLES, async (filename, buffer) => {
281+
video.addLocalSubtitles(filename, buffer);
282+
});
283+
273284
React.useEffect(() => {
274285
setError(null);
275286
video.unload();
@@ -586,13 +597,15 @@ const Player = ({ urlParams, queryParams }) => {
586597
video.events.on('ended', onEnded);
587598
video.events.on('subtitlesTrackLoaded', onSubtitlesTrackLoaded);
588599
video.events.on('extraSubtitlesTrackLoaded', onExtraSubtitlesTrackLoaded);
600+
video.events.on('extraSubtitlesTrackAdded', onExtraSubtitlesTrackAdded);
589601
video.events.on('implementationChanged', onImplementationChanged);
590602

591603
return () => {
592604
video.events.off('error', onError);
593605
video.events.off('ended', onEnded);
594606
video.events.off('subtitlesTrackLoaded', onSubtitlesTrackLoaded);
595607
video.events.off('extraSubtitlesTrackLoaded', onExtraSubtitlesTrackLoaded);
608+
video.events.off('extraSubtitlesTrackAdded', onExtraSubtitlesTrackAdded);
596609
video.events.off('implementationChanged', onImplementationChanged);
597610
};
598611
}, []);

src/routes/Player/SubtitlesMenu/SubtitlesMenu.js

+9-3
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ const styles = require('./styles');
1010
const { t } = require('i18next');
1111

1212
const ORIGIN_PRIORITIES = {
13+
'LOCAL': 3,
1314
'EMBEDDED': 2,
14-
'EXCLUSIVE': 1
15+
'EXCLUSIVE': 1,
1516
};
1617
const LANGUAGE_PRIORITIES = {
17-
'eng': 1
18+
'local': 2,
19+
'eng': 1,
1820
};
1921

2022
const SubtitlesMenu = React.memo((props) => {
@@ -161,7 +163,11 @@ const SubtitlesMenu = React.memo((props) => {
161163
</Button>
162164
{subtitlesLanguages.map((lang, index) => (
163165
<Button key={index} title={languages.label(lang)} className={classnames(styles['language-option'], { 'selected': selectedSubtitlesLanguage === lang })} data-lang={lang} onClick={subtitlesLanguageOnClick}>
164-
<div className={styles['language-label']}>{languages.label(lang)}</div>
166+
<div className={styles['language-label']}>
167+
{
168+
lang === 'local' ? t('LOCAL') : languages.label(lang)
169+
}
170+
</div>
165171
{
166172
selectedSubtitlesLanguage === lang ?
167173
<div className={styles['icon']} />

0 commit comments

Comments
 (0)