mirror of
https://github.com/Ernous/TorrServerJellyfin.git
synced 2025-12-20 22:16:09 +05:00
Video player with playback speed, PIP and Download support (#497)
* video playback support with play button in details view and torrent card * added translation keys and minor bug fix * removed unused "play" string * updated required go dependency * rearranged go dependencies to remove the warnings * reverted the build related changes * Added Playback speed, PIP and Dowload support * added translation keys for all languages and updated getVideoCaption function logic --------- Co-authored-by: nikk <1551446+tsynik@users.noreply.github.com>
This commit is contained in:
@@ -16,6 +16,7 @@ require (
|
|||||||
github.com/anacrolix/log v0.16.0
|
github.com/anacrolix/log v0.16.0
|
||||||
github.com/anacrolix/missinggo/v2 v2.8.0
|
github.com/anacrolix/missinggo/v2 v2.8.0
|
||||||
github.com/anacrolix/publicip v0.3.1
|
github.com/anacrolix/publicip v0.3.1
|
||||||
|
github.com/kljensen/snowball v0.9.0
|
||||||
github.com/anacrolix/torrent v1.58.1
|
github.com/anacrolix/torrent v1.58.1
|
||||||
github.com/dustin/go-humanize v1.0.1
|
github.com/dustin/go-humanize v1.0.1
|
||||||
github.com/gin-contrib/cors v1.7.5
|
github.com/gin-contrib/cors v1.7.5
|
||||||
@@ -36,6 +37,11 @@ require (
|
|||||||
gopkg.in/vansante/go-ffprobe.v2 v2.2.1
|
gopkg.in/vansante/go-ffprobe.v2 v2.2.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/RoaringBitmap/roaring v1.9.4 // indirect
|
github.com/RoaringBitmap/roaring v1.9.4 // indirect
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI
|
|||||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||||
@@ -426,6 +427,7 @@ github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVY
|
|||||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
@@ -465,6 +467,7 @@ github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcME
|
|||||||
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
|
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
|
||||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||||
@@ -538,6 +541,7 @@ github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4
|
|||||||
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||||
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||||
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
@@ -741,6 +745,7 @@ golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su
|
|||||||
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
|
golang.org/x/net v0.0.0-20220524220425-1d687d428aca/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { Button } from '@material-ui/core'
|
|||||||
import CopyToClipboard from 'react-copy-to-clipboard'
|
import CopyToClipboard from 'react-copy-to-clipboard'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import VideoPlayer from '../../VideoPlayer'
|
||||||
import { TableStyle, ShortTableWrapper, ShortTable } from './style'
|
import { TableStyle, ShortTableWrapper, ShortTable } from './style'
|
||||||
|
|
||||||
const { memo } = require('react')
|
const { memo, useState } = require('react')
|
||||||
|
|
||||||
// russian episode detection support
|
// russian episode detection support
|
||||||
ptt.addHandler('episode', /(\d{1,4})[- |. ]серия|серия[- |. ](\d{1,4})/i, { type: 'integer' })
|
ptt.addHandler('episode', /(\d{1,4})[- |. ]серия|серия[- |. ](\d{1,4})/i, { type: 'integer' })
|
||||||
@@ -18,6 +19,7 @@ ptt.addHandler('season', /сезон[- |. ](\d{1,3})|(\d{1,3})[- |. ]сезон/
|
|||||||
const Table = memo(
|
const Table = memo(
|
||||||
({ playableFileList, viewedFileList, selectedSeason, seasonAmount, hash }) => {
|
({ playableFileList, viewedFileList, selectedSeason, seasonAmount, hash }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [isSupported, setIsSupported] = useState(true)
|
||||||
const preloadBuffer = fileId => fetch(`${streamHost()}?link=${hash}&index=${fileId}&preload`)
|
const preloadBuffer = fileId => fetch(`${streamHost()}?link=${hash}&index=${fileId}&preload`)
|
||||||
const getFileLink = (path, id) =>
|
const getFileLink = (path, id) =>
|
||||||
`${streamHost()}/${encodeURIComponent(path.split('\\').pop().split('/').pop())}?link=${hash}&index=${id}&play`
|
`${streamHost()}/${encodeURIComponent(path.split('\\').pop().split('/').pop())}?link=${hash}&index=${id}&play`
|
||||||
@@ -67,13 +69,15 @@ const Table = memo(
|
|||||||
<Button onClick={() => preloadBuffer(id)} variant='outlined' color='primary' size='small'>
|
<Button onClick={() => preloadBuffer(id)} variant='outlined' color='primary' size='small'>
|
||||||
{t('Preload')}
|
{t('Preload')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{isSupported ? (
|
||||||
<a style={{ textDecoration: 'none' }} href={link} target='_blank' rel='noreferrer'>
|
<VideoPlayer title={title} videoSrc={link} onNotSupported={() => setIsSupported(false)} />
|
||||||
<Button style={{ width: '100%' }} variant='outlined' color='primary' size='small'>
|
) : (
|
||||||
{t('OpenLink')}
|
<a style={{ textDecoration: 'none' }} href={link} target='_blank' rel='noreferrer'>
|
||||||
</Button>
|
<Button style={{ width: '100%' }} variant='outlined' color='primary' size='small'>
|
||||||
</a>
|
{t('OpenLink')}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
<CopyToClipboard text={link}>
|
<CopyToClipboard text={link}>
|
||||||
<Button variant='outlined' color='primary' size='small'>
|
<Button variant='outlined' color='primary' size='small'>
|
||||||
{t('CopyLink')}
|
{t('CopyLink')}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Delete as DeleteIcon,
|
Delete as DeleteIcon,
|
||||||
} from '@material-ui/icons'
|
} from '@material-ui/icons'
|
||||||
import { getPeerString, humanizeSize, humanizeSpeed, removeRedundantCharacters } from 'utils/Utils'
|
import { getPeerString, humanizeSize, humanizeSpeed, removeRedundantCharacters } from 'utils/Utils'
|
||||||
import { playlistTorrHost, torrentsHost } from 'utils/Hosts'
|
import { playlistTorrHost, streamHost, torrentsHost } from 'utils/Hosts'
|
||||||
import { NoImageIcon } from 'icons'
|
import { NoImageIcon } from 'icons'
|
||||||
import DialogTorrentDetailsContent from 'components/DialogTorrentDetailsContent'
|
import DialogTorrentDetailsContent from 'components/DialogTorrentDetailsContent'
|
||||||
import Dialog from '@material-ui/core/Dialog'
|
import Dialog from '@material-ui/core/Dialog'
|
||||||
@@ -20,6 +20,8 @@ import { StyledDialog } from 'style/CustomMaterialUiStyles'
|
|||||||
import useOnStandaloneAppOutsideClick from 'utils/useOnStandaloneAppOutsideClick'
|
import useOnStandaloneAppOutsideClick from 'utils/useOnStandaloneAppOutsideClick'
|
||||||
import { GETTING_INFO, IN_DB, CLOSED, PRELOAD, WORKING } from 'torrentStates'
|
import { GETTING_INFO, IN_DB, CLOSED, PRELOAD, WORKING } from 'torrentStates'
|
||||||
import { TORRENT_CATEGORIES } from 'components/categories'
|
import { TORRENT_CATEGORIES } from 'components/categories'
|
||||||
|
import VideoPlayer from 'components/VideoPlayer'
|
||||||
|
import { isFilePlayable } from 'components/DialogTorrentDetailsContent/helpers'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
StatusIndicators,
|
StatusIndicators,
|
||||||
@@ -36,6 +38,7 @@ const Torrent = ({ torrent }) => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [isDetailedInfoOpened, setIsDetailedInfoOpened] = useState(false)
|
const [isDetailedInfoOpened, setIsDetailedInfoOpened] = useState(false)
|
||||||
const [isDeleteTorrentOpened, setIsDeleteTorrentOpened] = useState(false)
|
const [isDeleteTorrentOpened, setIsDeleteTorrentOpened] = useState(false)
|
||||||
|
const [isSupported, setIsSupported] = useState(true)
|
||||||
|
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down('md'))
|
const fullScreen = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
@@ -85,7 +88,18 @@ const Torrent = ({ torrent }) => {
|
|||||||
// main categories
|
// main categories
|
||||||
const catIndex = TORRENT_CATEGORIES.findIndex(e => e.key === category)
|
const catIndex = TORRENT_CATEGORIES.findIndex(e => e.key === category)
|
||||||
const catArray = TORRENT_CATEGORIES.find(e => e.key === category)
|
const catArray = TORRENT_CATEGORIES.find(e => e.key === category)
|
||||||
|
const getFileLink = (path, id) =>
|
||||||
|
`${streamHost()}/${encodeURIComponent(path.split('\\').pop().split('/').pop())}?link=${hash}&index=${id}&play`
|
||||||
|
|
||||||
|
const fileList = torrent?.data ? JSON.parse(torrent?.data)?.TorrServer?.Files : []
|
||||||
|
const playableVideoList = fileList.length ? fileList?.filter(({ path }) => isFilePlayable(path)) : []
|
||||||
|
const getVideoCaption = path => {
|
||||||
|
// Get base name without extension
|
||||||
|
const baseName = path.replace(/\.[^/.]+$/, '')
|
||||||
|
// Find a file with the same base name and a subtitle extension
|
||||||
|
const captionFile = fileList.find(file => file.path.startsWith(baseName) && /\.(srt|vtt)$/i.test(file.path))
|
||||||
|
return captionFile ? getFileLink(captionFile.path, captionFile.id) : ''
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TorrentCard>
|
<TorrentCard>
|
||||||
@@ -99,14 +113,23 @@ const Torrent = ({ torrent }) => {
|
|||||||
<span>{t('Details')}</span>
|
<span>{t('Details')}</span>
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
|
|
||||||
<StyledButton
|
{playableVideoList?.length === 1 && isSupported ? (
|
||||||
onClick={() => {
|
<VideoPlayer
|
||||||
window.open(fullPlaylistLink, '_blank')
|
title={title}
|
||||||
}}
|
videoSrc={getFileLink(playableVideoList[0].path, playableVideoList[0].id)}
|
||||||
>
|
captionSrc={getVideoCaption(playableVideoList[0].path)}
|
||||||
<PlayArrowIcon />
|
onNotSupported={() => setIsSupported(false)}
|
||||||
<span>{t('Playlist')}</span>
|
/>
|
||||||
</StyledButton>
|
) : (
|
||||||
|
<StyledButton
|
||||||
|
onClick={() => {
|
||||||
|
window.open(fullPlaylistLink, '_blank')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlayArrowIcon />
|
||||||
|
<span>{t('Playlist')}</span>
|
||||||
|
</StyledButton>
|
||||||
|
)}
|
||||||
|
|
||||||
<StyledButton onClick={() => dropTorrent(torrent)}>
|
<StyledButton onClick={() => dropTorrent(torrent)}>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
|
|||||||
493
web/src/components/VideoPlayer.jsx
Normal file
493
web/src/components/VideoPlayer.jsx
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
CircularProgress,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Slider,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
useMediaQuery,
|
||||||
|
} from '@material-ui/core'
|
||||||
|
import { makeStyles, withStyles } from '@material-ui/core/styles'
|
||||||
|
import CloseIcon from '@material-ui/icons/Close'
|
||||||
|
import Forward10Icon from '@material-ui/icons/Forward10'
|
||||||
|
import FullscreenIcon from '@material-ui/icons/Fullscreen'
|
||||||
|
import FullscreenExitIcon from '@material-ui/icons/FullscreenExit'
|
||||||
|
import GetAppIcon from '@material-ui/icons/GetApp'
|
||||||
|
import PauseIcon from '@material-ui/icons/Pause'
|
||||||
|
import PictureInPictureIcon from '@material-ui/icons/PictureInPicture'
|
||||||
|
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
|
||||||
|
import Replay10Icon from '@material-ui/icons/Replay10'
|
||||||
|
import SpeedIcon from '@material-ui/icons/Speed'
|
||||||
|
import VolumeOffIcon from '@material-ui/icons/VolumeOff'
|
||||||
|
import VolumeUpIcon from '@material-ui/icons/VolumeUp'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { StyledDialog } from 'style/CustomMaterialUiStyles'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { StyledButton } from './TorrentCard/style'
|
||||||
|
|
||||||
|
function getMimeType(url) {
|
||||||
|
const ext = url.split('?')[0].split('.').pop().toLowerCase()
|
||||||
|
switch (ext) {
|
||||||
|
case 'mp4':
|
||||||
|
return 'video/mp4'
|
||||||
|
case 'ogg':
|
||||||
|
return 'video/ogg'
|
||||||
|
case 'webm':
|
||||||
|
return 'video/webm'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PrettoSlider = withStyles(theme => ({
|
||||||
|
root: {
|
||||||
|
color: '#00a572',
|
||||||
|
height: 6,
|
||||||
|
[theme?.breakpoints?.down?.('sm')]: {
|
||||||
|
height: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thumb: {
|
||||||
|
height: 18,
|
||||||
|
width: 18,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
border: '2px solid currentColor',
|
||||||
|
marginTop: -6,
|
||||||
|
marginLeft: -12,
|
||||||
|
[theme?.breakpoints?.down?.('sm')]: {
|
||||||
|
height: 15,
|
||||||
|
width: 15,
|
||||||
|
marginTop: -5,
|
||||||
|
marginLeft: -7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 4,
|
||||||
|
[theme?.breakpoints?.down?.('sm')]: {
|
||||||
|
height: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rail: {
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 4,
|
||||||
|
[theme?.breakpoints?.down?.('sm')]: {
|
||||||
|
height: 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))(Slider)
|
||||||
|
|
||||||
|
const useStyles = makeStyles(theme => ({
|
||||||
|
dialogPaper: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: theme.spacing(1),
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: '#00a572',
|
||||||
|
color: '#fff',
|
||||||
|
padding: theme.spacing(1, 2),
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
videoWrapper: {
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
overflow: 'hidden',
|
||||||
|
'&:hover $controls, &:hover $centralControl, &:hover $skipButton': {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
width: '100%',
|
||||||
|
display: 'block',
|
||||||
|
cursor: 'pointer',
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
height: '94.5vh',
|
||||||
|
width: '100vw',
|
||||||
|
objectFit: 'contain',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
loadingOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
zIndex: 4,
|
||||||
|
},
|
||||||
|
centralControl: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 200ms',
|
||||||
|
zIndex: 3,
|
||||||
|
color: '#fff',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
animation: '$pulse 0.6s ease-out',
|
||||||
|
},
|
||||||
|
skipButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
color: '#fff',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 200ms',
|
||||||
|
zIndex: 3,
|
||||||
|
'&:hover': { backgroundColor: 'rgba(0,0,0,0.6)' },
|
||||||
|
},
|
||||||
|
leftSkip: { left: theme.spacing(2) },
|
||||||
|
rightSkip: { right: theme.spacing(2) },
|
||||||
|
controls: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
background: 'linear-gradient(to top, rgba(0,0,0,0.8), transparent)',
|
||||||
|
padding: theme.spacing(0, 3, 2, 3),
|
||||||
|
transition: 'opacity 200ms',
|
||||||
|
opacity: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: theme.spacing(0.5),
|
||||||
|
zIndex: 3,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
opacity: 1,
|
||||||
|
padding: theme.spacing(0, 1, 2, 1),
|
||||||
|
gap: theme.spacing(0),
|
||||||
|
background: 'linear-gradient(to top, rgba(0,0,0,0.95), transparent)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timeRow: {
|
||||||
|
color: '#fff',
|
||||||
|
paddingLeft: theme.spacing(2),
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
paddingLeft: theme.spacing(1),
|
||||||
|
fontSize: 9,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slider: {
|
||||||
|
color: '#00e68a',
|
||||||
|
'& .MuiSlider-thumb': { backgroundColor: '#00e68a' },
|
||||||
|
'& .MuiSlider-track': { borderRadius: 2 },
|
||||||
|
},
|
||||||
|
controlRow: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
color: '#fff',
|
||||||
|
padding: 12,
|
||||||
|
'&:hover': { backgroundColor: 'rgba(255,255,255,0.1)' },
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
speedMenu: { minWidth: 100 },
|
||||||
|
'@keyframes pulse': {
|
||||||
|
'0%': { transform: 'translate(-50%, -50%) scale(0.5)', opacity: 0 },
|
||||||
|
'50%': { transform: 'translate(-50%, -50%) scale(1)', opacity: 1 },
|
||||||
|
'100%': { transform: 'translate(-50%, -50%) scale(1.3)', opacity: 0 },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Helper function to format seconds to HH:MM:SS
|
||||||
|
const formatTime = seconds => {
|
||||||
|
if (!isFinite(seconds)) return '00:00:00'
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
const hh = h.toString().padStart(2, '0')
|
||||||
|
const mm = m.toString().padStart(2, '0')
|
||||||
|
const ss = s.toString().padStart(2, '0')
|
||||||
|
return `${hh}:${mm}:${ss}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoPlayer = ({ videoSrc, captionSrc = '', title, onNotSupported }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const isMobile = useMediaQuery('@media (max-width:930px)')
|
||||||
|
const videoRef = useRef(null)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [playing, setPlaying] = useState(false)
|
||||||
|
const [currentTime, setCurrentTime] = useState(0)
|
||||||
|
const [duration, setDuration] = useState(0)
|
||||||
|
const [muted, setMuted] = useState(false)
|
||||||
|
const [volume, setVolume] = useState(1)
|
||||||
|
const [fullscreen, setFullscreen] = useState(false)
|
||||||
|
const [anchorEl, setAnchorEl] = useState(null)
|
||||||
|
const [speed, setSpeed] = useState(1)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const vid = document.createElement('video')
|
||||||
|
if (!vid.canPlayType(getMimeType(videoSrc))) onNotSupported()
|
||||||
|
}, [videoSrc, onNotSupported])
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (!videoRef.current) return
|
||||||
|
playing ? videoRef.current.pause() : videoRef.current.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePlay = () => setPlaying(p => !p)
|
||||||
|
const handleTimeUpdate = () => setCurrentTime(videoRef.current.currentTime)
|
||||||
|
const handleLoaded = () => {
|
||||||
|
setDuration(videoRef.current.duration)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
const handleSeek = (_, val) => {
|
||||||
|
videoRef.current.currentTime = val
|
||||||
|
handleTimeUpdate()
|
||||||
|
}
|
||||||
|
const handleVolume = (_, val) => {
|
||||||
|
const v = val / 100
|
||||||
|
videoRef.current.volume = v
|
||||||
|
setVolume(v)
|
||||||
|
setMuted(v === 0)
|
||||||
|
}
|
||||||
|
const toggleMute = () => {
|
||||||
|
videoRef.current.muted = !muted
|
||||||
|
setMuted(m => !m)
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = secs => {
|
||||||
|
videoRef.current.currentTime = Math.min(Math.max(videoRef.current.currentTime + secs, 0), duration)
|
||||||
|
handleTimeUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterFull = () => videoRef.current.requestFullscreen()
|
||||||
|
const exitFull = () => document.exitFullscreen()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onFull = () => setFullscreen(!!document.fullscreenElement)
|
||||||
|
document.addEventListener('fullscreenchange', onFull)
|
||||||
|
return () => document.removeEventListener('fullscreenchange', onFull)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openSpeedMenu = e => setAnchorEl(e.currentTarget)
|
||||||
|
const closeSpeedMenu = () => setAnchorEl(null)
|
||||||
|
const changeSpeed = val => {
|
||||||
|
videoRef.current.playbackRate = val
|
||||||
|
setSpeed(val)
|
||||||
|
closeSpeedMenu()
|
||||||
|
}
|
||||||
|
const downloadVideo = () => {
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = videoSrc
|
||||||
|
a.download = ''
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKey = useCallback(
|
||||||
|
e => {
|
||||||
|
if (!open) return
|
||||||
|
switch (e.key) {
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault()
|
||||||
|
handlePlayPause()
|
||||||
|
break
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault()
|
||||||
|
skip(10)
|
||||||
|
break
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault()
|
||||||
|
skip(-10)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[open, duration, playing],
|
||||||
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('keydown', handleKey)
|
||||||
|
return () => document.removeEventListener('keydown', handleKey)
|
||||||
|
}, [handleKey])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StyledButton onClick={() => setOpen(true)}>
|
||||||
|
<PlayArrowIcon />
|
||||||
|
<span>{t('Play')}</span>
|
||||||
|
</StyledButton>
|
||||||
|
<StyledDialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
maxWidth='lg'
|
||||||
|
fullWidth
|
||||||
|
fullScreen={isMobile}
|
||||||
|
classes={{ paper: classes.dialogPaper }}
|
||||||
|
>
|
||||||
|
<DialogTitle className={classes.header} disableTypography>
|
||||||
|
<Typography variant='h6' noWrap>
|
||||||
|
{title || 'Video Player'}
|
||||||
|
</Typography>
|
||||||
|
<IconButton size='medium' onClick={() => setOpen(false)} className={classes.iconButton}>
|
||||||
|
<CloseIcon fontSize='medium' />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent style={{ padding: 0 }}>
|
||||||
|
<Box className={classes.videoWrapper} onClick={handlePlayPause} style={isMobile ? { minHeight: 240 } : {}}>
|
||||||
|
<video
|
||||||
|
autoPlay
|
||||||
|
ref={videoRef}
|
||||||
|
src={videoSrc}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={handleLoaded}
|
||||||
|
onPlay={togglePlay}
|
||||||
|
onPause={togglePlay}
|
||||||
|
className={classes.video}
|
||||||
|
>
|
||||||
|
<track kind='captions' srcLang='en' label='English captions' src={captionSrc} default />
|
||||||
|
</video>
|
||||||
|
{loading && (
|
||||||
|
<Box className={classes.loadingOverlay}>
|
||||||
|
<CircularProgress fontSize='medium' />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
size='medium'
|
||||||
|
className={classes.centralControl}
|
||||||
|
style={{
|
||||||
|
opacity: playing ? 0 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlayArrowIcon fontSize='medium' />
|
||||||
|
</IconButton>
|
||||||
|
<Box className={classes.controls} onClick={e => e.stopPropagation()}>
|
||||||
|
{isMobile && (
|
||||||
|
<Box className={classes.timeRow}>
|
||||||
|
<Typography variant='body2'>
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<PrettoSlider
|
||||||
|
className={classes.slider}
|
||||||
|
value={currentTime}
|
||||||
|
max={duration}
|
||||||
|
onChange={handleSeek}
|
||||||
|
size='medium'
|
||||||
|
/>
|
||||||
|
<Box className={classes.controlRow}>
|
||||||
|
<Tooltip title={playing ? t('Pause') : t('Play')}>
|
||||||
|
<IconButton size='medium' onClick={handlePlayPause} className={classes.iconButton}>
|
||||||
|
{playing ? <PauseIcon fontSize='medium' /> : <PlayArrowIcon fontSize='medium' />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('Rewind-10-Sec')}>
|
||||||
|
<IconButton
|
||||||
|
size='medium'
|
||||||
|
className={classes.iconButton}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
skip(-10)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Replay10Icon fontSize='medium' />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={t('Forward-10-Sec')}>
|
||||||
|
<IconButton
|
||||||
|
size='medium'
|
||||||
|
className={classes.iconButton}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
skip(10)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Forward10Icon fontSize='medium' />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={muted ? t('Unmute') : t('Mute')}>
|
||||||
|
<IconButton size='medium' className={classes.iconButton} onClick={toggleMute}>
|
||||||
|
{muted ? <VolumeOffIcon fontSize='medium' /> : <VolumeUpIcon fontSize='medium' />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
{!isMobile && (
|
||||||
|
<Slider
|
||||||
|
className={classes.slider}
|
||||||
|
value={volume * 100}
|
||||||
|
onChange={handleVolume}
|
||||||
|
size='medium'
|
||||||
|
style={{ width: 70 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isMobile && (
|
||||||
|
<Box className={classes.timeRow}>
|
||||||
|
<Typography variant='body2'>
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box flexGrow={1} />
|
||||||
|
<Tooltip title={t('Speed')}>
|
||||||
|
<IconButton size='medium' onClick={openSpeedMenu} className={classes.iconButton}>
|
||||||
|
<SpeedIcon fontSize='medium' />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
onClose={closeSpeedMenu}
|
||||||
|
className={classes.speedMenu}
|
||||||
|
>
|
||||||
|
{[0.5, 1, 1.5, 2].map(r => (
|
||||||
|
<MenuItem key={r} selected={r === speed} onClick={() => changeSpeed(r)}>
|
||||||
|
{r}x
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
<Tooltip title={t('PIP')}>
|
||||||
|
<IconButton
|
||||||
|
size='medium'
|
||||||
|
className={classes.iconButton}
|
||||||
|
onClick={() => videoRef.current.requestPictureInPicture()}
|
||||||
|
>
|
||||||
|
<PictureInPictureIcon fontSize='medium' />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={t('Download')}>
|
||||||
|
<IconButton size='medium' className={classes.iconButton} onClick={downloadVideo}>
|
||||||
|
<GetAppIcon fontSize='medium' />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={fullscreen ? t('ExitFullscreen') : t('Fullscreen')}>
|
||||||
|
<IconButton size='medium' onClick={fullscreen ? exitFull : enterFull} className={classes.iconButton}>
|
||||||
|
{fullscreen ? <FullscreenExitIcon fontSize='medium' /> : <FullscreenIcon fontSize='medium' />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
</StyledDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VideoPlayer
|
||||||
@@ -83,6 +83,16 @@
|
|||||||
"PiecesCount": "Брой парчета",
|
"PiecesCount": "Брой парчета",
|
||||||
"PiecesLength": "Дължина на парчетата",
|
"PiecesLength": "Дължина на парчетата",
|
||||||
"Playlist": "Плейлист",
|
"Playlist": "Плейлист",
|
||||||
|
"Play":"Възпроизвеждане",
|
||||||
|
"Pause":"Пауза",
|
||||||
|
"Rewind-10-Sec":"Върни 10 сек",
|
||||||
|
"Forward-10-Sec":"Напред 10 сек",
|
||||||
|
"Unmute":"Включи звук",
|
||||||
|
"Mute":"Изключи звук",
|
||||||
|
"PIP":"PIP",
|
||||||
|
"Download":"Изтегляне",
|
||||||
|
"ExitFullscreen":"Изход от цял екран",
|
||||||
|
"Fullscreen":"Цял екран",
|
||||||
"Preload": "Предварително зареждане",
|
"Preload": "Предварително зареждане",
|
||||||
"ProjectSource": "GitHub на проекта",
|
"ProjectSource": "GitHub на проекта",
|
||||||
"PWAGuide": {
|
"PWAGuide": {
|
||||||
|
|||||||
@@ -83,6 +83,16 @@
|
|||||||
"PiecesCount": "Pieces count",
|
"PiecesCount": "Pieces count",
|
||||||
"PiecesLength": "Pieces length",
|
"PiecesLength": "Pieces length",
|
||||||
"Playlist": "Playlist",
|
"Playlist": "Playlist",
|
||||||
|
"Play":"Play",
|
||||||
|
"Pause":"Pause",
|
||||||
|
"Rewind-10-Sec":"Rewind 10 Sec",
|
||||||
|
"Forward-10-Sec":"Forward 10 Sec",
|
||||||
|
"Unmute":"Unmute",
|
||||||
|
"Mute":"Mute",
|
||||||
|
"PIP":"Picture-In-Picture",
|
||||||
|
"Download":"Download",
|
||||||
|
"ExitFullscreen":"Exit Fullscreen",
|
||||||
|
"Fullscreen":"Fullscreen",
|
||||||
"Preload": "Preload",
|
"Preload": "Preload",
|
||||||
"ProjectSource": "Project GitHub",
|
"ProjectSource": "Project GitHub",
|
||||||
"PWAGuide": {
|
"PWAGuide": {
|
||||||
|
|||||||
@@ -83,6 +83,16 @@
|
|||||||
"PiecesCount": "Кол-во блоков",
|
"PiecesCount": "Кол-во блоков",
|
||||||
"PiecesLength": "Размер блока",
|
"PiecesLength": "Размер блока",
|
||||||
"Playlist": "Плейлист",
|
"Playlist": "Плейлист",
|
||||||
|
"Play":"Воспроизвести",
|
||||||
|
"Pause":"Пауза",
|
||||||
|
"Rewind-10-Sec":"Перемотать на 10 сек",
|
||||||
|
"Forward-10-Sec":"Перемотать на 10 сек",
|
||||||
|
"Unmute":"Включить звук",
|
||||||
|
"Mute":"Выключить звук",
|
||||||
|
"PIP":"Картинка в картинке",
|
||||||
|
"Download":"Скачать",
|
||||||
|
"ExitFullscreen":"Выход из полноэкранного режима",
|
||||||
|
"Fullscreen":"Полноэкранный режим",
|
||||||
"Preload": "Предзагр.",
|
"Preload": "Предзагр.",
|
||||||
"ProjectSource": "GitHub проекта",
|
"ProjectSource": "GitHub проекта",
|
||||||
"PWAGuide": {
|
"PWAGuide": {
|
||||||
|
|||||||
@@ -83,6 +83,16 @@
|
|||||||
"PiecesCount": "К-сть блоків",
|
"PiecesCount": "К-сть блоків",
|
||||||
"PiecesLength": "Розмір блоку",
|
"PiecesLength": "Розмір блоку",
|
||||||
"Playlist": "Плейлист",
|
"Playlist": "Плейлист",
|
||||||
|
"Play":"Відтворити",
|
||||||
|
"Pause":"Пауза",
|
||||||
|
"Rewind-10-Sec":"Перемотати на 10 сек",
|
||||||
|
"Forward-10-Sec":"Перемотати на 10 сек",
|
||||||
|
"Unmute":"Включити звук",
|
||||||
|
"Mute":"Вимкнути звук",
|
||||||
|
"PIP":"Картинка в картинці",
|
||||||
|
"Download":"Завантажити",
|
||||||
|
"ExitFullscreen":"Вийти з повноекранного режиму",
|
||||||
|
"Fullscreen":"Повноекранний режим",
|
||||||
"Preload": "Передзав.",
|
"Preload": "Передзав.",
|
||||||
"ProjectSource": "Сайт проекту",
|
"ProjectSource": "Сайт проекту",
|
||||||
"PWAGuide": {
|
"PWAGuide": {
|
||||||
|
|||||||
@@ -83,6 +83,16 @@
|
|||||||
"PiecesCount": "块数量",
|
"PiecesCount": "块数量",
|
||||||
"PiecesLength": "块长度",
|
"PiecesLength": "块长度",
|
||||||
"Playlist": "播放列表",
|
"Playlist": "播放列表",
|
||||||
|
"Play":"播放",
|
||||||
|
"Pause":"暂停",
|
||||||
|
"Rewind-10-Sec":"倒退 10 秒",
|
||||||
|
"Forward-10-Sec":"快进 10 秒",
|
||||||
|
"Unmute":"取消静音",
|
||||||
|
"Mute":"静音",
|
||||||
|
"PIP":"画中画",
|
||||||
|
"Download":"下载",
|
||||||
|
"ExitFullscreen":"退出全屏",
|
||||||
|
"Fullscreen":"全屏",
|
||||||
"Preload": "预加载",
|
"Preload": "预加载",
|
||||||
"ProjectSource": "项目GitHub",
|
"ProjectSource": "项目GitHub",
|
||||||
"PWAGuide": {
|
"PWAGuide": {
|
||||||
|
|||||||
Reference in New Issue
Block a user