Adds feature flags, and the ability to do browser playback
This commit is contained in:
10
Dockerfile
10
Dockerfile
@@ -18,7 +18,7 @@ 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
|
||||||
@@ -26,11 +26,17 @@ WORKDIR /app
|
|||||||
# Install only production dependencies
|
# Install only production dependencies
|
||||||
COPY backend/package*.json ./
|
COPY backend/package*.json ./
|
||||||
COPY package-lock.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"]
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ 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 | null = null;
|
private playerProcess: ChildProcess | null = null;
|
||||||
private socket: Promise<Socket>;
|
private socket: Promise<Socket>;
|
||||||
@@ -59,6 +65,10 @@ export class MediaPlayer {
|
|||||||
this.favoritesStore.onFavoritesChanged = (favorites) => {
|
this.favoritesStore.onFavoritesChanged = (favorites) => {
|
||||||
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
|
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.getFeatures().then(features => {
|
||||||
|
console.log("Features: ", features);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public startZeroconfService(port: number) {
|
public startZeroconfService(port: number) {
|
||||||
@@ -304,6 +314,14 @@ 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, "-1", options.join(',')]));
|
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]));
|
||||||
|
|
||||||
|
|||||||
@@ -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")));
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
@@ -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>
|
||||||
|
|||||||
1651
package-lock.json
generated
1651
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user