mirror of
https://github.com/chenasraf/nextcloud-jukebox.git
synced 2026-05-18 01:39:00 +00:00
feat: player + tracks list
This commit is contained in:
104
lib/Controller/ApiController.php
Normal file
104
lib/Controller/ApiController.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Jukebox\Controller;
|
||||
|
||||
use OCA\Jukebox\Db\JukeboxMediaMapper;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\FileDisplayResponse;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\Files\File;
|
||||
use OCP\Files\IRootFolder;
|
||||
use OCP\Files\NotFoundException;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class ApiController extends OCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private IAppConfig $config,
|
||||
private IL10N $l,
|
||||
private JukeboxMediaMapper $mediaMapper,
|
||||
private IUserSession $userSession,
|
||||
private IRootFolder $rootFolder,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tracks for the current user
|
||||
*
|
||||
* @return JSONResponse<Http::STATUS_OK, array{tracks: list<array<string, mixed>>}, array{}>
|
||||
*
|
||||
* 200: List of media tracks for current user
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/tracks')]
|
||||
public function listTracks(): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse(['message' => 'Unauthenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$tracks = $this->mediaMapper->findByMediaType($user->getUID(), 'track');
|
||||
return new JSONResponse(['tracks' => array_map(fn ($t) => $t->jsonSerialize(), $tracks)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a track file for playback
|
||||
*
|
||||
* @param int $id Track ID
|
||||
*
|
||||
* @return FileDisplayResponse<Http::STATUS_OK, array{}>
|
||||
* | JSONResponse<Http::STATUS_UNAUTHORIZED, array{message: string}, array{}>
|
||||
* | JSONResponse<Http::STATUS_FORBIDDEN, array{message: string}, array{}>
|
||||
* | JSONResponse<Http::STATUS_NOT_FOUND, array{message: string}, array{}>
|
||||
*
|
||||
* 200: File response returned successfully
|
||||
* 401: User not authenticated
|
||||
* 403: Track does not belong to current user
|
||||
* 404: Track file or record not found
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/tracks/{id}/stream')]
|
||||
#[NoCSRFRequired]
|
||||
public function streamTrack(int $id): FileDisplayResponse|JSONResponse {
|
||||
$this->logger->info('Received request to stream track with ID: ' . $id);
|
||||
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse(['message' => 'Unauthenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$this->logger->info('Streaming track with ID: ' . $id, ['user' => $user->getUID()]);
|
||||
|
||||
try {
|
||||
$media = $this->mediaMapper->find((string)$id);
|
||||
if ($media->getUserId() !== $user->getUID()) {
|
||||
return new JSONResponse(['message' => 'Forbidden'], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
$file = $this->rootFolder->get($media->getPath());
|
||||
|
||||
if (!($file instanceof File)) {
|
||||
$this->logger->error('Track file not found: ' . $media->getPath());
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return new FileDisplayResponse($file);
|
||||
} catch (NotFoundException $e) {
|
||||
$this->logger->error('Track file not found for ID: ' . $id, ['exception' => $e]);
|
||||
return new JSONResponse(['message' => 'Track not found'], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use OCA\Jukebox\AppInfo\Application;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IRequest;
|
||||
@@ -77,6 +78,6 @@ class SettingsController extends OCSController {
|
||||
$result['music_folder_path'] = $musicPath;
|
||||
}
|
||||
|
||||
return new DataResponse($result);
|
||||
return new JSONResponse($result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class JukeboxMedia extends Entity implements JsonSerializable {
|
||||
'album' => $this->album,
|
||||
'albumArtist' => $this->albumArtist,
|
||||
'duration' => $this->duration,
|
||||
'albumArt' => $this->albumArt,
|
||||
'albumArt' => $this->getAlbumArtBase64(),
|
||||
'genre' => $this->genre,
|
||||
'year' => $this->year,
|
||||
'bitrate' => $this->bitrate,
|
||||
|
||||
163
openapi.json
163
openapi.json
@@ -47,6 +47,169 @@
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/ocs/v2.php/apps/jukebox/api/tracks": {
|
||||
"get": {
|
||||
"operationId": "api-list-tracks",
|
||||
"summary": "List all tracks for the current user",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"api"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of media tracks for current user",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"tracks"
|
||||
],
|
||||
"properties": {
|
||||
"tracks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/jukebox/api/tracks/{id}/stream": {
|
||||
"get": {
|
||||
"operationId": "api-stream-track",
|
||||
"summary": "Stream a track file for playback",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"api"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "Track ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "File response returned successfully",
|
||||
"content": {
|
||||
"*/*": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "User not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Track does not belong to current user",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Track file or record not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/jukebox/api/settings": {
|
||||
"put": {
|
||||
"operationId": "settings-save-settings",
|
||||
|
||||
71
src/App.vue
71
src/App.vue
@@ -1,28 +1,44 @@
|
||||
<template>
|
||||
<NcContent app-name="jukebox">
|
||||
<NcAppNavigation>
|
||||
<template #search> <NcAppNavigationSearch v-model="searchValue" label="Search…" /> </template>
|
||||
<template #search>
|
||||
<NcAppNavigationSearch v-model="searchValue" label="Search…" />
|
||||
</template>
|
||||
<template #list>
|
||||
<NcAppNavigationItem name="Tracks" :to="{ path: '/tracks' }">
|
||||
<template #icon> <Music :size="20" /> </template>
|
||||
<template #icon>
|
||||
<Music :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem name="Albums" :to="{ path: '/albums' }">
|
||||
<template #icon> <Album :size="20" /> </template>
|
||||
<template #icon>
|
||||
<Album :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem name="Artists" :to="{ path: '/artists' }">
|
||||
<template #icon> <AccountMusic :size="20" /> </template>
|
||||
<template #icon>
|
||||
<AccountMusic :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem name="Podcasts" :to="{ path: '/podcasts' }">
|
||||
<template #icon> <Podcast :size="20" /> </template>
|
||||
<template #icon>
|
||||
<Podcast :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem name="Audiobooks" :to="{ path: '/audiobooks' }">
|
||||
<template #icon> <Book :size="20" /> </template>
|
||||
<template #icon>
|
||||
<Book :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem name="Videos" :to="{ path: '/videos' }">
|
||||
<template #icon> <Filmstrip :size="20" /> </template>
|
||||
<template #icon>
|
||||
<Filmstrip :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem name="Genres" :to="{ path: '/genres' }">
|
||||
<template #icon> <Tag :size="20" /> </template>
|
||||
<template #icon>
|
||||
<Tag :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
</template>
|
||||
<template #footer> <!-- Add footer controls if needed --> </template>
|
||||
@@ -37,17 +53,20 @@
|
||||
variant="tertiary"
|
||||
aria-label="Previous"
|
||||
size="normal"
|
||||
@click="prev">
|
||||
<template #icon> <SkipPrevious :size="20" /> </template>
|
||||
@click="playback.prev">
|
||||
<template #icon>
|
||||
<SkipPrevious :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton
|
||||
:disabled="false"
|
||||
variant="primary"
|
||||
aria-label="Play/Pause"
|
||||
size="normal"
|
||||
@click="togglePlay">
|
||||
@click="playback.togglePlay">
|
||||
<template #icon>
|
||||
<Play :size="20" v-if="!isPlaying" /> <Pause :size="20" v-else />
|
||||
<Play :size="20" v-if="!isPlaying" />
|
||||
<Pause :size="20" v-else />
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton
|
||||
@@ -55,8 +74,10 @@
|
||||
variant="tertiary"
|
||||
aria-label="Next"
|
||||
size="normal"
|
||||
@click="next">
|
||||
<template #icon> <SkipNext :size="20" /> </template>
|
||||
@click="playback.next">
|
||||
<template #icon>
|
||||
<SkipNext :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
<input type="range" min="0" max="100" v-model="seek" class="seekbar" />
|
||||
@@ -66,7 +87,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { defineComponent, computed } from 'vue'
|
||||
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
|
||||
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
|
||||
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
|
||||
@@ -86,6 +107,8 @@
|
||||
import Filmstrip from '@icons/Filmstrip.vue'
|
||||
import Tag from '@icons/Tag.vue'
|
||||
|
||||
import { usePlayback } from '@/composables/usePlayback'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'App',
|
||||
components: {
|
||||
@@ -112,24 +135,16 @@
|
||||
'NcContent:setHasAppNavigation': () => true,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
setup() {
|
||||
const playback = usePlayback()
|
||||
|
||||
return {
|
||||
searchValue: '',
|
||||
isPlaying: false,
|
||||
seek: 0,
|
||||
playback,
|
||||
isPlaying: computed(() => playback.isPlaying.value),
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
togglePlay() {
|
||||
this.isPlaying = !this.isPlaying
|
||||
},
|
||||
next() {
|
||||
// Placeholder
|
||||
},
|
||||
prev() {
|
||||
// Placeholder
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import { settingsAxios } from './axios'
|
||||
import { axios } from './axios'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { getFilePickerBuilder } from '@nextcloud/dialogs'
|
||||
import '@nextcloud/dialogs/style.css'
|
||||
@@ -66,8 +66,8 @@
|
||||
async fetchSettings() {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await settingsAxios.get('/settings')
|
||||
const data = response.data.ocs.data
|
||||
const response = await axios.get('/settings')
|
||||
const data = response.data
|
||||
this.musicFolder = data.music_folder_path || ''
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch settings:', e)
|
||||
@@ -82,7 +82,7 @@
|
||||
music_folder_path: this.musicFolder,
|
||||
}
|
||||
console.log('Saving settings :', data)
|
||||
await settingsAxios.put('/settings', { data })
|
||||
await axios.put('/settings', { data })
|
||||
} catch (e) {
|
||||
console.error('Failed to save settings:', e)
|
||||
} finally {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import axios from '@nextcloud/axios'
|
||||
import _axios from '@nextcloud/axios'
|
||||
|
||||
const baseURL = generateOcsUrl('/apps/jukebox/api')
|
||||
export const settingsAxios = axios.create({
|
||||
baseURL,
|
||||
})
|
||||
export const axios = _axios.create({ baseURL })
|
||||
|
||||
64
src/components/media/MediaListItem.vue
Normal file
64
src/components/media/MediaListItem.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="media-item" @click="onPlay">
|
||||
<img v-if="media.albumArt" :src="media.albumArt" alt="Cover" class="cover" />
|
||||
<div class="info">
|
||||
<strong>{{ media.title || 'Untitled' }}</strong>
|
||||
<small v-if="media.artist">{{ media.artist }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, type PropType } from 'vue'
|
||||
import { type Media } from '@/models/media'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MediaListItem',
|
||||
props: {
|
||||
media: {
|
||||
type: Object as PropType<Media>,
|
||||
required: true,
|
||||
},
|
||||
mediaType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['play'],
|
||||
setup(props, { emit }) {
|
||||
const onPlay = () => emit('play', props.media)
|
||||
|
||||
return {
|
||||
onPlay,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.media-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0.5em;
|
||||
border-bottom: 1px solid #ccc;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8f8f8;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
margin-right: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
68
src/composables/usePlayback.ts
Normal file
68
src/composables/usePlayback.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Media } from '@/models/media'
|
||||
import { axios } from '@/axios'
|
||||
|
||||
const audio = new Audio()
|
||||
const isPlaying = ref(false)
|
||||
const currentMedia = ref<Media | null>(null)
|
||||
|
||||
function play(media: Media) {
|
||||
if (currentMedia.value?.id !== media.id) {
|
||||
audio.src = axios.defaults.baseURL + `/tracks/${media.id}/stream`
|
||||
audio.load()
|
||||
currentMedia.value = media
|
||||
}
|
||||
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
isPlaying.value = true
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Playback failed:', err)
|
||||
isPlaying.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function pause() {
|
||||
audio.pause()
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (audio.paused) {
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
isPlaying.value = true
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Toggle play failed:', err)
|
||||
})
|
||||
} else {
|
||||
audio.pause()
|
||||
}
|
||||
}
|
||||
|
||||
audio.addEventListener('play', () => {
|
||||
isPlaying.value = true
|
||||
})
|
||||
|
||||
audio.addEventListener('pause', () => {
|
||||
isPlaying.value = false
|
||||
})
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
isPlaying.value = false
|
||||
})
|
||||
|
||||
export function usePlayback() {
|
||||
return {
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
isPlaying,
|
||||
currentMedia,
|
||||
}
|
||||
}
|
||||
|
||||
export default usePlayback
|
||||
19
src/models/media.ts
Normal file
19
src/models/media.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface Media {
|
||||
id: number
|
||||
mediaType: string
|
||||
path: string
|
||||
title: string | null
|
||||
trackNumber: number | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
albumArtist: string | null
|
||||
duration: number | null
|
||||
albumArt: string | null
|
||||
genre: string | null
|
||||
year: number | null
|
||||
bitrate: number | null
|
||||
codec: string | null
|
||||
userId: string
|
||||
mtime: number
|
||||
rawId3: string | null
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { settingsAxios } from './axios'
|
||||
import { axios } from './axios'
|
||||
import Settings from './Settings.vue'
|
||||
import './style.scss'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
console.log('[DEBUG] Mounting jukebox Settings')
|
||||
console.log('[DEBUG] Base URL:', settingsAxios.defaults.baseURL)
|
||||
console.log('[DEBUG] Base URL:', axios.defaults.baseURL)
|
||||
createApp(Settings).mount('#jukebox-settings')
|
||||
|
||||
@@ -1,18 +1,46 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="tracks-view">
|
||||
<h3>Track List</h3>
|
||||
<!-- We’ll populate this with real data later -->
|
||||
<MediaListItem v-for="track in tracks" :key="track.id" :media="track" media-type="track" @play="handlePlay" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'TracksView',
|
||||
}
|
||||
</script>
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import { axios } from '@/axios'
|
||||
|
||||
<style scoped lang="scss">
|
||||
h3 {
|
||||
import MediaListItem from '@/components/media/MediaListItem.vue'
|
||||
import { usePlayback } from '@/composables/usePlayback'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TracksView',
|
||||
components: { MediaListItem },
|
||||
setup() {
|
||||
const tracks = ref([])
|
||||
const { play } = usePlayback()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await axios.get('/tracks')
|
||||
tracks.value = res.data.tracks
|
||||
} catch (err) {
|
||||
console.error('Failed to load tracks:', err)
|
||||
}
|
||||
})
|
||||
|
||||
const handlePlay = (track: any) => {
|
||||
play(track)
|
||||
}
|
||||
|
||||
return {
|
||||
tracks,
|
||||
handlePlay,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="scss">
|
||||
h3 {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
"paths": {
|
||||
"@icons/*": [
|
||||
"node_modules/vue-material-design-icons/*"
|
||||
],
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export default createAppConfig(
|
||||
resolve: {
|
||||
alias: {
|
||||
'@icons': path.resolve(__dirname, 'node_modules/vue-material-design-icons'),
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user