mirror of
https://github.com/chenasraf/nextcloud-jukebox.git
synced 2026-05-17 17:38:02 +00:00
feat: improve gpodder sync, update queue ui
This commit is contained in:
@@ -12,7 +12,7 @@ module.exports = [
|
||||
...tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended),
|
||||
{
|
||||
rules: {
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
|
||||
@@ -327,8 +327,9 @@ class PodcastController extends OCSController {
|
||||
return new JSONResponse([], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$sub = null;
|
||||
try {
|
||||
$this->subMapper->find($user->getUID(), $id);
|
||||
$sub = $this->subMapper->find($user->getUID(), $id);
|
||||
} catch (\OCP\AppFramework\Db\DoesNotExistException) {
|
||||
$this->logger->error('Podcast subscription not found', ['id' => $id, 'userId' => $user->getUID()]);
|
||||
return new JSONResponse(['error' => 'Subscription not found'], Http::STATUS_NOT_FOUND);
|
||||
@@ -340,7 +341,15 @@ class PodcastController extends OCSController {
|
||||
=> ($b->getPubDate()?->getTimestamp() ?? 0) <=> ($a->getPubDate()?->getTimestamp() ?? 0)
|
||||
);
|
||||
|
||||
return new JSONResponse(['episodes' => array_map(fn ($ep) => $ep->jsonSerialize(), $episodes)], Http::STATUS_OK);
|
||||
return new JSONResponse([
|
||||
'episodes' => array_map(
|
||||
fn ($ep) => array_merge(
|
||||
$ep->jsonSerialize(),
|
||||
['image' => $sub->getImage()]
|
||||
),
|
||||
$episodes
|
||||
)
|
||||
], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,16 +33,16 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method void setUserId(string $userId)
|
||||
*/
|
||||
class GpodderPodcastEpisodeAction extends Entity implements JsonSerializable {
|
||||
protected string $podcast;
|
||||
protected string $episode;
|
||||
protected string $action;
|
||||
protected string $podcast = '';
|
||||
protected string $episode = '';
|
||||
protected string $action = '';
|
||||
protected int $position = -1;
|
||||
protected int $started = -1;
|
||||
protected int $total = -1;
|
||||
protected string $timestamp;
|
||||
protected int $timestampEpoch;
|
||||
protected string $timestamp = '';
|
||||
protected int $timestampEpoch = 0;
|
||||
protected ?string $guid = null;
|
||||
protected string $userId;
|
||||
protected string $userId = '';
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
|
||||
@@ -12,20 +12,20 @@ use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<GpoddePodcastEpisodeAction>
|
||||
* @template-extends QBMapper<GpodderPodcastEpisodeAction>
|
||||
*/
|
||||
class GpoddePodcastEpisodeActionMapper extends QBMapper {
|
||||
class GpodderPodcastEpisodeActionMapper extends QBMapper {
|
||||
public function __construct(
|
||||
IDBConnection $db,
|
||||
) {
|
||||
parent::__construct($db, 'gpodder_episode_action', GpoddePodcastEpisodeAction::class);
|
||||
parent::__construct($db, 'gpodder_episode_action', GpodderPodcastEpisodeAction::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function find(string $userId, string $id): GpoddePodcastEpisodeAction {
|
||||
public function find(string $userId, string $id): GpodderPodcastEpisodeAction {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
@@ -51,4 +51,21 @@ class GpoddePodcastEpisodeActionMapper extends QBMapper {
|
||||
$qb->select('*')->from($this->getTableName());
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all actions for a given user ID
|
||||
* @param string $userId
|
||||
* @return array<GpodderPodcastEpisodeAction>
|
||||
*/
|
||||
public function findAllByUserId(string $userId): array {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()
|
||||
->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
|
||||
);
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method int getUpdated()
|
||||
* @method void setUpdated($value)
|
||||
*/
|
||||
class GpoddePodcastSubscription extends Entity implements JsonSerializable {
|
||||
class GpodderPodcastSubscription extends Entity implements JsonSerializable {
|
||||
protected $userId = '';
|
||||
protected $url = '';
|
||||
protected $subscribed = false;
|
||||
|
||||
@@ -13,20 +13,20 @@ use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<GpoddePodcastSubscription>
|
||||
* @template-extends QBMapper<GpodderPodcastSubscription>
|
||||
*/
|
||||
class GpoddePodcastSubscriptionMapper extends QBMapper {
|
||||
class GpodderPodcastSubscriptionMapper extends QBMapper {
|
||||
public function __construct(
|
||||
IDBConnection $db,
|
||||
) {
|
||||
parent::__construct($db, 'gpodder_subscriptions', GpoddePodcastSubscription::class);
|
||||
parent::__construct($db, 'gpodder_subscriptions', GpodderPodcastSubscription::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function find(string $id): GpoddePodcastSubscription {
|
||||
public function find(string $id): GpodderPodcastSubscription {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
@@ -38,7 +38,7 @@ class GpoddePodcastSubscriptionMapper extends QBMapper {
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
public function findByUrl(string $userId, string $url): ?GpoddePodcastSubscription {
|
||||
public function findByUrl(string $userId, string $url): ?GpodderPodcastSubscription {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
@@ -68,7 +68,7 @@ class GpoddePodcastSubscriptionMapper extends QBMapper {
|
||||
/**
|
||||
* @param string $userId
|
||||
* @param bool $subscribed
|
||||
* @return array<GpoddePodcastSubscription>
|
||||
* @return array<GpodderPodcastSubscription>
|
||||
*/
|
||||
public function findAllBySubscribed(string $userId, bool $subscribed): array {
|
||||
/* @var $qb IQueryBuilder */
|
||||
@@ -88,7 +88,7 @@ class GpoddePodcastSubscriptionMapper extends QBMapper {
|
||||
|
||||
/**
|
||||
* @param string $projectId
|
||||
* @return array<GpoddePodcastSubscription>
|
||||
* @return array<GpodderPodcastSubscription>
|
||||
*/
|
||||
public function findAll(): array {
|
||||
/* @var $qb IQueryBuilder */
|
||||
@@ -96,4 +96,18 @@ class GpoddePodcastSubscriptionMapper extends QBMapper {
|
||||
$qb->select('*')->from($this->getTableName());
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find subscriptions by user ID
|
||||
* @param string $userId
|
||||
* @return array<GpodderPodcastSubscription>
|
||||
*/
|
||||
public function findAllByUserId(string $userId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,4 +94,31 @@ class PodcastEpisodePlayMapper extends QBMapper {
|
||||
|
||||
return $this->findEntity($qb, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing match in the gpodder database for a user, episode GUID, and timestamp
|
||||
* @param string $userId
|
||||
* @param string $guid
|
||||
* @param int $timestamp
|
||||
* @return PodcastEpisodePlay
|
||||
*/
|
||||
public function findGpodderExistingMatch(string $userId, string $guid, int $timestamp): ?PodcastEpisodePlay {
|
||||
try {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
|
||||
->andWhere($qb->expr()->eq('episode_guid', $qb->createNamedParameter($guid)))
|
||||
->andWhere($qb->expr()->eq('timestamp', $qb->createNamedParameter($timestamp)))
|
||||
->setMaxResults(1);
|
||||
|
||||
return $this->findEntity($qb);
|
||||
} catch (\OCP\AppFramework\Db\DoesNotExistException) {
|
||||
// No match found
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to find gpodder existing match: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ declare(strict_types=1);
|
||||
namespace OCA\Jukebox\Service;
|
||||
|
||||
use OCA\Jukebox\Cron\ParsePodcastSubscriptionTask;
|
||||
use OCA\Jukebox\Db\GpodderPodcastEpisodeActionMapper;
|
||||
use OCA\Jukebox\Db\GpodderPodcastSubscriptionMapper;
|
||||
use OCA\Jukebox\Db\PodcastEpisodeMapper;
|
||||
use OCA\Jukebox\Db\PodcastEpisodePlay;
|
||||
use OCA\Jukebox\Db\PodcastEpisodePlayMapper;
|
||||
@@ -30,6 +32,8 @@ class GpodderSyncService {
|
||||
private PodcastFeedParserService $parser,
|
||||
private PodcastSubscriptionWriterService $subWriter,
|
||||
private PodcastEpisodeWriterService $epWriter,
|
||||
private GpodderPodcastEpisodeActionMapper $gpEpActionMapper,
|
||||
private GpodderPodcastSubscriptionMapper $gpSubMapper,
|
||||
private IJobList $jobs,
|
||||
) {
|
||||
}
|
||||
@@ -53,18 +57,13 @@ class GpodderSyncService {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('id', 'url')
|
||||
->from('gpodder_subscriptions')
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
|
||||
$results = $qb->executeQuery()->fetchAll();
|
||||
$results = $this->gpSubMapper->findAllByUserId($userId);
|
||||
$count = 0;
|
||||
|
||||
foreach ($results as $row) {
|
||||
try {
|
||||
// Skip if already linked
|
||||
$this->subMapper->findByGpodderId($userId, (int)$row['id']);
|
||||
$this->subMapper->findByGpodderId($userId, $row->getId());
|
||||
continue;
|
||||
} catch (DoesNotExistException) {
|
||||
// Not yet imported, continue
|
||||
@@ -72,9 +71,9 @@ class GpodderSyncService {
|
||||
|
||||
$subscription = new PodcastSubscription();
|
||||
$subscription->setUserId($userId);
|
||||
$subscription->setSubscriptionId((int)$row['id']);
|
||||
$subscription->setSubscriptionId($row->getId());
|
||||
$subscription->setTitle('');
|
||||
$subscription->setUrl($row['url']);
|
||||
$subscription->setUrl($row->getUrl());
|
||||
$this->subMapper->insert($subscription);
|
||||
if (!$delayedFetch) {
|
||||
$this->subWriter->fetchSubscriptionMetadata($subscription);
|
||||
@@ -93,18 +92,13 @@ class GpodderSyncService {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('id', 'url')
|
||||
->from('gpodder_subscriptions')
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
|
||||
$results = $qb->executeQuery()->fetchAll();
|
||||
$results = $this->gpSubMapper->findAllByUserId($userId);
|
||||
|
||||
foreach ($results as $row) {
|
||||
$subscription = null;
|
||||
try {
|
||||
// Skip if already linked
|
||||
$subscription = $this->subMapper->findByGpodderId($userId, (int)$row['id']);
|
||||
$subscription = $this->subMapper->findByGpodderId($userId, $row->getId());
|
||||
} catch (DoesNotExistException) {
|
||||
// Not yet imported, continue
|
||||
continue;
|
||||
@@ -132,42 +126,48 @@ class GpodderSyncService {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from('gpodder_episode_action')
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
|
||||
$gpodderPlays = $qb->executeQuery()->fetchAll();
|
||||
$gpodderPlays = $this->gpEpActionMapper->findAllByUserId($userId);
|
||||
$count = 0;
|
||||
$missingEpisodes = [];
|
||||
|
||||
foreach ($gpodderPlays as $row) {
|
||||
$ep = null;
|
||||
if (in_array($row->getGuid(), $missingEpisodes) !== false) {
|
||||
// Skip if we already logged this episode as missing
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
->from('jukebox_podcast_ep_plays')
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
|
||||
->andWhere($qb->expr()->eq('episode_id', $qb->createNamedParameter($row['id'])))
|
||||
->andWhere($qb->expr()->eq('timestamp', $qb->createNamedParameter($row['timestamp'])))
|
||||
->setMaxResults(1);
|
||||
|
||||
$result = $qb->executeQuery()->fetchOne();
|
||||
if ($result !== false) {
|
||||
// Already exists, skip
|
||||
continue;
|
||||
}
|
||||
$ep = $this->epMapper->findByGuid($userId, $row->getGuid());
|
||||
} catch (DoesNotExistException) {
|
||||
// Not yet imported, continue
|
||||
// Episode not found, skip this action
|
||||
$this->logger->warning('Episode not found for gpodder action', [
|
||||
'userId' => $userId,
|
||||
'guid' => $row->getGuid(),
|
||||
'action' => $row->getAction(),
|
||||
]);
|
||||
$missingEpisodes[] = $row->getGuid();
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->epPlayMapper->findGpodderExistingMatch(
|
||||
$userId,
|
||||
$row->getGuid(),
|
||||
$row->getTimestampEpoch(),
|
||||
);
|
||||
if ($result !== null) {
|
||||
// Already exists, skip
|
||||
continue;
|
||||
}
|
||||
|
||||
$epPlay = new PodcastEpisodePlay();
|
||||
$epPlay->setUserId($userId);
|
||||
$epPlay->setAction($row['action']);
|
||||
$epPlay->setEpisodeId($row['id']);
|
||||
$epPlay->setTotal($row['total']);
|
||||
$epPlay->setPosition($row['position']);
|
||||
$epPlay->setEpisodeGuid($row['guid']);
|
||||
$epPlay->setAction($row->getAction());
|
||||
$epPlay->setEpisodeId($ep->getId());
|
||||
$epPlay->setTotal($row->getTotal());
|
||||
$epPlay->setPosition($row->getPosition());
|
||||
$epPlay->setEpisodeGuid($row->getGuid());
|
||||
$epPlay->setDevice('gpodder');
|
||||
$epPlay->setTimestamp($row['timestamp_epoch']);
|
||||
$epPlay->setTimestamp($row->getTimestampEpoch());
|
||||
|
||||
$this->epPlayMapper->insert($epPlay);
|
||||
$count++;
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
<template>
|
||||
<footer class="jukebox-player">
|
||||
<div class="controls">
|
||||
<NcButton
|
||||
variant="tertiary"
|
||||
aria-label="Previous"
|
||||
size="normal"
|
||||
@click="playback.prev"
|
||||
:disabled="isLoading">
|
||||
<NcButton variant="tertiary" aria-label="Previous" size="normal" @click="playback.prev" :disabled="isLoading">
|
||||
<template #icon>
|
||||
<SkipPrevious :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
|
||||
<NcButton
|
||||
class="play-button"
|
||||
variant="primary"
|
||||
aria-label="Play/Pause"
|
||||
size="normal"
|
||||
@click="playback.togglePlay"
|
||||
<NcButton class="play-button" variant="primary" aria-label="Play/Pause" size="normal" @click="playback.togglePlay"
|
||||
:disabled="isLoading">
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="isLoading" :size="24" />
|
||||
@@ -26,12 +16,7 @@
|
||||
</template>
|
||||
</NcButton>
|
||||
|
||||
<NcButton
|
||||
variant="tertiary"
|
||||
aria-label="Next"
|
||||
size="normal"
|
||||
@click="playback.next"
|
||||
:disabled="isLoading">
|
||||
<NcButton variant="tertiary" aria-label="Next" size="normal" @click="playback.next" :disabled="isLoading">
|
||||
<template #icon>
|
||||
<SkipNext :size="20" />
|
||||
</template>
|
||||
@@ -53,131 +38,129 @@
|
||||
<div ref="seekbarRef" class="seekbar" @pointerdown="startSeek">
|
||||
<div class="seekbar-fill" :style="{ width: effectiveSeekPercent * 100 + '%' }"></div>
|
||||
</div>
|
||||
<span class="time">{{ formattedDuration }}</span>
|
||||
<span class="time" v-if="Number.isFinite(playback.duration)">{{ formattedDuration }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed, onBeforeUnmount } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import QueuePopover from '@/components/media/QueuePopover.vue'
|
||||
import rawPlayback from '@/composables/usePlayback'
|
||||
import { formatDuration } from '@/utils/time'
|
||||
import { defineComponent, ref, computed, onBeforeUnmount } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import QueuePopover from '@/components/media/QueuePopover.vue'
|
||||
import rawPlayback, { type Playable } from '@/composables/usePlayback'
|
||||
import { formatDuration } from '@/utils/time'
|
||||
|
||||
import SkipPrevious from '@icons/SkipPrevious.vue'
|
||||
import SkipNext from '@icons/SkipNext.vue'
|
||||
import Play from '@icons/Play.vue'
|
||||
import Pause from '@icons/Pause.vue'
|
||||
import PlaylistMusic from '@icons/PlaylistMusic.vue'
|
||||
import SkipPrevious from '@icons/SkipPrevious.vue'
|
||||
import SkipNext from '@icons/SkipNext.vue'
|
||||
import Play from '@icons/Play.vue'
|
||||
import Pause from '@icons/Pause.vue'
|
||||
import PlaylistMusic from '@icons/PlaylistMusic.vue'
|
||||
|
||||
import type { Track } from '@/models/media'
|
||||
export default defineComponent({
|
||||
name: 'MediaControls',
|
||||
components: {
|
||||
NcButton,
|
||||
NcLoadingIcon,
|
||||
QueuePopover,
|
||||
SkipPrevious,
|
||||
SkipNext,
|
||||
Play,
|
||||
Pause,
|
||||
PlaylistMusic,
|
||||
},
|
||||
setup() {
|
||||
const showQueue = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const seekPercent = ref(0)
|
||||
const cachedDuration = ref(0)
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MediaControls',
|
||||
components: {
|
||||
NcButton,
|
||||
NcLoadingIcon,
|
||||
QueuePopover,
|
||||
SkipPrevious,
|
||||
SkipNext,
|
||||
Play,
|
||||
Pause,
|
||||
PlaylistMusic,
|
||||
},
|
||||
setup() {
|
||||
const showQueue = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const seekPercent = ref(0)
|
||||
const cachedDuration = ref(0)
|
||||
const seekbarRef = ref<HTMLElement | null>(null)
|
||||
let lastPointerX = 0
|
||||
|
||||
const seekbarRef = ref<HTMLElement | null>(null)
|
||||
let lastPointerX = 0
|
||||
const playback = {
|
||||
...rawPlayback,
|
||||
queue: computed(() => rawPlayback.queue.value as unknown[] as Playable[]),
|
||||
isPlaying: computed(() => rawPlayback.isPlaying.value),
|
||||
isLoading: computed(() => rawPlayback.loading.value),
|
||||
currentTime: computed(() => rawPlayback.currentTime.value),
|
||||
duration: computed(() => rawPlayback.duration.value),
|
||||
}
|
||||
|
||||
const playback = {
|
||||
...rawPlayback,
|
||||
queue: computed(() => rawPlayback.queue.value as unknown[] as Track[]),
|
||||
isPlaying: computed(() => rawPlayback.isPlaying.value),
|
||||
isLoading: computed(() => rawPlayback.loading.value),
|
||||
currentTime: computed(() => rawPlayback.currentTime.value),
|
||||
duration: computed(() => rawPlayback.duration.value),
|
||||
}
|
||||
const formattedCurrentTime = computed(() => formatDuration(playback.currentTime.value))
|
||||
const formattedDuration = computed(() => formatDuration(playback.duration.value))
|
||||
const displayedCurrentTime = computed(() =>
|
||||
isDragging.value
|
||||
? formatDuration(seekPercent.value * cachedDuration.value)
|
||||
: formattedCurrentTime.value,
|
||||
)
|
||||
const effectiveSeekPercent = computed(() =>
|
||||
isDragging.value ? seekPercent.value : playback.currentTime.value / playback.duration.value,
|
||||
)
|
||||
|
||||
const formattedCurrentTime = computed(() => formatDuration(playback.currentTime.value))
|
||||
const formattedDuration = computed(() => formatDuration(playback.duration.value))
|
||||
const displayedCurrentTime = computed(() =>
|
||||
isDragging.value
|
||||
? formatDuration(seekPercent.value * cachedDuration.value)
|
||||
: formattedCurrentTime.value,
|
||||
)
|
||||
const effectiveSeekPercent = computed(() =>
|
||||
isDragging.value ? seekPercent.value : playback.currentTime.value / playback.duration.value,
|
||||
)
|
||||
function updateSeekPosition(event: PointerEvent) {
|
||||
if (!seekbarRef.value || playback.duration.value <= 0) return
|
||||
|
||||
function updateSeekPosition(event: PointerEvent) {
|
||||
if (!seekbarRef.value || playback.duration.value <= 0) return
|
||||
const rect = seekbarRef.value.getBoundingClientRect()
|
||||
const offsetX = event.clientX - rect.left
|
||||
const percent = Math.min(Math.max(offsetX / rect.width, 0), 1)
|
||||
seekPercent.value = percent
|
||||
lastPointerX = event.clientX
|
||||
}
|
||||
|
||||
const rect = seekbarRef.value.getBoundingClientRect()
|
||||
const offsetX = event.clientX - rect.left
|
||||
const percent = Math.min(Math.max(offsetX / rect.width, 0), 1)
|
||||
seekPercent.value = percent
|
||||
lastPointerX = event.clientX
|
||||
}
|
||||
function startSeek(event: PointerEvent) {
|
||||
console.log('Seek start')
|
||||
isDragging.value = true
|
||||
cachedDuration.value = playback.duration.value
|
||||
updateSeekPosition(event)
|
||||
window.addEventListener('pointermove', updateSeekPosition)
|
||||
window.addEventListener('pointerup', stopSeek)
|
||||
}
|
||||
|
||||
function startSeek(event: PointerEvent) {
|
||||
console.log('Seek start')
|
||||
isDragging.value = true
|
||||
cachedDuration.value = playback.duration.value
|
||||
updateSeekPosition(event)
|
||||
window.addEventListener('pointermove', updateSeekPosition)
|
||||
window.addEventListener('pointerup', stopSeek)
|
||||
}
|
||||
function applySeek() {
|
||||
if (cachedDuration.value <= 0) return
|
||||
const newTime = seekPercent.value * cachedDuration.value
|
||||
console.log(`Seek commit to ${newTime.toFixed(2)}s (of ${cachedDuration.value}s)`)
|
||||
playback.setSeek(newTime)
|
||||
}
|
||||
|
||||
function applySeek() {
|
||||
if (cachedDuration.value <= 0) return
|
||||
const newTime = seekPercent.value * cachedDuration.value
|
||||
console.log(`Seek commit to ${newTime.toFixed(2)}s (of ${cachedDuration.value}s)`)
|
||||
playback.setSeek(newTime)
|
||||
}
|
||||
function stopSeek() {
|
||||
if (!isDragging.value) return
|
||||
console.log('Seek end')
|
||||
applySeek()
|
||||
isDragging.value = false
|
||||
window.removeEventListener('pointermove', updateSeekPosition)
|
||||
window.removeEventListener('pointerup', stopSeek)
|
||||
}
|
||||
|
||||
function stopSeek() {
|
||||
if (!isDragging.value) return
|
||||
console.log('Seek end')
|
||||
applySeek()
|
||||
isDragging.value = false
|
||||
window.removeEventListener('pointermove', updateSeekPosition)
|
||||
window.removeEventListener('pointerup', stopSeek)
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
stopSeek()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopSeek()
|
||||
})
|
||||
|
||||
return {
|
||||
showQueue,
|
||||
toggleQueue: () => {
|
||||
showQueue.value = !showQueue.value
|
||||
},
|
||||
playback,
|
||||
queue: playback.queue,
|
||||
isPlaying: playback.isPlaying,
|
||||
currentTime: playback.currentTime,
|
||||
duration: playback.duration,
|
||||
isLoading: playback.isLoading,
|
||||
formattedCurrentTime,
|
||||
displayedCurrentTime,
|
||||
formattedDuration,
|
||||
effectiveSeekPercent,
|
||||
startSeek,
|
||||
seekbarRef,
|
||||
}
|
||||
},
|
||||
})
|
||||
return {
|
||||
showQueue,
|
||||
toggleQueue: () => {
|
||||
showQueue.value = !showQueue.value
|
||||
},
|
||||
playback,
|
||||
queue: playback.queue,
|
||||
isPlaying: playback.isPlaying,
|
||||
currentTime: playback.currentTime,
|
||||
duration: playback.duration,
|
||||
isLoading: playback.isLoading,
|
||||
formattedCurrentTime,
|
||||
displayedCurrentTime,
|
||||
formattedDuration,
|
||||
effectiveSeekPercent,
|
||||
startSeek,
|
||||
seekbarRef,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.jukebox-player {
|
||||
.jukebox-player {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<template>
|
||||
<NcListItem
|
||||
:active="isActive"
|
||||
:name="episode.title || 'Untitled Episode'"
|
||||
@click.prevent="onPlay"
|
||||
:bold="false">
|
||||
<NcListItem :active="isActive" :name="episode.title || 'Untitled Episode'" @click.prevent="onPlay" :bold="false">
|
||||
<template #icon>
|
||||
<img v-if="cover" :src="cover" alt="Podcast cover" class="cover" width="44" height="44" />
|
||||
<img v-if="episode.image" :src="episode.image" alt="Podcast cover" class="cover" width="44" height="44" />
|
||||
<Podcast v-else :size="44" />
|
||||
</template>
|
||||
|
||||
@@ -41,84 +37,80 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, type PropType } from 'vue'
|
||||
import { type PodcastEpisode } from '@/models/media'
|
||||
import playback, { podcastEpisodeToPlayable } from '@/composables/usePlayback'
|
||||
import { formatDuration, formatDate } from '@/utils/time'
|
||||
import { defineComponent, computed, type PropType } from 'vue'
|
||||
import { type PodcastEpisode } from '@/models/media'
|
||||
import playback, { podcastEpisodeToPlayable } from '@/composables/usePlayback'
|
||||
import { formatDuration, formatDate } from '@/utils/time'
|
||||
|
||||
import NcListItem from '@nextcloud/vue/components/NcListItem'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import NcListItem from '@nextcloud/vue/components/NcListItem'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
|
||||
import Podcast from '@icons/Podcast.vue'
|
||||
import Play from '@icons/Play.vue'
|
||||
import SkipNext from '@icons/SkipNext.vue'
|
||||
import PlaylistPlus from '@icons/PlaylistPlus.vue'
|
||||
import Podcast from '@icons/Podcast.vue'
|
||||
import Play from '@icons/Play.vue'
|
||||
import SkipNext from '@icons/SkipNext.vue'
|
||||
import PlaylistPlus from '@icons/PlaylistPlus.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PodcastEpisodeListItem',
|
||||
props: {
|
||||
episode: {
|
||||
type: Object as PropType<PodcastEpisode>,
|
||||
required: true,
|
||||
},
|
||||
cover: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
disablePlay: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disablePlayNext: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableAddToQueue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
export default defineComponent({
|
||||
name: 'PodcastEpisodeListItem',
|
||||
props: {
|
||||
episode: {
|
||||
type: Object as PropType<PodcastEpisode>,
|
||||
required: true,
|
||||
},
|
||||
components: {
|
||||
NcListItem,
|
||||
NcActionButton,
|
||||
Podcast,
|
||||
Play,
|
||||
SkipNext,
|
||||
PlaylistPlus,
|
||||
disablePlay: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emits: ['play'],
|
||||
setup(props, { emit }) {
|
||||
const { currentMedia, addToQueue, addAsNext } = playback
|
||||
|
||||
const isActive = computed(() => props.episode.id === currentMedia.value?.id)
|
||||
|
||||
const durationFormatted = computed(() => {
|
||||
if (!props.episode.duration) return 'No duration'
|
||||
return formatDuration(props.episode.duration)
|
||||
})
|
||||
const pubDateFormatted = computed(() => {
|
||||
if (!props.episode.pub_date) return 'Unknown date'
|
||||
return formatDate(props.episode.pub_date)
|
||||
})
|
||||
|
||||
const onPlay = () => emit('play', props.episode)
|
||||
const onPlayNext = () => addAsNext(podcastEpisodeToPlayable(props.episode))
|
||||
const onAddToQueue = () => addToQueue(podcastEpisodeToPlayable(props.episode))
|
||||
|
||||
return {
|
||||
isActive,
|
||||
durationFormatted,
|
||||
pubDateFormatted,
|
||||
onPlay,
|
||||
onPlayNext,
|
||||
onAddToQueue,
|
||||
}
|
||||
disablePlayNext: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
disableAddToQueue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
NcListItem,
|
||||
NcActionButton,
|
||||
Podcast,
|
||||
Play,
|
||||
SkipNext,
|
||||
PlaylistPlus,
|
||||
},
|
||||
emits: ['play'],
|
||||
setup(props, { emit }) {
|
||||
const { currentMedia, addToQueue, addAsNext } = playback
|
||||
|
||||
const isActive = computed(() => props.episode.id === currentMedia.value?.id)
|
||||
|
||||
const durationFormatted = computed(() => {
|
||||
if (!props.episode.duration) return 'No duration'
|
||||
return formatDuration(props.episode.duration)
|
||||
})
|
||||
const pubDateFormatted = computed(() => {
|
||||
if (!props.episode.pub_date) return 'Unknown date'
|
||||
return formatDate(props.episode.pub_date)
|
||||
})
|
||||
|
||||
const onPlay = () => emit('play', props.episode)
|
||||
const onPlayNext = () => addAsNext(podcastEpisodeToPlayable(props.episode))
|
||||
const onAddToQueue = () => addToQueue(podcastEpisodeToPlayable(props.episode))
|
||||
|
||||
return {
|
||||
isActive,
|
||||
durationFormatted,
|
||||
pubDateFormatted,
|
||||
onPlay,
|
||||
onPlayNext,
|
||||
onAddToQueue,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cover {
|
||||
.cover {
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@@ -7,31 +7,45 @@
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<div
|
||||
class="queue-container"
|
||||
tabindex="0"
|
||||
role="dialog"
|
||||
aria-labelledby="queue-popover-title"
|
||||
ref="popoverRef">
|
||||
<div class="queue-container" tabindex="0" role="dialog" aria-labelledby="queue-popover-title" ref="popoverRef">
|
||||
<h2 id="queue-popover-title" class="popover-title">Playback Queue</h2>
|
||||
<div v-if="queue.length > 0" class="queue-list">
|
||||
<TrackListItem
|
||||
v-for="(media, index) in queue"
|
||||
:key="media.id"
|
||||
:media="media"
|
||||
mediaType="track"
|
||||
@play="onPlay(media)"
|
||||
disable-play-next
|
||||
disable-add-to-queue>
|
||||
<template #actions-end>
|
||||
<NcActionButton @click.stop="onRemove(media)">
|
||||
<template #icon>
|
||||
<Delete :size="20" />
|
||||
</template>
|
||||
Remove from Queue
|
||||
</NcActionButton>
|
||||
</template>
|
||||
</TrackListItem>
|
||||
<ul v-for="(media, index) in queue">
|
||||
<TrackListItem v-if="media.type == 'track'" :key="'track-' + media.id" :media="media as unknown as Track"
|
||||
mediaType="track" @play="onPlay(media)" disable-play-next disable-add-to-queue>
|
||||
<template #actions-end>
|
||||
<NcActionButton @click.stop="onRemove(media)">
|
||||
<template #icon>
|
||||
<Delete :size="20" />
|
||||
</template>
|
||||
Remove from Queue
|
||||
</NcActionButton>
|
||||
</template>
|
||||
</TrackListItem>
|
||||
<PodcastEpisodeListItem v-else-if="media.type == 'podcast'" :key="'podcast-' + media.id"
|
||||
:episode="media as unknown as PodcastEpisode" @play="onPlay(media)" disable-play-next
|
||||
disable-add-to-queue>
|
||||
<template #actions-end>
|
||||
<NcActionButton @click.stop="onRemove(media)">
|
||||
<template #icon>
|
||||
<Delete :size="20" />
|
||||
</template>
|
||||
Remove from Queue
|
||||
</NcActionButton>
|
||||
</template>
|
||||
</PodcastEpisodeListItem>
|
||||
<RadioStationListItem v-else-if="media.type == 'radio'" :key="'radio-' + media.id"
|
||||
:station="media as unknown as RadioStation" @play="onPlay(media)" disable-play-next disable-add-to-queue>
|
||||
<template #actions-end>
|
||||
<NcActionButton @click.stop="onRemove(media)">
|
||||
<template #icon>
|
||||
<Delete :size="20" />
|
||||
</template>
|
||||
Remove from Queue
|
||||
</NcActionButton>
|
||||
</template>
|
||||
</RadioStationListItem>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-else class="empty-message">The queue is empty.</p>
|
||||
</div>
|
||||
@@ -40,73 +54,82 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, onMounted, computed, onBeforeUnmount, type PropType } from 'vue'
|
||||
import NcPopover from '@nextcloud/vue/components/NcPopover'
|
||||
import TrackListItem from '@/components/media/TrackListItem.vue'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import Delete from '@icons/Delete.vue'
|
||||
import { type Track } from '@/models/media'
|
||||
import playback, { trackToPlayable } from '@/composables/usePlayback'
|
||||
import { defineComponent, ref, onMounted, computed, onBeforeUnmount, type PropType } from 'vue'
|
||||
import NcPopover from '@nextcloud/vue/components/NcPopover'
|
||||
import TrackListItem from '@/components/media/TrackListItem.vue'
|
||||
import PodcastEpisodeListItem from '@/components/media/PodcastEpisodeListItem.vue'
|
||||
import RadioStationListItem from '@/components/media/RadioStationListItem.vue'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import Delete from '@icons/Delete.vue'
|
||||
import playback, { toPlayable, type Playable } from '@/composables/usePlayback'
|
||||
import type { Track, PodcastEpisode, RadioStation } from '@/models/media'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'QueuePopover',
|
||||
components: { NcPopover, TrackListItem, NcActionButton, Delete },
|
||||
props: {
|
||||
shown: Boolean,
|
||||
queue: {
|
||||
type: Array as PropType<Track[]>,
|
||||
required: true,
|
||||
},
|
||||
export default defineComponent({
|
||||
name: 'QueuePopover',
|
||||
components: {
|
||||
NcPopover,
|
||||
TrackListItem,
|
||||
PodcastEpisodeListItem,
|
||||
RadioStationListItem,
|
||||
NcActionButton,
|
||||
Delete
|
||||
},
|
||||
props: {
|
||||
shown: Boolean,
|
||||
queue: {
|
||||
type: Array as PropType<Playable[]>,
|
||||
required: true,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const popoverRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const popoverRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const onClose = () => {
|
||||
emit('update:shown', false)
|
||||
const onClose = () => {
|
||||
emit('update:shown', false)
|
||||
}
|
||||
const onPlay = (media: Playable) => playback.playFromQueue(toPlayable(media))
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!popoverRef.value || !triggerRef.value) return
|
||||
if (
|
||||
!popoverRef.value.contains(event.target as Node) &&
|
||||
!triggerRef.value.contains(event.target as Node)
|
||||
) {
|
||||
onClose()
|
||||
}
|
||||
const onPlay = (media: Track) => playback.playFromQueue(trackToPlayable(media))
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!popoverRef.value || !triggerRef.value) return
|
||||
if (
|
||||
!popoverRef.value.contains(event.target as Node) &&
|
||||
!triggerRef.value.contains(event.target as Node)
|
||||
) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
})
|
||||
const onRemove = (media: Playable) => {
|
||||
playback.removeFromQueue(toPlayable(media))
|
||||
emit('update:shown', true)
|
||||
}
|
||||
|
||||
const onRemove = (media: Track) => {
|
||||
playback.removeFromQueue(trackToPlayable(media))
|
||||
emit('update:shown', true)
|
||||
}
|
||||
|
||||
return {
|
||||
shown: computed({
|
||||
get: () => props.shown,
|
||||
set: (val) => emit('update:shown', val),
|
||||
}),
|
||||
onClose,
|
||||
onPlay,
|
||||
onRemove,
|
||||
popoverRef,
|
||||
triggerRef,
|
||||
}
|
||||
},
|
||||
})
|
||||
return {
|
||||
shown: computed({
|
||||
get: () => props.shown,
|
||||
set: (val) => emit('update:shown', val),
|
||||
}),
|
||||
onClose,
|
||||
onPlay,
|
||||
onRemove,
|
||||
popoverRef,
|
||||
triggerRef,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.queue-container {
|
||||
.queue-container {
|
||||
width: 500px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
|
||||
114
src/components/media/RadioStationListItem.vue
Normal file
114
src/components/media/RadioStationListItem.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<NcListItem :active="isActive" :name="station.name || 'Unnamed Station'" @click.prevent="onPlay" :bold="false">
|
||||
<template #icon>
|
||||
<img v-if="station.favicon" :src="station.favicon" alt="Station icon" class="cover" width="44" height="44" />
|
||||
<Podcast v-else :size="44" />
|
||||
</template>
|
||||
|
||||
<template #subname>
|
||||
{{ codecInfo }}
|
||||
<span v-if="station.country"> — {{ station.country }}</span>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<slot name="actions-start" />
|
||||
|
||||
<NcActionButton v-if="!disablePlay" @click.stop="onPlay">
|
||||
<template #icon>
|
||||
<Play :size="20" />
|
||||
</template>
|
||||
Play
|
||||
</NcActionButton>
|
||||
|
||||
<NcActionButton v-if="!disablePlayNext" @click.stop="onPlayNext">
|
||||
<template #icon>
|
||||
<SkipNext :size="20" />
|
||||
</template>
|
||||
Play Next
|
||||
</NcActionButton>
|
||||
|
||||
<NcActionButton v-if="!disableAddToQueue" @click.stop="onAddToQueue">
|
||||
<template #icon>
|
||||
<PlaylistPlus :size="20" />
|
||||
</template>
|
||||
Add to Queue
|
||||
</NcActionButton>
|
||||
|
||||
<slot name="actions-end" />
|
||||
</template>
|
||||
</NcListItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, type PropType } from 'vue'
|
||||
import { type RadioStation } from '@/models/media'
|
||||
import playback, { radioStationToPlayable } from '@/composables/usePlayback'
|
||||
|
||||
import NcListItem from '@nextcloud/vue/components/NcListItem'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
|
||||
import Podcast from '@icons/Podcast.vue'
|
||||
import Play from '@icons/Play.vue'
|
||||
import SkipNext from '@icons/SkipNext.vue'
|
||||
import PlaylistPlus from '@icons/PlaylistPlus.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'RadioStationListItem',
|
||||
props: {
|
||||
station: {
|
||||
type: Object as PropType<RadioStation>,
|
||||
required: true,
|
||||
},
|
||||
disablePlay: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disablePlayNext: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableAddToQueue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
NcListItem,
|
||||
NcActionButton,
|
||||
Podcast,
|
||||
Play,
|
||||
SkipNext,
|
||||
PlaylistPlus,
|
||||
},
|
||||
emits: ['play'],
|
||||
setup(props, { emit }) {
|
||||
const { currentMedia, addToQueue, addAsNext } = playback
|
||||
|
||||
const isActive = computed(() => props.station.id === currentMedia.value?.id)
|
||||
|
||||
const codecInfo = computed(() => {
|
||||
if (!props.station.codec && !props.station.bitrate) return 'Unknown format'
|
||||
return `${props.station.codec || 'Unknown codec'}${props.station.bitrate ? ` (${props.station.bitrate} kbps)` : ''}`
|
||||
})
|
||||
|
||||
const onPlay = () => emit('play', props.station)
|
||||
const onPlayNext = () => addAsNext(radioStationToPlayable(props.station))
|
||||
const onAddToQueue = () => addToQueue(radioStationToPlayable(props.station))
|
||||
|
||||
return {
|
||||
isActive,
|
||||
codecInfo,
|
||||
onPlay,
|
||||
onPlayNext,
|
||||
onAddToQueue,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cover {
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@ import type { Track, PodcastEpisode, RadioStation } from '@/models/media'
|
||||
type MediaType = 'track' | 'podcast' | 'radio'
|
||||
|
||||
// Base media type
|
||||
interface Playable {
|
||||
export interface Playable {
|
||||
id: number | string
|
||||
type: MediaType
|
||||
duration?: number | null
|
||||
@@ -13,24 +13,26 @@ interface Playable {
|
||||
}
|
||||
|
||||
export function trackToPlayable(track: Track): Playable {
|
||||
return {
|
||||
type: 'track',
|
||||
...track,
|
||||
}
|
||||
return { type: 'track', ...track, }
|
||||
}
|
||||
|
||||
export function podcastEpisodeToPlayable(episode: PodcastEpisode): Playable {
|
||||
return {
|
||||
type: 'podcast',
|
||||
...episode,
|
||||
}
|
||||
return { type: 'podcast', ...episode, }
|
||||
}
|
||||
|
||||
export function radioStationToPlayable(station: RadioStation): Playable {
|
||||
return {
|
||||
type: 'radio',
|
||||
...station,
|
||||
return { type: 'radio', ...station, }
|
||||
}
|
||||
|
||||
export function toPlayable<T extends Track | PodcastEpisode | RadioStation | Playable>(media: T): Playable {
|
||||
if ('trackNumber' in media) {
|
||||
return trackToPlayable(media as Track)
|
||||
} else if ('guid' in media) {
|
||||
return podcastEpisodeToPlayable(media as PodcastEpisode)
|
||||
} else if ('remoteUuid' in media) {
|
||||
return radioStationToPlayable(media as RadioStation)
|
||||
}
|
||||
throw new Error('Unsupported media type')
|
||||
}
|
||||
|
||||
const audio = new Audio()
|
||||
@@ -52,7 +54,7 @@ const currentMedia = computed(() =>
|
||||
const streamPaths: Record<string, (_media: Playable) => string> = {
|
||||
track: (media) => `/music/tracks/${media.id}/stream`,
|
||||
podcast: (media) => `/podcasts/episodes/${media.id}/stream`,
|
||||
radio: (media) => `/radio/${media.uuid}/stream`,
|
||||
radio: (media) => `/radio/${media.remoteUuid}/stream`,
|
||||
}
|
||||
|
||||
function getStreamUrl(media: Playable): string {
|
||||
@@ -66,7 +68,7 @@ function getStreamUrl(media: Playable): string {
|
||||
function trackAction(media: Playable, action: 'play' | 'pause' | 'complete' | 'resume') {
|
||||
const endpoints: Record<
|
||||
MediaType,
|
||||
{ path: string; data: (_media: Playable) => unknown } | undefined
|
||||
{ path: string; data: (media: Playable) => unknown } | undefined
|
||||
> = {
|
||||
podcast: {
|
||||
path: '/podcasts/track',
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface PodcastEpisode {
|
||||
media_url: string | null
|
||||
description: string | null
|
||||
user_id: string | null
|
||||
image: string | null
|
||||
}
|
||||
|
||||
export interface RadioStation {
|
||||
|
||||
Reference in New Issue
Block a user