Compare commits
8 Commits
3.0
...
5e9842f02d
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e9842f02d | |||
| 480b30d909 | |||
| 484d08d3d4 | |||
| 663125aa0e | |||
| 63094f7e49 | |||
| d34363c650 | |||
| 1bde92b974 | |||
| 3552c9c476 |
11
Dockerfile
11
Dockerfile
@@ -18,18 +18,25 @@ RUN npm run build --workspaces
|
|||||||
FROM --platform=$TARGETPLATFORM debian:testing-20250203
|
FROM --platform=$TARGETPLATFORM debian:testing-20250203
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
mpv npm yt-dlp pulseaudio pulseaudio-utils \
|
mpv npm yt-dlp pulseaudio pulseaudio-utils ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install only production dependencies
|
# Install only production dependencies
|
||||||
COPY backend/package*.json ./
|
COPY backend/package*.json ./
|
||||||
|
COPY package-lock.json ./
|
||||||
|
|
||||||
|
RUN rm -rf node_modules/ # need to do a clean build
|
||||||
RUN npm ci --production
|
RUN npm ci --production
|
||||||
|
|
||||||
# Copy built files
|
# Copy built files
|
||||||
COPY --from=builder /app/backend/build ./build
|
COPY --from=builder /app/backend/build ./build
|
||||||
COPY --from=builder /app/frontend/dist ./dist/frontend
|
COPY --from=builder /app/frontend/dist ./dist/frontend
|
||||||
|
|
||||||
|
# Copy entrypoint script
|
||||||
|
COPY entrypoint.sh ./
|
||||||
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "build/server.js"]
|
CMD ["./entrypoint.sh"]
|
||||||
|
|||||||
2121
backend/package-lock.json
generated
2121
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node-fetch": "^2.6.12",
|
"@types/node-fetch": "^2.6.12",
|
||||||
|
"bonjour-service": "^1.3.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-ws": "^5.0.2",
|
"express-ws": "^5.0.2",
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export interface ThumbnailResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const USE_INVIDIOUS = process.env.USE_INVIDIOUS || true;
|
const USE_INVIDIOUS = process.env.USE_INVIDIOUS || true;
|
||||||
const INVIDIOUS_BASE_URL = process.env.INVIDIOUS_BASE_URL || 'http://invidious.nor';
|
const INVIDIOUS_BASE_URL = process.env.INVIDIOUS_BASE_URL || process.env.INVIDIOUS_URL || 'http://invidious.nor';
|
||||||
const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`;
|
const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`;
|
||||||
|
|
||||||
export const getInvidiousSearchURL = (query: string): string =>
|
export const getInvidiousSearchURL = (query: string): string =>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { WebSocket } from "ws";
|
|||||||
import { getLinkPreview } from "link-preview-js";
|
import { getLinkPreview } from "link-preview-js";
|
||||||
import { PlaylistItem, LinkMetadata } from './types';
|
import { PlaylistItem, LinkMetadata } from './types';
|
||||||
import { FavoritesStore } from "./FavoritesStore";
|
import { FavoritesStore } from "./FavoritesStore";
|
||||||
|
import { Bonjour } from "bonjour-service";
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
interface PendingCommand {
|
interface PendingCommand {
|
||||||
resolve: (value: any) => void;
|
resolve: (value: any) => void;
|
||||||
@@ -37,9 +39,16 @@ enum UserEvent {
|
|||||||
MPDUpdate = "mpd_update",
|
MPDUpdate = "mpd_update",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Features {
|
||||||
|
video: boolean;
|
||||||
|
screenshare: boolean;
|
||||||
|
browserPlayback: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class MediaPlayer {
|
export class MediaPlayer {
|
||||||
private playerProcess: ChildProcess;
|
private playerProcess: ChildProcess | null = null;
|
||||||
private socket: Socket;
|
private socket: Promise<Socket>;
|
||||||
|
|
||||||
private eventSubscribers: WebSocket[] = [];
|
private eventSubscribers: WebSocket[] = [];
|
||||||
private favoritesStore: FavoritesStore;
|
private favoritesStore: FavoritesStore;
|
||||||
|
|
||||||
@@ -47,11 +56,61 @@ export class MediaPlayer {
|
|||||||
private requestId: number = 1;
|
private requestId: number = 1;
|
||||||
private dataBuffer: string = '';
|
private dataBuffer: string = '';
|
||||||
private metadata: Map<string, LinkMetadata> = new Map();
|
private metadata: Map<string, LinkMetadata> = new Map();
|
||||||
|
private bonjourInstance: Bonjour | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.socket = this.tryRespawnPlayerProcess();
|
||||||
|
|
||||||
|
this.favoritesStore = new FavoritesStore();
|
||||||
|
this.favoritesStore.onFavoritesChanged = (favorites) => {
|
||||||
|
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
|
||||||
|
};
|
||||||
|
|
||||||
|
this.getFeatures().then(features => {
|
||||||
|
console.log("Features: ", features);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public startZeroconfService(port: number) {
|
||||||
|
if (this.bonjourInstance) {
|
||||||
|
console.log("Zeroconf service already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bonjourInstance = new Bonjour();
|
||||||
|
|
||||||
|
const service = this.bonjourInstance.publish({
|
||||||
|
name: `QueueCube Media Server (${os.hostname()})`,
|
||||||
|
type: 'queuecube',
|
||||||
|
port: port,
|
||||||
|
txt: {
|
||||||
|
version: '1.0.0',
|
||||||
|
features: 'playlist,favorites,screenshare'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
service.on('up', () => {
|
||||||
|
console.log(`Zeroconf service advertised: ${service.name} on port ${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
service.on('error', (err: Error) => {
|
||||||
|
console.error('Zeroconf service error:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public stopZeroconfService() {
|
||||||
|
if (this.bonjourInstance) {
|
||||||
|
this.bonjourInstance.destroy();
|
||||||
|
this.bonjourInstance = null;
|
||||||
|
console.log("Zeroconf service stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryRespawnPlayerProcess(): Promise<Socket> {
|
||||||
const socketFilename = Math.random().toString(36).substring(2, 10);
|
const socketFilename = Math.random().toString(36).substring(2, 10);
|
||||||
const socketPath = `/tmp/mpv-${socketFilename}`;
|
const socketPath = `/tmp/mpv-${socketFilename}`;
|
||||||
const enableVideo = process.env.ENABLE_VIDEO || false;
|
const enableVideo = process.env.ENABLE_VIDEO || false;
|
||||||
|
const logfilePath = `/tmp/mpv-logfile.txt`;
|
||||||
|
|
||||||
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
|
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
|
||||||
this.playerProcess = spawn("mpv", [
|
this.playerProcess = spawn("mpv", [
|
||||||
@@ -59,22 +118,31 @@ export class MediaPlayer {
|
|||||||
"--fullscreen",
|
"--fullscreen",
|
||||||
"--no-terminal",
|
"--no-terminal",
|
||||||
"--idle=yes",
|
"--idle=yes",
|
||||||
"--input-ipc-server=" + socketPath
|
"--input-ipc-server=" + socketPath,
|
||||||
|
"--log-file=" + logfilePath,
|
||||||
|
"--msg-level=all=v"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.socket = new Socket();
|
|
||||||
|
let socketReady!: (s: Socket) => void;
|
||||||
|
let socketPromise = new Promise<Socket>(resolve => {
|
||||||
|
socketReady = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
this.playerProcess.on("spawn", () => {
|
this.playerProcess.on("spawn", () => {
|
||||||
console.log(`Player process spawned, opening socket @ ${socketPath}`);
|
console.log(`Player process spawned, opening socket @ ${socketPath}`);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.connectToSocket(socketPath);
|
let socket = this.connectToSocket(socketPath);
|
||||||
|
socketReady(socket);
|
||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.favoritesStore = new FavoritesStore();
|
this.playerProcess.on("error", (error) => {
|
||||||
this.favoritesStore.onFavoritesChanged = (favorites) => {
|
console.error("Player process error:", error);
|
||||||
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
|
console.log("Continuing without mpv player...");
|
||||||
};
|
});
|
||||||
|
|
||||||
|
return socketPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getPlaylist(): Promise<PlaylistItem[]> {
|
public async getPlaylist(): Promise<PlaylistItem[]> {
|
||||||
@@ -246,8 +314,16 @@ export class MediaPlayer {
|
|||||||
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.updateFavoriteTitle(filename, title));
|
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.updateFavoriteTitle(filename, title));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getFeatures(): Promise<Features> {
|
||||||
|
return {
|
||||||
|
video: !!process.env.ENABLE_VIDEO,
|
||||||
|
screenshare: !!process.env.ENABLE_SCREENSHARE,
|
||||||
|
browserPlayback: !!process.env.ENABLE_BROWSER_PLAYBACK
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
|
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
|
||||||
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, options.join(',')]));
|
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]));
|
||||||
|
|
||||||
if (fetchMetadata) {
|
if (fetchMetadata) {
|
||||||
this.fetchMetadataAndNotify(url).catch(error => {
|
this.fetchMetadataAndNotify(url).catch(error => {
|
||||||
@@ -266,6 +342,9 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async writeCommand(command: string, args: any[]): Promise<any> {
|
private async writeCommand(command: string, args: any[]): Promise<any> {
|
||||||
|
// Wait for socket to become available.
|
||||||
|
let socket = await this.socket;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const id = this.requestId++;
|
const id = this.requestId++;
|
||||||
|
|
||||||
@@ -274,8 +353,13 @@ export class MediaPlayer {
|
|||||||
request_id: id
|
request_id: id
|
||||||
});
|
});
|
||||||
|
|
||||||
this.pendingCommands.set(id, { resolve, reject });
|
try {
|
||||||
this.socket.write(commandObject + '\n');
|
this.pendingCommands.set(id, { resolve, reject });
|
||||||
|
socket.write(commandObject + '\n');
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(`Error writing to socket: ${e}. Trying to respawn.`)
|
||||||
|
this.tryRespawnPlayerProcess();
|
||||||
|
}
|
||||||
|
|
||||||
// Add timeout to prevent hanging promises
|
// Add timeout to prevent hanging promises
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -313,9 +397,12 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private connectToSocket(path: string) {
|
private connectToSocket(path: string): Socket {
|
||||||
this.socket.connect(path);
|
let socket = new Socket();
|
||||||
this.socket.on("data", data => this.receiveData(data.toString()));
|
socket.connect(path);
|
||||||
|
socket.on("data", data => this.receiveData(data.toString()));
|
||||||
|
|
||||||
|
return socket;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleEvent(event: string, data: any) {
|
private handleEvent(event: string, data: any) {
|
||||||
|
|||||||
@@ -281,6 +281,11 @@ apiRouter.put("/favorites/:filename/title", withErrorHandling(async (req, res) =
|
|||||||
res.send(JSON.stringify({ success: true }));
|
res.send(JSON.stringify({ success: true }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
apiRouter.get("/features", withErrorHandling(async (req, res) => {
|
||||||
|
const features = await mediaPlayer.getFeatures();
|
||||||
|
res.send(JSON.stringify(features));
|
||||||
|
}));
|
||||||
|
|
||||||
// Serve static files for React app (after building)
|
// Serve static files for React app (after building)
|
||||||
app.use(express.static(path.join(__dirname, "../dist/frontend")));
|
app.use(express.static(path.join(__dirname, "../dist/frontend")));
|
||||||
|
|
||||||
@@ -295,12 +300,18 @@ app.get("*", (req, res) => {
|
|||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
const server = app.listen(port, () => {
|
const server = app.listen(port, () => {
|
||||||
console.log(`Server is running on port ${port}`);
|
console.log(`Server is running on port ${port}`);
|
||||||
|
|
||||||
|
// Start zeroconf service advertisement
|
||||||
|
mediaPlayer.startZeroconfService(Number(port));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add graceful shutdown handling
|
// Add graceful shutdown handling
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
console.log('Received shutdown signal. Closing server...');
|
console.log('Received shutdown signal. Closing server...');
|
||||||
|
|
||||||
|
// Stop zeroconf service
|
||||||
|
mediaPlayer.stopZeroconfService();
|
||||||
|
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
console.log('Server closed');
|
console.log('Server closed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
46
entrypoint.sh
Executable file
46
entrypoint.sh
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Check if browser playback is enabled
|
||||||
|
if [ "$ENABLE_BROWSER_PLAYBACK" = "1" ]; then
|
||||||
|
echo "Browser playback enabled - setting up audio streaming..."
|
||||||
|
|
||||||
|
echo "Starting PulseAudio..."
|
||||||
|
pulseaudio --start --log-target=syslog --system=false
|
||||||
|
|
||||||
|
# Wait a moment for PulseAudio to initialize
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Create virtual sink
|
||||||
|
echo "Creating virtual audio sink..."
|
||||||
|
pactl load-module module-null-sink sink_name=virtual_output sink_properties=device.description="Virtual_Audio_Output"
|
||||||
|
|
||||||
|
# Make it the default sink
|
||||||
|
pactl set-default-sink virtual_output
|
||||||
|
|
||||||
|
# Create stream directory if it doesn't exist
|
||||||
|
mkdir -p ./dist/frontend/stream
|
||||||
|
|
||||||
|
# Start FFmpeg streaming in background
|
||||||
|
echo "Starting audio stream..."
|
||||||
|
|
||||||
|
FFMPEG_OPTS="-loglevel error -f pulse \
|
||||||
|
-i virtual_output.monitor \
|
||||||
|
-c:a aac -b:a 128k \
|
||||||
|
-f hls \
|
||||||
|
-hls_time 1 \
|
||||||
|
-hls_list_size 3 \
|
||||||
|
-hls_flags delete_segments+append_list \
|
||||||
|
-hls_segment_type mpegts \
|
||||||
|
-hls_segment_filename ./dist/frontend/stream/segment_%03d.ts \
|
||||||
|
./dist/frontend/stream/audio.m3u8"
|
||||||
|
|
||||||
|
echo "FFmpeg options: $FFMPEG_OPTS"
|
||||||
|
|
||||||
|
ffmpeg $FFMPEG_OPTS &
|
||||||
|
else
|
||||||
|
echo "Browser playback disabled - skipping audio streaming setup"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start the Node.js server
|
||||||
|
echo "Starting Node.js server..."
|
||||||
|
exec node build/server.js
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
PORT = toString cfg.port;
|
PORT = toString cfg.port;
|
||||||
ENABLE_VIDEO = if cfg.enable_video then "1" else "0";
|
ENABLE_VIDEO = if cfg.enable_video then "1" else "0";
|
||||||
USE_INVIDIOUS = if cfg.invidious.enable then "1" else "0";
|
USE_INVIDIOUS = if cfg.invidious.enable then "1" else "0";
|
||||||
INVIDIOUS_URL = cfg.invidious.url;
|
INVIDIOUS_BASE_URL = cfg.invidious.url;
|
||||||
STORE_PATH = cfg.store_path;
|
STORE_PATH = cfg.store_path;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
'';
|
'';
|
||||||
|
|
||||||
# Let buildNpmPackage handle npm package hash
|
# Let buildNpmPackage handle npm package hash
|
||||||
npmDepsHash = "sha256-BqjJ4CxTPc14Od88sAm/ASwsLszkvcHHeNoZupotlFw=";
|
npmDepsHash = "sha256-G+rT0f3wZZmZVDBd4CIswNeUzlmJ+TKy/gmZ0B5GMxY=";
|
||||||
|
|
||||||
meta = with pkgs.lib; {
|
meta = with pkgs.lib; {
|
||||||
description = "NodeJS application with media playback capabilities";
|
description = "NodeJS application with media playback capabilities";
|
||||||
|
|||||||
3673
frontend/package-lock.json
generated
3673
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,12 @@ export interface NowPlayingResponse {
|
|||||||
currentFile: string;
|
currentFile: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Features {
|
||||||
|
video: boolean;
|
||||||
|
screenshare: boolean;
|
||||||
|
browserPlayback: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Metadata {
|
export interface Metadata {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -57,6 +63,11 @@ export enum ServerEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const API = {
|
export const API = {
|
||||||
|
async getFeatures(): Promise<Features> {
|
||||||
|
const response = await fetch('/api/features');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
async getPlaylist(): Promise<PlaylistItem[]> {
|
async getPlaylist(): Promise<PlaylistItem[]> {
|
||||||
const response = await fetch('/api/playlist');
|
const response = await fetch('/api/playlist');
|
||||||
return response.json();
|
return response.json();
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import NowPlaying from './NowPlaying';
|
|||||||
import AddSongPanel from './AddSongPanel';
|
import AddSongPanel from './AddSongPanel';
|
||||||
import RenameFavoriteModal from './RenameFavoriteModal';
|
import RenameFavoriteModal from './RenameFavoriteModal';
|
||||||
import { TabView, Tab } from './TabView';
|
import { TabView, Tab } from './TabView';
|
||||||
import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
|
import { API, Features, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
|
||||||
import { FaMusic, FaHeart, FaPlus, FaEdit } from 'react-icons/fa';
|
import { FaMusic, FaHeart, FaPlus, FaEdit } from 'react-icons/fa';
|
||||||
import useWebSocket from 'react-use-websocket';
|
import useWebSocket from 'react-use-websocket';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useScreenShare } from '../hooks/useScreenShare';
|
import { useScreenShare } from '../hooks/useScreenShare';
|
||||||
|
import AudioPlayer from './AudioPlayer';
|
||||||
|
|
||||||
enum Tabs {
|
enum Tabs {
|
||||||
Playlist = "playlist",
|
Playlist = "playlist",
|
||||||
@@ -93,6 +94,8 @@ const App: React.FC = () => {
|
|||||||
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
|
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
|
||||||
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
||||||
const [favoriteToRename, setFavoriteToRename] = useState<PlaylistItem | null>(null);
|
const [favoriteToRename, setFavoriteToRename] = useState<PlaylistItem | null>(null);
|
||||||
|
const [audioEnabled, setAudioEnabled] = useState(false);
|
||||||
|
const [features, setFeatures] = useState<Features | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
@@ -124,6 +127,9 @@ const App: React.FC = () => {
|
|||||||
setIsPlaying(!nowPlaying.isPaused);
|
setIsPlaying(!nowPlaying.isPaused);
|
||||||
setVolume(nowPlaying.volume);
|
setVolume(nowPlaying.volume);
|
||||||
setIsIdle(nowPlaying.playingItem ? !nowPlaying.playingItem.playing : true);
|
setIsIdle(nowPlaying.playingItem ? !nowPlaying.playingItem.playing : true);
|
||||||
|
|
||||||
|
const features = await API.getFeatures();
|
||||||
|
setFeatures(features);
|
||||||
}, [volumeSettingIsLocked]);
|
}, [volumeSettingIsLocked]);
|
||||||
|
|
||||||
const handleAddURL = async (url: string) => {
|
const handleAddURL = async (url: string) => {
|
||||||
@@ -313,6 +319,10 @@ const App: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen w-screen bg-black md:py-10">
|
<div className="flex items-center justify-center h-screen w-screen bg-black md:py-10">
|
||||||
<div className="bg-violet-900 w-full md:max-w-2xl h-full md:max-h-xl md:border md:rounded-2xl flex flex-col">
|
<div className="bg-violet-900 w-full md:max-w-2xl h-full md:max-h-xl md:border md:rounded-2xl flex flex-col">
|
||||||
|
{features?.browserPlayback && (
|
||||||
|
<AudioPlayer isPlaying={isPlaying} enabled={audioEnabled} />
|
||||||
|
)}
|
||||||
|
|
||||||
<NowPlaying
|
<NowPlaying
|
||||||
className="flex flex-row md:rounded-t-2xl"
|
className="flex flex-row md:rounded-t-2xl"
|
||||||
songName={nowPlayingSong || "(Not Playing)"}
|
songName={nowPlayingSong || "(Not Playing)"}
|
||||||
@@ -330,6 +340,9 @@ const App: React.FC = () => {
|
|||||||
onVolumeWillChange={() => setVolumeSettingIsLocked(true)}
|
onVolumeWillChange={() => setVolumeSettingIsLocked(true)}
|
||||||
onVolumeDidChange={() => setVolumeSettingIsLocked(false)}
|
onVolumeDidChange={() => setVolumeSettingIsLocked(false)}
|
||||||
isScreenSharingSupported={isScreenSharingSupported}
|
isScreenSharingSupported={isScreenSharingSupported}
|
||||||
|
features={features}
|
||||||
|
audioEnabled={audioEnabled}
|
||||||
|
onAudioEnabledChange={setAudioEnabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TabView selectedTab={selectedTab} onTabChange={setSelectedTab}>
|
<TabView selectedTab={selectedTab} onTabChange={setSelectedTab}>
|
||||||
|
|||||||
32
frontend/src/components/AudioPlayer.tsx
Normal file
32
frontend/src/components/AudioPlayer.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface AudioPlayerProps {
|
||||||
|
isPlaying: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudioPlayer: React.FC<AudioPlayerProps> = ({ isPlaying, enabled }) => {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled && isPlaying) {
|
||||||
|
console.log("Playing audio");
|
||||||
|
audioRef.current?.play().catch((error) => {
|
||||||
|
console.error("Audio playback error:", error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("Pausing audio");
|
||||||
|
audioRef.current?.pause();
|
||||||
|
}
|
||||||
|
}, [isPlaying, enabled]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src="/stream/audio.m3u8"
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AudioPlayer;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { HTMLAttributes } from 'react';
|
import React, { HTMLAttributes } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { FaPlay, FaPause, FaStepForward, FaStepBackward, FaVolumeUp, FaDesktop, FaStop } from 'react-icons/fa';
|
import { FaPlay, FaPause, FaStepForward, FaStepBackward, FaVolumeUp, FaDesktop, FaStop } from 'react-icons/fa';
|
||||||
|
import { Features } from '../api/player';
|
||||||
|
|
||||||
interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
|
interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
songName: string;
|
songName: string;
|
||||||
@@ -25,6 +26,11 @@ interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
|
|||||||
|
|
||||||
// Sent when the volume has changed
|
// Sent when the volume has changed
|
||||||
onVolumeDidChange: (volume: number) => void;
|
onVolumeDidChange: (volume: number) => void;
|
||||||
|
|
||||||
|
features: Features | null;
|
||||||
|
|
||||||
|
audioEnabled: boolean;
|
||||||
|
onAudioEnabledChange: (enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NowPlaying: React.FC<NowPlayingProps> = (props) => {
|
const NowPlaying: React.FC<NowPlayingProps> = (props) => {
|
||||||
@@ -80,7 +86,7 @@ const NowPlaying: React.FC<NowPlayingProps> = (props) => {
|
|||||||
<FaStepForward size={24} />
|
<FaStepForward size={24} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{props.isScreenSharingSupported && (
|
{(props.isScreenSharingSupported && props.features?.screenshare) && (
|
||||||
<button
|
<button
|
||||||
className={classNames("text-white hover:text-violet-300 transition-colors rounded-full p-2", props.isScreenSharing ? ' bg-violet-800' : '')}
|
className={classNames("text-white hover:text-violet-300 transition-colors rounded-full p-2", props.isScreenSharing ? ' bg-violet-800' : '')}
|
||||||
onClick={props.onScreenShare}
|
onClick={props.onScreenShare}
|
||||||
@@ -90,6 +96,20 @@ const NowPlaying: React.FC<NowPlayingProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{props.features?.browserPlayback && (
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2 text-white text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={props.audioEnabled}
|
||||||
|
onChange={(e) => props.onAudioEnabledChange(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-violet-600 bg-gray-100 border-gray-300 rounded focus:ring-violet-500 focus:ring-2"
|
||||||
|
/>
|
||||||
|
Enable audio playback in browser
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1689
package-lock.json
generated
1689
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user