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 (
+ (
+
+ )}
+ >
+ {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}}1>, 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,
}