Revamp the pop-up player modal

This commit is contained in:
Lei Nelissen
2022-05-10 23:52:58 +02:00
parent 37ead0ec98
commit b21766a352
21 changed files with 295 additions and 131 deletions

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Text } from 'react-native';
import styled from 'styled-components/native';
const Button = styled.View`
@@ -7,7 +8,9 @@ const Button = styled.View`
function Casting() {
return (
<Button />
<Button>
<Text>Local Playback</Text>
</Button>
);
}

View File

@@ -1,24 +1,57 @@
import { Text } from 'components/Typography';
import { THEME_COLOR } from 'CONSTANTS';
import React from 'react';
import AirPlayButton from 'react-native-airplay-button';
import styled from 'styled-components/native';
import { CastingProps } from './Casting';
import React, { useCallback } from 'react';
import { showRoutePicker, useAirplayRoutes } from 'react-airplay';
import { TouchableOpacity } from 'react-native';
import styled, { css } from 'styled-components/native';
import AirplayAudioIcon from 'assets/icons/airplay-audio.svg';
import useDefaultStyles from 'components/Colors';
const Button = styled.View`
margin: 20px 40px;
const Container = styled.View<{ active?: boolean }>`
display: flex;
flex-direction: row;
align-items: center;
flex: 1 1 auto;
${(props) => props.active && css`
padding: 8px;
margin: -8px 0;
border-radius: 8px;
`}
`;
function Casting({ fill }: CastingProps) {
const Label = styled(Text)<{ active?: boolean }>`
margin-left: 8px;
opacity: 0.5;
font-size: 13px;
${(props) => props.active && css`
color: ${THEME_COLOR};
opacity: 1;
`}
`;
function Casting() {
const defaultStyles = useDefaultStyles();
const routes = useAirplayRoutes();
const handleClick = useCallback(() => showRoutePicker({}), []);
return (
<>
<Button>
<AirPlayButton
activeTintColor={THEME_COLOR}
tintColor={fill}
style={{ width: 40, height: 40 }}
<TouchableOpacity onPress={handleClick} activeOpacity={0.6}>
<Container style={routes.length ? defaultStyles.activeBackground : undefined} active={routes.length > 0}>
<AirplayAudioIcon
width={20}
height={20}
fill={routes.length > 0 ? THEME_COLOR : defaultStyles.textHalfOpacity.color}
/>
</Button>
</>
<Label active={routes.length > 0} numberOfLines={1}>
{routes.length > 0
? `Playing on ${routes.map((route) => route.portName).join(', ')}`
: 'Local Playback'
}
</Label>
</Container>
</TouchableOpacity>
);
}

View File

@@ -1,19 +1,14 @@
import React, { useState, useCallback } from 'react';
import TrackPlayer, { Event, State, usePlaybackState, useTrackPlayerEvents } from 'react-native-track-player';
import React from 'react';
import TrackPlayer, { State, usePlaybackState } from 'react-native-track-player';
import { TouchableOpacity, useColorScheme } from 'react-native';
import styled from 'styled-components/native';
import { useHasNextQueue, useHasPreviousQueue } from 'utility/useQueue';
import ForwardIcon from 'assets/icons/forwards.svg';
import BackwardIcon from 'assets/icons/backwards.svg';
import ForwardIcon from 'assets/icons/forward-end.svg';
import BackwardIcon from 'assets/icons/backward-end.svg';
import PlayIcon from 'assets/icons/play.svg';
import PauseIcon from 'assets/icons/pause.svg';
import RepeatIcon from 'assets/icons/repeat.svg';
// import ShuffleIcon from 'assets/icons/shuffle.svg';
import { THEME_COLOR } from 'CONSTANTS';
import Casting from './Casting';
const BUTTON_SIZE = 40;
const BUTTON_SIZE_SMALL = 25;
const pause = TrackPlayer.pause;
const play = TrackPlayer.play;
@@ -22,7 +17,7 @@ const previous = TrackPlayer.skipToPrevious;
const Container = styled.View`
align-items: center;
margin: 20px 0;
margin-top: 40px;
`;
const Buttons = styled.View`
@@ -33,7 +28,8 @@ const Buttons = styled.View`
`;
const Button = styled.View`
margin: 20px 40px;
margin: 0 40px;
opacity: 0.75;
`;
export default function MediaControls() {
@@ -51,12 +47,6 @@ export default function MediaControls() {
<NextButton fill={fill} />
</Button>
</Buttons>
<Buttons>
<Button>
<RepeatButton fill={fill} />
</Button>
<Casting fill={fill} />
</Buttons>
</Container>
);
}
@@ -81,51 +71,6 @@ export function NextButton({ fill }: { fill: string }) {
);
}
export function RepeatButton({ fill }: { fill: string}) {
const [isRepeating, setRepeating] = useState(false);
const handlePress = useCallback(() => setRepeating(!isRepeating), [isRepeating, setRepeating]);
// The callback that should determine whether we need to repeeat or not
useTrackPlayerEvents([Event.PlaybackQueueEnded], async () => {
if (isRepeating) {
// Skip to the first track
await TrackPlayer.skip(0);
// Cautiously reset the seek time, as there might only be a single
// item in queue.
await TrackPlayer.seekTo(0);
// Then play the item
await TrackPlayer.play();
}
});
return (
<TouchableOpacity onPress={handlePress} style={{ opacity: isRepeating ? 1 : 0.5 }}>
<RepeatIcon
width={BUTTON_SIZE_SMALL}
height={BUTTON_SIZE_SMALL}
fill={isRepeating ? THEME_COLOR : fill}
/>
</TouchableOpacity>
);
}
// export function ShuffleButton({ fill }: { fill: string}) {
// const [isShuffling, setShuffling] = useState(false);
// const handlePress = useCallback(() => setShuffling(!isShuffling), [isShuffling, setShuffling]);
// return (
// <TouchableOpacity onPress={handlePress} style={{ opacity: isShuffling ? 1 : 0.5 }}>
// <ShuffleIcon
// width={BUTTON_SIZE_SMALL}
// height={BUTTON_SIZE_SMALL}
// fill={isShuffling ? THEME_COLOR : fill}
// />
// </TouchableOpacity>
// );
// }
export function MainButton({ fill }: { fill: string }) {
const state = usePlaybackState();

View File

@@ -20,7 +20,7 @@ import Reanimated, {
import ReText from 'components/ReText';
const DRAG_HANDLE_SIZE = 20;
const PADDING_TOP = 14;
const PADDING_TOP = 12;
const Container = styled.View`
padding-top: ${PADDING_TOP}px;

View File

@@ -1,29 +1,53 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import useQueue from 'utility/useQueue';
import { View, StyleSheet } from 'react-native';
import styled, { css } from 'styled-components/native';
import useCurrentTrack from 'utility/useCurrentTrack';
import TouchableHandler from 'components/TouchableHandler';
import TrackPlayer from 'react-native-track-player';
import TrackPlayer, { RepeatMode } from 'react-native-track-player';
import { t } from '@localisation';
import useDefaultStyles from 'components/Colors';
import { Text } from 'components/Typography';
import RepeatIcon from 'assets/icons/repeat.svg';
import Button from 'components/Button';
import { THEME_COLOR } from 'CONSTANTS';
import DownloadIcon from 'components/DownloadIcon';
import Divider from 'components/Divider';
import ticksToDuration from 'utility/ticksToDuration';
const ICON_SIZE = 16;
const Container = styled.View`
margin-top: 56px;
`;
const Header = styled.View`
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 8px;
`;
const IconButton = styled.TouchableOpacity`
padding: 8px;
border-radius: 4px;
`;
const TextHalfOpacity = styled(Text)`
opacity: 0.5;
`;
const QueueItem = styled.View<{ active?: boolean, alreadyPlayed?: boolean, isDark?: boolean }>`
padding: 10px;
border-bottom-width: 1px;
padding: 8px 0;
flex: 0 0 auto;
flex-direction: row;
align-items: center;
${props => props.active && css`
font-weight: 900;
padding: 20px 35px;
margin: 0 -25px;
padding: 8px 18px;
margin: 0 -18px;
border-radius: 8px;
`}
${props => props.alreadyPlayed && css`
@@ -44,18 +68,49 @@ const styles = StyleSheet.create({
export default function Queue() {
const defaultStyles = useDefaultStyles();
const queue = useQueue();
const [isRepeating, setIsRepeating] = useState(false);
const { index: currentIndex } = useCurrentTrack();
const playTrack = useCallback(async (index: number) => {
await TrackPlayer.skip(index);
await TrackPlayer.play();
}, []);
const clearQueue = useCallback(async () => {
await TrackPlayer.reset();
}, []);
const toggleLoop = useCallback(() => {
setIsRepeating((prev) => {
TrackPlayer.setRepeatMode(prev ? RepeatMode.Off : RepeatMode.Queue);
return !prev;
});
}, []);
// Retrieve the repeat mode and assign it to the state on component mount
useEffect(() => {
TrackPlayer.getRepeatMode()
.then((mode) => {
setIsRepeating(mode === RepeatMode.Queue);
});
}, []);
return (
<View>
<Text style={{ marginTop: 20, marginBottom: 20 }}>{t('queue')}</Text>
<Container>
<Header>
<Text>{t('queue')}</Text>
<Divider style={{ marginHorizontal: 18 }} />
<IconButton
style={isRepeating ? defaultStyles.activeBackground : undefined}
onPress={toggleLoop}
>
<RepeatIcon
fill={isRepeating ? THEME_COLOR : defaultStyles.textHalfOpacity.color}
width={ICON_SIZE}
height={ICON_SIZE}
/>
</IconButton>
</Header>
{queue.map((track, i) => (
<TouchableHandler id={i} onPress={playTrack} key={i}>
<QueueItem
@@ -67,12 +122,29 @@ export default function Queue() {
currentIndex === i ? defaultStyles.activeBackground : {},
]}
>
<View>
<Text style={currentIndex === i ? { color: THEME_COLOR, fontWeight: '700' } : styles.trackTitle}>{track.title}</Text>
<Text style={currentIndex === i ? { color: THEME_COLOR, fontWeight: '400' } : defaultStyles.textHalfOpacity}>{track.artist}</Text>
<View style={{ flex: 1, marginRight: 16 }}>
<Text
style={[currentIndex === i ? { color: THEME_COLOR, fontWeight: '500' } : styles.trackTitle, { marginBottom: 2 }]}
numberOfLines={1}
>
{track.title}
</Text>
<TextHalfOpacity
style={currentIndex === i ? { color: THEME_COLOR, fontWeight: '400' } : undefined}
numberOfLines={1}
>
{track.artist}{track.album && ' — ' + track.album}
</TextHalfOpacity>
</View>
<View style={{ marginLeft: 'auto' }}>
<DownloadIcon trackId={track.backendId} />
<View style={{ marginLeft: 'auto', marginRight: 8 }}>
<TextHalfOpacity
style={currentIndex === i ? { color: THEME_COLOR, fontWeight: '400' } : undefined}
>
{ticksToDuration(track.duration || 0)}
</TextHalfOpacity>
</View>
<View>
<DownloadIcon trackId={track.backendId} fill={currentIndex === i ? THEME_COLOR + '80' : undefined} />
</View>
</QueueItem>
</TouchableHandler>
@@ -80,6 +152,6 @@ export default function Queue() {
<ClearQueue>
<Button title={t('clear-queue')} onPress={clearQueue} />
</ClearQueue>
</View>
</Container>
);
}

View File

@@ -0,0 +1,61 @@
import React, { useMemo } from 'react';
import useCurrentTrack from 'utility/useCurrentTrack';
import CloudIcon from 'assets/icons/cloud.svg';
import InternalDriveIcon from 'assets/icons/internal-drive.svg';
import useDefaultStyles from 'components/Colors';
import { Text } from 'components/Typography';
import styled from 'styled-components/native';
import Casting from './Casting';
const ICON_SIZE = 16;
const Container = styled.View`
display: flex;
justify-content: space-between;
flex-direction: row;
margin-top: 24px;
`;
const Group = styled.View`
display: flex;
flex-direction: row;
margin-right: 8px;
flex: 1 1 auto;
`;
const Label = styled(Text)`
margin-left: 8px;
opacity: 0.5;
font-size: 13px;
`;
function StreamStatus() {
const { track } = useCurrentTrack();
const defaultStyles = useDefaultStyles();
const isLocalPlay = useMemo(() => {
const url = track?.url;
return typeof url === 'string' ? url.startsWith('file://') : false;
}, [track?.url]);
return (
<Container>
<Group>
{isLocalPlay ? (
<>
<InternalDriveIcon width={ICON_SIZE} height={ICON_SIZE} fill={defaultStyles.icon.color} />
<Label numberOfLines={1}>Local Playback</Label>
</>
) : (
<>
<CloudIcon width={ICON_SIZE} height={ICON_SIZE} fill={defaultStyles.icon.color} />
<Label numberOfLines={1}>Streaming</Label>
</>
)}
</Group>
<Casting />
</Container>
);
}
export default StreamStatus;