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

@@ -114,6 +114,8 @@ PODS:
- React-RCTSettings (= 0.68.1)
- React-RCTText (= 0.68.1)
- React-RCTVibration (= 0.68.1)
- react-airplay (1.1.2):
- React-Core
- React-callinvoker (0.68.1)
- React-Codegen (0.68.1):
- FBReactNativeSpec (= 0.68.1)
@@ -291,8 +293,6 @@ PODS:
- React-jsinspector (0.68.1)
- React-logger (0.68.1):
- glog
- react-native-airplay-button (1.1.0):
- React-Core
- react-native-blur (0.8.0):
- React
- react-native-flipper (0.127.0):
@@ -504,6 +504,7 @@ DEPENDENCIES:
- RCTRequired (from `../node_modules/react-native/Libraries/RCTRequired`)
- RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`)
- React (from `../node_modules/react-native/`)
- react-airplay (from `../node_modules/react-airplay`)
- React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`)
- React-Codegen (from `build/generated/ios`)
- React-Core (from `../node_modules/react-native/`)
@@ -515,7 +516,6 @@ DEPENDENCIES:
- React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
- React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`)
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- react-native-airplay-button (from `../node_modules/react-native-airplay-button`)
- "react-native-blur (from `../node_modules/@react-native-community/blur`)"
- react-native-flipper (from `../node_modules/react-native-flipper`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
@@ -591,6 +591,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/Libraries/TypeSafety"
React:
:path: "../node_modules/react-native/"
react-airplay:
:path: "../node_modules/react-airplay"
React-callinvoker:
:path: "../node_modules/react-native/ReactCommon/callinvoker"
React-Codegen:
@@ -609,8 +611,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/jsinspector"
React-logger:
:path: "../node_modules/react-native/ReactCommon/logger"
react-native-airplay-button:
:path: "../node_modules/react-native-airplay-button"
react-native-blur:
:path: "../node_modules/@react-native-community/blur"
react-native-flipper:
@@ -700,6 +700,7 @@ SPEC CHECKSUMS:
RCTRequired: 00581111c53531e39e3c6346ef0d2c0cf52a5a37
RCTTypeSafety: 07e03ee7800e7dd65cba8e52ad0c2edb06c96604
React: e61f4bf3c573d0c61c56b53dc3eb1d9daf0768a0
react-airplay: 19bc646b4cc698c00772f4cb0e45ca8e280d4c6e
React-callinvoker: 047d47230bb6fd66827f8cb0bea4e944ffd1309b
React-Codegen: bb0403cde7374af091530e84e492589485aab480
React-Core: a4a3a8e10d004b08e013c3d0438259dd89a3894c
@@ -709,7 +710,6 @@ SPEC CHECKSUMS:
React-jsiexecutor: 4a4bae5671b064a2248a690cf75957669489d08c
React-jsinspector: 218a2503198ff28a085f8e16622a8d8f507c8019
React-logger: f79dd3cc0f9b44f5611c6c7862badd891a862cf8
react-native-airplay-button: 90c7ba52402c8e92342003b8a1ff78dfb4357a9e
react-native-blur: cad4d93b364f91e7b7931b3fa935455487e5c33c
react-native-flipper: b9e2e817604af8da0d5a9ba20a8516e780e30f3c
react-native-netinfo: 3671b091c4843fda5e153612866ef4024b8f5d62

32
package-lock.json generated
View File

@@ -30,8 +30,8 @@
"i18n-js": "^3.9.2",
"lodash": "^4.17.21",
"react": "^17.0.2",
"react-airplay": "^1.1.2",
"react-native": "^0.68.1",
"react-native-airplay-button": "^1.1.0",
"react-native-collapsible": "^1.6.0",
"react-native-dotenv": "^3.3.1",
"react-native-fast-image": "^8.5.11",
@@ -14241,6 +14241,15 @@
"node": ">=0.10.0"
}
},
"node_modules/react-airplay": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/react-airplay/-/react-airplay-1.1.2.tgz",
"integrity": "sha512-elsnY0VLF5N0JqY+jdqFd28BmTnzhda0ljV4vTexFcdhrB6uht70J5QUN2E25U4Y0DWlcgprlxAYfWDbUOjTCQ==",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-devtools-core": {
"version": "4.24.4",
"resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-4.24.4.tgz",
@@ -14314,15 +14323,6 @@
"react": "17.0.2"
}
},
"node_modules/react-native-airplay-button": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/react-native-airplay-button/-/react-native-airplay-button-1.1.0.tgz",
"integrity": "sha512-UifB3XLh7AS9jWSz4Rn5kzFqmuXNa9NiZqn7BUNGjyie/4AO2omyXfeE3RVjO3rEZz7zBcP3/Wgd0QDBcaSt2Q==",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-codegen": {
"version": "0.0.16",
"resolved": "https://registry.npmjs.org/react-native-codegen/-/react-native-codegen-0.0.16.tgz",
@@ -28093,6 +28093,12 @@
"object-assign": "^4.1.1"
}
},
"react-airplay": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/react-airplay/-/react-airplay-1.1.2.tgz",
"integrity": "sha512-elsnY0VLF5N0JqY+jdqFd28BmTnzhda0ljV4vTexFcdhrB6uht70J5QUN2E25U4Y0DWlcgprlxAYfWDbUOjTCQ==",
"requires": {}
},
"react-devtools-core": {
"version": "4.24.4",
"resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-4.24.4.tgz",
@@ -28732,12 +28738,6 @@
}
}
},
"react-native-airplay-button": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/react-native-airplay-button/-/react-native-airplay-button-1.1.0.tgz",
"integrity": "sha512-UifB3XLh7AS9jWSz4Rn5kzFqmuXNa9NiZqn7BUNGjyie/4AO2omyXfeE3RVjO3rEZz7zBcP3/Wgd0QDBcaSt2Q==",
"requires": {}
},
"react-native-codegen": {
"version": "0.0.16",
"resolved": "https://registry.npmjs.org/react-native-codegen/-/react-native-codegen-0.0.16.tgz",

View File

@@ -34,8 +34,8 @@
"i18n-js": "^3.9.2",
"lodash": "^4.17.21",
"react": "^17.0.2",
"react-airplay": "^1.1.2",
"react-native": "^0.68.1",
"react-native-airplay-button": "^1.1.0",
"react-native-collapsible": "^1.6.0",
"react-native-dotenv": "^3.3.1",
"react-native-fast-image": "^8.5.11",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 56"><defs><clipPath id="clip-path"><path d="M39.31 44.79a.73.73 0 0 1-.54 1.21H17.23a.73.73 0 0 1-.54-1.21l10.68-12.3a.84.84 0 0 1 1.26 0Z"/></clipPath><clipPath id="clip-path-2"><path style="fill:none" d="M-4-3h64v64H-4z"/></clipPath><clipPath id="clip-path-3"><path d="M28 22a6 6 0 0 0-3.65 10.76.16.16 0 0 1 0 .23l-.86 1-.19.21a.18.18 0 0 1-.27 0l-.32-.2a8 8 0 1 1 10.56 0l-.31.25a.18.18 0 0 1-.27 0l-.2-.22-.85-1a.17.17 0 0 1 0-.24A6 6 0 0 0 28 22Zm0-5a11 11 0 0 0-7 19.49l.1.07a.15.15 0 0 1 0 .21L21 37l-.88 1a.2.2 0 0 1-.28 0h-.14a13 13 0 1 1 16.6 0l-.12.09a.14.14 0 0 1-.2 0L35.9 38l-.89-1-.14-.16a.14.14 0 0 1 0-.21l.14-.11A11 11 0 0 0 28 17ZM17.82 40.33a.21.21 0 0 1 0 .29l-.06.07-.92 1.07-.06.07a.2.2 0 0 1-.28 0l-.08-.07a18 18 0 1 1 23 .07.16.16 0 0 1-.21 0l-1-1.2-.05-.05a.16.16 0 0 1 0-.21h.06a16 16 0 1 0-20.49-.07Z"/></clipPath></defs><path d="M11.51 27.21h32.97V51H11.51z" style="clip-path:url(#clip-path)"/><path d="M5 5h46v41.92H5z" style="clip-path:url(#clip-path-3)"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,10 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3_22)">
<path d="M0.689175 29.9526H3.63979C4.83259 29.9526 5.44782 29.3373 5.44782 28.1445V20.0084C5.3976 19.8451 5.38505 19.6819 5.38505 19.4936C5.38505 19.3178 5.3976 19.142 5.44782 18.9913V10.8677C5.44782 9.64983 4.83259 9.07226 3.63979 9.05971H0.689175C-0.503626 9.05971 -1.11886 9.67494 -1.11886 10.8677V28.1445C-1.13142 29.3373 -0.516182 29.9526 0.689175 29.9526ZM21.243 29.6136C22.3479 29.6136 23.2771 28.7849 23.2771 27.2531V20.0084C23.4403 20.6236 23.9049 21.1384 24.6456 21.5778L37.5153 29.1364C38.0552 29.4503 38.5324 29.6136 39.0848 29.6136C40.1897 29.6136 41.1189 28.7849 41.1189 27.2531V11.7592C41.1189 10.2274 40.1897 9.39871 39.0848 9.39871C38.5324 9.39871 38.0552 9.56194 37.5153 9.87583L24.6456 17.4344C23.8923 17.8864 23.4403 18.3761 23.2771 18.9913V11.7592C23.2771 10.2274 22.3605 9.39871 21.2556 9.39871C20.7031 9.39871 20.226 9.56194 19.6735 9.87583L6.80385 17.4344C6.06306 17.8864 5.59849 18.3761 5.44782 18.9913V20.0084C5.61105 20.6236 6.06306 21.1384 6.80385 21.5778L19.6735 29.1364C20.226 29.4503 20.6906 29.6136 21.243 29.6136Z"/>
</g>
<defs>
<clipPath id="clip0_3_22">
<rect width="40" height="40" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,10 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3_26)">
<path d="M0.915182 29.5759C1.46764 29.5759 1.94476 29.4127 2.48466 29.0988L15.3544 21.5402C16.0951 21.1007 16.5597 20.5859 16.7104 19.9707V27.2154C16.7104 28.7347 17.6395 29.5759 18.7444 29.5759C19.2969 29.5759 19.774 29.4127 20.3139 29.0988L33.1836 21.5402C33.9369 21.1007 34.389 20.5859 34.5522 19.9707V28.1445C34.5522 29.3373 35.1549 29.9526 36.3602 29.9526H39.3108C40.5036 29.9526 41.1189 29.3373 41.1189 28.1445V10.8677C41.1189 9.64983 40.5036 9.05971 39.3108 9.05971H36.3602C35.1674 9.05971 34.5522 9.67494 34.5522 10.8677V18.9537C34.389 18.3384 33.9369 17.8488 33.1836 17.3968L20.3139 9.83817C19.774 9.52427 19.2969 9.36105 18.7444 9.36105C17.6395 9.36105 16.7104 10.1897 16.7104 11.7215V18.9537C16.5597 18.3384 16.1077 17.8488 15.3544 17.3968L2.48466 9.83817C1.9322 9.52427 1.46764 9.36105 0.902626 9.36105C-0.202285 9.36105 -1.11886 10.1897 -1.11886 11.7215V27.2154C-1.11886 28.7347 -0.189729 29.5759 0.915182 29.5759Z"/>
</g>
<defs>
<clipPath id="clip0_3_26">
<rect width="40" height="40" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -30,10 +30,6 @@ const ButtonText = styled.Text<{ active?: boolean }>`
color: ${THEME_COLOR};
font-weight: 500;
font-size: 14px;
${props => props.active && css`
color: white;
`}
`;
const Button = React.forwardRef<View, ButtonProps>(function Button(props, ref) {
@@ -47,20 +43,19 @@ const Button = React.forwardRef<View, ButtonProps>(function Button(props, ref) {
<BaseButton
{...rest}
disabled={disabled}
// @ts-expect-error styled-components has outdated react-native typings
ref={ref}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={[
props.style,
{ backgroundColor: isPressed ? THEME_COLOR : defaultStyles.button.backgroundColor }
{ backgroundColor: isPressed ? defaultStyles.activeBackground.backgroundColor : defaultStyles.button.backgroundColor }
]}
>
{Icon &&
<Icon
width={14}
height={14}
fill={isPressed ? '#fff' : THEME_COLOR}
fill={THEME_COLOR}
style={{
marginRight: 8,
}}

View File

@@ -57,7 +57,13 @@ function generateStyles(scheme: ColorSchemeName) {
},
stackHeader: {
color: scheme === 'dark' ? 'white' : 'black'
}
},
icon: {
color: scheme === 'dark' ? '#ffffff4d' : '#0000004d',
},
divider: {
backgroundColor: scheme === 'dark' ? '#333' : '#f6f6f6',
},
});
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { ViewProps } from 'react-native';
import styled from 'styled-components/native';
import useDefaultStyles from './Colors';
const Container = styled.View`
height: 1px;
flex: 1;
`;
function Divider({ style }: ViewProps) {
const defaultStyles = useDefaultStyles();
return (
<Container style={[defaultStyles.divider, style]} />
);
}
export default Divider;

View File

@@ -67,8 +67,6 @@ function DownloadManager () {
return;
}
console.log(ids);
/**
* Whenever the store is cleared, existing downloads get "lost" because
* the only reference we have is the store. This function checks for
@@ -94,7 +92,7 @@ function DownloadManager () {
dispatch(completeDownload({
id,
location: file.path,
size: Number.parseInt(file.size),
size: file.size,
}));
});
}

View File

@@ -25,6 +25,7 @@ import { Header, SubHeader } from 'components/Typography';
import { Text } from 'components/Typography';
import CoverImage from 'components/CoverImage';
import ticksToDuration from 'utility/ticksToDuration';
const styles = StyleSheet.create({
index: {
@@ -170,8 +171,7 @@ const TrackListView: React.FC<TrackListViewProps> = ({
]}
numberOfLines={1}
>
{Math.round(tracks[trackId]?.RunTimeTicks / 10000000 / 60)}
:{Math.round(tracks[trackId]?.RunTimeTicks / 10000000 % 60).toString().padStart(2, '0')}
{ticksToDuration(tracks[trackId]?.RunTimeTicks || 0)}
</Text>
<DownloadIcon trackId={trackId} fill={currentTrack?.backendId === trackId ? `${THEME_COLOR}44` : undefined} />
</View>

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;

View File

@@ -8,6 +8,7 @@ import useDefaultStyles from 'components/Colors';
import ConnectionNotice from './components/ConnectionNotice';
import { ScrollView } from 'react-native-gesture-handler';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import StreamStatus from './components/StreamStatus';
const styles = StyleSheet.create({
inner: {
@@ -23,6 +24,7 @@ export default function Player() {
<ScrollView contentContainerStyle={styles.inner} style={defaultStyles.view}>
<NowPlaying />
<ConnectionNotice />
<StreamStatus />
<ProgressBar />
<MediaControls />
<Queue />

View File

@@ -42,6 +42,7 @@ export function generateTrack(track: AlbumTrack, credentials: Credentials): Trac
title: track.Name,
artist: track.Artists.join(', '),
album: track.Album,
duration: track.RunTimeTicks,
artwork: track.AlbumId
? getImage(track.AlbumId, credentials)
: getImage(track.Id, credentials),

View File

@@ -0,0 +1,9 @@
function ticksToDuration(ticks: number) {
const seconds = Math.round(ticks / 10000000);
const minutes = Math.round(seconds / 60);
const hours = Math.round(minutes / 60);
return `${hours > 0 ? hours + ':' : ''}${minutes}:${(seconds % 60).toString().padStart(2, '0')}`;
}
export default ticksToDuration;

View File

@@ -57,7 +57,7 @@ export default function usePlayTracks() {
// Check if a downloaded version exists, and if so rewrite the URL
const download = downloads[trackId];
if (download?.location) {
generatedTrack.url = download.location;
generatedTrack.url = 'file://' + download.location;
}
return generatedTrack;