fix: streaming/seek playback

This commit is contained in:
2025-06-16 16:17:15 +03:00
parent 6af532bb38
commit 959acbbdad
16 changed files with 496 additions and 148 deletions

View File

@@ -15,17 +15,23 @@ use OCA\Jukebox\Db\PodcastEpisodePlayMapper;
use OCA\Jukebox\Db\PodcastSubscription;
use OCA\Jukebox\Db\PodcastSubscriptionMapper;
use OCA\Jukebox\Service\PodcastFeedParserService;
use OCA\Jukebox\Service\SettingsService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\OCSController;
use OCP\BackgroundJob\IJobList;
use OCP\IAppConfig;
use OCP\Files\File;
use OCP\Files\IMimeTypeDetector;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
@@ -33,7 +39,7 @@ class PodcastController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private IAppConfig $config,
private SettingsService $settings,
private IL10N $l,
private LoggerInterface $logger,
private IUserSession $userSession,
@@ -42,6 +48,8 @@ class PodcastController extends OCSController {
private PodcastEpisodePlayMapper $playMapper,
private PodcastEpisodeMapper $epMapper,
private IJobList $jobList,
private IRootFolder $rootFolder,
private IMimeTypeDetector $mimeTypeDetector,
) {
parent::__construct($appName, $request);
}
@@ -388,6 +396,17 @@ class PodcastController extends OCSController {
return new \OCP\AppFramework\Http\JSONResponse(['message' => 'Episode not found'], Http::STATUS_NOT_FOUND);
}
if ($this->settings->getBool($user->getUID(), 'download_podcast_episodes', false)) {
return $this->downloadAndStreamLocal($user, $episode);
}
return $this->streamRemote($user, $episode);
}
/**
* @return Http\JSONResponse<int,array|object|stdClass|JsonSerializable,array<string,mixed>>
*/
private function streamRemote(IUser $user, PodcastEpisode $episode): JSONResponse {
$url = $episode->getMediaUrl();
if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) {
return new \OCP\AppFramework\Http\JSONResponse(['message' => 'Invalid media URL'], Http::STATUS_BAD_REQUEST);
@@ -414,7 +433,7 @@ class PodcastController extends OCSController {
if ($response === false || $headerSize === false) {
$this->logger->error('Failed to stream podcast episode via cURL', [
'userId' => $user->getUID(),
'episodeId' => $id,
'episodeId' => $episode->getId(),
]);
return new JSONResponse(['message' => 'Stream failed'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
@@ -445,6 +464,100 @@ class PodcastController extends OCSController {
exit;
}
/**
* Stream a locally downloaded podcast episode.
* Downloads it if not available locally.
*
* @param IUser $user
* @param PodcastEpisode $episode
*
* @return FileDisplayResponse|JSONResponse
*/
private function downloadAndStreamLocal(IUser $user, PodcastEpisode $episode): FileDisplayResponse|JSONResponse {
try {
$path = $this->settings->getPodcastDownloadPath($user->getUID(), $episode->getSubscriptionId(), $episode->getId());
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
// Check if file exists already
if (!$userFolder->nodeExists($path)) {
$mediaUrl = $episode->getMediaUrl();
if (!$mediaUrl || !filter_var($mediaUrl, FILTER_VALIDATE_URL)) {
return new JSONResponse(['message' => 'Invalid media URL'], Http::STATUS_BAD_REQUEST);
}
// Download to temporary stream
$tempStream = fopen('php://temp', 'r+');
$download = @fopen($mediaUrl, 'r');
if (!$download) {
return new JSONResponse(['message' => 'Failed to download episode'], Http::STATUS_BAD_GATEWAY);
}
stream_copy_to_stream($download, $tempStream);
fclose($download);
rewind($tempStream);
// Ensure intermediate folders exist
$segments = explode('/', $path);
$fileName = array_pop($segments);
$current = $userFolder;
foreach ($segments as $segment) {
if (!$current->nodeExists($segment)) {
$current = $current->newFolder($segment);
} else {
$current = $current->get($segment);
}
}
// Create and write the file via stream to preserve range support
$file = $current->newFile($fileName);
$streamWrapper = $file->fopen('w');
stream_copy_to_stream($tempStream, $streamWrapper);
fclose($streamWrapper);
fclose($tempStream);
$mimeType = $this->mimeTypeDetector->detect($file->getName());
$this->logger->info('Streaming local podcast episode', [
'filePath' => $file->getPath(),
'fileName' => $file->getName(),
'mimeType' => $mimeType,
]);
$response = new FileDisplayResponse($file, Http::STATUS_PARTIAL_CONTENT);
$response->addHeader('Content-Type', $mimeType);
return $response;
}
// File already exists, stream it
$file = $userFolder->get($path);
if (!($file instanceof File)) {
throw new NotFoundException();
}
$mimeType = $this->mimeTypeDetector->detect($file->getName());
$this->logger->info('Streaming local podcast episode', [
'filePath' => $file->getPath(),
'fileName' => $file->getName(),
'mimeType' => $mimeType,
]);
$response = new FileDisplayResponse($file, Http::STATUS_PARTIAL_CONTENT);
$response->addHeader('Content-Type', $mimeType);
return $response;
} catch (NotFoundException $e) {
$this->logger->error('Local podcast file not found', [
'userId' => $user->getUID(),
'episodeId' => $episode->getId(),
'exception' => $e,
]);
return new JSONResponse(['message' => 'Episode file not found'], Http::STATUS_NOT_FOUND);
} catch (\Throwable $e) {
$this->logger->error('Failed to stream or download podcast episode', [
'userId' => $user->getUID(),
'episodeId' => $episode->getId(),
'exception' => $e,
]);
return new JSONResponse(['message' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get the last known playback position for a podcast episode
*

View File

@@ -4,14 +4,13 @@ declare(strict_types=1);
namespace OCA\Jukebox\Controller;
use OCA\Jukebox\AppInfo\Application;
use OCA\Jukebox\Service\SettingsService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\OCSController;
use OCP\IAppConfig;
use OCP\IRequest;
use OCP\IUserSession;
@@ -19,18 +18,13 @@ use OCP\IUserSession;
* Handles user-specific settings such as the music folder path.
*/
class SettingsController extends OCSController {
private IAppConfig $config;
private IUserSession $userSession;
public function __construct(
string $appName,
IRequest $request,
IAppConfig $config,
IUserSession $userSession,
private SettingsService $settings,
private IUserSession $userSession,
) {
parent::__construct($appName, $request);
$this->config = $config;
$this->userSession = $userSession;
}
/**
@@ -43,19 +37,28 @@ class SettingsController extends OCSController {
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'PUT', url: '/api/settings')]
public function saveSettings(mixed $data): DataResponse {
public function saveSettings(mixed $data): JSONResponse {
$user = $this->userSession->getUser();
if ($user === null) {
return new DataResponse(['status' => 'unauthenticated'], Http::STATUS_UNAUTHORIZED);
return new JSONResponse(['status' => 'unauthenticated'], Http::STATUS_UNAUTHORIZED);
}
$uid = $user->getUID();
if (array_key_exists('music_folder_path', $data)) {
$this->config->setValueString(Application::APP_ID, 'music_folder_path_' . $uid, $data['music_folder_path']);
$this->settings->setString($uid, 'music_folder_path', $data['music_folder_path']);
}
if (array_key_exists('download_podcast_episodes', $data)) {
$this->settings->setBool($uid, 'download_podcast_episodes', $data['download_podcast_episodes']);
}
if (array_key_exists('podcast_download_path', $data)) {
$this->settings->setString($uid, 'podcast_download_path', $data['podcast_download_path']);
}
if (array_key_exists('audiobooks_folder_path', $data)) {
$this->settings->setString($uid, 'audiobooks_folder_path', $data['audiobooks_folder_path']);
}
return new DataResponse(['status' => 'OK']);
return new JSONResponse(['status' => 'OK']);
}
/**
@@ -67,7 +70,7 @@ class SettingsController extends OCSController {
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/settings')]
public function getSettings(): DataResponse {
public function getSettings(): JSONResponse {
$user = $this->userSession->getUser();
if ($user === null) {
return new DataResponse([], Http::STATUS_UNAUTHORIZED);
@@ -76,10 +79,10 @@ class SettingsController extends OCSController {
$uid = $user->getUID();
$result = [];
$musicPath = $this->config->getValueString(Application::APP_ID, 'music_folder_path_' . $uid, 'Music');
if ($musicPath !== null) {
$result['music_folder_path'] = $musicPath;
}
$result['music_folder_path'] = $this->settings->getString($uid, 'music_folder_path', 'Music');
$result['download_podcast_episodes'] = $this->settings->getBool($uid, 'download_podcast_episodes', false);
$result['podcast_download_path'] = $this->settings->getString($uid, 'podcast_download_path', 'Podcasts');
$result['audiobooks_folder_path'] = $this->settings->getString($uid, 'audiobooks_folder_path', 'Audiobooks');
return new JSONResponse($result);
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Jukebox\Service;
use OCA\Jukebox\AppInfo\Application;
use OCA\Jukebox\Db\PodcastEpisodeMapper;
use OCA\Jukebox\Db\PodcastSubscriptionMapper;
use OCP\IAppConfig;
use Psr\Log\LoggerInterface;
class SettingsService {
public function __construct(
private LoggerInterface $logger,
private IAppConfig $config,
private PodcastSubscriptionMapper $subsMapper,
private PodcastEpisodeMapper $epMapper,
) {
//
}
public function setString(string $userId, string $name, string $value): void {
$key = "{$name}_{$userId}";
$this->config->setValueString(Application::APP_ID, $key, $value);
}
public function getString(string $userId, string $name, ?string $default): ?string {
$key = "{$name}_{$userId}";
return $this->config->getValueString(Application::APP_ID, $key, $default);
}
public function setBool(string $userId, string $name, bool $value): void {
$key = "{$name}_{$userId}";
$this->config->setValueBool(Application::APP_ID, $key, $value);
}
public function getBool(string $userId, string $name, ?bool $default): ?bool {
$key = "{$name}_{$userId}";
return $this->config->getValueBool(Application::APP_ID, $key, $default);
}
public function getPodcastDownloadPath(string $userId, int $subscriptionId, int $episodeId): string {
$path = $this->getString($userId, 'podcast_download_path', 'Podcasts');
$sub = $this->subsMapper->find($userId, $subscriptionId);
$ep = $this->epMapper->find($userId, $episodeId);
return rtrim($path, '/') . "/{$sub->getTitle()}/{$ep->getTitle()}.mp3";
}
}

View File

@@ -1223,13 +1223,6 @@
"default": null
}
},
{
"name": "range",
"in": "header",
"schema": {
"type": "string"
}
},
{
"name": "OCS-APIRequest",
"in": "header",

View File

@@ -74,7 +74,7 @@
</template>
<script lang="ts">
import { defineComponent, computed, ref } from 'vue'
import { defineComponent, computed, ref, onBeforeUnmount, onBeforeMount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
@@ -148,6 +148,20 @@
isRouterLoading.value = false
})
const handleBeforeUnload = () => {
if (playback.currentMedia.value && !playback.audio.paused) {
playback.trackAction(playback.currentMedia.value, 'pause')
}
}
onBeforeMount(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
return {
searchValue: '',
isRouterLoading,

View File

@@ -2,37 +2,96 @@
<div id="jukebox-user-settings" class="section">
<h2>{{ strings.header }}</h2>
<form @submit.prevent="save">
<NcAppSettingsSection :name="strings.musicLibrarySettings">
<div class="folder-select-wrapper">
<div class="input-with-button">
<NcTextField
v-model="musicFolder"
:label="strings.musicFolderLabel"
:placeholder="strings.musicFolderPlaceholder"
:disabled="true" />
<NcButton
@click="openFolderPicker"
icon="icon-folder"
:aria-label="strings.pickFolder"
:title="strings.pickFolder"
:disabled="loading"
class="folder-button">
{{ strings.pickFolder }}
</NcButton>
<fieldset :disabled="loading">
<NcAppSettingsSection
:name="strings.musicLibrarySettings"
id="jukebox-music-library-settings">
<div class="sections">
<div class="folder-select-wrapper">
<div class="input-with-button">
<NcTextField
v-model="musicFolder"
:label="strings.musicFolderLabel"
:placeholder="strings.musicFolderPlaceholder" />
<NcButton
@click="openFolderPicker('musicFolder')"
icon="icon-folder"
:aria-label="strings.pickFolder"
:title="strings.pickFolder"
:disabled="loading"
class="folder-button">
{{ strings.pickFolder }}
</NcButton>
</div>
</div>
</div>
</NcAppSettingsSection>
<NcAppSettingsSection
:name="strings.podcastLibrarySettings"
id="jukebox-podcast-library-settings">
<div class="sections">
<NcCheckboxRadioSwitch v-model="downloadPodcasts"
>{{ strings.downloadPodcastsLabel }}
</NcCheckboxRadioSwitch>
<div class="folder-select-wrapper">
<div class="input-with-button">
<NcTextField
v-model="podcastFolder"
:label="strings.podcastFolderLabel"
:placeholder="strings.podcastFolderPlaceholder" />
<NcButton
@click="openFolderPicker('podcastFolder')"
:disabled="loading || !downloadPodcasts"
icon="icon-folder"
:aria-label="strings.pickFolder"
:title="strings.pickFolder"
class="folder-button">
{{ strings.pickFolder }}
</NcButton>
</div>
</div>
</div>
</NcAppSettingsSection>
<NcAppSettingsSection
:name="strings.audiobooksLibrarySettings"
id="jukebox-audiobooks-library-settings">
<div class="sections">
<div class="folder-select-wrapper">
<div class="input-with-button">
<NcTextField
v-model="audiobooksFolder"
:label="strings.audiobooksFolderLabel"
:placeholder="strings.audiobooksFolderPlaceholder" />
<NcButton
@click="openFolderPicker('audiobooksFolder')"
icon="icon-folder"
:aria-label="strings.pickFolder"
:title="strings.pickFolder"
:disabled="loading"
class="folder-button">
{{ strings.pickFolder }}
</NcButton>
</div>
</div>
</div>
</NcAppSettingsSection>
<div class="submit-buttons">
<NcButton type="submit" :disabled="loading">{{ strings.save }}</NcButton>
</div>
</NcAppSettingsSection>
<div class="submit-buttons">
<NcButton type="submit" :disabled="loading">{{ strings.save }}</NcButton>
</div>
</fieldset>
</form>
</div>
</template>
<script>
<script lang="ts">
import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import { axios } from './axios'
import { t } from '@nextcloud/l10n'
import { getFilePickerBuilder } from '@nextcloud/dialogs'
@@ -44,16 +103,27 @@
NcAppSettingsSection,
NcTextField,
NcButton,
NcCheckboxRadioSwitch,
},
data() {
return {
loading: false,
musicFolder: '',
podcastFolder: '',
downloadPodcasts: false,
audiobooksFolder: '',
strings: {
header: t('jukebox', 'Jukebox'),
musicLibrarySettings: t('jukebox', 'Music Library'),
podcastLibrarySettings: t('jukebox', 'Podcast Library'),
musicFolderLabel: t('jukebox', 'Music Folder Path'),
musicFolderPlaceholder: t('jukebox', 'e.g. Music'),
podcastFolderLabel: t('jukebox', 'Podcast Download Path'),
podcastFolderPlaceholder: t('jukebox', 'e.g. Podcasts'),
audiobooksLibrarySettings: t('jukebox', 'Audiobooks Library'),
audiobooksFolderLabel: t('jukebox', 'Audiobooks Folder Path'),
audiobooksFolderPlaceholder: t('jukebox', 'e.g. Audiobooks'),
downloadPodcastsLabel: t('jukebox', 'Download podcasts for offline playback'),
pickFolder: t('jukebox', 'Pick a folder'),
save: t('jukebox', 'Save'),
},
@@ -68,7 +138,10 @@
try {
const response = await axios.get('/settings')
const data = response.data
this.musicFolder = data.music_folder_path || ''
this.musicFolder = data.music_folder_path || 'Music'
this.downloadPodcasts = data.download_podcast_episodes || false
this.podcastFolder = data.podcast_download_path || 'Podcasts'
this.audiobooksFolder = data.audiobooks_folder_path || 'Audiobooks'
} catch (e) {
console.error('Failed to fetch settings:', e)
} finally {
@@ -78,8 +151,14 @@
async save() {
this.loading = true
try {
this.musicFolder = this.cleanPath(this.musicFolder)
this.podcastFolder = this.cleanPath(this.podcastFolder)
this.audiobooksFolder = this.cleanPath(this.audiobooksFolder)
const data = {
music_folder_path: this.musicFolder,
download_podcast_episodes: this.downloadPodcasts,
podcast_download_path: this.podcastFolder,
audiobooks_folder_path: this.audiobooksFolder,
}
console.log('Saving settings :', data)
await axios.put('/settings', { data })
@@ -89,7 +168,7 @@
this.loading = false
}
},
async openFolderPicker() {
async openFolderPicker(folderType: string) {
try {
const picker = getFilePickerBuilder(this.strings.musicFolderLabel)
.allowDirectories(true)
@@ -97,27 +176,29 @@
label: t('jukebox', 'Select'),
callback: (nodes) => {
console.log('Selected nodes:', nodes)
const node = nodes?.[0]
const node = nodes?.[0] as any
if (!node || !node._data?.root || !node._data?.attributes?.filename) return
const root = node._data.root
const fullPath = node._data.attributes.filename
this.musicFolder = fullPath.startsWith(root)
const self = this as unknown as Record<string, string>
self[folderType] = fullPath.startsWith(root)
? fullPath.slice(root.length) || '/'
: fullPath
if (this.musicFolder.startsWith('/')) {
this.musicFolder = this.musicFolder.slice(1)
}
console.log('Selected folder path:', this.musicFolder)
self[folderType] = this.cleanPath(self[folderType])
// console.log('Selected folder path:', self[folderType])
},
})
.build()
await picker.pick()
} catch (e) {
if (e.message.includes('No nodes selected')) return
if ((e as Error).message.includes('No nodes selected')) return
console.error('Failed to open folder picker:', e)
}
},
cleanPath(path: string): string {
return path.startsWith('/') ? path.slice(1) : path
},
},
}
</script>
@@ -141,5 +222,11 @@
.folder-button {
flex-shrink: 0;
}
.sections {
display: flex;
flex-direction: column;
gap: 2rem;
}
}
</style>

View File

@@ -34,19 +34,22 @@
</div>
<div class="seekbar-row">
<span class="time">{{ formattedCurrentTime }}</span>
<input type="range" min="0" max="100" :value="seek" @input="onSeek" class="seekbar" :disabled="isLoading" />
<span class="time">{{ displayedCurrentTime }}</span>
<div ref="seekbarRef" class="seekbar" @pointerdown="startSeek">
<div class="seekbar-fill" :style="{ width: (effectiveSeekPercent * 100) + '%' }"></div>
</div>
<span class="time">{{ formattedDuration }}</span>
</div>
</footer>
</template>
<script lang="ts">
import { defineComponent, ref, computed } from 'vue'
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 playback from '@/composables/usePlayback'
import rawPlayback from '@/composables/usePlayback'
import { formatDuration } from '@/utils/time'
import SkipPrevious from '@icons/SkipPrevious.vue'
import SkipNext from '@icons/SkipNext.vue'
@@ -70,47 +73,93 @@ export default defineComponent({
},
setup() {
const showQueue = ref(false)
const isDragging = ref(false)
const seekPercent = ref(0)
const cachedDuration = ref(0)
function toggleQueue() {
if (!showQueue.value) {
showQueue.value = true
}
const seekbarRef = ref<HTMLElement | null>(null)
let lastPointerX = 0
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),
}
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, '0')}`
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
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 queue = computed(() => playback.queue.value as unknown[] as Track[])
const isPlaying = computed(() => playback.isPlaying.value)
const seek = computed(() => playback.seek.value)
const formattedCurrentTime = computed(() => formatTime(playback.currentTime.value))
const formattedDuration = computed(() => formatTime(playback.duration.value))
const isLoading = computed(() => playback.loading.value)
function onSeek(event: Event) {
const target = event.target as HTMLInputElement
playback.setSeek(Number(target.value))
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 stopSeek() {
if (!isDragging.value) return
console.log('Seek end')
applySeek()
isDragging.value = false
window.removeEventListener('pointermove', updateSeekPosition)
window.removeEventListener('pointerup', stopSeek)
}
onBeforeUnmount(() => {
stopSeek()
})
return {
showQueue,
toggleQueue,
toggleQueue: () => { showQueue.value = !showQueue.value },
playback,
queue,
isPlaying,
seek,
queue: playback.queue,
isPlaying: playback.isPlaying,
currentTime: playback.currentTime,
duration: playback.duration,
isLoading: playback.isLoading,
formattedCurrentTime,
displayedCurrentTime,
formattedDuration,
onSeek,
isLoading,
effectiveSeekPercent,
startSeek,
seekbarRef,
}
},
})
</script>
<style lang="scss">
.jukebox-player {
flex-shrink: 0;
@@ -154,7 +203,24 @@ export default defineComponent({
}
.seekbar {
position: relative;
width: 100%;
height: 8px;
border-radius: 4px;
background: var(--color-border);
cursor: pointer;
flex: 1;
user-select: none;
}
.seekbar-fill {
position: absolute;
height: 100%;
left: 0;
top: 0;
border-radius: 4px;
background: var(--color-primary);
pointer-events: none;
}
.time {

View File

@@ -29,6 +29,7 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import PlayCircle from '@icons/PlayCircle.vue'
import Delete from '@icons/Delete.vue'
import type { PodcastEpisode } from '@/models/media'
import { formatDuration, formatDate } from '@/utils/time'
export default defineComponent({
name: 'PodcastEpCardItem',
@@ -52,21 +53,6 @@ export default defineComponent({
const onClick = () => emit('click')
const remove = (ep: PodcastEpisode) => emit('remove', ep)
const formatDate = (iso: string): string => {
const date = new Date(iso)
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
return {
onClick,
remove,

View File

@@ -6,8 +6,7 @@
</template>
<template #subname>
{{ durationFormatted }} {{ episode.pub_date ? new Date(episode.pub_date).toLocaleDateString() : 'Unknown date'
}}
{{ durationFormatted }} {{ pubDateFormatted }}
</template>
<template #actions>
@@ -43,6 +42,7 @@
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'
@@ -92,9 +92,11 @@ export default defineComponent({
const durationFormatted = computed(() => {
if (!props.episode.duration) return 'No duration'
const minutes = Math.floor(props.episode.duration / 60)
const seconds = props.episode.duration % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
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)
@@ -104,6 +106,7 @@ export default defineComponent({
return {
isActive,
durationFormatted,
pubDateFormatted,
onPlay,
onPlayNext,
onAddToQueue,

View File

@@ -10,7 +10,7 @@
<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">
<MediaListItem v-for="(media, index) in queue" :key="media.id" :media="media" mediaType="track"
<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)">
@@ -20,7 +20,7 @@
Remove from Queue
</NcActionButton>
</template>
</MediaListItem>
</TrackListItem>
</div>
<p v-else class="empty-message">The queue is empty.</p>
</div>
@@ -32,7 +32,7 @@
<script lang="ts">
import { defineComponent, ref, onMounted, computed, onBeforeUnmount, type PropType } from 'vue'
import NcPopover from '@nextcloud/vue/components/NcPopover'
import MediaListItem from '@/components/media/MediaListItem.vue'
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'
@@ -40,7 +40,7 @@ import playback, { trackToPlayable } from '@/composables/usePlayback'
export default defineComponent({
name: 'QueuePopover',
components: { NcPopover, MediaListItem, NcActionButton, Delete },
components: { NcPopover, TrackListItem, NcActionButton, Delete },
props: {
shown: Boolean,
queue: {

View File

@@ -53,7 +53,7 @@ import SkipNext from '@icons/SkipNext.vue'
import PlaylistPlus from '@icons/PlaylistPlus.vue'
export default defineComponent({
name: 'MediaListItem',
name: 'TrackListItem',
props: {
media: {
type: Object as PropType<Track>,

View File

@@ -41,10 +41,9 @@ const loading = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const awaitingSeekResume = ref(false)
const seek = computed(() =>
duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0
)
const resumePosition = ref(0)
let seekInProgress = false
const suppressTimeUpdate = ref(false)
const currentMedia = computed(() =>
currentIndex.value >= 0 ? queue.value[currentIndex.value] ?? null : null
@@ -131,19 +130,13 @@ async function playMedia(media: Playable) {
resumePosition.value = await getStartPosition(media)
audio.src = src
audio.load()
currentTime.value = resumePosition.value
} else {
}
else {
resumePosition.value = 0
awaitingSeekResume.value = false
}
duration.value = typeof media.duration === 'number' ? media.duration : 0
audio
.play()
.catch(err => {
console.error('Playback failed:', err)
})
}
function playIndex(index: number) {
@@ -226,9 +219,14 @@ function playFromQueue(media: Playable) {
}
}
function setSeek(percent: number) {
const newTime = (Number(percent) / 100) * duration.value
function setSeek(newTime: number) {
// console.log('[setSeek] Seeking to:', newTime, { currentTime: audio.currentTime, duration: audio.duration });
seekInProgress = true
audio.currentTime = newTime
setTimeout(() => {
suppressTimeUpdate.value = false
}, 200) // 23 frames at 60fps
}
audio.addEventListener('play', () => {
@@ -255,15 +253,23 @@ audio.addEventListener('waiting', () => {
})
audio.addEventListener('seeking', () => {
// console.log('[audio event] seeking');
seekInProgress = true
loading.value = true
})
audio.addEventListener('seeked', () => {
// console.log('[audio event] seeked');
seekInProgress = false
loading.value = false
if (!suppressTimeUpdate.value) {
currentTime.value = audio.currentTime
}
})
audio.addEventListener('timeupdate', () => {
if (seekInProgress) return
// console.log('[audio event] timeupdate', { currentTime: audio.currentTime, duration: audio.duration });
currentTime.value = audio.currentTime
if (!suppressTimeUpdate.value) {
currentTime.value = audio.currentTime
}
if (!duration.value && audio.duration) {
duration.value = audio.duration || 0
}
@@ -273,11 +279,6 @@ audio.addEventListener('loadedmetadata', () => {
if (!duration.value && audio.duration) {
duration.value = audio.duration
}
if (awaitingSeekResume.value && resumePosition.value > 0) {
audio.currentTime = resumePosition.value
currentTime.value = resumePosition.value
}
})
audio.addEventListener('loadstart', () => {
// console.log('[audio event] loadstart');
@@ -288,15 +289,16 @@ audio.addEventListener('canplay', () => {
// console.log('[audio event] canplay', { resumePosition: resumePosition.value, currentTime: currentTime.value, awaitingSeekResume: awaitingSeekResume.value, currentMedia: currentMedia.value });
loading.value = false
audio.play().catch(err => {
console.warn('Resume playback after seek failed:', err)
})
if (awaitingSeekResume.value) {
trackAction(currentMedia.value!, 'resume')
if (awaitingSeekResume.value && resumePosition.value > 0) {
audio.currentTime = resumePosition.value
currentTime.value = resumePosition.value
awaitingSeekResume.value = false
resumePosition.value = 0
}
audio.play().catch(err => {
console.warn('Resume playback after seek failed:', err)
})
})
audio.addEventListener('error', () => {
@@ -312,6 +314,7 @@ watch(currentMedia, (_newMedia, oldMedia) => {
function usePlayback() {
return {
audio,
isPlaying,
currentMedia,
queue,
@@ -319,7 +322,6 @@ function usePlayback() {
currentIndex,
currentTime,
duration,
seek,
playMedia,
addToQueue,
addAsNext,
@@ -333,6 +335,7 @@ function usePlayback() {
togglePlay,
pause,
setSeek,
trackAction,
}
}

28
src/utils/time.ts Normal file
View File

@@ -0,0 +1,28 @@
export function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor(seconds / 60 - h * 60)
const s = Math.floor(seconds % 60)
let formatted = ''
if (h > 0) {
formatted += `${h}:${m.toString().padStart(2, '0')}:`
} else {
formatted += `${m}:`
}
formatted += s.toString().padStart(2, '0')
return formatted
}
export const formatDate = (iso: string): string => {
const date = new Date(iso)
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}

View File

@@ -20,7 +20,7 @@
</div>
<div v-if="album">
<MediaListItem v-for="track in album.tracks" :key="track.id" :media="track" media-type="track"
<TrackListItem v-for="track in album.tracks" :key="track.id" :media="track" media-type="track"
@play="handlePlay(track)" />
</div>
</Page>
@@ -34,14 +34,14 @@ import type { Album, Track } from '@/models/media'
import { useGoToArtist } from '@/utils/routing'
import Page from '@/components/Page.vue'
import MediaListItem from '@/components/media/MediaListItem.vue'
import TrackListItem from '@/components/media/TrackListItem.vue'
import Music from '@icons/Music.vue'
import playback, { trackToPlayable } from '@/composables/usePlayback'
import NcButton from '@nextcloud/vue/components/NcButton'
export default defineComponent({
name: 'AlbumView',
components: { Page, MediaListItem, Music, NcButton },
components: { Page, TrackListItem, Music, NcButton },
setup() {
const route = useRoute()
const album = ref<Album | null>(null)

View File

@@ -21,7 +21,7 @@
</div>
<div v-if="artist">
<MediaListItem v-for="track in artist.tracks" :key="track.id" :media="track" media-type="track"
<TrackListItem v-for="track in artist.tracks" :key="track.id" :media="track" media-type="track"
@play="handlePlay(track)" />
</div>
</Page>
@@ -34,14 +34,14 @@ import { axios } from '@/axios'
import type { Track, Artist } from '@/models/media'
import Page from '@/components/Page.vue'
import MediaListItem from '@/components/media/MediaListItem.vue'
import TrackListItem from '@/components/media/TrackListItem.vue'
import AlbumCardItem from '@/components/media/AlbumCardItem.vue'
import Music from '@icons/Music.vue'
import playback, { trackToPlayable } from '@/composables/usePlayback'
export default defineComponent({
name: 'ArtistView',
components: { Page, MediaListItem, AlbumCardItem, Music },
components: { Page, TrackListItem, AlbumCardItem, Music },
setup() {
const route = useRoute()
const artist = ref<Artist | null>(null)

View File

@@ -4,7 +4,7 @@
Tracks
</template>
<MediaListItem v-for="track in tracks" :key="track.id" :media="track" media-type="track" @play="handlePlay" />
<TrackListItem v-for="track in tracks" :key="track.id" :media="track" media-type="track" @play="handlePlay" />
</Page>
</template>
@@ -13,13 +13,13 @@ import { defineComponent, onMounted, ref } from 'vue'
import { axios } from '@/axios'
import { type Track } from '@/models/media'
import MediaListItem from '@/components/media/MediaListItem.vue'
import TrackListItem from '@/components/media/TrackListItem.vue'
import Page from '@/components/Page.vue'
import playback, { trackToPlayable } from '@/composables/usePlayback'
export default defineComponent({
name: 'TracksView',
components: { MediaListItem, Page },
components: { TrackListItem, Page },
setup() {
const tracks = ref<Track[]>([])
const isLoading = ref(true)