feat: improve gpodder sync, update queue ui

This commit is contained in:
2025-06-21 20:05:29 +03:00
parent 08455a0891
commit bcfe99f2cc
14 changed files with 536 additions and 354 deletions

View File

@@ -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: '^_' },

View File

@@ -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);
}
/**

View File

@@ -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 [

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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++;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View 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>

View File

@@ -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',

View File

@@ -47,6 +47,7 @@ export interface PodcastEpisode {
media_url: string | null
description: string | null
user_id: string | null
image: string | null
}
export interface RadioStation {