Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion __tests__/defaults.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe('default handlers', () => {
registerDefaultHandlers()
registerDefaultHandlers()

expect(scope.handlers!.size).toBe(3)
expect(scope.handlers!.size).toBe(4)
expect(warn).not.toHaveBeenCalled()
})
})
4 changes: 2 additions & 2 deletions __tests__/entry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe('importing @nextcloud/viewer', () => {
expect(getViewer()).toBe(scope.service)
})

it('registers no handler until asked, then the image, video and audio ones', async () => {
it('registers no handler until asked, then the built-in ones', async () => {
const { getHandlers, registerDefaultHandlers } = await importEntry()

// The server asks from an init script; a second copy on the page
Expand All @@ -38,7 +38,7 @@ describe('importing @nextcloud/viewer', () => {

registerDefaultHandlers()

expect([...getHandlers().keys()].sort()).toEqual(['audios', 'images', 'videos'])
expect([...getHandlers().keys()].sort()).toEqual(['audios', 'images', 'sheetmusic', 'videos'])
})

it('does not mount anything, or fetch its heavy half, until a file is opened', async () => {
Expand Down
36 changes: 36 additions & 0 deletions __tests__/models.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,42 @@ describe('audios model', () => {
})
})

describe('sheetmusic model', () => {
it.each([
'application/vnd.recordare.musicxml',
'application/vnd.recordare.musicxml+xml',
])('enables the score mime %s', async (mime) => {
const { registerSheetmusicHandler } = await import('../lib/models/sheetmusic.ts')
registerSheetmusicHandler()
const handler = handlerById('sheetmusic')
expect(handler.enabled([makeFile({ mime })])).toBe(true)
})

it('does not claim the type every unrecognised file falls back to', async () => {
// An earlier attempt registered application/octet-stream, because
// the extensions had no mapping yet. They do since server 32, and
// claiming it would hand this handler every unknown binary
const { registerSheetmusicHandler } = await import('../lib/models/sheetmusic.ts')
registerSheetmusicHandler()
const handler = handlerById('sheetmusic')
expect(handler.enabled([makeFile({ mime: 'application/octet-stream' })])).toBe(false)
})

it('rejects a file that is not a score', async () => {
const { registerSheetmusicHandler } = await import('../lib/models/sheetmusic.ts')
registerSheetmusicHandler()
const handler = handlerById('sheetmusic')
expect(handler.enabled([makeFile({ mime: 'image/jpeg' })])).toBe(false)
})

it('disables an empty nodes array', async () => {
const { registerSheetmusicHandler } = await import('../lib/models/sheetmusic.ts')
registerSheetmusicHandler()
const handler = handlerById('sheetmusic')
expect(handler.enabled([])).toBe(false)
})
})

describe('images model', () => {
it.each([
'image/apng',
Expand Down
62 changes: 62 additions & 0 deletions e2e/sheetmusic.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { expect, test } from '@playwright/test'
import { ViewerPage } from './support/viewer.ts'

test.describe('Sheet music', () => {
test('draws the score on screen', async ({ page }) => {
const viewer = new ViewerPage(page)
await viewer.open('score.musicxml')
await viewer.waitForOpen()

// The renderer draws the staves as SVG, so asking whether anything
// was drawn is asking what is inside that element. A handler that
// opened and rendered nothing would still satisfy the modal being
// visible.
const sheet = viewer.container.locator('.sheetmusic__sheet')
await expect(sheet.locator('svg')).toBeVisible()

await expect(async () => {
const drawn = await sheet.evaluate((element) => ({
// Staff lines and note heads are drawn as paths
paths: element.querySelectorAll('svg path').length,
text: element.textContent ?? '',
}))
expect(drawn.paths).toBeGreaterThan(10)
// The title comes from the file, so this is the score we asked for
expect(drawn.text).toContain('Viewer test score')
}).toPass({ timeout: 20_000 })
})

test('draws a compressed score too', async ({ page }) => {
// A .mxl is the same score zipped, which is the form most editors
// export, so the renderer is handed the bytes rather than text
const viewer = new ViewerPage(page)
await viewer.open('score.mxl')
await viewer.waitForOpen()

const sheet = viewer.container.locator('.sheetmusic__sheet')
await expect(sheet.locator('svg')).toBeVisible()
await expect(async () => {
const drawn = await sheet.evaluate((element) => ({
paths: element.querySelectorAll('svg path').length,
text: element.textContent ?? '',
}))
expect(drawn.paths).toBeGreaterThan(10)
expect(drawn.text).toContain('Viewer test score compressed')
}).toPass({ timeout: 20_000 })
})

test('does not claim files it cannot draw', async ({ page }) => {
// An earlier attempt at this registered application/octet-stream,
// which is the type every unrecognised file falls back to
const viewer = new ViewerPage(page)
await viewer.open('photo.jpg')
await viewer.waitForOpen()

await expect(viewer.container.locator('.sheetmusic__sheet')).toHaveCount(0)
await expect(viewer.container.locator('img')).toBeVisible()
})
})
165 changes: 165 additions & 0 deletions lib/components/Sheetmusic.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<div class="sheetmusic" :style="frame">
<div ref="sheet" class="sheetmusic__sheet" />
</div>
</template>

<script setup lang="ts">
import type { ViewerEmits, ViewerProps } from '../viewer.ts'

import axios from '@nextcloud/axios'
import { computed, onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
import { logger } from '../services/logger.ts'
import { t } from '../utils/l10n.ts'

defineOptions({
name: 'ViewerSheetmusic',
})

const props = defineProps<ViewerProps>()
const emit = defineEmits<ViewerEmits>()

const sheet = useTemplateRef<HTMLDivElement>('sheet')

/**
* The space to draw in.
*
* A handler is mounted as a custom element, which is inline until it is
* told otherwise, so a sheet sized against its parent would be drawn into
* a box a few pixels wide. The viewer passes the room it has; use that.
*/
const frame = computed(() => ({
width: `${props.maxWidth}px`,
height: `${props.maxHeight}px`,
}))

/** The renderer, once the chunk carrying it has arrived */
let display: { render: () => void, clear: () => void } | null = null

/** What is fetching the current file, so it can be dropped on a move */
let controller: AbortController | null = null

/**
* Wait until an element has been given a width, and answer with it.
*
* Zero is answered too, after a few frames: a score drawn into nothing is
* a poor result but a better one than a viewer that never stops waiting.
*
* @param element the element to measure
*/
async function widthOf(element: HTMLElement): Promise<number> {
for (let frame = 0; frame < 30; frame++) {
if (element.offsetWidth > 0) {
return element.offsetWidth
}
await new Promise((resolve) => requestAnimationFrame(resolve))
}
return element.offsetWidth
}

/**
* Draw the file on screen.
*
* The renderer is imported here rather than at the top of the module
* because it is two megabytes: pulled in with the rest of the viewer it
* would be downloaded by everyone who ever opens a photo. Behind this
* await it is a chunk of its own, fetched the first time somebody opens
* a piece of sheet music and never otherwise.
*/
async function draw(): Promise<void> {
controller?.abort()
controller = new AbortController()
const { signal } = controller

const element = sheet.value
if (element === null) {
return
}

try {
const { OpenSheetMusicDisplay } = await import('opensheetmusicdisplay')

// A .mxl is a zip holding the score, a .musicxml is the score
// itself. The renderer tells them apart by looking, so both are
// handed over as they came off the wire rather than decoded here.
const response = await axios.get(props.file.encodedSource, {
responseType: 'blob',
signal,
})
if (signal.aborted) {
return
}

display?.clear()
const osmd = new OpenSheetMusicDisplay(element, {
autoResize: true,
// The viewer's backdrop is dark whatever the server theme is
darkMode: true,
})
await osmd.load(response.data as Blob)
if (signal.aborted) {
return
}

// The viewer keeps a handler hidden until it says it has loaded, and
// an element that is not displayed has no width to draw into. Say so
// first, then draw once the frame it was given is really there.
display = osmd
emit('loaded')
await widthOf(element)
if (signal.aborted) {
return
}
osmd.render()
} catch (error) {
if (signal.aborted) {
return
}
logger.error('Could not render the sheet music', { error })
emit('errored', new Error(t('Failed to load sheet music.')))
}
}

// Drawing needs the element, which a watcher running during setup would
// not have yet, so the first draw waits for the mount and the watcher
// covers only the moves from one file to the next
onMounted(() => void draw())
watch(() => props.file.source, () => void draw())

// The viewer measures the room it has after the handler is mounted, so the
// first draw can land in a frame of no width and come out as a score zero
// pixels wide. Draw it again against the size that arrived.
watch([() => props.maxWidth, () => props.maxHeight], () => {
try {
display?.render()
} catch (error) {
logger.debug('Could not redraw the sheet music at the new size', { error })
}
})

onUnmounted(() => {
controller?.abort()
display?.clear()
display = null
})
</script>

<style scoped>
.sheetmusic {
overflow: auto;
}

.sheetmusic__sheet {
/* The renderer draws onto a white page, which needs its own ground
rather than the viewer's dark backdrop showing through the staves */
margin: 0 auto;
padding: 16px;
max-width: 1200px;
background-color: #fff;
}
</style>
2 changes: 2 additions & 0 deletions lib/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/
import { registerAudioHandler } from './models/audios.ts'
import { registerImageHandler } from './models/images.ts'
import { registerSheetmusicHandler } from './models/sheetmusic.ts'
import { registerVideoHandler } from './models/videos.ts'

let registered = false
Expand All @@ -27,4 +28,5 @@ export function registerDefaultHandlers(): void {
registerAudioHandler()
registerVideoHandler()
registerImageHandler()
registerSheetmusicHandler()
}
63 changes: 63 additions & 0 deletions lib/models/sheetmusic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import MusicClefTrebleSvg from '@mdi/svg/svg/music-clef-treble.svg?raw'
import { defineCustomElement } from 'vue'
import { registerHandler } from '../handlers.ts'
import { logger } from '../services/logger.ts'
import { defineCustomElementOnce } from '../utils/customElements.ts'
import { t } from '../utils/l10n.ts'

/**
* What a score is stored as.
*
* `.musicxml` is the score as XML and `.mxl` is that same XML zipped, which
* is why one of these is not an `+xml` type. Nothing else is claimed here:
* an earlier attempt at this registered `application/octet-stream` too,
* because the extensions had no mapping at the time and a score arrived as
* an unknown binary. They map since server 32, and claiming the type every
* unrecognised file falls back to would have handed this handler far more
* than sheet music.
*/
const supportedMimes = [
'application/vnd.recordare.musicxml',
'application/vnd.recordare.musicxml+xml',
]

export const tagname = 'oca-viewer-sheetmusic'

/**
* Register the sheet music custom element.
*/
export async function registerSheetmusicCustomElement(): Promise<void> {
const { default: Sheetmusic } = await import('../components/Sheetmusic.vue')
const SheetmusicElement = defineCustomElement(Sheetmusic, {
shadowRoot: false,
})

defineCustomElementOnce(tagname, SheetmusicElement)
}

/**
* Register the sheet music handler.
*/
export function registerSheetmusicHandler() {
registerHandler({
id: 'sheetmusic',
displayName: t('Sheet music'),
tagname,

iconSvgInline: MusicClefTrebleSvg,

enabled: (nodes) => {
if (nodes.length === 0) {
return false
}

return nodes.every((node) => supportedMimes.includes(node.mime))
},
})
logger.info('Sheet music handler registered', { tagname })
}
2 changes: 2 additions & 0 deletions lib/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Viewer from './views/Viewer.vue'
import plyrIcons from './img/plyr.svg?raw'
import { registerAudioCustomElement } from './models/audios.ts'
import { registerImageCustomElement } from './models/images.ts'
import { registerSheetmusicCustomElement } from './models/sheetmusic.ts'
import { registerVideoCustomElement } from './models/videos.ts'
import { logger } from './services/logger.ts'
import { getViewer } from './viewer.ts'
Expand All @@ -25,6 +26,7 @@ export async function mount(): Promise<void> {
await Promise.all([
registerAudioCustomElement(),
registerImageCustomElement(),
registerSheetmusicCustomElement(),
registerVideoCustomElement(),
])

Expand Down
Loading
Loading