feat: player + tracks list

This commit is contained in:
2025-06-07 11:37:24 +03:00
parent 7cf08951eb
commit 752a4c1891
14 changed files with 512 additions and 48 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,18 +1,46 @@
<template>
<div>
<div class="tracks-view">
<h3>Track List</h3>
<!-- Well 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>

View File

@@ -9,6 +9,9 @@
"paths": {
"@icons/*": [
"node_modules/vue-material-design-icons/*"
],
"@/*": [
"src/*"
]
}
}

View File

@@ -13,6 +13,7 @@ export default createAppConfig(
resolve: {
alias: {
'@icons': path.resolve(__dirname, 'node_modules/vue-material-design-icons'),
'@': path.resolve(__dirname, 'src'),
},
},
build: {