diff --git a/README.md b/README.md index 7fa814e2..6dfb30f7 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,9 @@ - Localization—[see info on contributing](./packages/metastream-app/src/locale#contributing). #### Are you a website owner? + Easily add watch party support to your website by redirecting the user to Metastream. + ```html Watch in Metastream ``` @@ -41,10 +43,10 @@ Easily add watch party support to your website by redirecting the user to Metast - [x] Add localization ([#5](https://github.com/samuelmaddock/metastream/issues/5)) - [x] Improve networking reliability ([#74](https://github.com/samuelmaddock/metastream/issues/74)) - [x] Port Metastream from Electron to a web app ([#94](https://github.com/samuelmaddock/metastream/issues/94)) -- [ ] Improve UX and stability -- [ ] Add favorites/bookmarks ([#21](https://github.com/samuelmaddock/metastream/issues/21)) +- [x] Improve UX and stability +- [x] Add favorites/bookmarks ([#21](https://github.com/samuelmaddock/metastream/issues/21)) - [ ] Add playlists -- [ ] Add audio mode ([#22](https://github.com/samuelmaddock/metastream/issues/22)) +- [x] Add audio mode ([#22](https://github.com/samuelmaddock/metastream/issues/22)) Have a feature in mind? Make a request by [creating a GitHub issue](https://github.com/samuelmaddock/metastream/issues). diff --git a/packages/metastream-app/src/components/lobby/FavoritesList.tsx b/packages/metastream-app/src/components/lobby/FavoritesList.tsx new file mode 100644 index 00000000..c781a034 --- /dev/null +++ b/packages/metastream-app/src/components/lobby/FavoritesList.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { connect } from 'react-redux' +import { ListOverlay } from './ListOverlay' +import { IMediaItem } from '../../lobby/reducers/mediaPlayer' +import { IAppState } from '../../reducers' +import { removeFavorite } from '../../reducers/favorites' +import { MediaItem } from '../media/MediaItem' +import MenuItem from '@material-ui/core/MenuItem' +import { WithNamespaces, withNamespaces } from 'react-i18next' + +interface ConnectedProps { + favorites: IMediaItem[] +} + +interface DispatchProps { + removeFavorite(id: string): void +} + +type Props = ConnectedProps & DispatchProps & WithNamespaces + +const _FavoritesList: React.SFC = ({ favorites, t, removeFavorite }) => { + return ( + ( + { + removeFavorite(item.id) + close() + }} + > + {t('remove')} + + )} + > + {favorites.map((media) => ( + {}} /> + ))} + + ) +} + +export const FavoritesList = withNamespaces()( + connect( + (state) => ({ favorites: state.favorites.items }), + (dispatch) => ({ removeFavorite: (id) => dispatch(removeFavorite(id)) }), + )(_FavoritesList), +) diff --git a/packages/metastream-app/src/components/lobby/MediaList.tsx b/packages/metastream-app/src/components/lobby/MediaList.tsx index 3bd5b1ed..2b7d6414 100644 --- a/packages/metastream-app/src/components/lobby/MediaList.tsx +++ b/packages/metastream-app/src/components/lobby/MediaList.tsx @@ -7,12 +7,12 @@ import { IMediaItem } from '../../lobby/reducers/mediaPlayer' import { getCurrentMedia, getMediaQueue, - hasPlaybackPermissions + hasPlaybackPermissions, } from '../../lobby/reducers/mediaPlayer.helpers' import { server_requestDeleteMedia, server_requestMoveToTop, - server_requestToggleQueueLock + server_requestToggleQueueLock, } from '../../lobby/actions/mediaPlayer' import { IconButton } from '../common/button' @@ -25,6 +25,7 @@ import { copyMediaLink, openMediaInBrowser } from '../../media/utils' import { withNamespaces, WithNamespaces } from 'react-i18next' import { sendMediaRequest } from 'lobby/actions/media-request' import { setSetting } from 'actions/settings' +import { addFavorite } from 'reducers/favorites' interface IProps { className?: string @@ -47,6 +48,7 @@ interface DispatchProps { deleteMedia(mediaId: string): void toggleQueueLock(): void toggleCollapsed(): void + addFavorite(media: IMediaItem): void } type Props = IProps & IConnectedProps & DispatchProps & WithNamespaces @@ -79,12 +81,12 @@ class _MediaList extends Component { let items = [ { label: t('openInBrowser'), - onClick: () => openMediaInBrowser(media) + onClick: () => openMediaInBrowser(media), }, { label: t('copyLink'), - onClick: () => copyMediaLink(media) - } + onClick: () => copyMediaLink(media), + }, ] if (media.description) { @@ -92,8 +94,8 @@ class _MediaList extends Component { ...items, { label: t('info'), - onClick: () => this.props.onShowInfo(media) - } + onClick: () => this.props.onShowInfo(media), + }, ] } @@ -102,16 +104,20 @@ class _MediaList extends Component { ...items, { label: t('moveToTop'), - onClick: () => this.props.moveToTop(media.id) + onClick: () => this.props.moveToTop(media.id), + }, + { + label: t('addFavorite'), + onClick: () => this.props.addFavorite(media), }, { label: t('duplicate'), - onClick: () => this.props.sendMediaRequest(media.requestUrl) + onClick: () => this.props.sendMediaRequest(media.requestUrl), }, { label: t('remove'), - onClick: () => this.props.deleteMedia(media.id) - } + onClick: () => this.props.deleteMedia(media.id), + }, ] } @@ -131,12 +137,12 @@ class _MediaList extends Component { > {this.props.collapsible && this.props.collapsed ? null - : this.mediaList.map(media => { + : this.mediaList.map((media) => { return ( { + onClickMenu={(e) => { this.listOverlay!.onSelect(e, media) }} /> @@ -186,17 +192,19 @@ export const MediaList = withNamespaces()( currentMedia: getCurrentMedia(state), mediaQueue: getMediaQueue(state), mediaQueueLocked: state.mediaPlayer.queueLocked, - collapsed: !!state.settings.mediaListCollapsed + collapsed: !!state.settings.mediaListCollapsed, }), (dispatch): DispatchProps => ({ moveToTop(mediaId) { dispatch(server_requestMoveToTop(mediaId) as any) }, sendMediaRequest(url) { - dispatch(sendMediaRequest({ - url, - source: 'media-context-menu-duplicate' - }) as any) + dispatch( + sendMediaRequest({ + url, + source: 'media-context-menu-duplicate', + }) as any, + ) }, deleteMedia(mediaId: string) { dispatch(server_requestDeleteMedia(mediaId) as any) @@ -205,8 +213,11 @@ export const MediaList = withNamespaces()( dispatch(server_requestToggleQueueLock() as any) }, toggleCollapsed() { - dispatch(setSetting('mediaListCollapsed', collapsed => !collapsed)) - } - }) - )(_MediaList) + dispatch(setSetting('mediaListCollapsed', (collapsed) => !collapsed)) + }, + addFavorite(media) { + dispatch(addFavorite(media)) + }, + }), + )(_MediaList), ) diff --git a/packages/metastream-app/src/components/lobby/VideoPlayer.css b/packages/metastream-app/src/components/lobby/VideoPlayer.css index 69cf8873..de5d7be0 100644 --- a/packages/metastream-app/src/components/lobby/VideoPlayer.css +++ b/packages/metastream-app/src/components/lobby/VideoPlayer.css @@ -24,6 +24,10 @@ background: #000; } +.audioOnly { + visibility: hidden; +} + .interactTrigger { composes: absolute-full from '~styles/layout.css'; z-index: 2; diff --git a/packages/metastream-app/src/components/lobby/VideoPlayer.tsx b/packages/metastream-app/src/components/lobby/VideoPlayer.tsx index dcc0fe05..b835f5ea 100644 --- a/packages/metastream-app/src/components/lobby/VideoPlayer.tsx +++ b/packages/metastream-app/src/components/lobby/VideoPlayer.tsx @@ -9,7 +9,7 @@ import { updatePlaybackTimer, server_requestSeek, server_requestPlayPause, - server_requestSetPlaybackRate + server_requestSetPlaybackRate, } from 'lobby/actions/mediaPlayer' import { clamp } from 'utils/math' import { MEDIA_REFERRER, MEDIA_SESSION_USER_AGENT } from 'constants/http' @@ -89,7 +89,7 @@ const mapStateToProps = (state: IAppState): IConnectedProps => { isExtensionInstalled: state.ui.isExtensionInstalled, playerSettings: getPlayerSettings(state), safeBrowseEnabled: state.settings.safeBrowse, - popupPlayer: state.ui.popupPlayer + popupPlayer: state.ui.popupPlayer, } } @@ -279,7 +279,7 @@ class _VideoPlayer extends PureComponent { this.webview.dispatchRemoteEvent( 'metastream-host-event', { type, payload }, - { allFrames: true } + { allFrames: true }, ) } } @@ -337,7 +337,7 @@ class _VideoPlayer extends PureComponent { } }, 200, - { leading: true, trailing: true } + { leading: true, trailing: true }, ) private onMediaSeek = throttle( @@ -350,7 +350,7 @@ class _VideoPlayer extends PureComponent { } }, 500, - { leading: true, trailing: true } + { leading: true, trailing: true }, ) private onMediaVolumeChange = debounce((volume: number) => { @@ -365,7 +365,7 @@ class _VideoPlayer extends PureComponent { this.props.dispatch(server_requestSetPlaybackRate(playbackRate)) }, 200, - { leading: true, trailing: true } + { leading: true, trailing: true }, ) private onMediaReady = (isTopSubFrame: boolean = false, payload?: MediaReadyPayload) => { @@ -400,7 +400,7 @@ class _VideoPlayer extends PureComponent { const isLiveMedia = prevDuration === 0 const noDuration = !prevDuration - const isLongerDuration = nextDuration && (prevDuration && nextDuration > prevDuration) + const isLongerDuration = nextDuration && prevDuration && nextDuration > prevDuration if (nextDuration && !isLiveMedia && (noDuration || isLongerDuration)) { this.props.dispatch(updateMedia({ duration: nextDuration })) @@ -547,7 +547,8 @@ class _VideoPlayer extends PureComponent { className={cx(styles.video, { [styles.interactive]: this.state.interacting, [styles.playing]: !!this.props.current, - [styles.mediaReady]: this.state.mediaReady + [styles.mediaReady]: this.state.mediaReady, + [styles.audioOnly]: this.props.playerSettings.audioMode, })} allowScripts popup={this.shouldRenderPopup} @@ -606,7 +607,7 @@ class _VideoPlayer extends PureComponent { if (this.webview) { this.webview.loadURL(this.mediaUrl, { httpReferrer: this.httpReferrer, - userAgent: MEDIA_SESSION_USER_AGENT + userAgent: MEDIA_SESSION_USER_AGENT, }) } } diff --git a/packages/metastream-app/src/components/settings/sections/Appearance.tsx b/packages/metastream-app/src/components/settings/sections/Appearance.tsx index 7c934794..5ee1ab42 100644 --- a/packages/metastream-app/src/components/settings/sections/Appearance.tsx +++ b/packages/metastream-app/src/components/settings/sections/Appearance.tsx @@ -43,6 +43,14 @@ export default class AppearanceSettings extends Component { onChange={checked => setSetting('theaterMode', checked)} /> + setSetting('audioMode', checked)} + /> + = ({ className, popup }) => { }} collapsible /> + } /> diff --git a/packages/metastream-app/src/constants/storage.ts b/packages/metastream-app/src/constants/storage.ts index d62c4271..df41c3c6 100644 --- a/packages/metastream-app/src/constants/storage.ts +++ b/packages/metastream-app/src/constants/storage.ts @@ -3,5 +3,6 @@ export const enum StorageKey { AutoplayNotice = 'autoplayNotice', HasInteracted = 'hasInteracted', Login = 'login', - TipsDismissed = 'tipsDismissed' + TipsDismissed = 'tipsDismissed', + Favorites = 'favorites', } diff --git a/packages/metastream-app/src/locale/en-US.ts b/packages/metastream-app/src/locale/en-US.ts index ea50b235..cfad770c 100644 --- a/packages/metastream-app/src/locale/en-US.ts +++ b/packages/metastream-app/src/locale/en-US.ts @@ -45,6 +45,7 @@ export default { donate: 'Donate', donators: 'Donators', duplicate: 'Duplicate', + addFavorite: 'Add to favorites', embedBlocked: 'To enable playback with <1>{{host}}, Metastream must open the website in a popup.', endSessionTitle: 'End Session?', @@ -134,12 +135,15 @@ export default { theaterMode: 'Theater mode', theaterModeDesc: 'Hide all non-video content on the webpage. Note that this might also hide soft subtitles.', + audioMode: 'Audio mode', + audioModeDesc: 'Hide video and play audio only.', thirdPartyIntegrations: 'Third-party Integrations', toggleDJ: 'Toggle DJ', uiDockToRight: 'Dock to right side', uiUndock: 'Undock into floating overlays', unlimited: 'Unlimited', unlockQueue: 'Unlock queue', + favorites: 'Favorites', updateAvailable: 'An update for Metastream is available. Press the UPDATE button in the top-right to receive the update.', username: 'Username', @@ -153,5 +157,4 @@ export default { welcome: 'Welcome', welcomeToMetastream: 'Welcome to Metastream', welcomeMessage1: 'Hi, thanks for trying out Metastream!', - } diff --git a/packages/metastream-app/src/reducers/favorites.ts b/packages/metastream-app/src/reducers/favorites.ts new file mode 100644 index 00000000..b99bb29c --- /dev/null +++ b/packages/metastream-app/src/reducers/favorites.ts @@ -0,0 +1,32 @@ +import { Reducer } from 'redux' +import { createAction } from '@reduxjs/toolkit' +import { IMediaItem } from '../lobby/reducers/mediaPlayer' + +export interface IFavoritesState { + items: IMediaItem[] +} + +export const addFavorite = createAction('favorites/add') +export const removeFavorite = createAction('favorites/remove') + +const initialState: IFavoritesState = { + items: [], +} + +export const favorites: Reducer = ( + state: IFavoritesState = initialState, + action: any, +) => { + if (addFavorite.match(action)) { + // Avoid duplicates by URL + if (!state.items.find((item) => item.url === action.payload.url)) { + return { ...state, items: [...state.items, action.payload] } + } + } else if (removeFavorite.match(action)) { + return { + ...state, + items: state.items.filter((item) => item.id !== action.payload), + } + } + return state +} diff --git a/packages/metastream-app/src/reducers/index.ts b/packages/metastream-app/src/reducers/index.ts index eac9f599..dc432b05 100644 --- a/packages/metastream-app/src/reducers/index.ts +++ b/packages/metastream-app/src/reducers/index.ts @@ -4,6 +4,7 @@ import { merge } from 'lodash-es' import { settings, ISettingsState } from './settings' import { ui, IUIState } from './ui' +import { favorites, IFavoritesState } from './favorites' import { ILobbyNetState, lobbyReducers } from '../lobby/reducers' import { AnyAction } from 'redux' @@ -18,6 +19,7 @@ import { History } from 'history' export interface IAppState extends ILobbyNetState { settings: ISettingsState + favorites: IFavoritesState ui: IUIState router: RouterState } @@ -33,6 +35,7 @@ export const createReducer = (history: History) => { router: connectRouter(history), ...lobbyReducers, settings, + favorites, ui }) diff --git a/packages/metastream-app/src/reducers/settings.ts b/packages/metastream-app/src/reducers/settings.ts index 28d39411..16bc7510 100644 --- a/packages/metastream-app/src/reducers/settings.ts +++ b/packages/metastream-app/src/reducers/settings.ts @@ -8,7 +8,7 @@ import { COLOR_LEN, DEFAULT_COLOR, DEFAULT_USERNAME, - USERNAME_MIN_LEN + USERNAME_MIN_LEN, } from 'constants/settings' import { IAppState } from '.' import { stripEmoji } from 'utils/string' @@ -25,7 +25,7 @@ export const enum SessionMode { Offline = 1, /** Permission to join is requested upon connection. */ - Private = 2 + Private = 2, } export interface ISettingsState { @@ -44,6 +44,7 @@ export interface ISettingsState { mediaListCollapsed?: boolean autoFullscreen: boolean theaterMode: boolean + audioMode: boolean safeBrowse: boolean } @@ -57,12 +58,13 @@ const initialState: ISettingsState = { chatTimestamp: false, autoFullscreen: true, theaterMode: false, - safeBrowse: true + audioMode: false, + safeBrowse: true, } export const settings: Reducer = ( state: ISettingsState = initialState, - action: any + action: any, ) => { if (isType(action, setSetting as any)) { const { key, value } = action.payload as { key: keyof ISettingsState; value: any } @@ -75,18 +77,18 @@ export const settings: Reducer = ( return { ...state, mute: false, - volume: clamp(action.payload, 0, 1) + volume: clamp(action.payload, 0, 1), } } else if (isType(action, addVolume)) { return { ...state, mute: false, - volume: clamp(state.volume + action.payload, 0, 1) + volume: clamp(state.volume + action.payload, 0, 1), } } else if (isType(action, setMute)) { return { ...state, - mute: action.payload + mute: action.payload, } } @@ -118,6 +120,7 @@ export const getLocalAvatar = (state: IAppState) => { export interface PlayerSettings { autoFullscreen: boolean theaterMode: boolean + audioMode: boolean // UNUSED mediaSessionProxy?: boolean @@ -128,6 +131,7 @@ export interface PlayerSettings { /** Gets a subset of settings to pass to player extension */ export const getPlayerSettings = createStructuredSelector({ - autoFullscreen: state => state.settings.autoFullscreen, - theaterMode: state => state.settings.theaterMode + autoFullscreen: (state) => state.settings.autoFullscreen, + theaterMode: (state) => state.settings.theaterMode, + audioMode: (state) => state.settings.audioMode, }) diff --git a/packages/metastream-app/src/store/persistStore.ts b/packages/metastream-app/src/store/persistStore.ts index 9b0b884d..b5e4d45a 100644 --- a/packages/metastream-app/src/store/persistStore.ts +++ b/packages/metastream-app/src/store/persistStore.ts @@ -3,7 +3,7 @@ import storage from 'redux-persist/lib/storage' import autoMergeLevel2 from 'redux-persist/es/stateReconciler/autoMergeLevel2' import { createMigrate, PersistedState } from 'redux-persist' -const whitelist: (keyof IAppState)[] = ['mediaPlayer', 'settings'] +const whitelist: (keyof IAppState)[] = ['mediaPlayer', 'settings', 'favorites'] const migrations: { [version: number]: (state: any) => any } = { 2: function removeDefaultAvatarMigration(state) { @@ -12,10 +12,10 @@ const migrations: { [version: number]: (state: any) => any } = { ...state, settings: { ...state.settings, - avatar: avatar === 'asset:default.svg' ? undefined : avatar - } + avatar: avatar === 'asset:default.svg' ? undefined : avatar, + }, } - } + }, } export default { @@ -24,5 +24,5 @@ export default { whitelist, stateReconciler: autoMergeLevel2, migrate: createMigrate(migrations, { debug: process.env.NODE_ENV === 'development' }), - version: 2 + version: 2, }