Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement chromecast support #186

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
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
8 changes: 8 additions & 0 deletions client/src/javascript/actions/TorrentActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,14 @@ const TorrentActions = {
}api/torrents/${hash}/contents/${indices.join(',')}/data?token=${res.data}`,
),

getTorrentContentsSubtitlePermalink: (hash: TorrentProperties['hash'], index: number) =>
axios
.get(`${ConfigStore.baseURI}api/torrents/${hash}/contents/${index}/token`)
.then(
(res) =>
`${window.location.protocol}//${window.location.host}${ConfigStore.baseURI}api/torrents/${hash}/contents/${index}/subtitles?token=${res.data}`,
),

moveTorrents: (options: MoveTorrentsOptions) =>
axios
.post(`${baseURI}api/torrents/move`, options)
Expand Down
25 changes: 14 additions & 11 deletions client/src/javascript/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {Route, Switch} from 'react-router';
import {Router} from 'react-router-dom';
import {useMedia} from 'react-use';
import {QueryParamProvider} from 'use-query-params';
import {CastProvider} from 'react-cast-sender';

import AuthActions from './actions/AuthActions';
import AppWrapper from './components/AppWrapper';
Expand Down Expand Up @@ -82,17 +83,19 @@ const FloodApp: FC = observer(() => {
return (
<Suspense fallback={<LoadingOverlay />}>
<AsyncIntlProvider>
<Router history={history}>
<QueryParamProvider ReactRouterRoute={Route}>
<AppWrapper className={ConfigStore.isPreferDark ? 'dark' : undefined}>
<Switch>
<Route path="/login" component={Login} />
<Route path="/overview" component={Overview} />
<Route path="/register" component={Register} />
</Switch>
</AppWrapper>
</QueryParamProvider>
</Router>
<CastProvider receiverApplicationId="CC1AD845">
<Router history={history}>
<QueryParamProvider ReactRouterRoute={Route}>
<AppWrapper className={ConfigStore.isPreferDark ? 'dark' : undefined}>
<Switch>
<Route path="/login" component={Login} />
<Route path="/overview" component={Overview} />
<Route path="/register" component={Register} />
</Switch>
</AppWrapper>
</QueryParamProvider>
</Router>
</CastProvider>
</AsyncIntlProvider>
</Suspense>
);
Expand Down
3 changes: 3 additions & 0 deletions client/src/javascript/components/modals/Modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {observer} from 'mobx-react';
import {useKeyPressEvent} from 'react-use';

import AddTorrentsModal from './add-torrents-modal/AddTorrentsModal';
import ChromecastModal from './chromecast-modal/ChromecastModal';
import ConfirmModal from './confirm-modal/ConfirmModal';
import FeedsModal from './feeds-modal/FeedsModal';
import GenerateMagnetModal from './generate-magnet-modal/GenerateMagnetModal';
Expand All @@ -22,6 +23,8 @@ const createModal = (id: Modal['id']): React.ReactNode => {
switch (id) {
case 'add-torrents':
return <AddTorrentsModal />;
case 'chromecast':
return <ChromecastModal />;
case 'confirm':
return <ConfirmModal />;
case 'feeds':
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import {FC, useEffect, useState} from 'react';
import {useLingui} from '@lingui/react';
import {CastButton, useCast, useCastPlayer} from 'react-cast-sender';
import classNames from 'classnames';

import type {TorrentContent} from '@shared/types/TorrentContent';

import {Form, FormRow, Select, SelectItem, FormRowItem} from '../../../ui';
import ProgressBar from '../../general/ProgressBar';
import Tooltip from '../../general/Tooltip';
import {Start, Stop, Pause} from '../../../ui/icons';
import Modal from '../Modal';
import TorrentActions from '../../../actions/TorrentActions';
import UIStore from '../../../stores/UIStore';
import {getChromecastContentType, isFileChromecastable, isFileSubtitles} from '../../../util/chromecastUtil';

type Subtitles = number | 'none';

const GenerateMagnetModal: FC = () => {
const {i18n} = useLingui();

const {connected, initialized} = useCast();
const {loadMedia, currentTime, duration, isPaused, isMediaLoaded, togglePlay} = useCastPlayer();

const [contents, setContents] = useState<TorrentContent[]>([]);
const [selectedFileIndex, setSelectedFileIndex] = useState<number>(0);
const [selectedSubtitles, setSelectedSubtitles] = useState<Subtitles>('none');

useEffect(() => {
if (UIStore.activeModal?.id === 'chromecast') {
TorrentActions.fetchTorrentContents(UIStore.activeModal?.hash).then((fetchedContents) => {
if (fetchedContents != null) {
setContents(fetchedContents);
}
});
}
}, []);

if (UIStore.activeModal?.id !== 'chromecast') {
return null;
}

if (!initialized)
return (
<Modal
heading={i18n._('chromecast.modal.title')}
content={<div className="modal__content inverse">{i18n._('chromecast.modal.notSupported')}</div>}
actions={[
{
clickHandler: null,
content: i18n._('button.close'),
triggerDismiss: true,
type: 'tertiary',
},
]}
/>
);

const hash = UIStore.activeModal?.hash;
const mediaFiles = contents.filter((file) => isFileChromecastable(file.filename));
const selectedFileName = (contents[selectedFileIndex]?.filename || '').replace(/\.\w+$/, '');
const subtitleSources: Subtitles[] = [
'none',
...contents
.filter((file) => file.filename.startsWith(selectedFileName) && isFileSubtitles(file.filename))
.map((file) => file.index),
];

const beginCasting = async () => {
if (!connected) return;

const {filename} = contents[selectedFileIndex];
const contentType = getChromecastContentType(filename);
if (!contentType) return;

const mediaInfo = new window.chrome.cast.media.MediaInfo(
await TorrentActions.getTorrentContentsDataPermalink(hash, [selectedFileIndex]),
contentType,
);

const metadata = new chrome.cast.media.GenericMediaMetadata();
metadata.title = contents[selectedFileIndex].filename;

mediaInfo.metadata = metadata;

const request = new window.chrome.cast.media.LoadRequest(mediaInfo);
if (selectedSubtitles !== 'none') {
mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle();
mediaInfo.textTrackStyle.backgroundColor = '#00000000';
mediaInfo.textTrackStyle.edgeColor = '#000000FF';
mediaInfo.textTrackStyle.edgeType = chrome.cast.media.TextTrackEdgeType.DROP_SHADOW;
mediaInfo.textTrackStyle.fontFamily = 'SANS_SERIF';
mediaInfo.textTrackStyle.fontScale = 1.0;
mediaInfo.textTrackStyle.foregroundColor = '#FFFFFF';

const track = new chrome.cast.media.Track(0, chrome.cast.media.TrackType.TEXT);
track.name = 'Text';
track.subtype = chrome.cast.media.TextTrackType.CAPTIONS;
track.trackContentId = await TorrentActions.getTorrentContentsSubtitlePermalink(hash, selectedSubtitles);
track.trackContentType = 'text/vtt';

mediaInfo.tracks = [track];
request.activeTrackIds = [0];
}

loadMedia(request);
};

const stopCasting = () => {
const castSession = window.cast.framework.CastContext.getInstance().getCurrentSession();
if (!castSession) return;

const media = castSession.getMediaSession();
if (!media) return;

media.stop(
new chrome.cast.media.StopRequest(),
() => {},
() => {},
);
};

return (
<Modal
heading={i18n._('chromecast.modal.title')}
content={
<div className="modal__content inverse">
<Form>
<FormRow>
<Select
id="fileIndex"
label={i18n._('chromecast.modal.file')}
onSelect={(fileIndex) => {
setSelectedFileIndex(Number(fileIndex));
setSelectedSubtitles('none');
}}>
{mediaFiles.map((file, i) => (
<SelectItem key={file.index} id={i}>
{file.filename}
</SelectItem>
))}
</Select>
</FormRow>
<FormRow>
<Select
id="subtitleSource"
label={i18n._('chromecast.modal.subtitle')}
onSelect={(id) => {
if (id === 'none') setSelectedSubtitles('none');
else setSelectedSubtitles(Number(id));
}}>
{subtitleSources.map((source) => (
<SelectItem key={source} id={`${source}`}>
{source === 'none' ? i18n._('chromecast.modal.subtitle.none') : contents[source].filename}
</SelectItem>
))}
</Select>
</FormRow>
<FormRow align="center">
<FormRowItem width="one-sixteenth">
<CastButton />
</FormRowItem>

<FormRowItem width="one-sixteenth">
<Tooltip
content={i18n._(isMediaLoaded ? 'chromecast.modal.stop' : 'chromecast.modal.start')}
onClick={isMediaLoaded ? stopCasting : beginCasting}
suppress={!connected}
interactive={connected}
position="bottom"
wrapperClassName={classNames('modal__action', 'modal__icon-button', 'tooltip__wrapper', {
'modal__icon-button--interactive': connected,
})}>
{isMediaLoaded ? <Stop /> : <Start />}
</Tooltip>
</FormRowItem>

{isMediaLoaded && (
<FormRowItem width="one-sixteenth">
<Tooltip
content={i18n._(isPaused ? 'chromecast.modal.play' : 'chromecast.modal.pause')}
onClick={togglePlay}
suppress={!connected}
interactive={connected}
position="bottom"
wrapperClassName={classNames('modal__action', 'modal__icon-button', 'tooltip__wrapper', {
'modal__icon-button--interactive': connected,
})}>
{isPaused ? <Start /> : <Pause />}
</Tooltip>
</FormRowItem>
)}

<FormRowItem width="seven-eighths">
<ProgressBar percent={isMediaLoaded ? (100 * currentTime) / duration : 0} />
</FormRowItem>
</FormRow>
</Form>
</div>
}
actions={[
{
clickHandler: null,
content: i18n._('button.close'),
triggerDismiss: true,
type: 'tertiary',
},
]}
/>
);
};

export default GenerateMagnetModal;
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,17 @@ const getContextMenuItems = (torrent: TorrentProperties): Array<ContextMenuItem>
document.body.removeChild(link);
},
},
{
type: 'action',
action: 'chromecast',
label: TorrentContextMenuActions.chromecast,
clickHandler: () => {
UIActions.displayModal({
id: 'chromecast',
hash: getLastSelectedTorrent(),
});
},
},
{
type: 'action',
action: 'generateMagnet',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const TorrentContextMenuActions = {
torrentDetails: 'torrents.list.context.details',
downloadContents: 'torrents.list.context.download.contents',
downloadMetainfo: 'torrents.list.context.download.metainfo',
chromecast: 'torrents.list.context.chromecast',
generateMagnet: 'torrents.list.context.generate.magnet',
setInitialSeeding: 'torrents.list.context.initial.seeding',
setSequential: 'torrents.list.context.sequential',
Expand Down
11 changes: 11 additions & 0 deletions client/src/javascript/i18n/strings/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@
"button.save.feed": "Save",
"button.state.adding": "Adding...",
"button.yes": "Yes",
"chromecast.modal.title": "Chromecast",
"chromecast.modal.file": "Media file",
"chromecast.modal.subtitle": "Subtitle source",
"chromecast.modal.subtitle.none": "None",
"chromecast.modal.start": "Start",
"chromecast.modal.stop": "Stop",
"chromecast.modal.play": "Play",
"chromecast.modal.pause": "Pause",
"chromecast.modal.not.supported": "Chromecasting is not supported on this browser",
"connection-interruption.action.selection.retry": "Retry with current client connection settings",
"connection-interruption.action.selection.config": "Update client connection settings",
"connection-interruption.action.selection.retry": "Retry with current client connection settings",
"connection-interruption.heading": "Cannot connect to the client",
Expand Down Expand Up @@ -318,6 +328,7 @@
"torrents.list.context.check.hash": "Check Hash",
"torrents.list.context.details": "Torrent Details",
"torrents.list.context.download.contents": "Download Contents",
"torrents.list.context.chromecast": "Chromecast",
"torrents.list.context.download.metainfo": "Download .torrent",
"torrents.list.context.generate.magnet": "Generate Magnet Link",
"torrents.list.context.initial.seeding": "Initial Seeding",
Expand Down
2 changes: 1 addition & 1 deletion client/src/javascript/stores/UIStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export type Modal =
actions: Array<ModalAction>;
}
| {
id: 'torrent-details';
id: 'chromecast' | 'torrent-details';
hash: string;
};

Expand Down
3 changes: 2 additions & 1 deletion client/src/javascript/ui/components/FormRowItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export interface FormRowItemProps {
| 'one-half'
| 'five-eighths'
| 'three-quarters'
| 'seven-eighths';
| 'seven-eighths'
| 'one-sixteenth';
}

const FormRowItem = forwardRef<HTMLDivElement, FormRowItemProps>(
Expand Down
18 changes: 18 additions & 0 deletions client/src/javascript/ui/icons/Pause.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import classnames from 'classnames';
import {FC, memo} from 'react';

interface StartProps {
className?: string;
}

const Start: FC<StartProps> = memo(({className}: StartProps) => (
<svg className={classnames('icon', 'icon--start', className)} viewBox="0 0 64 64">
<path d="M10 12 L24 12 L24 52 L10 52 M52 12 L38 12 L38 52 L52 52" />
</svg>
));

Start.defaultProps = {
className: undefined,
};

export default Start;
1 change: 1 addition & 0 deletions client/src/javascript/ui/icons/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export {default as Lock} from './Lock';
export {default as Logout} from './Logout';
export {default as Menu} from './Menu';
export {default as Notification} from './Notification';
export {default as Pause} from './Pause';
export {default as Peers} from './Peers';
export {default as Radar} from './Radar';
export {default as RadioDot} from './RadioDot';
Expand Down
Loading