Fixes #4721 -- Base elapsed time in players on server time, not client time.

This commit is contained in:
Buster Neece 2022-12-28 09:49:12 -06:00
parent 03b593f6b0
commit 6a5276820b
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
11 changed files with 241 additions and 129 deletions

View File

@ -142,6 +142,7 @@ return static function (CallableEventDispatcherInterface $dispatcher) {
App\Sync\Task\RotateLogsTask::class,
App\Sync\Task\RunAnalyticsTask::class,
App\Sync\Task\RunBackupTask::class,
App\Sync\Task\SendTimeOnSocketTask::class,
App\Sync\Task\UpdateGeoLiteTask::class,
App\Sync\Task\UpdateStorageLocationSizesTask::class,
]);

View File

@ -17,7 +17,7 @@ const props = defineProps({
},
});
const np = useNowPlaying(props);
const {np} = useNowPlaying(props);
const history = computed(() => {
return np.value.song_history ?? [];

View File

@ -28,18 +28,18 @@
<h4 class="now-playing-title">{{ np.now_playing.song.text }}</h4>
</div>
<div class="time-display" v-if="time_display_played != null">
<div class="time-display" v-if="currentTimeElapsedDisplay != null">
<div class="time-display-played text-secondary">
{{ time_display_played }}
{{ currentTimeElapsedDisplay }}
</div>
<div class="time-display-progress">
<div class="progress">
<div class="progress-bar bg-secondary" role="progressbar"
:style="{ width: time_percent+'%' }"></div>
:style="{ width: currentTrackPercent+'%' }"></div>
</div>
</div>
<div class="time-display-total text-secondary">
{{ time_display_total }}
{{ currentTimeTotalDisplay }}
</div>
</div>
</div>
@ -48,14 +48,14 @@
<hr>
<div class="radio-controls">
<play-button class="radio-control-play-button" icon-class="outlined lg" :url="current_stream.url"
:is-hls="current_stream.hls" is-stream></play-button>
<play-button class="radio-control-play-button" icon-class="outlined lg" :url="currentStream.url"
:is-hls="currentStream.hls" is-stream></play-button>
<div class="radio-control-select-stream">
<div v-if="streams.length > 1" class="dropdown">
<button class="btn btn-sm btn-outline-primary dropdown-toggle" type="button" id="btn-select-stream"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ current_stream.name }}
{{ currentStream.name }}
</button>
<div class="dropdown-menu" aria-labelledby="btn-select-stream">
<a class="dropdown-item" v-for="stream in streams" href="javascript:"
@ -212,7 +212,7 @@ import AudioPlayer from '~/components/Common/AudioPlayer';
import Icon from '~/components/Common/Icon';
import PlayButton from "~/components/Common/PlayButton";
import {computed, onMounted, ref, shallowRef, watch} from "vue";
import {useIntervalFn, useMounted, useStorage} from "@vueuse/core";
import {useMounted, useStorage} from "@vueuse/core";
import formatTime from "~/functions/formatTime";
import {useTranslate} from "~/vendor/gettext";
import useNowPlaying from "~/functions/useNowPlaying";
@ -224,9 +224,9 @@ const props = defineProps({
const emit = defineEmits(['np_updated']);
const np = useNowPlaying(props);
const np_elapsed = ref(0);
const current_stream = shallowRef({
const {np, currentTrackElapsed, currentTrackDuration} = useNowPlaying(props);
const currentStream = shallowRef({
'name': '',
'url': '',
'hls': false,
@ -240,11 +240,11 @@ const enable_hls = computed(() => {
const {$gettext} = useTranslate();
const streams = computed(() => {
let all_streams = [];
let allStreams = [];
let $np = np.value;
if (enable_hls.value) {
all_streams.push({
allStreams.push({
'name': $gettext('HLS'),
'url': $np.station.hls_url,
'hls': true,
@ -252,60 +252,55 @@ const streams = computed(() => {
}
$np.station.mounts.forEach(function (mount) {
all_streams.push({
allStreams.push({
'name': mount.name,
'url': mount.url,
'hls': false,
});
});
$np.station.remotes.forEach(function (remote) {
all_streams.push({
allStreams.push({
'name': remote.name,
'url': remote.url,
'hls': false,
});
});
return all_streams;
return allStreams;
});
const time_total = computed(() => {
let $np = np.value;
return $np?.now_playing?.duration ?? 0;
});
const currentTrackPercent = computed(() => {
let $currentTrackElapsed = currentTrackElapsed.value;
let $currentTrackDuration = currentTrackDuration.value;
const time_percent = computed(() => {
let $np_elapsed = np_elapsed.value;
let $time_total = time_total.value;
if (!$time_total) {
if (!$currentTrackDuration) {
return 0;
}
if ($np_elapsed > $time_total) {
if ($currentTrackElapsed > $currentTrackDuration) {
return 100;
}
return ($np_elapsed / $time_total) * 100;
return ($currentTrackElapsed / $currentTrackDuration) * 100;
});
const time_display_played = computed(() => {
let $np_elapsed = np_elapsed.value;
let $time_total = time_total.value;
const currentTimeElapsedDisplay = computed(() => {
let $currentTrackElapsed = currentTrackElapsed.value;
let $currentTrackDuration = currentTrackDuration.value;
if (!$time_total) {
if (!$currentTrackDuration) {
return null;
}
if ($np_elapsed > $time_total) {
$np_elapsed = $time_total;
if ($currentTrackElapsed > $currentTrackDuration) {
$currentTrackElapsed = $currentTrackDuration;
}
return formatTime($np_elapsed);
return formatTime($currentTrackElapsed);
});
const time_display_total = computed(() => {
let $time_total = time_total.value;
return ($time_total) ? formatTime($time_total) : null;
const currentTimeTotalDisplay = computed(() => {
let $currentTrackDuration = currentTrackDuration.value;
return ($currentTrackDuration) ? formatTime($currentTrackDuration) : null;
});
const isMounted = useMounted();
@ -323,31 +318,13 @@ const fullVolume = () => {
};
const switchStream = (new_stream) => {
current_stream.value = new_stream;
currentStream.value = new_stream;
player.value.toggle(new_stream.url, true, new_stream.hls);
};
onMounted(() => {
useIntervalFn(
() => {
let $np = np.value;
let current_time = Math.floor(Date.now() / 1000);
let $np_elapsed = current_time - $np.now_playing.played_at;
if ($np_elapsed < 0) {
$np_elapsed = 0;
} else if ($np_elapsed >= $np.now_playing.duration) {
$np_elapsed = $np.now_playing.duration;
}
np_elapsed.value = $np_elapsed;
},
1000
);
if (props.autoplay) {
switchStream(current_stream.value);
switchStream(currentStream.value);
}
});
@ -356,27 +333,27 @@ const onNowPlayingUpdated = (np_new) => {
// Set a "default" current stream if none exists.
let $streams = streams.value;
let $current_stream = current_stream.value;
let $currentStream = currentStream.value;
if ($current_stream.url === '' && $streams.length > 0) {
if ($currentStream.url === '' && $streams.length > 0) {
if (props.hlsIsDefault && enable_hls.value) {
current_stream.value = $streams[0];
currentStream.value = $streams[0];
} else {
$current_stream = null;
$currentStream = null;
if (np_new.station.listen_url !== '') {
$streams.forEach(function (stream) {
if (stream.url === np_new.station.listen_url) {
$current_stream = stream;
$currentStream = stream;
}
});
}
if ($current_stream === null) {
$current_stream = $streams[0];
if ($currentStream === null) {
$currentStream = $streams[0];
}
current_stream.value = $current_stream;
currentStream.value = $currentStream;
}
}
};

View File

@ -17,5 +17,5 @@ export default {
autoplay: {
type: Boolean,
default: false
}
},
}

View File

@ -1,7 +1,8 @@
import NowPlaying from '~/components/Entity/NowPlaying';
import {onMounted, shallowRef, watch} from "vue";
import {useEventSource} from "@vueuse/core";
import {onMounted, ref, shallowRef, watch} from "vue";
import {useEventSource, useIntervalFn} from "@vueuse/core";
import {useAxios} from "~/vendor/axios";
import {has} from "lodash";
export const nowPlayingProps = {
nowPlayingUri: {
@ -23,15 +24,25 @@ export const nowPlayingProps = {
default() {
return NowPlaying;
}
}
},
timeUri: {
type: String,
required: true
},
};
export default function useNowPlaying(props) {
const np = shallowRef(props.initialNowPlaying);
const currentTime = ref(Math.floor(Date.now() / 1000));
const currentTrackDuration = ref(0);
const currentTrackElapsed = ref(0);
const setNowPlaying = (np_new) => {
np.value = np_new;
currentTrackDuration.value = np_new?.now_playing?.duration ?? 0;
// Update the browser metadata for browsers that support it (i.e. Mobile Chrome)
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
@ -48,16 +59,21 @@ export default function useNowPlaying(props) {
}));
}
// Trigger initial NP set.
setNowPlaying(np.value);
if (props.useSse) {
const {data} = useEventSource(props.sseUri);
watch(data, (sse_data_raw) => {
const sse_data = JSON.parse(sse_data_raw);
const sse_np = sse_data?.pub?.data?.np || null;
watch(data, (data_raw) => {
const json_data = JSON.parse(data_raw);
const json_data_np = json_data?.pub?.data ?? {};
if (sse_np) {
if (has(json_data_np, 'np')) {
setTimeout(() => {
setNowPlaying(sse_np);
setNowPlaying(json_data_np.np);
}, 3000);
} else if (has(json_data_np, 'time')) {
currentTime.value = json_data_np.time;
}
});
} else {
@ -76,12 +92,51 @@ export default function useNowPlaying(props) {
}).catch(() => {
setTimeout(checkNowPlaying, (!document.hidden) ? 30000 : 120000);
});
}
};
const checkTime = () => {
axios.get(props.timeUri, {
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Expires': '0',
}
}).then((response) => {
currentTime.value = response.data.timestamp;
}).finally(() => {
setTimeout(checkTime, (!document.hidden) ? 300000 : 600000);
});
};
onMounted(() => {
setTimeout(checkTime, 5000);
setTimeout(checkNowPlaying, 5000);
});
}
return np;
onMounted(() => {
useIntervalFn(
() => {
let currentTrackPlayedAt = np.value?.now_playing?.played_at ?? 0;
let elapsed = currentTime.value - currentTrackPlayedAt;
if (elapsed < 0) {
elapsed = 0;
} else if (elapsed >= currentTrackDuration.value) {
elapsed = currentTrackDuration.value;
}
currentTrackElapsed.value = elapsed;
currentTime.value = currentTime.value + 1;
},
1000
);
});
return {
np,
currentTime,
currentTrackDuration,
currentTrackElapsed
};
}

View File

@ -4,16 +4,16 @@ declare(strict_types=1);
namespace App\Controller\Frontend\PublicPages;
use App\Entity;
use App\Exception\StationNotFoundException;
use App\Http\Response;
use App\Http\ServerRequest;
use App\VueComponent\NowPlayingComponent;
use Psr\Http\Message\ResponseInterface;
final class HistoryAction
{
public function __construct(
private readonly Entity\ApiGenerator\NowPlayingApiGenerator $npApiGenerator
private readonly NowPlayingComponent $nowPlayingComponent
) {
}
@ -28,14 +28,6 @@ final class HistoryAction
throw new StationNotFoundException();
}
$np = $this->npApiGenerator->currentOrEmpty($station);
$np->resolveUrls($request->getRouter()->getBaseUrl());
$customization = $request->getCustomization();
$router = $request->getRouter();
$useStatic = $customization->useStaticNowPlaying();
return $request->getView()->renderVuePage(
response: $response->withHeader('X-Frame-Options', '*'),
component: 'Vue_PublicHistory',
@ -46,13 +38,7 @@ final class HistoryAction
'page_class' => 'embed station-' . $station->getShortName(),
'hide_footer' => true,
],
props: [
'initialNowPlaying' => $np,
'showAlbumArt' => !$customization->hideAlbumArt(),
'nowPlayingUri' => $useStatic
? '/api/nowplaying_static/' . urlencode($station->getShortName()) . '.json'
: $router->named('api:nowplaying:index', ['station_id' => $station->getShortName()]),
],
props: $this->nowPlayingComponent->getProps($request),
);
}
}

View File

@ -8,15 +8,14 @@ use App\Entity;
use App\Exception\StationNotFoundException;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Service\Centrifugo;
use App\VueComponent\NowPlayingComponent;
use Psr\Http\Message\ResponseInterface;
final class PlayerAction
{
public function __construct(
private readonly Entity\ApiGenerator\NowPlayingApiGenerator $npApiGenerator,
private readonly Entity\Repository\CustomFieldRepository $customFieldRepo,
private readonly Centrifugo $centrifugo
private readonly NowPlayingComponent $nowPlayingComponent
) {
}
@ -36,32 +35,10 @@ final class PlayerAction
throw new StationNotFoundException();
}
$baseUrl = $request->getRouter()->getBaseUrl();
$np = $this->npApiGenerator->currentOrEmpty($station);
$np->resolveUrls($baseUrl);
// Build Vue props.
$customization = $request->getCustomization();
$router = $request->getRouter();
$backendConfig = $station->getBackendConfig();
$props = [
'initialNowPlaying' => $np,
'showAlbumArt' => !$customization->hideAlbumArt(),
'autoplay' => !empty($request->getQueryParam('autoplay')),
'showHls' => $backendConfig->getHlsEnableOnPublicPlayer(),
'hlsIsDefault' => $backendConfig->getHlsIsDefault(),
'nowPlayingUri' => $customization->useStaticNowPlaying()
? '/api/nowplaying_static/' . urlencode($station->getShortName()) . '.json'
: $router->named('api:nowplaying:index', ['station_id' => $station->getShortName()]),
];
if ($customization->useStaticNowPlaying() && $this->centrifugo->isSupported()) {
$props['useSse'] = true;
$props['sseUri'] = $this->centrifugo->getSseUrl($station);
}
$props = $this->nowPlayingComponent->getProps($request);
// Render embedded player.
if (!empty($embed)) {

View File

@ -10,6 +10,9 @@ use GuzzleHttp\Client;
final class Centrifugo
{
public const GLOBAL_TIME_CHANNEL = 'global:time';
public function __construct(
private readonly Environment $environment,
private readonly Client $client,
@ -21,20 +24,38 @@ final class Centrifugo
return $this->environment->isDocker();
}
public function sendTime(): void
{
$this->send([
'method' => 'publish',
'params' => [
'channel' => self::GLOBAL_TIME_CHANNEL,
'data' => [
'time' => time(),
],
],
]);
}
public function publishToStation(Station $station, mixed $message): void
{
$this->send([
'method' => 'publish',
'params' => [
'channel' => $this->getChannelName($station),
'data' => [
'np' => $message,
],
],
]);
}
private function send(array $body): void
{
$this->client->post(
'http://localhost:6025/api',
[
'json' => [
'method' => 'publish',
'params' => [
'channel' => $this->getChannelName($station),
'data' => [
'np' => $message,
],
],
],
'json' => $body,
]
);
}
@ -47,6 +68,7 @@ final class Centrifugo
[
'subs' => [
$this->getChannelName($station) => [],
self::GLOBAL_TIME_CHANNEL => [],
],
],
JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR | JSON_FORCE_OBJECT

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Sync\Task;
use App\Doctrine\ReloadableEntityManagerInterface;
use App\Service\Centrifugo;
use Psr\Log\LoggerInterface;
final class SendTimeOnSocketTask extends AbstractTask
{
public function __construct(
private readonly Centrifugo $centrifugo,
ReloadableEntityManagerInterface $em,
LoggerInterface $logger,
) {
parent::__construct($em, $logger);
}
public static function getSchedulePattern(): string
{
return '* * * * *';
}
public function run(bool $force = false): void
{
if (!$this->centrifugo->isSupported()) {
return;
}
$this->centrifugo->sendTime();
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\VueComponent;
use App\Entity\ApiGenerator\NowPlayingApiGenerator;
use App\Http\ServerRequest;
use App\Service\Centrifugo;
final class NowPlayingComponent implements VueComponentInterface
{
public function __construct(
private readonly NowPlayingApiGenerator $npApiGenerator,
private readonly Centrifugo $centrifugo
) {
}
public function getProps(ServerRequest $request): array
{
$station = $request->getStation();
$baseUrl = $request->getRouter()->getBaseUrl();
$np = $this->npApiGenerator->currentOrEmpty($station);
$np->resolveUrls($baseUrl);
$customization = $request->getCustomization();
$router = $request->getRouter();
$backendConfig = $station->getBackendConfig();
$props = [
'initialNowPlaying' => $np,
'showAlbumArt' => !$customization->hideAlbumArt(),
'autoplay' => !empty($request->getQueryParam('autoplay')),
'showHls' => $backendConfig->getHlsEnableOnPublicPlayer(),
'hlsIsDefault' => $backendConfig->getHlsIsDefault(),
'nowPlayingUri' => $customization->useStaticNowPlaying()
? '/api/nowplaying_static/' . urlencode($station->getShortName()) . '.json'
: $router->named('api:nowplaying:index', ['station_id' => $station->getShortName()]),
'timeUri' => $router->named('api:index:time'),
'useSse' => false,
];
if ($customization->useStaticNowPlaying() && $this->centrifugo->isSupported()) {
$props['useSse'] = true;
$props['sseUri'] = $this->centrifugo->getSseUrl($station);
}
return $props;
}
}

View File

@ -20,6 +20,13 @@ namespaces:
allow_history_for_client: true
allow_history_for_anonymous: true
- name: "global"
history_size: 0
allow_subscribe_for_client: true
allow_subscribe_for_anonymous: true
allow_history_for_client: true
allow_history_for_anonymous: true
{{if isTrue .Env.ENABLE_REDIS }}
engine: "redis"
redis_address: "{{ .Env.REDIS_HOST }}:{{ default .Env.REDIS_PORT "6379" }}"