mirror of
https://github.com/chenasraf/nextcloud-jukebox.git
synced 2026-05-18 01:39:00 +00:00
fix: streaming/seek playback
This commit is contained in:
@@ -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
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
52
lib/Service/SettingsService.php
Normal file
52
lib/Service/SettingsService.php
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -1223,13 +1223,6 @@
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
|
||||
16
src/App.vue
16
src/App.vue
@@ -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,
|
||||
|
||||
149
src/Settings.vue
149
src/Settings.vue
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>,
|
||||
@@ -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) // 2–3 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
28
src/utils/time.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user