Create a Plugin
This page walks through connecting to the MediaVault API step by step. The goal is to show how to map custom JSON responses to AN Player types and provide playable stream URLs.
Overview
What the plugin does:
- Receives
configin its factory function - Implements
async getFeedExtractor→ calls/api/root, maps folders toAlbum[] - Implements
async getAlbumExtractor→ calls/api/browse, maps folders toAlbum[]and files toMedia[] - Implements
async getMediaExtractor→ resolves/api/streamas aDirectStream
URI scheme the plugin creates (so AN Player can route URIs back to the correct extractor):
| URI pattern | Meaning |
|---|---|
https://<host>/album?path=<encoded> | A browseable folder |
https://<host>/media?path=<encoded> | A playable media file |
The host is always the configured server host, so isSupported can check uri.hostname.
Plugin Configuration
The config argument is a string. While many plugins use JSON.parse(), you are free to use any string format (e.g., a simple API key, a comma-separated list, or a custom protocol).
type Config = {
host: string;
};Step 1: isSupported
async isSupported(uri) {
// Parsing as JSON is common, but not required.
const parsedConfig = JSON.parse(config) as Config;
return uri.hostname === parsedConfig.host;
},AN Player calls this before doing anything else. If it returns false, the plugin is skipped and the next installed plugin gets a chance to handle the URI.
Step 2: isAlbum and isMediaTrack
async isAlbum(uri) {
return uri.pathname === '/album';
},
async isMediaTrack(uri) {
return uri.pathname === '/media';
},These let AN Player know whether to open a URI as a browseable container or as a playable item without having to call the extractor first.
Step 3: Feed Extractor
The feed appears on the Discover screen. For MediaVault we return the root folders.
async getFeedExtractor() {
const parsedConfig = JSON.parse(config);
return {
async getPage(_page: number) {
const response = await fetch(`${parsedConfig.serverUrl}/api/root`);
const data: { folders: { name: string; path: string }[] } =
await response.json();
const serverHost = new URL(parsedConfig.serverUrl).hostname;
const items = data.folders.map((folder) =>
createAlbum({
id: encodeURIComponent(folder.path),
title: folder.name,
uri: `https://${serverHost}/album?path=${encodeURIComponent(folder.path)}`,
}),
);
return { items, hasNextPage: false };
},
};
},TIP
The feed only has one page (all root folders fit on one screen), so hasNextPage is always false. For APIs with pagination you would check whether a next page token exists and return true.
Step 4: Album Extractor
Called when the user taps an album. We fetch the folder contents and return both sub-folders and media files.
async getAlbumExtractor(uri) {
return {
async getPage(_page: number) {
const folderPath = uri.searchParams.get('path') ?? '';
const serverHost = uri.hostname;
const serverUrl = `https://${serverHost}`;
const response = await fetch(
`${serverUrl}/api/browse?path=${encodeURIComponent(folderPath)}`,
);
const data: {
folders: { name: string; path: string }[];
files: { name: string; path: string; mimeType: string }[];
} = await response.json();
const albums = data.folders.map((f) =>
createAlbum({
id: encodeURIComponent(f.path),
title: f.name,
uri: `https://${serverHost}/album?path=${encodeURIComponent(f.path)}`,
}),
);
const mediaItems = data.files.map((f) =>
createMedia({
id: encodeURIComponent(f.path),
title: f.name,
uri: `https://${serverHost}/media?path=${encodeURIComponent(f.path)}`,
}),
);
return { items: [...albums, ...mediaItems], hasNextPage: false };
},
};
},Step 5: Media Extractor
Called when the user taps a media item. We return a DirectStream that points directly at the server's stream endpoint.
async getMediaExtractor(uri) {
return {
async getStream() {
const filePath = uri.searchParams.get('path') ?? '';
const serverHost = uri.hostname;
return createDirectStream({
uri: `https://${serverHost}/api/stream?path=${encodeURIComponent(filePath)}`,
});
},
};
},AN Player passes this URL directly to Media3/ExoPlayer, which streams the bytes from the server.
Complete Example
Below are two ways to implement the plugin. The Simple version is easier to read but lacks pagination. The Full version correctly implements the next() method for infinite scrolling, matching the pattern used in the official TMDB Plugin.
import {
AnPlayerExtractor,
createAlbum,
createMedia,
createDirectStream,
ExtractorNotImplementedError,
} from '@xeinebiu/anplayer-plugin-core';
type Config = { host: string };
const MediaVaultPlugin: AnPlayerExtractor = ({ config, logger }) => {
// Config is passed as a string (often JSON serialized); parse it if needed.
const parsedConfig = JSON.parse(config) as Config;
return {
/**
* Check if this module supports the provided URI.
*/
async isSupported(uri) {
return uri.hostname === parsedConfig.host;
},
async isAlbum(uri) {
return uri.pathname === '/album';
},
async isMediaTrack(uri) {
return uri.pathname === '/media';
},
/**
* Discovery feed - returns one page of content.
*/
async getFeedExtractor() {
return {
next: async () => {
const response = await fetch(`https://${parsedConfig.host}/api/root`);
const data = await response.json();
return data.folders.map((folder) =>
createAlbum({
id: encodeURIComponent(folder.path),
title: folder.name,
uri: `https://${parsedConfig.host}/album?path=${encodeURIComponent(folder.path)}`,
}),
);
}
};
},
async getMediaExtractor(uri) {
return {
async getStream() {
const filePath = uri.searchParams.get('path') ?? '';
return createDirectStream({
uri: `https://${uri.hostname}/api/stream?path=${encodeURIComponent(filePath)}`,
});
},
};
},
};
};
export default MediaVaultPlugin;For more advanced implementations, refer to the xeinebiu/android_anplayer_plugin repository for available interfaces.
Build
npm run buildThis produces dist/index.js, a single minified file ready to embed in the plugin manifest.
Next Steps
Go to Build & Distribute to package your plugin, host it, and install it in AN Player.