Ott 138 match page player (#97)
* Ott 138 match page player part 1 (#93) * feat(#138): added svg icons * feat(#138): added video player control components Co-authored-by: mirlan.maksitaliev <mirlan.maksitaliev@instatsport.com> * feat(#138): added player (#96) Co-authored-by: mirlan.maksitaliev <mirlan.maksitaliev@instatsport.com> Co-authored-by: mirlan.maksitaliev <mirlan.maksitaliev@instatsport.com>keep-around/af30b88d367751c9e05a735e4a0467a96238ef47
|
After Width: | Height: | Size: 472 B |
|
After Width: | Height: | Size: 441 B |
|
After Width: | Height: | Size: 218 B |
|
After Width: | Height: | Size: 326 B |
|
After Width: | Height: | Size: 440 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 643 B |
|
After Width: | Height: | Size: 452 B |
@ -1,13 +1,17 @@ |
||||
import React from 'react' |
||||
|
||||
import { VideoPlayer } from 'features/VideoPlayer' |
||||
|
||||
import { MatchProfileCard } from './MatchProfileCard' |
||||
import { MainWrapper, Container } from './styled' |
||||
|
||||
import { |
||||
MainWrapper, |
||||
} from './styled' |
||||
const url = 'https://bserv.instatfootball.tv/common/outhls.m3u8' |
||||
|
||||
export const MatchPage = () => ( |
||||
<MainWrapper> |
||||
<MatchProfileCard /> |
||||
<Container> |
||||
<VideoPlayer url={url} /> |
||||
</Container> |
||||
</MainWrapper> |
||||
) |
||||
|
||||
@ -1,6 +1,13 @@ |
||||
import styled from 'styled-components/macro' |
||||
|
||||
export const MainWrapper = styled.div` |
||||
margin-left: 18px; |
||||
margin-top: 63px; |
||||
margin: 63px 16px 0 16px; |
||||
` |
||||
|
||||
export const Container = styled.div` |
||||
max-width: 1504px; |
||||
max-height: 896px; |
||||
margin-top: 14px; |
||||
display: flex; |
||||
flex-direction: column; |
||||
` |
||||
|
||||
@ -0,0 +1,39 @@ |
||||
import React from 'react' |
||||
|
||||
import { secondsToHms } from 'helpers' |
||||
|
||||
import { useSlider } from 'features/VideoPlayer/hooks/useSlider' |
||||
|
||||
import { TimeTooltip } from '../TimeTooltip' |
||||
import { |
||||
ProgressBarList, |
||||
LoadedProgress, |
||||
PlayedProgress, |
||||
Scrubber, |
||||
} from './styled' |
||||
|
||||
type Props = { |
||||
loadedProgress: number, |
||||
onPlayedProgressChange: (progress: number) => void, |
||||
playedProgress: number, |
||||
playedSeconds: number, |
||||
} |
||||
|
||||
export const ProgressBar = ({ |
||||
loadedProgress, |
||||
onPlayedProgressChange, |
||||
playedProgress, |
||||
playedSeconds, |
||||
}: Props) => { |
||||
const progressBarRef = useSlider({ onChange: onPlayedProgressChange }) |
||||
|
||||
return ( |
||||
<ProgressBarList ref={progressBarRef}> |
||||
<LoadedProgress value={loadedProgress} /> |
||||
<PlayedProgress value={playedProgress} /> |
||||
<Scrubber value={playedProgress}> |
||||
<TimeTooltip time={secondsToHms(playedSeconds)} /> |
||||
</Scrubber> |
||||
</ProgressBarList> |
||||
) |
||||
} |
||||
@ -0,0 +1,44 @@ |
||||
import styled from 'styled-components/macro' |
||||
|
||||
export const ProgressBarList = styled.div` |
||||
flex-grow: 1; |
||||
height: 4px; |
||||
position: relative; |
||||
background-color: rgba(255, 255, 255, 0.3); |
||||
cursor: pointer; |
||||
` |
||||
|
||||
type ProgressProps = { |
||||
value: number, |
||||
} |
||||
|
||||
export const LoadedProgress = styled.div<ProgressProps>` |
||||
position: absolute; |
||||
z-index: 1; |
||||
background-color: rgba(255, 255, 255, 0.6);; |
||||
height: 100%; |
||||
width: ${({ value }) => value}%; |
||||
` |
||||
|
||||
export const PlayedProgress = styled.div<ProgressProps>` |
||||
position: absolute; |
||||
z-index: 2; |
||||
background-color: #F2C94C; |
||||
height: 100%; |
||||
width: ${({ value }) => value}%; |
||||
` |
||||
|
||||
export const Scrubber = styled.button<ProgressProps>` |
||||
border: none; |
||||
outline: none; |
||||
position: absolute; |
||||
top: 0; |
||||
left: ${({ value }) => value}%; |
||||
transform: translate(-50%, -38%); |
||||
z-index: 3; |
||||
width: 18px; |
||||
height: 18px; |
||||
background-color: #F2C94C; |
||||
border-radius: 50%; |
||||
cursor: pointer; |
||||
` |
||||
@ -0,0 +1,13 @@ |
||||
import React from 'react' |
||||
|
||||
import { Wrapper, Time } from './styled' |
||||
|
||||
type Props = { |
||||
time: string, |
||||
} |
||||
|
||||
export const TimeTooltip = ({ time }: Props) => ( |
||||
<Wrapper> |
||||
<Time>{time}</Time> |
||||
</Wrapper> |
||||
) |
||||
@ -0,0 +1,33 @@ |
||||
import styled from 'styled-components/macro' |
||||
|
||||
export const Wrapper = styled.div` |
||||
position: absolute; |
||||
bottom: 100%; |
||||
transform: translate(-50%, -50%); |
||||
display: flex; |
||||
justify-content: center; |
||||
align-items: center; |
||||
background-color: #000; |
||||
min-width: 60px; |
||||
height: 32px; |
||||
|
||||
::after { |
||||
content: ''; |
||||
position: absolute; |
||||
bottom: 0; |
||||
left: 50%; |
||||
width: 9px; |
||||
height: 9px; |
||||
transform: rotate(45deg) translateY(50%); |
||||
background-color: #000; |
||||
} |
||||
` |
||||
|
||||
export const Time = styled.span` |
||||
padding: 0 6px; |
||||
color: #F2C94C; |
||||
text-align: center; |
||||
font-weight: bold; |
||||
font-size: 16px; |
||||
line-height: 20px; |
||||
` |
||||
@ -0,0 +1,36 @@ |
||||
import React from 'react' |
||||
|
||||
import { useSlider } from 'features/VideoPlayer/hooks/useSlider' |
||||
|
||||
import { |
||||
Wrapper, |
||||
VolumeButton, |
||||
VolumeProgressList, |
||||
VolumeProgress, |
||||
Scrubber, |
||||
} from './styled' |
||||
|
||||
type Props = { |
||||
muted: boolean, |
||||
onChange: (progress: number) => void, |
||||
onClick: () => void, |
||||
value: number, |
||||
} |
||||
|
||||
export const VolumeBar = ({ |
||||
muted, |
||||
onChange, |
||||
onClick, |
||||
value, |
||||
}: Props) => { |
||||
const progressRef = useSlider({ onChange }) |
||||
return ( |
||||
<Wrapper> |
||||
<VolumeButton onClick={onClick} muted={muted} /> |
||||
<VolumeProgressList ref={progressRef}> |
||||
<VolumeProgress value={muted ? 0 : value} /> |
||||
<Scrubber value={muted ? 0 : value} /> |
||||
</VolumeProgressList> |
||||
</Wrapper> |
||||
) |
||||
} |
||||
@ -0,0 +1,61 @@ |
||||
import styled from 'styled-components/macro' |
||||
|
||||
export const Wrapper = styled.div` |
||||
display: flex; |
||||
align-items: center; |
||||
margin-right: 18px; |
||||
` |
||||
|
||||
type VolumeButtonProps = { |
||||
muted: boolean, |
||||
} |
||||
|
||||
export const VolumeButton = styled.button<VolumeButtonProps>` |
||||
outline: none; |
||||
border: none; |
||||
padding: 0; |
||||
width: 40px; |
||||
height: 26px; |
||||
margin-right: 9px; |
||||
cursor: pointer; |
||||
background-repeat: no-repeat; |
||||
background-position: center; |
||||
background-size: cover; |
||||
background-color: transparent; |
||||
background-image: ${({ muted }) => ( |
||||
muted |
||||
? 'url(/images/player-volume-off.svg)' |
||||
: 'url(/images/player-volume-on.svg)' |
||||
)}; |
||||
` |
||||
|
||||
export const VolumeProgressList = styled.div` |
||||
width: 77px; |
||||
height: 4px; |
||||
position: relative; |
||||
background-color: rgba(255, 255, 255, 0.3); |
||||
cursor: pointer; |
||||
` |
||||
|
||||
type VolumeProgressProps = { |
||||
value: number, |
||||
} |
||||
|
||||
export const VolumeProgress = styled.div<VolumeProgressProps>` |
||||
height: 100%; |
||||
width: ${({ value }) => value}%; |
||||
background-color: #fff; |
||||
` |
||||
|
||||
export const Scrubber = styled.button<VolumeProgressProps>` |
||||
border: none; |
||||
outline: none; |
||||
width: 13px; |
||||
height: 13px; |
||||
left: ${({ value }) => value}%;; |
||||
position: absolute; |
||||
border-radius: 50%; |
||||
background-color: #fff; |
||||
transform: translate(-50%, -65%); |
||||
cursor: pointer; |
||||
` |
||||
@ -0,0 +1,12 @@ |
||||
export const playerConfig = { |
||||
file: { |
||||
forceHLS: true, |
||||
hlsOptions: { |
||||
liveSyncDuration: 10, |
||||
maxBufferLength: 10, |
||||
maxBufferSize: 0, |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
export const progressCallbackInterval = 100 |
||||
@ -0,0 +1,91 @@ |
||||
import type { MouseEvent } from 'react' |
||||
import { |
||||
useCallback, |
||||
useState, |
||||
useRef, |
||||
} from 'react' |
||||
import ReactPlayer from 'react-player' |
||||
|
||||
import throttle from 'lodash/throttle' |
||||
|
||||
import { useFullscreen } from './useFullscreen' |
||||
import { useVolume } from './useVolume' |
||||
|
||||
type ProgressState = { |
||||
loaded: number, |
||||
loadedSeconds: number, |
||||
played: number, |
||||
playedSeconds: number, |
||||
} |
||||
|
||||
export const useVideoPlayer = () => { |
||||
const [ready, setReady] = useState(false) |
||||
const [playing, setPlaying] = useState(false) |
||||
const [duration, setDuration] = useState(0) |
||||
const [loadedProgress, setLoadedProgress] = useState(0) |
||||
const [playedProgress, setPlayedProgress] = useState(0) |
||||
const [playedSeconds, setPlayedSeconds] = useState(0) |
||||
const wrapperRef = useRef<HTMLDivElement>(null) |
||||
const playerRef = useRef<ReactPlayer>(null) |
||||
|
||||
const startPlaying = () => { |
||||
setReady(true) |
||||
setPlaying(true) |
||||
} |
||||
|
||||
const togglePlaying = () => { |
||||
if (ready) { |
||||
setPlaying(!playing) |
||||
} |
||||
} |
||||
|
||||
const onPlayerClick = (e: MouseEvent<HTMLDivElement>) => { |
||||
if (e.target === playerRef.current?.getInternalPlayer()) { |
||||
togglePlaying() |
||||
} |
||||
} |
||||
|
||||
const setPlayerProgress = useCallback( |
||||
throttle((value: number) => { |
||||
playerRef.current?.seekTo(value, 'fraction') |
||||
}, 100), |
||||
[], |
||||
) |
||||
|
||||
const onProgressChange = useCallback((progress: number) => { |
||||
setPlayedProgress(progress * 100) |
||||
setPlayerProgress(progress) |
||||
}, [ |
||||
setPlayedProgress, |
||||
setPlayerProgress, |
||||
]) |
||||
|
||||
const setProgress = ({ |
||||
loaded, |
||||
played, |
||||
playedSeconds: seconds, |
||||
}: ProgressState) => { |
||||
setLoadedProgress(loaded * 100) |
||||
setPlayedProgress(played * 100) |
||||
setPlayedSeconds(seconds) |
||||
} |
||||
|
||||
return { |
||||
duration, |
||||
loadedProgress, |
||||
onPlayerClick, |
||||
onProgressChange, |
||||
playedProgress, |
||||
playedSeconds, |
||||
playerRef, |
||||
playing, |
||||
setDuration, |
||||
setProgress, |
||||
setReady, |
||||
startPlaying, |
||||
togglePlaying, |
||||
wrapperRef, |
||||
...useFullscreen(wrapperRef), |
||||
...useVolume(), |
||||
} |
||||
} |
||||
@ -0,0 +1,36 @@ |
||||
import type { RefObject } from 'react' |
||||
import { useEffect } from 'react' |
||||
import screenfull from 'screenfull' |
||||
|
||||
import noop from 'lodash/noop' |
||||
|
||||
import { useToggle } from 'hooks' |
||||
|
||||
export const useFullscreen = (wrapperRef: RefObject<HTMLDivElement>) => { |
||||
const { |
||||
isOpen: isFullscreen, |
||||
setIsOpen: setFullscreen, |
||||
} = useToggle() |
||||
|
||||
useEffect(() => { |
||||
if (!screenfull.isEnabled) return noop |
||||
const changeListener = () => { |
||||
setFullscreen(screenfull.isEnabled && screenfull.isFullscreen) |
||||
} |
||||
screenfull.onchange(changeListener) |
||||
|
||||
return () => { |
||||
if (screenfull.isEnabled) { |
||||
screenfull.off('change', changeListener) |
||||
} |
||||
} |
||||
}, [setFullscreen]) |
||||
|
||||
const onFullscreenClick = () => { |
||||
if (screenfull.isEnabled && wrapperRef.current) { |
||||
screenfull.toggle(wrapperRef.current) |
||||
} |
||||
} |
||||
|
||||
return { isFullscreen, onFullscreenClick } |
||||
} |
||||
@ -0,0 +1,81 @@ |
||||
import { |
||||
useCallback, |
||||
useEffect, |
||||
useRef, |
||||
} from 'react' |
||||
|
||||
const getNormalizedProgress = (fraction: number) => { |
||||
if (fraction > 1) return 1 |
||||
if (fraction < 0) return 0 |
||||
return fraction |
||||
} |
||||
|
||||
const useMouseState = () => { |
||||
const mouseDownRef = useRef(false) |
||||
const setMouseDown = useCallback(() => { |
||||
mouseDownRef.current = true |
||||
}, []) |
||||
const setMouseUp = useCallback(() => { |
||||
mouseDownRef.current = false |
||||
}, []) |
||||
|
||||
return { |
||||
mouseDownRef, |
||||
setMouseDown, |
||||
setMouseUp, |
||||
} |
||||
} |
||||
|
||||
type Args = { |
||||
onChange: (progress: number) => void, |
||||
} |
||||
|
||||
export const useSlider = ({ onChange }: Args) => { |
||||
const ref = useRef<HTMLDivElement>(null) |
||||
const { |
||||
mouseDownRef, |
||||
setMouseDown, |
||||
setMouseUp, |
||||
} = useMouseState() |
||||
|
||||
useEffect(() => { |
||||
const handleProgress = (mouseX: number) => { |
||||
const track = ref.current |
||||
if (!mouseDownRef.current || !track) return |
||||
|
||||
const x = mouseX - track.getBoundingClientRect().left |
||||
const progress = getNormalizedProgress(x / track.offsetWidth) |
||||
onChange(progress) |
||||
} |
||||
|
||||
const mouseDownListener = ({ clientX, target }: MouseEvent) => { |
||||
const track = ref.current |
||||
if (target === track || track?.contains(target as Node)) { |
||||
setMouseDown() |
||||
handleProgress(clientX) |
||||
} |
||||
} |
||||
|
||||
const mouseUpListener = setMouseUp |
||||
|
||||
const mouseMoveListener = (e: MouseEvent) => { |
||||
handleProgress(e.clientX) |
||||
} |
||||
|
||||
window.addEventListener('mousedown', mouseDownListener) |
||||
window.addEventListener('mouseup', mouseUpListener) |
||||
window.addEventListener('mousemove', mouseMoveListener) |
||||
return () => { |
||||
window.removeEventListener('mousedown', mouseDownListener) |
||||
window.removeEventListener('mouseup', mouseUpListener) |
||||
window.removeEventListener('mousemove', mouseMoveListener) |
||||
} |
||||
}, [ |
||||
onChange, |
||||
mouseDownRef, |
||||
setMouseDown, |
||||
setMouseUp, |
||||
]) |
||||
|
||||
return ref |
||||
} |
||||
@ -0,0 +1,36 @@ |
||||
import { useState } from 'react' |
||||
|
||||
import { useToggle } from 'hooks' |
||||
|
||||
export const useVolume = () => { |
||||
const { |
||||
close: unmute, |
||||
isOpen: muted, |
||||
open: mute, |
||||
toggle: toggleMuted, |
||||
} = useToggle() |
||||
const [volume, setVolume] = useState(0.5) |
||||
|
||||
const onVolumeChange = (value: number) => { |
||||
if (value === 0) { |
||||
mute() |
||||
} else { |
||||
unmute() |
||||
} |
||||
setVolume(value) |
||||
} |
||||
|
||||
const onVolumeClick = () => { |
||||
if (muted && volume === 0) { |
||||
setVolume(0.1) |
||||
} |
||||
toggleMuted() |
||||
} |
||||
|
||||
return { |
||||
muted, |
||||
onVolumeChange, |
||||
onVolumeClick, |
||||
volume, |
||||
} |
||||
} |
||||
@ -0,0 +1,87 @@ |
||||
import React from 'react' |
||||
|
||||
import { playerConfig, progressCallbackInterval } from './config' |
||||
import { useVideoPlayer } from './hooks' |
||||
import { ProgressBar } from './components/ProgressBar' |
||||
import { VolumeBar } from './components/VolumeBar' |
||||
import { |
||||
PlayerWrapper, |
||||
ReactPlayer, |
||||
Controls, |
||||
Prev, |
||||
Next, |
||||
PlayStop, |
||||
Fullscreen, |
||||
Settings, |
||||
} from './styled' |
||||
|
||||
type Props = { |
||||
url: string, |
||||
} |
||||
|
||||
export const VideoPlayer = ({ url }: Props) => { |
||||
const { |
||||
isFullscreen, |
||||
loadedProgress, |
||||
muted, |
||||
onFullscreenClick, |
||||
onPlayerClick, |
||||
onProgressChange, |
||||
onVolumeChange, |
||||
onVolumeClick, |
||||
playedProgress, |
||||
playedSeconds, |
||||
playerRef, |
||||
playing, |
||||
setDuration, |
||||
setProgress, |
||||
startPlaying, |
||||
togglePlaying, |
||||
volume, |
||||
wrapperRef, |
||||
} = useVideoPlayer() |
||||
|
||||
const volumeInPercent = volume * 100 |
||||
|
||||
return ( |
||||
<PlayerWrapper |
||||
ref={wrapperRef} |
||||
playing={playing} |
||||
onClick={onPlayerClick} |
||||
> |
||||
<ReactPlayer |
||||
width='100%' |
||||
height='100%' |
||||
url={url} |
||||
ref={playerRef} |
||||
playing={playing} |
||||
volume={volume} |
||||
config={playerConfig} |
||||
progressInterval={progressCallbackInterval} |
||||
onProgress={setProgress} |
||||
onDuration={setDuration} |
||||
onEnded={togglePlaying} |
||||
onReady={startPlaying} |
||||
/> |
||||
<Controls> |
||||
<Prev /> |
||||
<PlayStop onClick={togglePlaying} playing={playing} /> |
||||
<Next /> |
||||
<VolumeBar |
||||
value={volumeInPercent} |
||||
muted={muted} |
||||
onChange={onVolumeChange} |
||||
onClick={onVolumeClick} |
||||
/> |
||||
<ProgressBar |
||||
onPlayedProgressChange={onProgressChange} |
||||
playedSeconds={playedSeconds} |
||||
playedProgress={playedProgress} |
||||
loadedProgress={loadedProgress} |
||||
/> |
||||
<Fullscreen onClick={onFullscreenClick} isFullscreen={isFullscreen} /> |
||||
<Settings /> |
||||
</Controls> |
||||
</PlayerWrapper> |
||||
) |
||||
} |
||||
@ -0,0 +1,106 @@ |
||||
import styled from 'styled-components/macro' |
||||
import ReactPlayerBase from 'react-player' |
||||
|
||||
export const ReactPlayer = styled(ReactPlayerBase)` |
||||
position: absolute; |
||||
top: 0; |
||||
left: 0; |
||||
` |
||||
|
||||
export const Controls = styled.div` |
||||
position: absolute; |
||||
width: 100%; |
||||
bottom: 22px; |
||||
display: flex; |
||||
align-items: center; |
||||
padding: 0 25px; |
||||
transition: opacity 0.3s ease-in-out; |
||||
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.25)); |
||||
` |
||||
|
||||
export const PlayerWrapper = styled.div<PlayStopProps>` |
||||
position: relative; |
||||
width: 100%; |
||||
height: 0px; |
||||
padding-top: 56.25%; |
||||
background-color: #000; |
||||
|
||||
:hover ${Controls} { |
||||
opacity: 1; |
||||
} |
||||
|
||||
${Controls} { |
||||
opacity: ${({ playing }) => ( |
||||
playing |
||||
? '0' |
||||
: '1' |
||||
)}; |
||||
} |
||||
` |
||||
|
||||
const ButtonBase = styled.button` |
||||
outline: none; |
||||
border: none; |
||||
color: #fff; |
||||
cursor: pointer; |
||||
background-color: transparent; |
||||
background-repeat: no-repeat; |
||||
background-position: center; |
||||
background-size: contain; |
||||
` |
||||
|
||||
type PlayStopProps = { |
||||
playing: boolean, |
||||
} |
||||
|
||||
export const PlayStop = styled(ButtonBase)<PlayStopProps>` |
||||
width: 24px; |
||||
height: 24px; |
||||
margin-right: 18px; |
||||
background-image: ${({ playing }) => ( |
||||
playing |
||||
? 'url(/images/player-pause.svg)' |
||||
: 'url(/images/player-play.svg)' |
||||
)}; |
||||
` |
||||
|
||||
export const Prev = styled(ButtonBase)` |
||||
width: 29px; |
||||
height: 28px; |
||||
margin-right: 19px; |
||||
background-image: url(/images/player-prev.svg); |
||||
` |
||||
|
||||
export const Next = styled(Prev)` |
||||
margin-right: 24px; |
||||
transform: rotate(180deg); |
||||
` |
||||
|
||||
export const Volume = styled(ButtonBase)` |
||||
width: 40px; |
||||
height: 26px; |
||||
margin-right: 23px; |
||||
background-image: url(/images/player-volume-off.svg); |
||||
` |
||||
|
||||
type FullscreenProps = { |
||||
isFullscreen: boolean, |
||||
} |
||||
|
||||
export const Fullscreen = styled(ButtonBase)<FullscreenProps>` |
||||
width: 22px; |
||||
height: 20px; |
||||
margin-left: 31px; |
||||
margin-right: 23px; |
||||
background-image: ${({ isFullscreen }) => ( |
||||
isFullscreen |
||||
? 'url(/images/player-fullscreen-off.svg)' |
||||
: 'url(/images/player-fullscreen-on.svg)' |
||||
)}; |
||||
` |
||||
|
||||
export const Settings = styled(ButtonBase)` |
||||
width: 22px; |
||||
height: 20px; |
||||
background-image: url(/images/player-settings.svg); |
||||
` |
||||
@ -0,0 +1,11 @@ |
||||
import { secondsToHms } from '..' |
||||
|
||||
describe('secondsToHms helper', () => { |
||||
it('returns correct formatted time', () => { |
||||
expect(secondsToHms(0)).toBe('00:00') |
||||
expect(secondsToHms(120)).toBe('02:00') |
||||
expect(secondsToHms(600)).toBe('10:00') |
||||
expect(secondsToHms(3600)).toBe('01:00:00') |
||||
expect(secondsToHms(3700)).toBe('01:01:40') |
||||
}) |
||||
}) |
||||
@ -0,0 +1,16 @@ |
||||
const prependZero = (value: number | string) => ( |
||||
`0${value}`.slice(-2) |
||||
) |
||||
|
||||
/** |
||||
* Форматирует секунды в hh:mm:ss |
||||
*/ |
||||
export const secondsToHms = (seconds: number) => { |
||||
const hours = prependZero(Math.floor(seconds / 3600)) |
||||
const minutes = prependZero(Math.floor(seconds % 3600 / 60)) |
||||
const secondsStr = prependZero(Math.floor(seconds % 3600 % 60)) |
||||
|
||||
if (Number(hours) > 0) return `${hours}:${minutes}:${secondsStr}` |
||||
|
||||
return `${minutes}:${secondsStr}` |
||||
} |
||||